123 lines
8.6 KiB
TypeScript
123 lines
8.6 KiB
TypeScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { quoteCoalitionFunding, reconcileCoalitionPledges, settleCoalitionFunding, type CoalitionFundingInput } from '../game/coalition-funding';
|
|
import type { CoalitionSeat, CoalitionState } from '../game/coalition-alliance';
|
|
|
|
function fixture(): CoalitionFundingInput {
|
|
const seats: CoalitionSeat[] = ['a', 'b', 'c', 'd'].map(id => ({ id, ally: null }));
|
|
const coalitionState: CoalitionState = { version: 1, revision: 3, proposal: null, usedEvents: [], groups: [{
|
|
id: 'abc', members: ['a', 'b', 'c'], formedTurn: 2, edges: [
|
|
{ members: ['a', 'b'], formedTurn: 2 }, { members: ['a', 'c'], formedTurn: 3 },
|
|
{ members: ['b', 'c'], formedTurn: 3 },
|
|
],
|
|
}] };
|
|
return { seats, coalitionState, purpose: 'bid', payer: 'a', cost: 10, ownAvailable: 4,
|
|
authorizations: [{ members: ['a', 'b'], epoch: 'ab1' }, { members: ['a', 'c'], epoch: 'ac1' },
|
|
{ members: ['b', 'c'], epoch: 'bc1' }],
|
|
pledges: [
|
|
{ donor: 'b', recipient: 'a', amount: 4, epoch: 'ab1' }, { donor: 'c', recipient: 'a', amount: 5, epoch: 'ac1' },
|
|
{ donor: 'b', recipient: 'c', amount: 2, epoch: 'bc1' },
|
|
], contributions: [{ donor: 'b', amount: 3 }, { donor: 'c', amount: 4 }] };
|
|
}
|
|
|
|
void test('exact split uses each donor escrow and preserves unrelated pledges', () => {
|
|
const input = fixture(), original = structuredClone(input);
|
|
const quote = quoteCoalitionFunding(input);
|
|
assert.deepEqual(quote, { purpose: 'bid', payer: 'a', cost: 10, own: 3,
|
|
contributions: [{ donor: 'b', amount: 3 }, { donor: 'c', amount: 4 }] });
|
|
assert.deepEqual(settleCoalitionFunding(input), { quote, pledges: [
|
|
{ donor: 'b', recipient: 'a', amount: 1, epoch: 'ab1' }, { donor: 'c', recipient: 'a', amount: 1, epoch: 'ac1' },
|
|
{ donor: 'b', recipient: 'c', amount: 2, epoch: 'bc1' },
|
|
] });
|
|
assert.deepEqual(input, original);
|
|
});
|
|
|
|
void test('pair and own-only payments retain exact split including zero-cost shipments', () => {
|
|
const seats: CoalitionSeat[] = [{ id: 'a', ally: 'b' }, { id: 'b', ally: 'a' }, { id: 'c', ally: null }];
|
|
const input: CoalitionFundingInput = { seats, purpose: 'shipment', payer: 'a', cost: 6, ownAvailable: 4,
|
|
authorizations: [{ members: ['a', 'b'], epoch: 'ab1' }],
|
|
pledges: [{ donor: 'b', recipient: 'a', amount: 3, epoch: 'ab1' }], contributions: [{ donor: 'b', amount: 2 }] };
|
|
assert.deepEqual(settleCoalitionFunding(input), { quote: { purpose: 'shipment', payer: 'a', cost: 6,
|
|
own: 4, contributions: [{ donor: 'b', amount: 2 }] }, pledges: [{ donor: 'b', recipient: 'a', amount: 1, epoch: 'ab1' }] });
|
|
assert.equal(quoteCoalitionFunding({ ...input, cost: 4, contributions: [] }).own, 4);
|
|
assert.equal(quoteCoalitionFunding({ ...input, cost: 0, ownAvailable: 0, contributions: [] }).own, 0);
|
|
});
|
|
|
|
void test('settlement revalidates current pledges, coalition and payer budget', () => {
|
|
const input = fixture();
|
|
quoteCoalitionFunding(input);
|
|
assert.throws(() => settleCoalitionFunding({ ...input, ownAvailable: 2 }), /remaining cost/);
|
|
assert.throws(() => settleCoalitionFunding({ ...input, pledges: [
|
|
{ donor: 'b', recipient: 'a', amount: 2, epoch: 'ab1' }, ...input.pledges.slice(1),
|
|
] }), /current escrow/);
|
|
const seats: CoalitionSeat[] = [{ id: 'a', ally: 'b' }, { id: 'b', ally: 'a' },
|
|
{ id: 'c', ally: null }, { id: 'd', ally: null }];
|
|
assert.throws(() => settleCoalitionFunding({ ...input, seats, coalitionState: undefined,
|
|
authorizations: [{ members: ['a', 'b'], epoch: 'ab1' }] }), /Reconcile and refund stale/);
|
|
assert.throws(() => settleCoalitionFunding({ ...input, coalitionState: null as unknown as CoalitionState }), /ledger cannot be null/);
|
|
});
|
|
|
|
void test('only the stated recipient can use a pledge and no split silently reallocates', () => {
|
|
const input = fixture();
|
|
assert.throws(() => quoteCoalitionFunding({ ...input, contributions: [{ donor: 'd', amount: 1 }] }), /current escrow/);
|
|
assert.throws(() => quoteCoalitionFunding({ ...input, payer: 'c', contributions: [{ donor: 'c', amount: 1 }] }), /current escrow/);
|
|
assert.throws(() => quoteCoalitionFunding({ ...input, cost: 2 }), /exceed the cost/);
|
|
assert.throws(() => quoteCoalitionFunding({ ...input, contributions: [{ donor: 'b', amount: 3 }, { donor: 'b', amount: 1 }] }), /each donating ally once/i);
|
|
assert.throws(() => quoteCoalitionFunding({ ...input, contributions: [{ donor: 'b', amount: 0 }] }), /positive safe/);
|
|
assert.throws(() => quoteCoalitionFunding({ ...input, pledges: [...input.pledges, input.pledges[0]] }), /only one pledge/);
|
|
assert.throws(() => quoteCoalitionFunding({ ...input, pledges: [{ donor: 'd', recipient: 'a', amount: 1, epoch: 'ad1' }] }), /Reconcile and refund stale/);
|
|
});
|
|
|
|
void test('safe integer boundaries avoid overflow and JSON round trips keep the split', () => {
|
|
const input = fixture(), max = Number.MAX_SAFE_INTEGER;
|
|
const large: CoalitionFundingInput = { ...input, cost: max, ownAvailable: 0,
|
|
pledges: [{ donor: 'b', recipient: 'a', amount: max, epoch: 'ab1' }, { donor: 'c', recipient: 'a', amount: max, epoch: 'ac1' }],
|
|
contributions: [{ donor: 'b', amount: max }] };
|
|
assert.equal(settleCoalitionFunding(large).quote.own, 0);
|
|
assert.deepEqual(settleCoalitionFunding(JSON.parse(JSON.stringify(input)) as CoalitionFundingInput), settleCoalitionFunding(input));
|
|
for (const bad of [-1, 0.5, NaN, Infinity, max + 1]) {
|
|
assert.throws(() => quoteCoalitionFunding({ ...input, cost: bad }), /safe spice/);
|
|
assert.throws(() => quoteCoalitionFunding({ ...input, contributions: [{ donor: 'b', amount: bad }] }), /positive safe/);
|
|
assert.throws(() => quoteCoalitionFunding({ ...input, pledges: [{ donor: 'b', recipient: 'a', amount: bad, epoch: 'ab1' }] }), /safe escrow/);
|
|
}
|
|
});
|
|
|
|
void test('a broken and re-formed alliance cannot spend an old pledge even in the same turn', () => {
|
|
const seats: CoalitionSeat[] = [{ id: 'a', ally: 'b' }, { id: 'b', ally: 'a' }, { id: 'c', ally: null }];
|
|
const original: CoalitionFundingInput = { seats, purpose: 'bid', payer: 'a', cost: 4, ownAvailable: 0,
|
|
authorizations: [{ members: ['a', 'b'], epoch: 'first-edge' }],
|
|
pledges: [{ donor: 'b', recipient: 'a', amount: 4, epoch: 'first-edge' }],
|
|
contributions: [{ donor: 'b', amount: 4 }] };
|
|
assert.equal(quoteCoalitionFunding(original).own, 0);
|
|
const broken = { ...original, seats: seats.map(seat => ({ ...seat, ally: null })), authorizations: [] };
|
|
assert.deepEqual(reconcileCoalitionPledges(broken), { pledges: [], refunds: original.pledges });
|
|
const reformed = { ...original, authorizations: [{ members: ['a', 'b'] as [string, string], epoch: 'new-edge' }] };
|
|
assert.throws(() => settleCoalitionFunding(reformed), /Reconcile and refund stale/);
|
|
assert.deepEqual(reconcileCoalitionPledges(reformed), { pledges: [], refunds: original.pledges });
|
|
const fresh = { ...reformed, pledges: [{ donor: 'b', recipient: 'a', amount: 4, epoch: 'new-edge' }] };
|
|
assert.equal(settleCoalitionFunding(fresh).pledges[0].amount, 0);
|
|
});
|
|
|
|
void test('unrelated stale pledges require explicit reconciliation without consuming valid escrow', () => {
|
|
const input = fixture();
|
|
const changed = { ...input, authorizations: [input.authorizations[0], input.authorizations[1],
|
|
{ members: ['b', 'c'] as [string, string], epoch: 'bc2' }] };
|
|
assert.throws(() => quoteCoalitionFunding(changed), /Reconcile and refund stale/);
|
|
const reconciled = reconcileCoalitionPledges(changed);
|
|
assert.deepEqual(reconciled.refunds, [input.pledges[2]]);
|
|
assert.deepEqual(reconciled.pledges, input.pledges.slice(0, 2));
|
|
assert.equal(settleCoalitionFunding({ ...changed, pledges: reconciled.pledges }).quote.own, 3);
|
|
assert.deepEqual(input.pledges, fixture().pledges);
|
|
});
|
|
|
|
void test('authorizations must cover exactly the current edges with valid ordered epochs', () => {
|
|
const input = fixture();
|
|
assert.throws(() => quoteCoalitionFunding({ ...input, authorizations: input.authorizations.slice(1) }), /Every current alliance edge/);
|
|
assert.throws(() => quoteCoalitionFunding({ ...input, authorizations: [...input.authorizations, input.authorizations[0]] }), /exactly/);
|
|
assert.throws(() => quoteCoalitionFunding({ ...input, authorizations: [input.authorizations[0],
|
|
{ members: ['a', 'c'], epoch: 'ab1' }, input.authorizations[2]] }), /uniquely/);
|
|
assert.throws(() => quoteCoalitionFunding({ ...input, authorizations: [{ members: ['b', 'a'], epoch: 'bad' }] }), /ordered pair/);
|
|
assert.throws(() => quoteCoalitionFunding({ ...input, authorizations: [{ members: ['a', 'b'], epoch: ' ' }] }), /ordered pair/);
|
|
assert.throws(() => quoteCoalitionFunding({ ...input, pledges: [{ donor: 'b', recipient: 'a', amount: 1, epoch: '' }] }), /epoch/);
|
|
});
|