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);
});
+55
View File
@@ -0,0 +1,55 @@
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,
mentatBribeInventory as inventory, mentatBribePlayer as player,
} from './fixture-mentat-bribes';
/** Native turn-one Basic Mentat: starting hands and purses come from actual
* setup and auctions; only unopened source ordering was selected. */
function accepted() {
const f = fixture();
let game = act(f.game, f.asker, { type: 'dealOffer', target: f.target, terms: 'Pay the agreed spice this turn.',
payment: { kind: 'deferredBribe', payer: f.target, recipient: f.asker, amount: 3, dueTurn: f.game.turn } });
const deal = game.deals!.at(-1)!;
game = act(game, f.target, { type: 'dealRespond', deal: deal.id, accept: true });
assert.equal(game.deals!.at(-1)!.paymentStatus, 'pending');
return { f, game, deal: deal.id };
}
void test('human-controlled seats are not given an automatic deferred payment', () => {
const { f, game, deal } = accepted();
const view = viewGame(game, f.target);
assert.equal(view.deferredDealPaymentOptions[deal]?.blocked, null);
assert.ok(!botActions(view).some(action => action.type === 'dealPay'));
assert.equal(player(game, f.target).spice, 10);
assert.equal(game.deals!.at(-1)!.paymentStatus, 'pending');
});
void test('a delegated human seat pays its own exact pending deal legally', () => {
const { f, game: source, deal } = accepted();
const delegated = act(source, f.target, { type: 'setAutopilot', difficulty: 'Easy' });
assert.equal(player(delegated, f.target).autopilot, 'Easy');
const action = botActions(viewGame(delegated, f.target))[0];
assert.deepEqual(action, { type: 'dealPay', deal });
const settled = act(delegated, f.target, action);
assert.equal(settled.deals!.at(-1)!.paymentStatus, 'paid');
assert.equal(player(settled, f.target).spice, 7);
assert.equal(player(settled, f.asker).bribes, 3);
assert.deepEqual(inventory(settled), f.originalCards);
});
void test('a native Truthtrance response blocks autopilot payment until it resolves', () => {
const { f, game: source, deal } = accepted();
let game: Game = act(source, f.target, { type: 'setAutopilot', difficulty: 'Easy' });
game = act(game, f.asker, { type: 'card', card: f.truths[0] });
assert.ok(game.truthtrance, 'An actual original Truthtrance opens a native response.');
const view = viewGame(game, f.target);
assert.ok(view.deferredDealPaymentOptions[deal]?.blocked);
assert.ok(!botActions(view).some(action => action.type === 'dealPay'));
assert.equal(game.deals!.at(-1)!.paymentStatus, 'pending');
assert.equal(player(game, f.target).spice, 10);
assert.deepEqual(inventory(game), f.originalCards);
});
@@ -0,0 +1,53 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { type Game } from '../game/engine';
import {
actTruthCharityPayments as act,
createTruthCharityPaymentsFixture as fixture,
truthCharityPaymentsInventory as inventory,
truthCharityPaymentsPlayer as player,
} from './fixture-truth-charity-payments';
function agree(game: Game, proposer: string, payer: string, amount: number) {
const offered = act(game, proposer, { type: 'dealOffer', target: payer, terms: 'Pay this turn.',
payment: { kind: 'deferredBribe', payer, recipient: proposer, amount, dueTurn: game.turn } });
const id = offered.deals!.at(-1)!.id;
return { game: act(offered, payer, { type: 'dealRespond', deal: id, accept: true }), id };
}
void test('two whole deal payments can be funded by paying one before a real Charity claim', () => {
// The native source plays the actual Amal at Spice Blow, halving the
// Emperor's original ten spice to five, then enters actual Basic Charity.
// Only unopened card/Spice ordering was selected; no wallet or hand staged.
const f = fixture({ source: 'phase2', advanced: false });
assert.equal(f.game.phase, 2);
assert.equal(player(f.game, f.target).spice, 5);
assert.notEqual(player(f.game, f.target).charityTurn, f.game.turn);
const first = agree(f.game, f.asker, f.target, 4);
const second = agree(first.game, f.asker, f.target, 2);
const accepted = second.game;
assert.equal(player(accepted, f.target).spice, 5, 'Both exact deals can be accepted before either payment.');
assert.equal(accepted.deals!.find(row => row.id === first.id)!.paymentStatus, 'pending');
assert.equal(accepted.deals!.find(row => row.id === second.id)!.paymentStatus, 'pending');
const beforeMalformed = structuredClone(accepted);
assert.throws(() => act(accepted, f.target, { type: 'dealPay', deal: first.id, amount: 1 }));
assert.deepEqual(accepted, beforeMalformed, 'A partial dealPay cannot settle either exact payment.');
const separate = act(accepted, f.target, { type: 'bribe', target: f.asker, amount: 1 });
assert.equal(separate.deals!.find(row => row.id === first.id)!.paymentStatus, 'pending');
assert.equal(separate.deals!.find(row => row.id === second.id)!.paymentStatus, 'pending',
'An untagged transfer does not silently settle either distinct deal.');
let game = act(accepted, f.target, { type: 'dealPay', deal: first.id });
assert.equal(player(game, f.target).spice, 1);
assert.equal(game.deals!.find(row => row.id === first.id)!.paymentStatus, 'paid');
assert.equal(game.deals!.find(row => row.id === second.id)!.paymentStatus, 'pending');
game = act(game, f.target, { type: 'charity' });
assert.equal(player(game, f.target).spice, 2);
assert.equal(player(game, f.target).charityTurn, game.turn);
game = act(game, f.target, { type: 'dealPay', deal: second.id });
assert.equal(player(game, f.target).spice, 0);
assert.equal(player(game, f.asker).bribes, 6);
assert.equal(game.deals!.find(row => row.id === second.id)!.paymentStatus, 'paid');
assert.deepEqual(inventory(game), f.originalCards);
});
@@ -0,0 +1,93 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createElement, isValidElement, type ReactElement, type ReactNode } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
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: 2 } 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 html = (game: GameView) => renderToStaticMarkup(createElement(PublicDealList, { game, act() {}, disabled: false }));
function buttons(game: GameView, calls: Action[], disabled = false) {
return elements(PublicDealList({ game, act: action => calls.push(action), disabled }))
.filter(element => typeof element.props.onClick === 'function');
}
const find = (nodes: Element[], text: string) => nodes.find(node => node.props.children === text);
void test('an offer binds the exact deferred gross payment, due turn and distinct participants', () => {
assert.deepEqual(publicDealOfferAction(['b'], ' Terms ', payment, 'a'),
{ type: 'dealOffer', target: 'b', terms: 'Terms', payment });
for (const bad of [
{ ...payment, payer: 'b' }, { ...payment, recipient: 'outside' },
{ ...payment, amount: 0 }, { ...payment, amount: 1.5 },
{ ...payment, dueTurn: 0 }, { ...payment, dueTurn: 1.5 },
]) assert.equal(publicDealOfferAction(['b'], 'Terms', bad, 'a'), null);
});
void test('only the payer can pay a pending active term through its exact deal', () => {
const calls: Action[] = [];
const markup = html(base);
assert.match(markup, /Deferred payment term/);
assert.match(markup, /exactly 3 spice/);
assert.match(markup, /end of turn 2/);
assert.match(markup, /Payment status: pending/);
assert.match(markup, /next Mentat opening/);
assert.match(markup, /Bureaucrat/);
const button = find(buttons(base, calls), 'Pay agreed amount');
assert.ok(button); assert.equal(button.props.disabled, false);
(button.props.onClick as () => void)();
assert.deepEqual(calls, [{ type: 'dealPay', deal: 'deal' }]);
assert.equal(find(buttons({ ...base, me: 'b' }, calls), 'Pay agreed amount'), undefined);
assert.equal(find(buttons({ ...base, deals: [{ ...deal, paymentStatus: 'paid' }] } as unknown as GameView, calls), 'Pay agreed amount'), undefined);
assert.equal(find(buttons({ ...base, deals: [{ ...deal, status: 'released', paymentStatus: 'released' }] } as unknown as GameView, calls), 'Pay agreed amount'), undefined);
});
void test('payer sees the server reason and cannot initiate an unavailable payment', () => {
const calls: Action[] = [];
const blocked = { ...base, deferredDealPaymentOptions: { deal: { blocked: 'Finish the current decision.' } } } as unknown as GameView;
assert.match(html(blocked), /Finish the current decision/);
const button = find(buttons(blocked, calls), 'Pay agreed amount');
assert.ok(button); assert.equal(button.props.disabled, true);
assert.equal(find(buttons(base, calls, true), 'Pay agreed amount')?.props.disabled, true);
const unknown = { ...base, deferredDealPaymentOptions: {} } as unknown as GameView;
assert.equal(find(buttons(unknown, calls), 'Pay agreed amount')?.props.disabled, true);
assert.deepEqual(calls, []);
});
void test('a pending payment opens the payer’s persistent request panel and remains until resolved', () => {
assert.deepEqual(publicDealRequests(base), [['payment', 'deal']]);
assert.match(renderToStaticMarkup(createElement(PublicDeals, { game: base, act() {}, busy: false })), /Public deals · payment due/);
assert.deepEqual(publicDealRequests({ ...base, me: 'b' }), []);
const requested = { ...base, deals: [{ ...deal, releaseVotes: ['b'] }] } as unknown as GameView;
assert.deepEqual(publicDealRequests(requested), [['release', 'deal', 'b'], ['payment', 'deal']]);
const paid = { ...base, deals: [{ ...deal, paymentStatus: 'paid' }] } as unknown as GameView;
assert.deepEqual(publicDealRequests(paid), []);
assert.doesNotMatch(renderToStaticMarkup(createElement(PublicDeals, { game: paid, act() {}, busy: false })), /Public deals · payment due/);
const voided = { ...base, deals: [{ ...deal, paymentStatus: 'void' }] } as unknown as GameView;
assert.deepEqual(publicDealRequests(voided), []);
});
void test('deferred final acceptance does not require an immediate bribe window, but an expired offer cannot be accepted', () => {
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[] = [];
const accept = find(buttons(game, calls), 'Accept deal');
assert.ok(accept); assert.equal(accept.props.disabled, false);
(accept.props.onClick as () => void)();
assert.deepEqual(calls, [{ type: 'dealRespond', deal: 'deal', accept: true }]);
const expired = { ...game, turn: 3 };
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);
});
+100
View File
@@ -0,0 +1,100 @@
// @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 HTTP test creates only its own room on the isolated test server. It
* neither reuses nor deletes any existing room or local development game. */
void test('authenticated participants activate and the exact payer settles a deferred public deal once', async () => {
const host = await request('/api/rooms', undefined, {
name: 'Deferred 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: 'Deferred 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: 'Deferred 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 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: view.turn };
await act(host.cookie, { type: 'dealOffer', targets: [payer.data.me, third.data.me],
terms: 'Pay the agreed spice this turn.', payment });
const deal = view.deals.at(-1); assert.ok(deal); assert.equal(deal.status, 'offered');
assert.deepEqual(deal.payment, payment);
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));
assert.equal(read.data.deals.find(row => row.id === deal.id)?.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 pay = { type: 'dealPay', deal: deal.id };
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.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,57 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { type Action, type Game } from '../game/engine';
import {
actTruthBribe as act, createTruthBribeFixture as fixture,
truthBribeInventory as inventory, truthBribePlayer as player,
} from './fixture-truth-bribe';
import { enterMentatBribeWithUnpaidPromise as enterMentat, finishMentatBribe as finish } from './fixture-mentat-bribes';
function rejected(game: Game, actor: string, action: Action) {
const before = structuredClone(game);
assert.throws(() => act(game, actor, action));
assert.deepEqual(game, before, 'A rejected action leaves the source state unchanged.');
}
/** This is a genuine public H/A/E Basic source at Collection. Its starting
* deal/auction and Spice cards were selected while unopened; all payments and
* Mentat transitions below are actual native actions, with no purse staging. */
void test('already paid bribe escrow can fund a deferred deal at this turn’s real Mentat opening', () => {
const f = fixture({ phase: 7 }), initial = f.game;
assert.equal(initial.phase, 7);
assert.equal(player(initial, f.asker).spice, 10);
// H spends nine now, then receives an independently completed E→H bribe.
let game = act(initial, f.asker, { type: 'bribe', target: f.opponent, amount: 9 });
game = act(game, f.target, { type: 'bribe', target: f.asker, amount: 3 });
assert.equal(player(game, f.asker).spice, 1);
assert.equal(player(game, f.asker).bribes, 3);
assert.equal(player(game, f.opponent).bribes, 9);
// A has not yet received its own nine either; E's three are already in
// H's native escrow and cannot be spent until this turn's real Mentat.
game = act(game, f.asker, { type: 'dealOffer', target: f.opponent,
terms: 'Pay four after the upcoming Mentat opening.',
payment: { kind: 'deferredBribe', payer: f.asker, recipient: f.opponent, amount: 4, dueTurn: game.turn } });
const id = game.deals!.at(-1)!.id;
game = act(game, f.opponent, { type: 'dealRespond', deal: id, accept: true });
assert.equal(game.deals!.at(-1)!.paymentStatus, 'pending');
assert.equal(player(game, f.asker).spice, 1);
assert.equal(player(game, f.asker).bribes, 3);
rejected(game, f.asker, { type: 'dealPay', deal: id });
rejected(game, f.asker, { type: 'bribe', target: f.target, amount: 1 });
const mentat = enterMentat(game);
assert.equal(mentat.phase, 8); assert.equal(mentat.turn, initial.turn);
assert.equal(player(mentat, f.asker).spice, 4);
assert.equal(player(mentat, f.asker).bribes, 0);
assert.equal(player(mentat, f.opponent).bribes, 0);
const paid = act(mentat, f.asker, { type: 'dealPay', deal: id });
assert.equal(paid.deals!.find(deal => deal.id === id)!.paymentStatus, 'paid');
assert.equal(player(paid, f.asker).spice, 0);
assert.equal(player(paid, f.opponent).bribes, 4,
'A payment after this Mentat opening waits for the next opening.');
const next = finish(paid);
assert.equal(next.turn, initial.turn + 1); assert.equal(next.phase, 0);
assert.equal(player(next, f.opponent).bribes, 4);
assert.deepEqual(inventory(next), f.originalCards);
});
@@ -0,0 +1,45 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { viewGame, type Game } from '../game/engine';
import {
actMentatBribe as act, createMentatBribeFixture as fixture,
mentatBribeInventory as inventory, mentatBribePlayer as player,
} from './fixture-mentat-bribes';
function agree(game: Game, proposer: string, payer: string) {
const offered = act(game, proposer, { type: 'dealOffer', target: payer, terms: 'Pay this turn.',
payment: { kind: 'deferredBribe', payer, recipient: proposer, amount: 4, dueTurn: game.turn } });
const id = offered.deals!.at(-1)!.id;
return { game: act(offered, payer, { type: 'dealRespond', deal: id, accept: true }), id };
}
const spice = (game: Game) => game.players.reduce((sum, seat) => sum + seat.spice + seat.bribes, 0);
for (const pick of ['first', 'second'] as const) {
void test(`external shortage preserves both individually payable deals until payer chooses ${pick}`, () => {
// The fixture reaches actual Basic Mentat with Emperor's original ten
// spice. Both independent agreements are really offered and accepted.
const f = fixture(), first = agree(f.game, f.asker, f.target);
const second = agree(first.game, f.asker, f.target);
const staged = structuredClone(second.game), target = player(staged, f.target), other = player(staged, f.opponent);
const originalSpice = spice(staged);
// Model an external loss: transfer five existing spice, not create or
// destroy it. Each four-spice payment is now possible on its own.
target.spice -= 5; other.spice += 5;
const waiting = act(staged, f.opponent, { type: 'ready' });
assert.equal(waiting.deals!.find(row => row.id === first.id)!.paymentStatus, 'pending');
assert.equal(waiting.deals!.find(row => row.id === second.id)!.paymentStatus, 'pending');
const options = viewGame(waiting, f.target).deferredDealPaymentOptions;
assert.equal(options[first.id]?.blocked, null);
assert.equal(options[second.id]?.blocked, null);
const chosen = pick === 'first' ? first.id : second.id;
const unchosen = pick === 'first' ? second.id : first.id;
const paid = act(waiting, f.target, { type: 'dealPay', deal: chosen });
assert.equal(paid.deals!.find(row => row.id === chosen)!.paymentStatus, 'paid');
assert.equal(paid.deals!.find(row => row.id === unchosen)!.paymentStatus, 'void');
assert.equal(player(paid, f.target).spice, 1);
assert.equal(player(paid, f.asker).bribes, 4);
assert.equal(spice(paid), originalSpice);
assert.deepEqual(inventory(paid), f.originalCards);
});
}
@@ -0,0 +1,41 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { Game } from '../game/engine';
import {
actTruthBribe as act, createTruthBribeFixture as fixture,
truthBribeInventory as inventory, truthBribePlayer as player,
} from './fixture-truth-bribe';
function agree(game: Game, recipient: string, payer: string, amount: number) {
const offered = act(game, recipient, { type: 'dealOffer', target: payer, terms: 'Pay this turn.',
payment: { kind: 'deferredBribe', payer, recipient, amount, dueTurn: game.turn } });
const deal = offered.deals!.at(-1)!;
const accepted = act(offered, payer, { type: 'dealRespond', deal: deal.id, accept: true });
return { game: accepted, id: deal.id };
}
void test('a temporarily allied recipient does not let the payer evade a different, still-payable deal', () => {
// Real Basic setup, ordinary auction, hands and ten-spice Emperor purse.
// Both agreements are genuinely proposed and unanimously accepted. Only
// their later mutual alliance is staged; no card or spice is fabricated.
const f = fixture(), first = agree(f.game, f.asker, f.target, 4);
const second = agree(first.game, f.opponent, f.target, 3);
const game = structuredClone(second.game);
player(game, f.target).ally = f.asker;
player(game, f.asker).ally = f.target;
assert.equal(player(game, f.target).spice, 10);
assert.equal(game.deals!.find(deal => deal.id === first.id)!.paymentStatus, 'pending');
assert.equal(game.deals!.find(deal => deal.id === second.id)!.paymentStatus, 'pending');
const before = structuredClone(game);
assert.throws(() => act(game, f.target, { type: 'bribe', target: f.opponent, amount: 8 }),
/deal|payment|fund/i, 'Spending below the other payable deal cannot use an unrelated temporarily blocked term as an escape.');
assert.deepEqual(game, before, 'Rejected spending preserves both agreements and the complete source state.');
const paid = act(game, f.target, { type: 'dealPay', deal: second.id });
assert.equal(paid.deals!.find(deal => deal.id === second.id)!.paymentStatus, 'paid');
assert.equal(paid.deals!.find(deal => deal.id === first.id)!.paymentStatus, 'pending');
assert.equal(player(paid, f.target).spice, 7);
assert.equal(player(paid, f.opponent).bribes, 3);
assert.deepEqual(inventory(paid), f.originalCards);
});
+144
View File
@@ -0,0 +1,144 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { viewGame, type Action, type Game } from '../game/engine';
import type { PublicDeal } from '../game/public-deals';
import {
actMentatBribe as act, createMentatBribeFixture as fixture,
finishMentatBribe as finish, mentatBribeInventory as inventory,
mentatBribePlayer as player,
} from './fixture-mentat-bribes';
type DeferredDeal = PublicDeal & { paymentStatus?: 'pending' | 'paid' | 'void' | 'released' };
const getDeal = (game: Game, id: string): DeferredDeal => {
const deal = game.deals?.find(row => row.id === id); assert.ok(deal); return deal as DeferredDeal;
};
function rejected(game: Game, actor: string, action: Action) {
const before = structuredClone(game);
assert.throws(() => act(game, actor, action));
assert.deepEqual(game, before, 'Rejected packets preserve the original game.');
}
function offer(game: Game, proposer: string, payer: string, recipient: string, amount: number,
targets: string[] = [payer]) {
const payment = { kind: 'deferredBribe', payer, recipient, amount, dueTurn: game.turn };
const fields = targets.length === 1 ? { target: targets[0] } : { targets };
const next = act(game, proposer, { type: 'dealOffer', ...fields, terms: 'Pay the agreed spice this turn.', payment });
const deal = next.deals?.at(-1); assert.ok(deal);
return { game: next, id: deal.id, payment };
}
function accept(game: Game, id: string, actors: string[]) {
for (const actor of actors) game = act(game, actor, { type: 'dealRespond', deal: id, accept: true });
return game;
}
function pay(game: Game, payer: string, id: string) {
return act(game, payer, { type: 'dealPay', deal: id });
}
/** Each test reaches real Basic turn-one Mentat through original setup,
* bidding and Collection. The source orders only unopened physical cards;
* no dealt hand, purse, clock or agreement is installed. */
void test('unanimous deferred consent creates a pending exact payment without moving spice', () => {
const f = fixture(), before = f.game;
const { game: proposed, id, payment } = offer(before, f.opponent, f.target, f.asker, 3, [f.asker, f.target]);
const first = accept(proposed, id, [f.asker]);
assert.equal(getDeal(first, id).status, 'offered');
assert.equal(player(first, f.target).spice, player(before, f.target).spice);
const active = accept(first, id, [f.target]);
assert.equal(getDeal(active, id).status, 'active');
assert.equal(getDeal(active, id).paymentStatus, 'pending');
assert.deepEqual(getDeal(active, id).payment, payment);
assert.equal(player(active, f.target).spice, player(before, f.target).spice);
assert.equal(player(active, f.asker).bribes, player(before, f.asker).bribes);
assert.deepEqual(viewGame(active, f.opponent).deals.find(row => row.id === id), getDeal(active, id));
rejected(active, f.opponent, { type: 'dealPay', deal: id });
const paid = pay(active, f.target, id);
assert.equal(getDeal(paid, id).paymentStatus, 'paid');
assert.equal(player(paid, f.target).spice, player(before, f.target).spice - 3);
assert.equal(player(paid, f.asker).bribes, player(before, f.asker).bribes + 3);
assert.equal(player(paid, f.asker).spice, player(before, f.asker).spice);
rejected(paid, f.target, { type: 'dealPay', deal: id });
assert.deepEqual(inventory(paid), f.originalCards);
});
void test('two independently agreed debts need two exact native transfers', () => {
const f = fixture();
const first = offer(f.game, f.asker, f.target, f.asker, 3);
let game = accept(first.game, first.id, [f.target]);
const second = offer(game, f.asker, f.target, f.asker, 4);
game = accept(second.game, second.id, [f.target]);
assert.equal(player(game, f.target).spice, 10);
game = pay(game, f.target, second.id);
assert.equal(getDeal(game, first.id).paymentStatus, 'pending');
assert.equal(getDeal(game, second.id).paymentStatus, 'paid');
assert.equal(player(game, f.target).spice, 6);
game = pay(game, f.target, first.id);
assert.equal(player(game, f.target).spice, 3);
assert.equal(player(game, f.asker).bribes, 7);
assert.equal(getDeal(game, first.id).paymentStatus, 'paid');
assert.deepEqual(inventory(game), f.originalCards);
});
void test('unanimous release removes only the unpaid agreement and never refunds a paid one', () => {
const f = fixture();
const first = offer(f.game, f.asker, f.target, f.asker, 3);
let game = accept(first.game, first.id, [f.target]);
const second = offer(game, f.asker, f.target, f.asker, 4);
game = accept(second.game, second.id, [f.target]);
game = pay(game, f.target, first.id);
game = act(game, f.asker, { type: 'dealRelease', deal: second.id, agree: true });
assert.equal(getDeal(game, second.id).paymentStatus, 'pending');
game = act(game, f.target, { type: 'dealRelease', deal: second.id, agree: true });
assert.equal(getDeal(game, second.id).paymentStatus, 'released');
rejected(game, f.target, { type: 'dealPay', deal: second.id });
game = act(game, f.asker, { type: 'dealRelease', deal: first.id, agree: true });
game = act(game, f.target, { type: 'dealRelease', deal: first.id, agree: true });
assert.equal(getDeal(game, first.id).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('voluntary spending preserves enough cash for each accepted exact payment', () => {
const f = fixture();
const first = offer(f.game, f.asker, f.target, f.asker, 3);
let game = accept(first.game, first.id, [f.target]);
const second = offer(game, f.asker, f.target, f.asker, 4);
game = accept(second.game, second.id, [f.target]);
rejected(game, f.target, { type: 'bribe', target: f.opponent, amount: 4 });
game = act(game, f.target, { type: 'bribe', target: f.opponent, amount: 3 });
assert.equal(player(game, f.target).spice, 7);
game = pay(game, f.target, first.id);
game = pay(game, f.target, second.id);
assert.equal(player(game, f.target).spice, 0);
assert.equal(player(game, f.opponent).bribes, 3);
assert.equal(player(game, f.asker).bribes, 7);
assert.deepEqual(inventory(game), f.originalCards);
});
void test('a payable current-turn debt blocks its payer ending Mentat, then real turn advance retains the receipt', () => {
const f = fixture();
const offerResult = offer(f.game, f.asker, f.target, f.asker, 3);
let game = accept(offerResult.game, offerResult.id, [f.target]);
rejected(game, f.target, { type: 'ready' });
game = pay(game, f.target, offerResult.id);
game = act(game, f.target, { type: 'ready' });
const next = finish(game);
assert.equal(next.turn, f.game.turn + 1); assert.equal(next.phase, 0);
assert.equal(getDeal(next, offerResult.id).paymentStatus, 'paid');
assert.equal(player(next, f.asker).bribes, 3, 'Late Mentat transfers wait for the next Mentat opening.');
assert.deepEqual(inventory(next), f.originalCards);
});
void test('a staged externally lost purse is voided on another player’s actual next action', () => {
const f = fixture();
const offered = offer(f.game, f.asker, f.target, f.asker, 3);
let game = accept(offered.game, offered.id, [f.target]);
// Controlled external loss: move existing spice, never create/destroy it.
const staged = structuredClone(game), target = player(staged, f.target), other = player(staged, f.opponent);
target.spice -= 8; other.spice += 8;
game = act(staged, f.opponent, { type: 'ready' });
assert.equal(getDeal(game, offered.id).paymentStatus, 'void');
rejected(game, f.target, { type: 'dealPay', deal: offered.id });
assert.equal(player(game, f.target).spice, 2);
assert.equal(player(game, f.asker).bribes, 0);
assert.deepEqual(inventory(game), f.originalCards);
});
@@ -0,0 +1,62 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { viewGame, type Action, type Game } from '../game/engine';
import {
actMentatBribe as act, askMentatBribe as ask,
createMentatBribeFixture as fixture, declareMentatBribe as declare,
mentatBribeInventory as inventory, mentatBribePlayer as player,
} from './fixture-mentat-bribes';
function rejected(game: Game, actor: string, action: Action) {
const before = structuredClone(game);
assert.throws(() => act(game, actor, action));
assert.deepEqual(game, before, 'A rejected answer or action must not mutate the source.');
}
function offer(game: Game, asker: string, target: string, amount: number) {
game = act(game, asker, { type: 'dealOffer', target, terms: 'A payment before this turn ends.',
payment: { kind: 'deferredBribe', payer: target, recipient: asker, amount, dueTurn: game.turn } });
const id = game.deals!.at(-1)!.id;
return { game: act(game, target, { type: 'dealRespond', deal: id, accept: true }), id };
}
function openQuestion(f: ReturnType<typeof fixture>, game: Game, minimum: number) {
const opened = declare(f, game);
return ask(opened.game, f.asker, f.target, f.asker, minimum);
}
/** Genuine Basic H/A/E turn-one Mentat; the fixture preorders unopened cards,
* not an already-dealt hand, purse, promise, battle, or phase. */
void test('a matching Truthtrance Yes remains legal and a conflicting No is not offered', () => {
const f = fixture(), agreed = offer(f.game, f.asker, f.target, 3);
const asked = openQuestion(f, agreed.game, 3);
const options = viewGame(asked, f.target).truthBribeAnswers;
assert.ok(options);
assert.ok(options.includes('yes'));
assert.ok(!options.includes('no'), 'A no answer cannot contradict the earlier accepted exact payment.');
rejected(asked, f.target, { type: 'truthAnswer', answer: 'no' });
let game = act(asked, f.target, { type: 'truthAnswer', answer: 'yes' });
assert.ok(game.bribePromises?.some(row => row.player === f.target && row.recipient === f.asker && row.answer && !row.fulfilled));
game = act(game, f.target, { type: 'dealPay', deal: agreed.id });
assert.equal(game.deals!.find(row => row.id === agreed.id)!.paymentStatus, 'paid');
assert.ok(game.bribePromises?.some(row => row.player === f.target && row.recipient === f.asker && row.fulfilled));
assert.equal(player(game, f.target).spice, 7);
assert.equal(player(game, f.asker).bribes, 3);
assert.deepEqual(inventory(game), f.originalCards);
});
void test('one payment can satisfy a Truthtrance minimum but cannot satisfy two distinct deals', () => {
const f = fixture(), first = offer(f.game, f.asker, f.target, 3);
const second = offer(first.game, f.asker, f.target, 3);
const asked = openQuestion(f, second.game, 3);
assert.ok(viewGame(asked, f.target).truthBribeAnswers?.includes('yes'));
let game = act(asked, f.target, { type: 'truthAnswer', answer: 'yes' });
game = act(game, f.target, { type: 'dealPay', deal: first.id });
assert.ok(game.bribePromises?.some(row => row.player === f.target && row.recipient === f.asker && row.fulfilled));
assert.equal(game.deals!.find(row => row.id === first.id)!.paymentStatus, 'paid');
assert.equal(game.deals!.find(row => row.id === second.id)!.paymentStatus, 'pending');
rejected(game, f.target, { type: 'ready' });
game = act(game, f.target, { type: 'dealPay', deal: second.id });
assert.equal(game.deals!.find(row => row.id === second.id)!.paymentStatus, 'paid');
assert.equal(player(game, f.target).spice, 4);
assert.equal(player(game, f.asker).bribes, 6);
assert.deepEqual(inventory(game), f.originalCards);
});