Support future-turn public deal payments

This commit is contained in:
Quintine committed 2026-10-28 19:21:32 +11:00
1 parent bb193d8f8c
commit 7d71647a90
16 files changed
+641 -35

No files matched your search

+2 -2
View File
@@ -22,7 +22,7 @@ void test('deferred deal terms bind an exact positive payment to this turn witho
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), dueTurn: 2 }, { ...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);
@@ -61,7 +61,7 @@ void test('mutual release and proven impossibility end only an unpaid accepted p
const bad = [
{ ...first, paymentStatus: undefined }, { ...first, paymentStatus: 'released' },
{ ...first, status: 'offered', paymentStatus: 'pending' },
{ ...first, payment: { ...deferred(4), dueTurn: 4 } },
{ ...first, payment: { ...deferred(4), dueTurn: 2 } },
{ ...first, payment: { kind: 'immediateBribe', payer: 'a', recipient: 'b', amount: 4 }, paymentStatus: 'paid' },
];
for (const deal of bad) assert.throws(() => validatePublicDeals([deal], seats), PublicDealError);
+1 -1
View File
@@ -46,7 +46,7 @@ void test('deal form exposes human non-allied recipients and clearly describes l
assert.doesNotMatch(html, /Duncan · Spacing Guild/);
assert.match(html, /cannot automatically enforce free-text promises/);
assert.match(html, /free-text terms do not automatically transfer spice/);
assert.match(html, /Include an immediate spice payment/);
assert.match(html, /Include a spice payment/);
assert.match(html, /Do not trade cards, forces, leaders or faction powers/);
assert.match(html, /maxlength="500"/i);
assert.match(html, /Alice \(Atreides\) → Basil \(Harkonnen\)/);
+92
View File
@@ -0,0 +1,92 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { botActions } from '../game/bots';
import { viewGame, type Game } from '../game/engine';
import {
actMentatBribe as act, createMentatBribeFixture as fixture,
finishMentatBribe as finish, mentatBribeInventory as inventory, mentatBribePlayer as player,
nextMentatBribeOpening as nextMentat,
} from './fixture-mentat-bribes';
function offer(f: ReturnType<typeof fixture>, game: Game, amount: number, dueTurn: number) {
game = act(game, f.asker, { type: 'dealOffer', target: f.target, terms: 'Pay the agreed spice.',
payment: { kind: 'deferredBribe', payer: f.target, recipient: f.asker, amount, dueTurn } });
const deal = game.deals!.at(-1)!.id;
game = act(game, f.target, { type: 'dealRespond', deal, accept: true });
return { game, deal };
}
void test('human autopilot waits for the actual due turn before paying a future deal', () => {
const f = fixture();
const accepted = offer(f, f.game, 3, f.game.turn + 1);
let game = act(accepted.game, f.target, { type: 'setAutopilot', difficulty: 'Easy' });
assert.equal(player(game, f.target).autopilot, 'Easy');
assert.equal(viewGame(game, f.target).deferredDealPaymentOptions[accepted.deal]?.blocked, null);
assert.ok(!botActions(viewGame(game, f.target)).some(action => action.type === 'dealPay'));
assert.equal(player(game, f.target).spice, 10);
assert.equal(game.deals!.find(row => row.id === accepted.deal)!.paymentStatus, 'pending');
assert.deepEqual(inventory(game), f.originalCards);
game = finish(game);
assert.equal(game.turn, f.game.turn + 1);
assert.equal(game.phase, 0);
assert.equal(player(game, f.target).spice, 10);
const action = botActions(viewGame(game, f.target))[0];
assert.deepEqual(action, { type: 'dealPay', deal: accepted.deal });
game = act(game, f.target, action);
assert.equal(game.deals!.find(row => row.id === accepted.deal)!.paymentStatus, 'paid');
assert.equal(player(game, f.target).spice, 7);
assert.equal(player(game, f.asker).bribes, 3);
assert.deepEqual(inventory(game), f.originalCards);
});
void test('an insolvent Mentat payer cannot sacrifice an individually payable current deal for a future one', () => {
const f = fixture();
const first = offer(f, f.game, 6, f.game.turn + 1);
const second = offer(f, first.game, 6, f.game.turn + 1);
const future = offer(f, second.game, 2, f.game.turn + 2);
const smallerFuture = offer(f, future.game, 1, f.game.turn + 2);
let game = act(smallerFuture.game, f.target, { type: 'bribe', target: f.asker, amount: 3 });
game = nextMentat(finish(game));
assert.equal(game.turn, f.game.turn + 1);
assert.equal(game.phase, 8);
assert.equal(player(game, f.target).spice, 7);
for (const deal of [first.deal, second.deal])
assert.equal(game.deals!.find(row => row.id === deal)!.paymentStatus, 'pending');
const before = structuredClone(game);
assert.ok(viewGame(game, f.target).deferredDealPaymentOptions[future.deal]?.blocked);
assert.throws(() => act(game, f.target, { type: 'dealPay', deal: future.deal }));
assert.deepEqual(game, before);
assert.equal(viewGame(game, f.target).deferredDealPaymentOptions[smallerFuture.deal]?.blocked, null);
const sparePaid = act(game, f.target, { type: 'dealPay', deal: smallerFuture.deal });
assert.equal(player(sparePaid, f.target).spice, 6);
assert.equal(sparePaid.deals!.find(row => row.id === smallerFuture.deal)!.paymentStatus, 'paid');
for (const deal of [first.deal, second.deal])
assert.equal(sparePaid.deals!.find(row => row.id === deal)!.paymentStatus, 'pending');
const paid = act(game, f.target, { type: 'dealPay', deal: first.deal });
assert.equal(player(paid, f.target).spice, 1);
assert.equal(paid.deals!.find(row => row.id === first.deal)!.paymentStatus, 'paid');
assert.equal(paid.deals!.find(row => row.id === second.deal)!.paymentStatus, 'void');
assert.equal(paid.deals!.find(row => row.id === future.deal)!.paymentStatus, 'pending');
assert.deepEqual(inventory(paid), f.originalCards);
});
void test('early future payment cannot spend the funds needed for a currently due deal', () => {
const f = fixture();
const due = offer(f, f.game, 8, f.game.turn);
const future = offer(f, due.game, 3, f.game.turn + 1);
const before = structuredClone(future.game);
assert.ok(viewGame(before, f.target).deferredDealPaymentOptions[future.deal]?.blocked);
assert.throws(() => act(future.game, f.target, { type: 'dealPay', deal: future.deal }));
assert.deepEqual(future.game, before);
assert.equal(player(future.game, f.target).spice, 10);
assert.equal(future.game.deals!.find(row => row.id === due.deal)!.paymentStatus, 'pending');
assert.equal(future.game.deals!.find(row => row.id === future.deal)!.paymentStatus, 'pending');
const paid = act(future.game, f.target, { type: 'dealPay', deal: due.deal });
assert.equal(player(paid, f.target).spice, 2);
assert.equal(player(paid, f.asker).bribes, 8);
assert.equal(paid.deals!.find(row => row.id === due.deal)!.paymentStatus, 'paid');
assert.equal(paid.deals!.find(row => row.id === future.deal)!.paymentStatus, 'pending');
assert.deepEqual(inventory(paid), f.originalCards);
});
@@ -0,0 +1,140 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { readFileSync } from 'node:fs';
import { runInNewContext } from 'node:vm';
import { createElement, isValidElement, type ReactElement, type ReactNode } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import * as jsxRuntime from 'react/jsx-runtime';
import ts from 'typescript';
import * as catalog from '../game/catalog';
import * as publicDeals from '../game/public-deals';
import { PublicDealList, PublicDeals, publicDealOfferAction, publicDealRequests } from '../components/public-deals';
import type { Action, GameView } from '../game/engine';
type Element = ReactElement<{ children?: ReactNode; [key: string]: unknown }>;
function elements(node: ReactNode): Element[] {
if (Array.isArray(node)) return node.flatMap(elements);
if (!isValidElement<{ children?: ReactNode; [key: string]: unknown }>(node)) return [];
return [node, ...elements(node.props.children)];
}
const payment = { kind: 'deferredBribe', payer: 'a', recipient: 'b', amount: 3, dueTurn: 5 } as const;
const deal = { id: 'deal', from: 'a', to: 'b', terms: 'A promise.', turn: 2,
status: 'active', releaseVotes: [], payment, paymentStatus: 'pending' };
const base = { status: 'playing', turn: 2, me: 'a', roomControl: null,
players: [{ id: 'a', name: 'Alice', faction: 'atreides' }, { id: 'b', name: 'Basil', faction: 'harkonnen' }],
deals: [deal], deferredDealPaymentOptions: { deal: { blocked: null } },
} as unknown as GameView;
const buttons = (game: GameView, calls: Action[]) => elements(PublicDealList({ game, act: action => calls.push(action), disabled: false }))
.filter(element => typeof element.props.onClick === 'function');
const find = (nodes: Element[], label: string) => nodes.find(node => node.props.children === label);
const html = (game: GameView) => renderToStaticMarkup(createElement(PublicDealList, { game, act() {}, disabled: false }));
void test('offers accept a whole current or future deadline without an invented maximum', () => {
for (const dueTurn of [2, 5, 1_000_000]) {
assert.deepEqual(publicDealOfferAction(['b'], ' Terms ', { ...payment, dueTurn }, 'a', 2),
{ type: 'dealOffer', target: 'b', terms: 'Terms', payment: { ...payment, dueTurn } });
}
for (const dueTurn of [0, 1, 1.5, NaN, Number.MAX_SAFE_INTEGER + 1]) {
assert.equal(publicDealOfferAction(['b'], 'Terms', { ...payment, dueTurn }, 'a', 2), null);
}
});
void test('future offers remain acceptable through the deadline, then can only be declined', () => {
const offered = { ...deal, status: 'offered', paymentStatus: undefined };
const game = { ...base, me: 'b', bribeOptions: { blocked: 'Finish the current decision.' }, deals: [offered] } as unknown as GameView;
const calls: Action[] = [];
for (const turn of [2, 5]) {
assert.equal(find(buttons({ ...game, turn }, calls), 'Accept deal')?.props.disabled, false);
assert.doesNotMatch(html({ ...game, turn }), /due turn has passed/);
}
const expired = { ...game, turn: 6 };
assert.match(html(expired), /due turn has passed/);
assert.equal(find(buttons(expired, calls), 'Accept deal')?.props.disabled, true);
assert.equal(find(buttons(expired, calls), 'Decline deal')?.props.disabled, false);
});
void test('a future pending payment stays visible and can be paid early when projected legal', () => {
const calls: Action[] = [];
assert.match(html(base), /on or before the end of turn 5/);
assert.match(html(base), /Payment status: pending/);
const pay = find(buttons(base, calls), 'Pay agreed amount');
assert.ok(pay); assert.equal(pay.props.disabled, false);
(pay.props.onClick as () => void)();
assert.deepEqual(calls, [{ type: 'dealPay', deal: 'deal' }]);
const blocked = { ...base, deferredDealPaymentOptions: { deal: { blocked: 'Finish the current decision.' } } } as unknown as GameView;
assert.match(html(blocked), /Finish the current decision/);
assert.equal(find(buttons(blocked, calls), 'Pay agreed amount')?.props.disabled, true);
});
void test('payment attention starts on the due turn while future payment remains in history', () => {
assert.deepEqual(publicDealRequests(base), []);
const future = renderToStaticMarkup(createElement(PublicDeals, { game: base, act() {}, busy: false }));
assert.doesNotMatch(future, /Public deals · payment due/);
assert.match(future, /Payment status: pending/);
for (const turn of [5, 6]) assert.deepEqual(publicDealRequests({ ...base, turn }), [['payment', 'deal']]);
const released = { ...base, deals: [{ ...deal, status: 'released', paymentStatus: 'released' }] } as unknown as GameView;
assert.deepEqual(publicDealRequests({ ...released, turn: 5 }), []);
const withRelease = { ...base, deals: [{ ...deal, releaseVotes: ['b'] }] } as unknown as GameView;
assert.deepEqual(publicDealRequests(withRelease), [['release', 'deal', 'b']]);
});
/** Exercise the real controlled form callbacks without claiming browser hydration. */
function controller(game: GameView) {
const states: unknown[] = [], sent: Action[] = [];
let cursor = 0;
const exports: { PublicDeals?: typeof PublicDeals } = {};
runInNewContext(ts.transpileModule(readFileSync(new URL('../components/public-deals.tsx', import.meta.url), 'utf8'), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX },
}).outputText, {
exports,
require(name: string) {
if (name === 'react/jsx-runtime') return jsxRuntime;
if (name === 'react') return {
useState(initial: unknown) {
const index = cursor++;
if (!(index in states)) states[index] = typeof initial === 'function' ? initial() : initial;
return [states[index], (next: unknown) => { states[index] = typeof next === 'function' ? next(states[index]) : next; }];
},
useRef: () => ({ current: null }), useEffect: () => {},
};
if (name === '@/components/ui/button') return { Button: 'button' };
if (name === '@/game/catalog') return catalog;
if (name === '@/game/public-deals') return publicDeals;
throw new Error(`Unexpected import ${name}`);
},
});
assert.ok(exports.PublicDeals);
const render = () => { cursor = 0; return elements(exports.PublicDeals!({ game, busy: false, act: action => sent.push(action) })); };
return { render, sent };
}
void test('the authoring form selects and validates a cross-turn deadline', () => {
const ui = controller(base);
const check = (index: number) => {
const input = ui.render().filter(element => element.type === 'input' && element.props.type === 'checkbox')[index];
assert.ok(input);
(input.props.onChange as (event: { target: { checked: boolean } }) => void)({ target: { checked: true } });
};
const choose = (index: number, value: string) => {
const select = ui.render().filter(element => element.type === 'select')[index]; assert.ok(select);
(select.props.onChange as (event: { target: { value: string } }) => void)({ target: { value } });
};
check(0); check(1); choose(0, 'deferredBribe'); choose(1, 'a'); choose(2, 'b');
const textarea = ui.render().find(element => element.type === 'textarea'); assert.ok(textarea);
(textarea.props.onChange as (event: { target: { value: string } }) => void)({ target: { value: 'Terms' } });
const editDue = (value: string) => {
const input = ui.render().find(element => element.type === 'input' && element.props.type === 'number' && element.props.min === 2);
assert.ok(input); assert.equal(input.props.max, undefined);
(input.props.onChange as (event: { target: { value: string } }) => void)({ target: { value } });
};
editDue('5');
const form = ui.render().find(element => element.type === 'form'); assert.ok(form);
(form.props.onSubmit as (event: { preventDefault(): void }) => void)({ preventDefault() {} });
assert.deepEqual(JSON.parse(JSON.stringify(ui.sent[0])), { type: 'dealOffer', target: 'b', terms: 'Terms', payment: { ...payment, amount: 1 } });
editDue('1');
const submit = ui.render().find(element => element.type === 'button' && element.props.type === 'submit');
assert.ok(submit); assert.equal(submit.props.disabled, true);
const invalidForm = ui.render().find(element => element.type === 'form'); assert.ok(invalidForm);
(invalidForm.props.onSubmit as (event: { preventDefault(): void }) => void)({ preventDefault() {} });
assert.equal(ui.sent.length, 1);
});
+104
View File
@@ -0,0 +1,104 @@
// @dune-suite integration
import assert from 'node:assert/strict';
import test from 'node:test';
import type { GameView } from '../game/engine';
const base = process.env.DUNE_TEST_URL ?? 'http://localhost:3000';
async function request(path: string, cookie?: string, body?: unknown, selected?: string) {
const response = await fetch(base + path, {
method: body === undefined ? 'GET' : 'POST', signal: AbortSignal.timeout(15_000),
headers: {
...(body === undefined ? {} : { 'Content-Type': 'application/json' }),
...(cookie ? { cookie } : {}),
...(selected ? { 'X-Dune-Seat': selected } : {}),
},
...(body === undefined ? {} : { body: JSON.stringify(body) }),
});
return { status: response.status, cookie: response.headers.get('set-cookie')?.split(';')[0],
data: await response.json() as GameView & { error?: string } };
}
/** This test creates only its own room on the isolated integration server. */
void test('authenticated participants consent to a future-due deal and its payer settles early once', async () => {
const host = await request('/api/rooms', undefined, {
name: 'Future deal recipient', faction: 'atreides', advanced: false, expansions: [],
});
assert.equal(host.status, 201, JSON.stringify(host.data)); assert.ok(host.cookie);
const path = `/api/rooms/${host.data.code}`;
const payer = await request(path, undefined, { type: 'join', name: 'Future deal payer', faction: 'harkonnen' });
assert.equal(payer.status, 200, JSON.stringify(payer.data)); assert.ok(payer.cookie);
const third = await request(path, undefined, { type: 'join', name: 'Future deal witness', faction: 'emperor' });
assert.equal(third.status, 200, JSON.stringify(third.data)); assert.ok(third.cookie);
const seats = [
{ id: host.data.me, cookie: host.cookie },
{ id: payer.data.me, cookie: payer.cookie },
{ id: third.data.me, cookie: third.cookie },
];
let view = third.data;
const act = async (cookie: string, action: object) => {
const next = await request(path, cookie, { version: view.version, action });
assert.equal(next.status, 200, JSON.stringify(next.data)); view = next.data; return next.data;
};
for (const seat of seats) await act(seat.cookie, { type: 'ready' });
await act(host.cookie, { type: 'start' });
for (const seat of seats) {
const own = await request(path, seat.cookie);
assert.equal(own.status, 200, JSON.stringify(own.data)); view = own.data;
const choices = own.data.players.find(player => player.id === seat.id)?.traitorChoices;
if (choices?.length) await act(seat.cookie, { type: 'traitor', leader: choices[0] });
}
assert.equal(view.status, 'playing'); assert.equal(view.phase, 0);
const offerTurn = view.turn;
const payerBefore = await request(path, payer.cookie);
const recipientBefore = await request(path, host.cookie);
assert.equal(payerBefore.status, 200); assert.equal(recipientBefore.status, 200);
const payerSpice = payerBefore.data.players.find(player => player.id === payer.data.me)!.spice;
const recipientSpice = recipientBefore.data.players.find(player => player.id === host.data.me)!.spice;
const recipientIncoming = recipientBefore.data.bribeOptions.incoming;
assert.ok(typeof payerSpice === 'number' && payerSpice >= 3);
const payment = { kind: 'deferredBribe', payer: payer.data.me, recipient: host.data.me,
amount: 3, dueTurn: offerTurn + 1 };
await act(host.cookie, { type: 'dealOffer', targets: [payer.data.me, third.data.me],
terms: 'Pay the agreed spice by next turn.', payment });
const deal = view.deals.at(-1); assert.ok(deal); assert.equal(deal.status, 'offered');
assert.deepEqual(deal.payment, payment);
const pay = { type: 'dealPay', deal: deal.id };
const premature = await request(path, payer.cookie, { version: view.version, action: pay });
assert.equal(premature.status, 409, JSON.stringify(premature.data));
await act(payer.cookie, { type: 'dealRespond', deal: deal.id, accept: true });
assert.equal(view.deals.find(row => row.id === deal.id)?.status, 'offered');
await act(third.cookie, { type: 'dealRespond', deal: deal.id, accept: true });
assert.equal(view.deals.find(row => row.id === deal.id)?.status, 'active');
assert.equal(view.deals.find(row => row.id === deal.id)?.paymentStatus, 'pending');
for (const seat of seats) {
const read = await request(path, seat.cookie); assert.equal(read.status, 200, JSON.stringify(read.data));
const visible: GameView['deals'][number] | undefined = read.data.deals.find(row => row.id === deal.id);
assert.deepEqual(visible?.payment, payment); assert.equal(visible?.paymentStatus, 'pending');
}
const pendingPayer = await request(path, payer.cookie);
const pendingRecipient = await request(path, host.cookie);
assert.equal(pendingPayer.data.players.find(player => player.id === payer.data.me)!.spice, payerSpice);
assert.equal(pendingRecipient.data.bribeOptions.incoming, recipientIncoming);
assert.equal(pendingPayer.data.deferredDealPaymentOptions[deal.id]?.blocked, null);
assert.equal(pendingRecipient.data.deferredDealPaymentOptions[deal.id], undefined);
const otherSeat = await request(path, host.cookie, { version: view.version, action: pay });
assert.equal(otherSeat.status, 409, JSON.stringify(otherSeat.data));
const forged = await request(path, host.cookie, { version: view.version, action: pay }, payer.data.me);
assert.equal(forged.status, 409, JSON.stringify(forged.data));
const previousVersion = view.version;
await act(payer.cookie, pay);
assert.equal(view.turn, offerTurn); assert.ok(view.turn < payment.dueTurn);
assert.equal(view.deals.find(row => row.id === deal.id)?.paymentStatus, 'paid');
const paidPayer = await request(path, payer.cookie);
const paidRecipient = await request(path, host.cookie);
assert.equal(paidPayer.data.players.find(player => player.id === payer.data.me)!.spice, payerSpice - 3);
assert.equal(paidRecipient.data.players.find(player => player.id === host.data.me)!.spice, recipientSpice);
assert.equal(paidRecipient.data.bribeOptions.incoming, recipientIncoming + 3);
const stale = await request(path, payer.cookie, { version: previousVersion, action: pay });
assert.equal(stale.status, 409, JSON.stringify(stale.data));
const replay = await request(path, payer.cookie, { version: view.version, action: pay });
assert.equal(replay.status, 409, JSON.stringify(replay.data));
assert.equal((await request(path, host.cookie)).data.bribeOptions.incoming, recipientIncoming + 3);
});
@@ -0,0 +1,68 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
createPublicDeal, markPublicDealPaid, parsePublicDealPayment, PublicDealError,
releasePublicDeal, respondPublicDeal, validatePublicDeals,
type PublicDeal, type PublicDealSeat,
} from '../game/public-deals';
import {
outstandingPublicDealPayments, pendingPublicDealPayments, publicDealPaymentTotal,
} from '../game/public-deal-obligations';
const seats: PublicDealSeat[] = [{ id: 'a' }, { id: 'b' }, { id: 'c' }];
const payment = (dueTurn: number, amount = 3) => ({
kind: 'deferredBribe' as const, payer: 'a', recipient: 'b', amount, dueTurn,
});
const offer = (id: string, dueTurn: number, turn = 3) =>
createPublicDeal(seats, 'a', 'b', 'Pay by the agreed turn.', turn, id, payment(dueTurn));
const accept = (id: string, dueTurn: number, turn = 3) =>
respondPublicDeal(offer(id, dueTurn, turn), seats, 'b', true);
void test('a deferred term retains an exact safe deadline at or after its offer turn', () => {
const immediate = { kind: 'immediateBribe', payer: 'a', recipient: 'b', amount: 2 } as const;
assert.deepEqual(parsePublicDealPayment(immediate, ['a', 'b']), immediate);
assert.deepEqual(parsePublicDealPayment(payment(3), ['a', 'b'], 3), payment(3));
assert.deepEqual(parsePublicDealPayment(payment(7), ['a', 'b'], 3), payment(7));
assert.deepEqual(parsePublicDealPayment(payment(100), ['a', 'b'], 3), payment(100),
'A later agreed game extension may make a distant deadline reachable.');
for (const invalid of [payment(2), payment(0), payment(3.5), payment(Infinity),
payment(Number.MAX_SAFE_INTEGER + 1), { ...payment(4), extra: true }])
assert.throws(() => parsePublicDealPayment(invalid, ['a', 'b'], 3), PublicDealError);
assert.throws(() => parsePublicDealPayment(payment(4), ['a', 'b']), PublicDealError);
assert.throws(() => parsePublicDealPayment(payment(4), ['a', 'b'], 0), PublicDealError);
assert.throws(() => parsePublicDealPayment({ ...payment(4), recipient: 'c' }, ['a', 'b'], 3), PublicDealError);
});
void test('separate pending and outstanding views preserve current-turn semantics', () => {
const current = accept('current', 3);
const later = accept('later', 5);
const farther = accept('farther', 9);
const paid = markPublicDealPaid(accept('paid', 8), seats, 'a');
const declined = respondPublicDeal(offer('declined', 6), seats, 'b', false);
const offered = offer('offered', 7);
const deals: PublicDeal[] = [current, later, farther, paid, declined, offered];
assert.deepEqual(pendingPublicDealPayments(deals, 'a', 3).map(row => row.deal), ['current']);
assert.equal(publicDealPaymentTotal(deals, 'a', 3), 3);
assert.deepEqual(outstandingPublicDealPayments(deals, 'a', 3).map(row => row.deal), ['current', 'later', 'farther']);
assert.deepEqual(outstandingPublicDealPayments(deals, 'a', 4).map(row => row.deal), ['later', 'farther']);
assert.deepEqual(pendingPublicDealPayments(deals, 'a', 5).map(row => row.deal), ['later']);
assert.equal(publicDealPaymentTotal(deals, 'a', 5), 3);
assert.deepEqual(outstandingPublicDealPayments(deals, 'a', 10), []);
assert.deepEqual(outstandingPublicDealPayments(deals, 'c', 3), []);
assert.equal(deals[0].paymentStatus, 'pending', 'Reading after a deadline never mutates a receipt.');
});
void test('early settlement and mutual release preserve exact future receipt history', () => {
const pending = accept('future', 6);
const copied = JSON.parse(JSON.stringify(pending)) as PublicDeal;
validatePublicDeals([copied], seats);
const paid = markPublicDealPaid(copied, seats, 'a');
assert.equal(paid.paymentStatus, 'paid');
const stillPaid = releasePublicDeal(releasePublicDeal(paid, seats, 'a', true), seats, 'b', true);
assert.equal(stillPaid.status, 'released');
assert.equal(stillPaid.paymentStatus, 'paid');
const canceled = releasePublicDeal(releasePublicDeal(pending, seats, 'a', true), seats, 'b', true);
assert.equal(canceled.paymentStatus, 'released');
assert.deepEqual(outstandingPublicDealPayments([stillPaid, canceled], 'a', 3), []);
assert.equal(pending.paymentStatus, 'pending', 'Pure transitions preserve the original record.');
});
@@ -0,0 +1,127 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { viewGame, type Action, type Game } from '../game/engine';
import {
actMentatBribe as act, advanceMentatBribe as advance,
createMentatBribeFixture as fixture, finishMentatBribe as finish,
mentatBribeClean as clean, mentatBribeInventory as inventory,
mentatBribePlayer as player,
} from './fixture-mentat-bribes';
function createDeal(game: Game, proposer: string, payer: string, recipient: string,
amount: number, dueTurn: number) {
const offered = act(game, proposer, { type: 'dealOffer', target: payer,
terms: 'Pay the agreed spice by the stated turn.',
payment: { kind: 'deferredBribe', payer, recipient, amount, dueTurn } });
const deal = offered.deals?.at(-1); assert.ok(deal);
const accepted = act(offered, payer, { type: 'dealRespond', deal: deal.id, accept: true });
assert.equal(accepted.deals?.find(row => row.id === deal.id)?.paymentStatus, 'pending');
return { game: accepted, id: deal.id };
}
function status(game: Game, id: string) {
const deal = game.deals?.find(row => row.id === id); assert.ok(deal);
return deal.paymentStatus;
}
function reject(game: Game, actor: string, action: Action) {
const before = structuredClone(game);
assert.throws(() => act(game, actor, action));
assert.deepEqual(game, before, 'A rejected payment leaves the original game intact.');
}
/** Each route begins at the original H/A/E Basic turn-one Mentat, earned by
* native setup, bidding and collection. The shared fixture selects only
* unopened physical card/Spice positions; no hand or wallet is installed. */
void test('an accepted next-turn deal survives the real turn boundary and may be paid at its due turn', () => {
const f = fixture(), payer = f.target, recipient = f.asker;
const initialSpice = player(f.game, payer).spice;
const { game: accepted, id } = createDeal(f.game, recipient, payer, recipient, 3, f.game.turn + 1);
assert.equal(player(accepted,payer).spice,initialSpice);
const next = finish(accepted);
assert.equal(next.turn,f.game.turn + 1); assert.equal(status(next,id),'pending');
assert.equal(player(next,payer).spice,initialSpice);
assert.equal(viewGame(next,payer).deferredDealPaymentOptions[id]?.blocked,null);
const paid = act(next,payer,{type:'dealPay',deal:id});
assert.equal(status(paid,id),'paid');
assert.equal(player(paid,payer).spice,initialSpice - 3);
assert.equal(player(paid,recipient).bribes,3);
reject(paid,payer,{type:'dealPay',deal:id});
const beforeOpening = advance(paid, game => game.turn === next.turn && game.phase === 7 && clean(game));
const recipientBefore = player(beforeOpening,recipient).spice;
const opening = advance(beforeOpening, game => game.turn === next.turn && game.phase === 8 && clean(game));
assert.equal(player(opening,recipient).spice,recipientBefore + 3);
assert.equal(player(opening,recipient).bribes,0);
assert.equal(status(opening,id),'paid');
assert.deepEqual(inventory(opening),f.originalCards);
});
void test('a future-due deal can be paid early, and its actual late Mentat receipt waits until the next opening', () => {
const f = fixture(), payer = f.target, recipient = f.asker;
const { game: accepted, id } = createDeal(f.game,recipient,payer,recipient,3,f.game.turn + 1);
const recipientSpice = player(accepted,recipient).spice;
const paid = act(accepted,payer,{type:'dealPay',deal:id});
assert.equal(status(paid,id),'paid');
assert.equal(player(paid,recipient).bribes,3);
assert.equal(player(paid,recipient).spice,recipientSpice);
const beforeOpening = advance(paid, game => game.turn === f.game.turn + 1 && game.phase === 7 && clean(game));
const priorSpice = player(beforeOpening,recipient).spice;
const opening = advance(beforeOpening, game => game.turn === f.game.turn + 1 && game.phase === 8 && clean(game));
assert.equal(player(opening,recipient).spice,priorSpice + 3);
assert.equal(player(opening,recipient).bribes,0);
assert.equal(status(opening,id),'paid');
assert.deepEqual(inventory(opening),f.originalCards);
});
void test('before the due turn an accepted debt neither reserves current spice nor blocks ending the turn', () => {
const f = fixture(), payer = f.target, recipient = f.asker;
const { game: accepted, id } = createDeal(f.game,recipient,payer,recipient,6,f.game.turn + 1);
const spent = act(accepted,payer,{type:'bribe',target:f.opponent,amount:8});
assert.equal(player(spent,payer).spice,2);
assert.equal(status(spent,id),'pending');
const next = finish(spent);
assert.equal(next.turn,f.game.turn + 1);
assert.equal(status(next,id),'pending', 'Future funding is unknown, not automatically impossible.');
assert.deepEqual(inventory(next),f.originalCards);
});
void test('a payable debt blocks its payer from ending the actual due-turn Mentat', () => {
const f = fixture(), payer = f.target, recipient = f.asker;
const { game: accepted, id } = createDeal(f.game,recipient,payer,recipient,3,f.game.turn + 1);
const due = advance(accepted, game => game.turn === f.game.turn + 1 && game.phase === 8 && clean(game));
assert.equal(status(due,id),'pending');
reject(due,payer,{type:'ready'});
const paid = act(due,payer,{type:'dealPay',deal:id});
const ready = act(paid,payer,{type:'ready'});
const next = finish(ready);
assert.equal(next.turn,f.game.turn + 2);
assert.equal(status(next,id),'paid');
assert.deepEqual(inventory(next),f.originalCards);
});
void test('unanimous early release cancels only the future unpaid term', () => {
const f = fixture(), payer = f.target, recipient = f.asker;
const { game: accepted, id } = createDeal(f.game,recipient,payer,recipient,3,f.game.turn + 1);
const requested = act(accepted,recipient,{type:'dealRelease',deal:id,agree:true});
assert.equal(status(requested,id),'pending');
const released = act(requested,payer,{type:'dealRelease',deal:id,agree:true});
assert.equal(status(released,id),'released');
reject(released,payer,{type:'dealPay',deal:id});
const next = finish(released);
assert.equal(status(next,id),'released');
assert.equal(player(next,payer).spice,player(f.game,payer).spice);
assert.deepEqual(inventory(next),f.originalCards);
});
void test('different due turns remain separate and may be settled in either order', () => {
const f = fixture(), payer = f.target, recipient = f.asker, turn = f.game.turn;
const first = createDeal(f.game,recipient,payer,recipient,3,turn + 1);
const second = createDeal(first.game,recipient,payer,recipient,4,turn + 2);
const next = finish(second.game);
assert.equal(status(next,first.id),'pending'); assert.equal(status(next,second.id),'pending');
const paidSecond = act(next,payer,{type:'dealPay',deal:second.id});
assert.equal(status(paidSecond,first.id),'pending'); assert.equal(status(paidSecond,second.id),'paid');
assert.equal(player(paidSecond,recipient).bribes,4);
const paidFirst = act(paidSecond,payer,{type:'dealPay',deal:first.id});
assert.equal(status(paidFirst,first.id),'paid');
assert.equal(player(paidFirst,recipient).bribes,7);
assert.deepEqual(inventory(paidFirst),f.originalCards);
});