Support paid current-turn travel deals
Verify and publish checkpoint container / container (push) Successful in 10m34s

This commit is contained in:
Quintine committed 2026-10-29 07:49:56 +11:00
1 parent 82c639c977
commit a1b2aacbac
9 files changed
+371 -23

No files matched your search

@@ -0,0 +1,50 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { viewGame } from '../game/engine';
import { actTruthAlliance as act, advanceTruthAlliance as advance, createTruthAllianceFixture,
truthAllianceClean as clean, truthAllianceInventory as inventory, truthAlliancePlayer as player } from './fixture-truth-alliance';
import { declareTruthBribe } from './fixture-truth-bribe';
/** The original Basic H/A/E setup, real worm/Nexus alliance and later Bidding
* from the shared fixture. The offer, Truthtrance question, answer and final
* consent are all real actions; no wallet, pledge or commitment is staged. */
void test('a previously offered paid travel deal can release a later unaffordable pledge on recipient consent', () => {
const f = createTruthAllianceFixture({ consent: 'allied' });
let game = advance(f.game, state => state.phase === 3 && !!state.auction && clean(state));
assert.equal(player(game, f.target).ally, f.partner);
assert.equal(player(game, f.partner).ally, f.target);
assert.equal(player(game, f.asker).ally, null);
const minimum = 4, initialCash = player(game, f.target).spice;
const amount = initialCash - minimum + 1;
assert.ok(amount > 0);
game = act(game, f.target, { type: 'dealOffer', target: f.asker,
terms: 'Immediate payment for a current-turn travel restriction.',
payment: { kind: 'immediateBribe', payer: f.target, recipient: f.asker, amount },
travel: { kind: 'ship', territory: 'polar_sink', minimum: 1, answer: false } });
const offer = game.deals?.at(-1); assert.ok(offer);
game = declareTruthBribe(f, game).game;
assert.ok(viewGame(game, f.asker).truthPledgeTargets.includes(f.target));
game = act(game, f.asker, { type: 'truthAsk', question: { kind: 'pledgeAid', target: f.target,
turn: game.turn, phase: game.phase, ally: f.partner, minimum } });
assert.ok(viewGame(game, f.target).truthPledgeAnswers?.includes('yes'));
game = act(game, f.target, { type: 'truthAnswer', answer: 'yes' });
const pledge = game.pledgePromises?.at(-1); assert.ok(pledge);
assert.ok(!pledge.fulfilled && !pledge.released);
// A new voluntary payment cannot evade the later pledge, but the original
// offer already consented to payment before that answer was made.
const before = structuredClone(game);
assert.throws(() => act(game, f.target, { type: 'bribe', target: f.asker, amount }));
assert.deepEqual(game, before);
const incoming = player(game, f.asker).bribes;
const next = act(game, f.asker, { type: 'dealRespond', deal: offer.id, accept: true });
assert.equal(next.deals?.find(deal => deal.id === offer.id)?.status, 'active');
assert.equal(player(next, f.target).spice, initialCash - amount);
assert.equal(player(next, f.asker).bribes, incoming + amount);
assert.deepEqual(next.aid[f.target], before.aid[f.target]);
assert.equal(next.pledgePromises?.at(-1)?.released, true);
assert.ok(!next.pledgePromises?.at(-1)?.fulfilled);
assert.equal(next.travelPromises?.filter(row => row.sourceDeal === offer.id).length, 1);
assert.equal(viewGame(next, f.asker).dealTravelStatus[offer.id], 'pending');
assert.deepEqual(inventory(next), f.originalCards);
});
@@ -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 { actTruthTravel as act, advanceTruthTravel as advance, createTruthTravelFixture as fixture,
settleTruthTravel as settle, truthTravelClean as clean, truthTravelInventory as inventory,
truthTravelPlayer as player } from './fixture-truth-travel';
type Term = { kind: 'ship' | 'move'; territory: string; minimum: number; answer: boolean };
function reject(game: Game, actor: string, action: Action) {
const before = structuredClone(game);
assert.throws(() => act(game, actor, action));
assert.deepEqual(game, before, 'Failed consent and action packets preserve the source table.');
}
function offer(game: Game, proposer: string, target: string, travel: Term, amount: number) {
const payment = { kind: 'immediateBribe', payer: proposer, recipient: target, amount };
const next = act(game, proposer, { type: 'dealOffer', target, terms: 'Payment for agreed travel.', payment, travel });
const deal = next.deals?.at(-1); assert.ok(deal);
return { game: next, id: deal.id, payment, travel };
}
function accept(game: Game, target: string, id: string) {
return act(game, target, { type: 'dealRespond', deal: id, accept: true });
}
function status(game: Game, viewer: string, id: string) {
return viewGame(game, viewer).dealTravelStatus[id];
}
/** Native Basic Atreides/Emperor game at the real pre-shipment or shipped
* phase-five boundary. Only original unopened deals/Spice and scalar lotteries
* are selected by the shared TruthTravel fixture; all agreements and payments
* below are real game actions. */
void test('final consent atomically pays the proposer bribe and creates the travel obligation', () => {
const f = fixture(), initial = f.game;
const beforePayer = player(initial, f.asker).spice, beforeCash = player(initial, f.target).spice,
beforeEscrow = player(initial, f.target).bribes;
assert.ok(beforePayer >= 3);
const offered = offer(initial, f.asker, f.target,
{ kind: 'ship', territory: f.shipmentTerritory, minimum: 1, answer: true }, 3);
assert.equal(player(offered.game, f.asker).spice, beforePayer);
assert.equal(player(offered.game, f.target).bribes, beforeEscrow);
const game = accept(offered.game, f.target, offered.id);
assert.equal(game.deals!.find(deal => deal.id === offered.id)!.status, 'active');
assert.equal(player(game, f.asker).spice, beforePayer - 3);
assert.equal(player(game, f.target).spice, beforeCash);
assert.equal(player(game, f.target).bribes, beforeEscrow + 3);
assert.equal(game.travelPromises!.filter(row => row.sourceDeal === offered.id).length, 1);
assert.equal(status(game, f.target, offered.id), 'pending');
reject(game, f.target, { type: 'dealRespond', deal: offered.id, accept: true });
const next = settle(act(game, f.target, f.shipmentAction));
assert.equal(status(next, f.target, offered.id), 'fulfilled');
assert.equal(player(next, f.target).spice, beforeCash - f.shipmentCost);
assert.equal(player(next, f.target).bribes, beforeEscrow + 3);
assert.deepEqual(inventory(next), f.originalCards);
const collection = advance(next, state => state.turn === initial.turn && state.phase === 7 && clean(state));
const cashBeforeMentat = player(collection, f.target).spice;
assert.equal(player(collection, f.target).bribes, beforeEscrow + 3);
const mentat = advance(collection, state => state.turn === initial.turn && state.phase === 8 && clean(state));
assert.equal(player(mentat, f.target).spice, cashBeforeMentat + beforeEscrow + 3);
assert.equal(player(mentat, f.target).bribes, 0);
assert.deepEqual(inventory(mentat), f.originalCards);
});
void test('a payer who spends after offering cannot partly activate or pay the deal', () => {
const f = fixture(), payerCash = player(f.game, f.asker).spice; assert.ok(payerCash >= 1);
const offered = offer(f.game, f.asker, f.target,
{ kind: 'ship', territory: f.shipmentTerritory, minimum: 1, answer: true }, payerCash);
const spent = act(offered.game, f.asker, { type: 'bribe', target: f.target, amount: 1 });
assert.equal(player(spent, f.asker).spice, payerCash - 1);
const before = structuredClone(spent);
reject(spent, f.target, { type: 'dealRespond', deal: offered.id, accept: true });
assert.deepEqual(spent, before);
assert.equal(spent.deals!.find(deal => deal.id === offered.id)!.status, 'offered');
assert.equal(spent.travelPromises?.some(row => row.sourceDeal === offered.id) ?? false, false);
assert.deepEqual(inventory(spent), f.originalCards);
});
void test('an infeasible promised shipment rolls back payment and final acceptance', () => {
const f = fixture({ window: 'shipped' }), before = f.game;
const offered = offer(before, f.asker, f.target,
{ kind: 'ship', territory: f.shipmentTerritory, minimum: 1, answer: true }, 1);
const payer = player(offered.game, f.asker).spice, cash = player(offered.game, f.target).spice,
escrow = player(offered.game, f.target).bribes;
reject(offered.game, f.target, { type: 'dealRespond', deal: offered.id, accept: true });
assert.equal(offered.game.deals!.find(deal => deal.id === offered.id)!.status, 'offered');
assert.equal(player(offered.game, f.asker).spice, payer);
assert.equal(player(offered.game, f.target).spice, cash);
assert.equal(player(offered.game, f.target).bribes, escrow);
assert.equal(offered.game.travelPromises?.some(row => row.sourceDeal === offered.id) ?? false, false);
});
void test('the recipient cannot use incoming Mentat escrow to fund the promised shipment', () => {
const f = fixture(), cash = player(f.game, f.target).spice; assert.ok(cash >= 2);
const spent = act(f.game, f.target, { type: 'bribe', target: f.asker, amount: cash - 1 });
assert.equal(player(spent, f.target).spice, 1);
assert.ok(player(spent, f.asker).spice >= 3);
const offered = offer(spent, f.asker, f.target,
{ kind: 'ship', territory: f.shipmentTerritory, minimum: 1, answer: true }, 3);
reject(offered.game, f.target, { type: 'dealRespond', deal: offered.id, accept: true });
assert.equal(player(offered.game, f.target).spice, 1);
assert.equal(offered.game.deals!.find(deal => deal.id === offered.id)!.status, 'offered');
assert.equal(offered.game.travelPromises?.some(row => row.sourceDeal === offered.id) ?? false, false);
});
void test('unanimous release ends the pending travel obligation without refunding payment', () => {
const f = fixture(), offered = offer(f.game, f.asker, f.target,
{ kind: 'ship', territory: f.shipmentTerritory, minimum: 1, answer: true }, 1);
let game = accept(offered.game, f.target, offered.id);
const paid = player(game, f.asker).spice, escrow = player(game, f.target).bribes;
game = act(game, f.asker, { type: 'dealRelease', deal: offered.id, agree: true });
assert.equal(status(game, f.target, offered.id), 'pending');
game = act(game, f.target, { type: 'dealRelease', deal: offered.id, agree: true });
assert.equal(status(game, f.target, offered.id), 'released');
assert.equal(player(game, f.asker).spice, paid);
assert.equal(player(game, f.target).bribes, escrow);
game = settle(act(game, f.target, f.currentEnd));
assert.equal(player(game, f.asker).spice, paid);
assert.equal(player(game, f.target).bribes, escrow);
});
void test('paid travel excludes reversed, deferred and multiparty payment variants', () => {
const f = fixture(), travel = { kind: 'ship', territory: f.shipmentTerritory, minimum: 1, answer: true };
const base = { type: 'dealOffer', target: f.target, terms: 'Travel.', travel } satisfies Action;
reject(f.game, f.asker, { ...base, payment: { kind: 'immediateBribe', payer: f.target, recipient: f.asker, amount: 1 } });
reject(f.game, f.asker, { ...base, payment: { kind: 'deferredBribe', payer: f.asker, recipient: f.target, amount: 1, dueTurn: f.game.turn } });
reject(f.game, f.asker, { type: 'dealOffer', targets: [f.target, f.asker], terms: 'Travel.', travel,
payment: { kind: 'immediateBribe', payer: f.asker, recipient: f.target, amount: 1 } });
});
+101 -1
View File
@@ -1,7 +1,15 @@
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 board from '../game/board';
import * as catalog from '../game/catalog';
import * as coalitionAlliance from '../game/coalition-alliance';
import * as publicDeals from '../game/public-deals';
import { PublicDealList, PublicDeals, publicDealOfferAction, publicDealRequests } from '../components/public-deals';
import { TravelPromises } from '../components/travel-promises';
import type { Action, GameView } from '../game/engine';
@@ -29,7 +37,11 @@ void test('the offer packet retains the original recipient action and validates
assert.deepEqual(publicDealOfferAction(['b'], 'Avoid', undefined, 'a', 2, avoid),
{type:'dealOffer', target:'b', terms:'Avoid', travel:avoid});
assert.equal(publicDealOfferAction(['b','c'], 'Terms', undefined, 'a', 2, travel), null);
assert.equal(publicDealOfferAction(['b'], 'Terms', payment, 'a', 2, travel), null);
assert.deepEqual(publicDealOfferAction(['b'], 'Terms', payment, 'a', 2, travel),
{type:'dealOffer', target:'b', terms:'Terms', payment, travel});
assert.equal(publicDealOfferAction(['b'], 'Terms', {...payment,payer:'b',recipient:'a'}, 'a', 2, travel), null);
assert.equal(publicDealOfferAction(['b'], 'Terms', {kind:'deferredBribe',payer:'a',recipient:'b',amount:2,dueTurn:2}, 'a', 2, travel), null);
assert.equal(publicDealOfferAction(['b','c'], 'Terms', payment, 'a', 2, travel), null);
assert.equal(publicDealOfferAction(['a'], 'Terms', undefined, 'a', 2, travel), null);
assert.equal(publicDealOfferAction(['b'], 'Terms', undefined, undefined, 2, travel), null);
for (const invalid of [
@@ -43,6 +55,94 @@ void test('the offer packet retains the original recipient action and validates
assert.deepEqual(publicDealOfferAction(['b','c'], 'Terms'), {type:'dealOffer', targets:['b','c'], terms:'Terms'});
});
void test('the combined offer explains payment timing and the recipient can accept or decline', () => {
const offered = {...base, deals:[{...deal,status:'offered',payment}], dealTravelStatus:{},
bribeOptions:{blocked:null}} as unknown as GameView;
const html = markup(offered);
assert.match(html, /Alice \(Atreides\) pays 2 spice to Basil \(Harkonnen\) on final acceptance/);
assert.match(html, /recipient collects their share at the next Mentat opening/);
assert.match(html, /cannot use this payment to fund the current travel/);
const sent:Action[] = [];
const tree = PublicDealList({game:offered, disabled:false, act(action) {sent.push(action);}});
const accept = elements(tree).find(node => node.props.children === 'Accept deal');
const decline = elements(tree).find(node => node.props.children === 'Decline deal');
assert.ok(accept); assert.ok(decline);
assert.equal(accept.props.disabled, false); assert.equal(decline.props.disabled, false);
(accept.props.onClick as () => void)();
assert.deepEqual(sent, [{type:'dealRespond',deal:'deal',accept:true}]);
const busyTree = PublicDealList({game:offered, disabled:true, act() {}});
assert.equal(elements(busyTree).find(node => node.props.children === 'Accept deal')?.props.disabled,true);
});
/** Stateful VM exercises the actual authoring callbacks without claiming browser hydration. */
function combinedForm() {
const states:unknown[] = [], sent:Action[] = [];
let cursor = 0;
const exported:{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:exported, 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/board') return board;
if (name === '@/game/catalog') return catalog;
if (name === '@/game/coalition-alliance') return coalitionAlliance;
if (name === '@/game/public-deals') return publicDeals;
throw new Error(`Unexpected import ${name}`);
}});
assert.ok(exported.PublicDeals);
const game = {...base, me:'a', deals:[]} as GameView;
const render = () => {cursor = 0; return elements(exported.PublicDeals!({game,busy:false,act(action) {sent.push(action);}}));};
const check = (index:number) => {
const node = render().filter(element => element.type === 'input' && element.props.type === 'checkbox')[index];
assert.ok(node); assert.equal(node.props.disabled,false);
(node.props.onChange as (event:{target:{checked:boolean}}) => void)({target:{checked:true}});
};
const select = (index:number,value:string) => {
const node = render().filter(element => element.type === 'select')[index]; assert.ok(node);
(node.props.onChange as (event:{target:{value:string}}) => void)({target:{value}});
};
return {render, check, select, sent};
}
void test('the authoring controls compose a proposer payment with recipient travel and reject invalid combinations', () => {
const ui = combinedForm();
ui.check(0); // Select recipient.
ui.check(1); // Include payment.
ui.select(1,'a'); ui.select(2,'b');
ui.check(2); // Include travel while payment is selected.
ui.select(5,'carthag');
const terms = ui.render().find(node => node.type === 'textarea'); assert.ok(terms);
(terms.props.onChange as (event:{target:{value:string}}) => void)({target:{value:'Travel for spice'}});
const button = () => ui.render().find(node => node.type === 'button' && node.props.type === 'submit');
assert.equal(button()?.props.disabled,false);
const form = ui.render().find(node => node.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:'Travel for spice',
payment:{kind:'immediateBribe',payer:'a',recipient:'b',amount:1},
travel:{kind:'ship',territory:'carthag',minimum:1,answer:true}});
ui.select(0,'deferredBribe'); assert.equal(button()?.props.disabled,true);
ui.select(0,'immediateBribe'); ui.select(1,'b'); ui.select(2,'a');
assert.equal(button()?.props.disabled,true);
});
void test('a payment can also be added after selecting the travel term', () => {
const ui = combinedForm();
ui.check(0); ui.check(2);
ui.select(2,'carthag');
ui.check(1);
ui.select(1,'a'); ui.select(2,'b');
const terms = ui.render().find(node => node.type === 'textarea'); assert.ok(terms);
(terms.props.onChange as (event:{target:{value:string}}) => void)({target:{value:'Travel for spice'}});
const submit = ui.render().find(node => node.type === 'button' && node.props.type === 'submit');
assert.equal(submit?.props.disabled,false);
});
void test('the public deal shows the exact recipient, travel intent, turn and authoritative status', () => {
const html = markup(base);
assert.match(html, /Current-turn travel term/);
+45 -5
View File
@@ -57,21 +57,61 @@ void test('a recipient accepts and both release an unchanged current-turn travel
assert.deepEqual(withdrawPublicDeal(offered, seats, 'a').travel, term);
});
void test('travel is incompatible with multi-party, payment, replacement and malformed persisted terms', () => {
void test('travel is incompatible with multi-party, replacement and malformed persisted terms', () => {
const multi = createMultipartyPublicDeal(seats, 'a', ['b', 'c'], 'Terms', 3, 'multi');
const payment = createPublicDeal(seats, 'a', 'b', 'Terms', 3, 'paid',
{ kind: 'immediateBribe', payer: 'a', recipient: 'b', amount: 1 });
const offered = setPublicDealTravel(fresh(), term, seats);
const active = respondPublicDeal(offered, seats, 'b', true);
for (const deal of [multi, payment, offered, active])
for (const deal of [multi, offered, active])
assert.throws(() => setPublicDealTravel(deal, term, seats), PublicDealError);
for (const invalid of [
{ ...multi, travel: term }, { ...payment, travel: term },
{ ...multi, travel: term },
{ ...fresh(), travel: undefined }, { ...fresh(), travel: { ...term, sector: 0 } },
{ ...offered, paymentStatus: 'pending' }, { ...offered, travelStatus: 'pending' },
]) assert.throws(() => validatePublicDeals([invalid], seats), PublicDealError);
});
void test('a proposer-to-recipient immediate payment can accompany travel through the complete deal lifecycle', () => {
const payment = { kind: 'immediateBribe', payer: 'a', recipient: 'b', 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 reverse, deferred, multiparty and forged persisted payment shapes', () => {
const reverse = { kind: 'immediateBribe', payer: 'b', recipient: 'a', amount: 1 } as const;
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 [reverse, 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);
assert.throws(() => setPublicDealTravel(multi, term, seats), PublicDealError);
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');