Files
Dune/tests/public-deals-travel-schema.test.ts
T
Quintine 6c1530382d
Verify and publish checkpoint container / container (push) Successful in 10m49s
Support immediate payments in group travel deals
2026-10-29 17:41:58 +11:00

261 lines
15 KiB
TypeScript

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);
});