Support current-turn payments in public deals

This commit is contained in:
Quintine committed 2026-10-28 19:13:17 +11:00
1 parent 974b7fd99f
commit bb193d8f8c
19 files changed
+1203 -44

No files matched your search

+98
View File
@@ -0,0 +1,98 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
createMultipartyPublicDeal, createPublicDeal, markPublicDealPaid, parsePublicDealPayment,
PublicDealError, releasePublicDeal, respondPublicDeal, validatePublicDeals,
voidPublicDealPayment, type PublicDeal, type PublicDealSeat,
} from '../game/public-deals';
import {
pendingPublicDealPayments, publicDealPaymentTotal, PublicDealObligationError,
reconcilePublicDealPayments,
} from '../game/public-deal-obligations';
const seats: PublicDealSeat[] = [{ id: 'a' }, { id: 'b' }, { id: 'c' }];
const deferred = (amount: number) => ({
kind: 'deferredBribe' as const, payer: 'a', recipient: 'b', amount, dueTurn: 3,
});
const accept = (id: string, amount: number) =>
respondPublicDeal(createPublicDeal(seats, 'a', 'b', 'Pay during this turn', 3, id, deferred(amount)), seats, 'b', true);
void test('deferred deal terms bind an exact positive payment to this turn without changing immediate terms', () => {
const immediate = { kind: 'immediateBribe', payer: 'a', recipient: 'b', amount: 2 } as const;
assert.deepEqual(parsePublicDealPayment(immediate, ['a', 'b']), immediate);
assert.deepEqual(parsePublicDealPayment(deferred(4), ['a', 'b'], 3), deferred(4));
for (const input of [
{ ...deferred(4), dueTurn: 4 }, { ...deferred(0) },
{ ...deferred(4), recipient: 'c' }, { ...deferred(4), extra: true },
]) assert.throws(() => parsePublicDealPayment(input, ['a', 'b'], 3), PublicDealError);
assert.throws(() => parsePublicDealPayment(deferred(4), ['a', 'b']), PublicDealError);
assert.throws(() => createPublicDeal(seats, 'a', 'b', 'Pay later', 4, 'wrong-turn', deferred(4)), PublicDealError);
});
void test('all signatories accept before a future payment becomes pending; only its payer can settle it once', () => {
const offered = createMultipartyPublicDeal(seats, 'a', ['b', 'c'], 'Pay later', 3, 'group', deferred(4));
const partially = respondPublicDeal(offered, seats, 'b', true);
assert.equal(partially.status, 'offered');
assert.equal(partially.paymentStatus, undefined);
const active = respondPublicDeal(partially, seats, 'c', true);
assert.equal(active.paymentStatus, 'pending');
assert.throws(() => markPublicDealPaid(active, seats, 'b'), PublicDealError);
const paid = markPublicDealPaid(active, seats, 'a');
assert.equal(paid.paymentStatus, 'paid');
assert.throws(() => markPublicDealPaid(paid, seats, 'a'), PublicDealError);
let released = releasePublicDeal(paid, seats, 'a', true);
released = releasePublicDeal(released, seats, 'b', true);
released = releasePublicDeal(released, seats, 'c', true);
assert.equal(released.status, 'released');
assert.equal(released.paymentStatus, 'paid');
validatePublicDeals([{ ...offered, id: 'offered' }, active, { ...released, id: 'released' }], seats);
});
void test('mutual release and proven impossibility end only an unpaid accepted payment', () => {
const first = accept('first', 4);
const vote = releasePublicDeal(first, seats, 'a', true);
assert.equal(vote.paymentStatus, 'pending');
const released = releasePublicDeal(vote, seats, 'b', true);
assert.equal(released.paymentStatus, 'released');
const impossible = voidPublicDealPayment(first, seats);
assert.equal(impossible.paymentStatus, 'void');
assert.throws(() => markPublicDealPaid(impossible, seats, 'a'), PublicDealError);
assert.throws(() => voidPublicDealPayment(released, seats), PublicDealError);
const bad = [
{ ...first, paymentStatus: undefined }, { ...first, paymentStatus: 'released' },
{ ...first, status: 'offered', paymentStatus: 'pending' },
{ ...first, payment: { ...deferred(4), dueTurn: 4 } },
{ ...first, payment: { kind: 'immediateBribe', payer: 'a', recipient: 'b', amount: 4 }, paymentStatus: 'paid' },
];
for (const deal of bad) assert.throws(() => validatePublicDeals([deal], seats), PublicDealError);
});
void test('distinct pending agreements add, voluntary spending is guarded, and uncertain external shortage does not erase them', () => {
const deals: PublicDeal[] = [accept('first', 4), accept('second', 5)];
const original = structuredClone(deals);
assert.deepEqual(pendingPublicDealPayments(deals, 'a', 3).map(row => [row.deal, row.amount]), [['first', 4], ['second', 5]]);
assert.equal(publicDealPaymentTotal(deals, 'a', 3), 9);
assert.equal(publicDealPaymentTotal(deals, 'b', 3), 0);
assert.equal(publicDealPaymentTotal(deals, 'a', 4), 0);
assert.throws(() => reconcilePublicDealPayments(deals, seats, 'a', 3,
{ available: 8, change: 'voluntary' }), PublicDealObligationError);
assert.deepEqual(reconcilePublicDealPayments(deals, seats, 'a', 3,
{ available: 8, change: 'external' }), deals, 'Current shortage does not prove impossibility.');
assert.deepEqual(reconcilePublicDealPayments(deals, seats, 'a', 3,
{ available: null, change: 'external' }), deals);
const reconciled = reconcilePublicDealPayments(deals, seats, 'a', 3,
{ available: null, change: 'external', impossibleDealIds: ['second'] });
assert.equal(reconciled[0].paymentStatus, 'pending');
assert.equal(reconciled[1].paymentStatus, 'void');
assert.equal(publicDealPaymentTotal(reconciled, 'a', 3), 4);
assert.throws(() => reconcilePublicDealPayments(deals, seats, 'a', 3,
{ available: 9, change: 'voluntary', impossibleDealIds: ['second'] }), PublicDealObligationError);
assert.throws(() => reconcilePublicDealPayments(deals, seats, 'a', 3,
{ available: 9, change: 'external', impossibleDealIds: ['other'] }), PublicDealObligationError);
assert.deepEqual(deals, original, 'Rejected quotes and pure transitions preserve the caller’s state.');
});
void test('individually valid payments cannot overflow their combined obligation', () => {
const deals = [accept('large', Number.MAX_SAFE_INTEGER), accept('one', 1)];
assert.throws(() => publicDealPaymentTotal(deals, 'a', 3), PublicDealObligationError);
});