261 lines
15 KiB
TypeScript
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);
|
|
});
|