import assert from 'node:assert/strict'; import test from 'node:test'; import { createMultipartyPublicDeal, createPublicDeal, parsePublicDealTravel, PublicDealError, publicDealTravelActor, publicDealTravelDueTurn, releasePublicDeal, respondPublicDeal, setPublicDealTravel, validatePublicDeals, withdrawPublicDeal, type PublicDeal, type PublicDealSeat, } from '../game/public-deals'; const seats: PublicDealSeat[] = [{ id: 'a' }, { id: 'b' }, { id: 'c' }]; const term = { kind: 'ship', territory: 'polar_sink', minimum: 1, answer: true } as const; const fresh = () => createPublicDeal(seats, 'a', 'b', 'Travel this turn.', 3, 'offer'); void test('travel terms accept only exact bounded native actions and an explicit answer', () => { for (const kind of ['ship', 'move'] as const) for (const answer of [true, false]) { for (const minimum of [1, 20]) { const input = { kind, territory: 'polar_sink', minimum, answer }; assert.deepEqual(parsePublicDealTravel(input), input); assert.notEqual(parsePublicDealTravel(input), input); } } for (const invalid of [null, undefined, [], {}, { ...term, kind: 'fly' }, { ...term, territory: 'not_a_territory' }, ...[0, -1, 21, 1.5, Infinity, Number.MAX_SAFE_INTEGER + 1, '1'].map(minimum => ({ ...term, minimum })), { ...term, answer: 'yes' }, { ...term, answer: undefined }, { ...term, sector: 0 }, { ...term, turn: 4 }, { ...term, actor: '' }, { ...term, actor: undefined }, { ...term, actor: ' c ' }, ]) assert.throws(() => parsePublicDealTravel(invalid), PublicDealError); assert.deepEqual(parsePublicDealTravel({...term, actor:'c'}), {...term, actor:'c'}); }); void test('travel due turns are exact positive safe integers and cannot predate the offer', () => { for (const dueTurn of [1, 3, 4, Number.MAX_SAFE_INTEGER]) { assert.deepEqual(parsePublicDealTravel({...term, dueTurn}), {...term, dueTurn}); assert.deepEqual(parsePublicDealTravel({...term, actor:'c', dueTurn}), {...term, actor:'c', dueTurn}); } for (const dueTurn of [0, -1, 2.5, Infinity, NaN, Number.MAX_SAFE_INTEGER + 1, '4', undefined, null]) { assert.throws(() => parsePublicDealTravel({...term, dueTurn}), PublicDealError); assert.throws(() => parsePublicDealTravel({...term, actor:'c', dueTurn}), PublicDealError); } const old = setPublicDealTravel(fresh(), term, seats); assert.equal(publicDealTravelDueTurn(old), 3); assert.equal(Object.hasOwn(old.travel!, 'dueTurn'), false); for (const dueTurn of [1, 2]) { assert.throws(() => setPublicDealTravel(fresh(), {...term, dueTurn}, seats), PublicDealError); assert.throws(() => validatePublicDeals([{...fresh(), travel:{...term, dueTurn}}], seats), PublicDealError); } for (const dueTurn of [3, 4]) { const offered = setPublicDealTravel(fresh(), {...term, dueTurn}, seats); assert.equal(publicDealTravelDueTurn(offered), dueTurn); assert.deepEqual(offered.travel, {...term, dueTurn}); } }); void test('future two-party and group terms retain due turns across consent and release', () => { const payment = {kind:'immediateBribe', payer:'a', recipient:'b', amount:2} as const; const pair = setPublicDealTravel(createPublicDeal(seats, 'a', 'b', 'Future travel.', 3, 'pair', payment), {...term, dueTurn:5}, seats); const pairActive = respondPublicDeal(pair, seats, 'b', true); const pairReleased = releasePublicDeal(releasePublicDeal(pairActive, seats, 'a', true), seats, 'b', true); const group = setPublicDealTravel(createMultipartyPublicDeal(seats, 'a', ['b','c'], 'Future travel.', 3, 'group'), {...term, actor:'c', dueTurn:5}, seats); assert.throws(() => respondPublicDeal(group, seats, 'c', true), PublicDealError); const groupActive = respondPublicDeal(respondPublicDeal(group, seats, 'b', true), seats, 'c', true); const groupReleased = releasePublicDeal(releasePublicDeal(releasePublicDeal(groupActive, seats, 'a', true), seats, 'b', true), seats, 'c', true); for (const record of [pair, pairActive, pairReleased, group, groupActive, groupReleased]) { const roundtrip = JSON.parse(JSON.stringify(record)) as PublicDeal; validatePublicDeals([roundtrip], seats); assert.equal(publicDealTravelDueTurn(roundtrip), 5); assert.deepEqual(roundtrip.travel, record.travel); assert.deepEqual(roundtrip.payment, record.payment); } const backwardsGroup = {...group, travel:{...term, actor:'c', dueTurn:2}}; assert.throws(() => validatePublicDeals([backwardsGroup], seats), PublicDealError); const paidGroup = { ...group, payment, travel: { ...term, actor: 'c', dueTurn: 5 } }; validatePublicDeals([paidGroup], seats); assert.equal(publicDealTravelDueTurn(paidGroup), 5); assert.deepEqual(paidGroup.payment, payment); }); void test('a recipient accepts and both release an unchanged current-turn travel term', () => { const source = fresh(); const input = { kind: 'ship' as const, territory: 'polar_sink', minimum: 1, answer: true }; const offered = setPublicDealTravel(source, input, seats); input.minimum = 2; assert.equal(source.travel, undefined); assert.deepEqual(offered.travel, term); assert.equal(offered.turn, 3); assert.equal(publicDealTravelActor(offered), 'b'); assert.throws(() => respondPublicDeal(offered, seats, 'a', true), PublicDealError); assert.throws(() => respondPublicDeal(offered, seats, 'c', true), PublicDealError); const active = respondPublicDeal(offered, seats, 'b', true); assert.equal(active.status, 'active'); assert.deepEqual(active.travel, term); const first = releasePublicDeal(active, seats, 'a', true); assert.equal(first.status, 'active'); const released = releasePublicDeal(first, seats, 'b', true); assert.equal(released.status, 'released'); assert.deepEqual(released.travel, term); const roundtrip = JSON.parse(JSON.stringify([offered, active, released])) as PublicDeal[]; // Ledger IDs must be unique; each lifecycle snapshot is validated separately. for (const deal of roundtrip) { const before = JSON.stringify(deal); validatePublicDeals([deal], seats); assert.equal(JSON.stringify(deal), before); assert.deepEqual(deal.travel, term); assert.equal(Object.hasOwn(deal, 'travelStatus'), false); } assert.deepEqual(respondPublicDeal(offered, seats, 'b', false).travel, term); assert.deepEqual(withdrawPublicDeal(offered, seats, 'a').travel, term); }); void test('travel requires an actor for multiparty offers and cannot replace an existing term', () => { const multi = createMultipartyPublicDeal(seats, 'a', ['b', 'c'], 'Terms', 3, 'multi'); const offered = setPublicDealTravel(fresh(), term, seats); const active = respondPublicDeal(offered, seats, 'b', true); for (const deal of [multi, offered, active]) assert.throws(() => setPublicDealTravel(deal, term, seats), PublicDealError); for (const invalid of [ { ...multi, travel: term }, { ...fresh(), travel: {...term, actor:'b'} }, { ...fresh(), travel: undefined }, { ...fresh(), travel: { ...term, sector: 0 } }, { ...offered, paymentStatus: 'pending' }, { ...offered, travelStatus: 'pending' }, ]) assert.throws(() => validatePublicDeals([invalid], seats), PublicDealError); }); void test('multiparty travel requires the committed recipient to accept last and all parties to release', () => { const people: PublicDealSeat[] = ['a','b','c','d','e','f'].map(id => ({id})); for (const actor of ['b','c','d','e','f']) { const multi = createMultipartyPublicDeal(people, 'a', ['b','c','d','e','f'], 'Travel together.', 3, `multi-${actor}`); const offered = setPublicDealTravel(multi, {...term,actor}, people); assert.deepEqual(offered.travel, {...term,actor}); assert.equal(publicDealTravelActor(offered), actor); assert.equal(multi.travel, undefined); assert.throws(() => respondPublicDeal(offered, people, actor, true), PublicDealError); assert.equal(respondPublicDeal(offered, people, actor, false).status, 'declined'); assert.equal(withdrawPublicDeal(offered, people, 'a').status, 'withdrawn'); let current = offered; // Deliberately vote in reverse roster order; serialization stays canonical. for (const voter of ['f','e','d','c','b'].filter(id => id !== actor)) { current = respondPublicDeal(current, people, voter, true); assert.equal(current.status, 'offered'); if (current.acceptVotes!.length < people.length - 1) assert.throws(() => respondPublicDeal(current, people, actor, true), PublicDealError); } const last = respondPublicDeal(current, people, actor, true); assert.equal(last.status, 'active'); assert.deepEqual(last.acceptVotes, ['a','b','c','d','e','f']); let released = last; for (const voter of ['f','e','d','c','b','a']) released = releasePublicDeal(released, people, voter, true); assert.equal(released.status, 'released'); for (const snapshot of [offered,current,last,released]) { const copy = JSON.parse(JSON.stringify(snapshot)) as PublicDeal; validatePublicDeals([copy], people); assert.deepEqual(copy.travel, {...term,actor}); } } }); void test('multiparty travel rejects forged actors, early votes and nonfresh offers', () => { const multi = createMultipartyPublicDeal(seats, 'a', ['b','c'], 'Terms', 3, 'multi'); for (const actor of ['a','d','',undefined]) { const invalid = {...term, actor}; assert.throws(() => setPublicDealTravel(multi, invalid, seats), PublicDealError); assert.throws(() => validatePublicDeals([{...multi, travel:invalid}], seats), PublicDealError); } const offered = setPublicDealTravel(multi, {...term,actor:'b'}, seats); for (const status of ['offered','withdrawn','declined'] as const) { assert.throws(() => validatePublicDeals([{...offered,status,acceptVotes:['a','b']}], seats), PublicDealError); } const acceptedOther = respondPublicDeal(offered, seats, 'c', true); assert.throws(() => setPublicDealTravel(respondPublicDeal(multi, seats, 'c', true), {...term,actor:'b'}, seats), PublicDealError); assert.equal(respondPublicDeal(acceptedOther, seats, 'b', true).status, 'active'); }); for (const [payer, recipient] of [['a', 'b'], ['b', 'a']] as const) void test(`a ${payer}-to-${recipient} immediate payment can accompany travel through the complete deal lifecycle`, () => { const payment = { kind: 'immediateBribe', payer, recipient, amount: 3 } as const; const original = createPublicDeal(seats, 'a', 'b', 'Payment for travel.', 3, 'paid-travel', payment); const offered = setPublicDealTravel(original, term, seats); assert.equal(original.travel, undefined); const active = respondPublicDeal(offered, seats, 'b', true); const released = releasePublicDeal(releasePublicDeal(active, seats, 'a', true), seats, 'b', true); for (const deal of [offered, active, released, respondPublicDeal(offered, seats, 'b', false), withdrawPublicDeal(offered, seats, 'a')]) { const roundtrip = JSON.parse(JSON.stringify(deal)) as PublicDeal; const before = JSON.stringify(roundtrip); validatePublicDeals([roundtrip], seats); assert.equal(JSON.stringify(roundtrip), before); assert.deepEqual(roundtrip.payment, payment); assert.deepEqual(roundtrip.travel, term); assert.equal(Object.hasOwn(roundtrip, 'paymentStatus'), false); } assert.equal(active.status, 'active'); assert.equal(released.status, 'released'); }); void test('paid travel rejects deferred and forged persisted payment shapes', () => { const deferred = { kind: 'deferredBribe', payer: 'a', recipient: 'b', amount: 1, dueTurn: 3 } as const; const paid = { kind: 'immediateBribe', payer: 'a', recipient: 'b', amount: 1 } as const; for (const payment of [deferred]) { const offer = createPublicDeal(seats, 'a', 'b', 'Terms', 3, `invalid-${payment.kind}`, payment); assert.throws(() => setPublicDealTravel(offer, term, seats), PublicDealError); assert.throws(() => validatePublicDeals([{ ...offer, travel: term }], seats), PublicDealError); } const multi = createMultipartyPublicDeal(seats, 'a', ['b', 'c'], 'Terms', 3, 'multi-paid', paid); for (const invalid of [ { ...multi, travel: term }, { ...fresh(), payment: { ...paid, recipient: 'c' }, travel: term }, { ...fresh(), payment: { ...paid, amount: 0 }, travel: term }, { ...fresh(), payment: { ...paid, extra: true }, travel: term }, { ...fresh(), payment: undefined, travel: term }, { ...fresh(), payment: paid, paymentStatus: 'paid', travel: term }, ]) assert.throws(() => validatePublicDeals([invalid], seats), PublicDealError); }); void test('legacy two-party and multi-party records validate without acquiring travel fields', () => { const legacy = fresh(); const multi = createMultipartyPublicDeal(seats, 'a', ['b', 'c'], 'Terms', 3, 'multi'); const before = JSON.stringify([legacy, multi]); validatePublicDeals([legacy, multi], seats); assert.equal(JSON.stringify([legacy, multi]), before); assert.equal(Object.hasOwn(legacy, 'travel'), false); assert.equal(Object.hasOwn(multi, 'travel'), false); }); for (const payer of ['a', 'b', 'c']) for (const recipient of ['a', 'b', 'c']) { if (payer === recipient) continue; void test(`group travel holds ${payer}-to-${recipient} payment until every party accepts`, () => { const payment = { kind: 'immediateBribe', payer, recipient, amount: 3 } as const; const group = createMultipartyPublicDeal(seats, 'a', ['b', 'c'], 'Travel and payment.', 3, `group-${payer}-${recipient}`, payment); const offered = setPublicDealTravel(group, { ...term, actor: 'b' }, seats); assert.throws(() => respondPublicDeal(offered, seats, 'b', true), PublicDealError); const partial = respondPublicDeal(offered, seats, 'c', true); assert.equal(partial.status, 'offered'); const active = respondPublicDeal(partial, seats, 'b', true); assert.equal(active.status, 'active'); assert.deepEqual(active.acceptVotes, ['a', 'b', 'c']); assert.deepEqual(active.payment, payment); assert.deepEqual(active.travel, { ...term, actor: 'b' }); for (const deal of [offered, partial, active, respondPublicDeal(offered, seats, 'c', false), withdrawPublicDeal(offered, seats, 'a')]) validatePublicDeals([JSON.parse(JSON.stringify(deal))], seats); const released = ['a', 'b', 'c'].reduce((deal, actor) => releasePublicDeal(deal, seats, actor, true), active); assert.equal(released.status, 'released'); validatePublicDeals([released], seats); }); } void test('group travel still rejects deferred payments and forged party identities', () => { const deferred = { kind: 'deferredBribe', payer: 'a', recipient: 'b', amount: 1, dueTurn: 3 } as const; const group = createMultipartyPublicDeal(seats, 'a', ['b', 'c'], 'Terms', 3, 'group-deferred', deferred); assert.throws(() => setPublicDealTravel(group, { ...term, actor: 'b' }, seats), PublicDealError); assert.throws(() => validatePublicDeals([{ ...group, travel: { ...term, actor: 'b' } }], seats), PublicDealError); const immediate = { kind: 'immediateBribe', payer: 'a', recipient: 'b', amount: 1 } as const; const paid = setPublicDealTravel(createMultipartyPublicDeal(seats, 'a', ['b', 'c'], 'Terms', 3, 'group-paid', immediate), { ...term, actor: 'b' }, seats); assert.throws(() => validatePublicDeals([{ ...paid, payment: { ...immediate, payer: 'd' } }], seats), PublicDealError); assert.throws(() => validatePublicDeals([{ ...paid, acceptVotes: ['a', 'b'] }], seats), PublicDealError); });