Add enforceable current-turn travel deals
Verify and publish checkpoint container / container (push) Successful in 10m49s

This commit is contained in:
Quintine committed 2026-10-29 07:45:17 +11:00
1 parent ac1f32c859
commit 82c639c977
13 files changed
+777 -28

No files matched your search

@@ -7,6 +7,8 @@ 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 board from '../game/board';
import * as coalitionAlliance from '../game/coalition-alliance';
import * as publicDeals from '../game/public-deals';
import { PublicDealList, PublicDeals, publicDealOfferAction, publicDealRequests } from '../components/public-deals';
import type { Action, GameView } from '../game/engine';
@@ -99,6 +101,8 @@ function controller(game: GameView) {
};
if (name === '@/components/ui/button') return { Button: 'button' };
if (name === '@/game/catalog') return catalog;
if (name === '@/game/board') return board;
if (name === '@/game/coalition-alliance') return coalitionAlliance;
if (name === '@/game/public-deals') return publicDeals;
throw new Error(`Unexpected import ${name}`);
},
@@ -6,6 +6,8 @@ import { isValidElement, type ReactElement, type ReactNode } from 'react';
import * as jsxRuntime from 'react/jsx-runtime';
import ts from 'typescript';
import * as catalog from '../game/catalog';
import * as board from '../game/board';
import * as coalitionAlliance from '../game/coalition-alliance';
import * as publicDeals from '../game/public-deals';
import type { Action, GameView } from '../game/engine';
import type { PublicDeals } from '../components/public-deals';
@@ -56,6 +58,8 @@ function controller() {
};
if (name === '@/components/ui/button') return { Button: 'button' };
if (name === '@/game/catalog') return catalog;
if (name === '@/game/board') return board;
if (name === '@/game/coalition-alliance') return coalitionAlliance;
if (name === '@/game/public-deals') return publicDeals;
throw new Error(`Unexpected import ${name}`);
},
+119
View File
@@ -0,0 +1,119 @@
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 { TravelPromises } from '../components/travel-promises';
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 travel = {kind:'ship', territory:'carthag', minimum:3, answer:true} as const;
const payment = {kind:'immediateBribe', payer:'a', recipient:'b', amount:2} as const;
const deal = {id:'deal', from:'a', to:'b', terms:'The agreed journey.', turn:2,
status:'active', releaseVotes:[], travel};
const base = {status:'playing', turn:2, me:'b', roomControl:null, coalitionState:null,
players:[{id:'a',name:'Alice',faction:'atreides',ally:null}, {id:'b',name:'Basil',faction:'harkonnen',ally:null}],
deals:[deal], dealTravelStatus:{deal:'pending'}} as unknown as GameView;
const markup = (game:GameView, disabled = false) =>
renderToStaticMarkup(createElement(PublicDealList, {game, act() {}, disabled}));
void test('the offer packet retains the original recipient action and validates one exact physical term', () => {
assert.deepEqual(publicDealOfferAction(['b'], ' Terms ', undefined, 'a', 2, travel),
{type:'dealOffer', target:'b', terms:'Terms', travel});
const avoid = {...travel, kind:'move' as const, answer:false, minimum:20};
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.equal(publicDealOfferAction(['a'], 'Terms', undefined, 'a', 2, travel), null);
assert.equal(publicDealOfferAction(['b'], 'Terms', undefined, undefined, 2, travel), null);
for (const invalid of [
{...travel, minimum:0}, {...travel, minimum:21}, {...travel, minimum:1.5},
{...travel, territory:'not-a-territory'}, {...travel, answer:'yes'},
{...travel, kind:'ride'}, {...travel, extra:true},
]) assert.equal(publicDealOfferAction(['b'], 'Terms', undefined, 'a', 2,
invalid as unknown as typeof travel), null);
// Older callers retain their original exact wire packets.
assert.deepEqual(publicDealOfferAction(['b'], ' Terms '), {type:'dealOffer', target:'b', terms:'Terms'});
assert.deepEqual(publicDealOfferAction(['b','c'], 'Terms'), {type:'dealOffer', targets:['b','c'], terms:'Terms'});
});
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/);
assert.match(html, /Basil \(Harkonnen\) agrees to make a reserve shipment of at least 3 physical forces in one action to Carthag during turn 2/);
assert.match(html, /Travel status: pending/);
const avoid = {...base, me:'a', deals:[{...deal, travel:{kind:'move',territory:'arrakeen',minimum:2,answer:false}}],
dealTravelStatus:{deal:'fulfilled'}} as unknown as GameView;
assert.match(markup(avoid), /agrees to avoid a ground movement of at least 2 physical forces in one action to Arrakeen/);
assert.match(markup(avoid), /Travel status: fulfilled/);
for (const status of ['void','released'] as const)
assert.match(markup({...base, dealTravelStatus:{deal:status}} as unknown as GameView), new RegExp(`Travel status: ${status}`));
});
void test('only the obligated recipient gets a persistent pending reminder', () => {
assert.deepEqual(publicDealRequests(base), [['travel','deal']]);
assert.match(renderToStaticMarkup(createElement(PublicDeals,{game:base,act() {},busy:false})), /Public deals · travel term pending/);
assert.deepEqual(publicDealRequests({...base,me:'a'}), []);
assert.deepEqual(publicDealRequests({...base,dealTravelStatus:{deal:'fulfilled'}} as unknown as GameView), []);
assert.deepEqual(publicDealRequests({...base,deals:[{...deal,status:'released'}]} as unknown as GameView), []);
});
void test('an offered travel term remains actionable to the recipient until its current turn has passed', () => {
const offered = {...base,deals:[{...deal,status:'offered'}],dealTravelStatus:{}} as unknown as GameView;
const actions:Action[] = [];
const tree = PublicDealList({game:offered,disabled:false,act(action) {actions.push(action);}});
const accept = elements(tree).find(node => node.props.children === 'Accept deal');
assert.ok(accept); assert.equal(accept.props.disabled,false);
(accept.props.onClick as () => void)();
assert.deepEqual(actions,[{type:'dealRespond',deal:'deal',accept:true}]);
const expired = {...offered,turn:3};
assert.match(markup(expired), /travel term’s turn has passed/);
const expiredTree = PublicDealList({game:expired,disabled:false,act() {}});
assert.equal(elements(expiredTree).find(node => node.props.children === 'Accept deal')?.props.disabled,true);
assert.equal(elements(expiredTree).find(node => node.props.children === 'Decline deal')?.props.disabled,false);
});
void test('declined and withdrawn travel offers do not claim to be awaiting acceptance', () => {
for (const status of ['declined', 'withdrawn'] as const) {
const closed = {...base, deals:[{...deal, status}], dealTravelStatus:{}} as unknown as GameView;
const html = markup(closed);
assert.match(html, /The term did not become binding/);
assert.match(html, /Basil \(Harkonnen\) would make a reserve shipment/);
assert.doesNotMatch(html, /Basil \(Harkonnen\) agrees to/);
assert.doesNotMatch(html, /The term becomes binding on final acceptance/);
}
});
void test('the recipient guide distinguishes a void deal from unanimous release', () => {
// Staged public projections isolate presentation; native creation and lifecycle
// are exercised by the separate runtime integration cases.
const promise = {event:'travel-event', sourceDeal:'deal', player:'b', asker:'a', turn:2,
mixed:{kind:'action', action:{kind:'ship', territory:'carthag', minimum:3}},
answer:true, fulfilled:false, released:true};
for (const [status, expected, absent] of [
['void', /Void: this agreement is no longer possible/, /Released: this whole commitment/],
['released', /Released: this whole commitment is no longer binding/, /Void: this agreement/],
] as const) {
const game = {...base, allLeaders:[], combatLocations:[{id:'carthag',name:'Carthag'}],
travelPromises:[promise], dealTravelStatus:{deal:status}} as unknown as GameView;
const html = renderToStaticMarkup(createElement(TravelPromises,{game, busy:false, act() {}}));
assert.match(html, /Public deal: agreed Yes/);
assert.match(html, expected);
assert.doesNotMatch(html, absent);
assert.match(html, /after the answer or deal acceptance/);
assert.equal(TravelPromises({game:{...game, me:'a'}, busy:false, act() {}}), null);
}
});
void test('the real offer form explains single-recipient eligibility before a travel term is selected', () => {
const html = renderToStaticMarkup(createElement(PublicDeals,{game:base,act() {},busy:false}));
assert.match(html, /Include a current-turn travel term/);
assert.match(html, /Select exactly one human recipient to add a travel term/);
assert.match(html, /cannot automatically enforce free-text promises/);
});
+292
View File
@@ -0,0 +1,292 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { normalizeAutomaticGame, viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { actTruthTravel as act, createTruthTravelFixture as fixture,
advanceTruthTravel as advance, settleTruthTravel as settle, truthTravelInventory as inventory,
truthTravelPlayer as player } from './fixture-truth-travel';
import { bindTruthBribe, createTruthBribeFixture } from './fixture-truth-bribe';
import { createTruthMixedBribeFixture, declareTruthMixedBribe } from './fixture-truth-mixed-bribe';
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, 'Rejected actions do not modify the table.');
}
function offer(game: Game, proposer: string, target: string, travel: Term) {
const next = act(game, proposer, { type: 'dealOffer', target, terms: 'Agreed travel this turn.', travel });
const deal = next.deals?.at(-1); assert.ok(deal);
return { game: next, id: deal.id };
}
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];
}
function receipt(game: Game, id: string) {
const found = game.travelPromises?.find(row => row.sourceDeal === id);
assert.ok(found, 'Final consent creates a source-linked native travel receipt.');
return found;
}
/** Original Basic base33 setup and real first-turn phase-five prefix from the
* native TruthTravel fixture. Only its unopened deal/Spice order is selected;
* these tests offer and accept the agreement through real player actions. */
void test('accepted shipment Yes preserves the real shipment, forbids divergence and settles once', () => {
const f = fixture(), before = f.game;
const term: Term = { kind: 'ship', territory: f.shipmentTerritory, minimum: 1, answer: true };
const offered = offer(before, f.asker, f.target, term);
assert.equal(offered.game.travelPromises?.length ?? 0, 0, 'An offer alone creates no commitment.');
const game = accept(offered.game, f.target, offered.id);
assert.equal(receipt(game, offered.id).player, f.target);
assert.equal(receipt(game, offered.id).asker, f.asker);
for (const viewer of [f.asker, f.target]) assert.equal(status(game, viewer, offered.id), 'pending');
reject(game, f.target, { ...f.shipmentAction, territory: 'carthag', sector: 11 });
reject(game, f.target, f.currentEnd);
const next = settle(act(game, f.target, f.shipmentAction));
assert.equal(player(next, f.target).forces['polar_sink:0'], 1);
assert.equal(player(next, f.target).reserves, player(before, f.target).reserves - 1);
assert.equal(status(next, f.asker, offered.id), 'fulfilled');
assert.equal(status(next, f.target, offered.id), 'fulfilled');
assert.equal(next.travelPromises!.filter(row => row.sourceDeal === offered.id).length, 1);
assert.deepEqual(inventory(next), f.originalCards);
});
void test('a negative shipment term forbids its matching physical shipment and settles after closing', () => {
const f = fixture(), offered = offer(f.game, f.asker, f.target,
{ kind: 'ship', territory: f.shipmentTerritory, minimum: 1, answer: false });
let game = accept(offered.game, f.target, offered.id);
assert.equal(status(game, f.target, offered.id), 'pending');
reject(game, f.target, f.shipmentAction);
game = settle(act(game, f.target, f.currentEnd));
assert.equal(status(game, f.target, offered.id), 'fulfilled');
assert.equal(player(game, f.target).forces['polar_sink:0'] ?? 0, 0);
assert.deepEqual(inventory(game), f.originalCards);
});
for (const answer of [true, false]) void test(`accepted movement ${answer ? 'Yes' : 'No'} uses the original physical ground action`, () => {
const f = fixture({ window: 'shipped' });
const offered = offer(f.game, f.asker, f.target,
{ kind: 'move', territory: f.movementTerritory, minimum: 1, answer });
let game = accept(offered.game, f.target, offered.id);
assert.equal(receipt(game, offered.id).player, f.target);
assert.equal(status(game, f.target, offered.id), 'pending');
if (answer) {
reject(game, f.target, f.currentEnd);
game = settle(act(game, f.target, f.firstMove));
assert.equal(player(game, f.target).forces['imperial_basin:9'], 1);
} else {
reject(game, f.target, f.firstMove);
game = settle(act(game, f.target, f.currentEnd));
assert.equal(player(game, f.target).forces['imperial_basin:9'] ?? 0, 0);
}
assert.equal(status(game, f.asker, offered.id), 'fulfilled');
assert.deepEqual(inventory(game), f.originalCards);
});
void test('both parties must release a pending obligation and only then may its target close', () => {
const f = fixture(), offered = offer(f.game, f.asker, f.target,
{ kind: 'ship', territory: f.shipmentTerritory, minimum: 1, answer: true });
let game = accept(offered.game, f.target, offered.id);
game = act(game, f.asker, { type: 'dealRelease', deal: offered.id, agree: true });
assert.equal(status(game, f.target, offered.id), 'pending');
reject(game, f.target, f.currentEnd);
game = act(game, f.target, { type: 'dealRelease', deal: offered.id, agree: true });
assert.equal(status(game, f.target, offered.id), 'released');
assert.equal(receipt(game, offered.id).released, true);
game = settle(act(game, f.target, f.currentEnd));
assert.equal(status(game, f.asker, offered.id), 'released');
assert.deepEqual(inventory(game), f.originalCards);
});
void test('a released deal cannot retain an unfulfilled live travel receipt', () => {
const f = fixture(), offered = offer(f.game, f.asker, f.target,
{ kind: 'ship', territory: f.shipmentTerritory, minimum: 1, answer: true });
let game = accept(offered.game, f.target, offered.id);
game = act(game, f.asker, { type: 'dealRelease', deal: offered.id, agree: true });
game = act(game, f.target, { type: 'dealRelease', deal: offered.id, agree: true });
assert.equal(status(game, f.target, offered.id), 'released');
assert.equal(receipt(game, offered.id).fulfilled, false);
assert.equal(receipt(game, offered.id).released, true);
const corrupted = structuredClone(game);
receipt(corrupted, offered.id).released = false;
const before = structuredClone(corrupted);
assert.throws(() => viewGame(corrupted, f.target));
assert.deepEqual(corrupted, before);
reject(corrupted, f.target, f.currentEnd);
});
void test('travel terms reject payment combinations, multiparty packets and malformed claims', () => {
const f = fixture(), term: Term = { kind: 'ship', territory: f.shipmentTerritory, minimum: 1, answer: true };
const basic = { type: 'dealOffer', target: f.target, terms: 'Agreed travel.', travel: term } satisfies Action;
reject(f.game, f.asker, { ...basic, payment: { kind: 'deferredBribe', payer: f.target,
recipient: f.asker, amount: 1, dueTurn: f.game.turn } });
reject(f.game, f.asker, { type: 'dealOffer', targets: [f.target, f.asker], terms: 'Travel.', travel: term });
reject(f.game, f.asker, { ...basic, travel: { ...term, minimum: 0 } });
reject(f.game, f.asker, { ...basic, travel: { ...term, answer: 'yes' } });
reject(f.game, f.asker, { ...basic, travel: { ...term, territory: 'not_a_territory' } });
});
void test('two compatible accepted agreements share one real qualifying shipment', () => {
const f = fixture(), term: Term = { kind: 'ship', territory: f.shipmentTerritory, minimum: 1, answer: true };
const first = offer(f.game, f.asker, f.target, term);
let game = accept(first.game, f.target, first.id);
const second = offer(game, f.asker, f.target, term);
game = accept(second.game, f.target, second.id);
assert.notEqual(receipt(game, first.id).event, receipt(game, second.id).event);
assert.equal(status(game, f.target, first.id), 'pending');
assert.equal(status(game, f.target, second.id), 'pending');
const next = settle(act(game, f.target, f.shipmentAction));
assert.equal(status(next, f.target, first.id), 'fulfilled');
assert.equal(status(next, f.target, second.id), 'fulfilled');
assert.equal(player(next, f.target).reserves, player(f.game, f.target).reserves - 1);
assert.deepEqual(inventory(next), f.originalCards);
});
void test('final consent to a conflicting obligation rolls back without disturbing the original', () => {
const f = fixture(), yes: Term = { kind: 'ship', territory: f.shipmentTerritory, minimum: 1, answer: true };
const first = offer(f.game, f.asker, f.target, yes);
let game = accept(first.game, f.target, first.id);
const second = offer(game, f.asker, f.target, { ...yes, answer: false });
game = second.game;
const before = structuredClone(game);
reject(game, f.target, { type: 'dealRespond', deal: second.id, accept: true });
assert.deepEqual(game, before);
assert.equal(game.deals!.find(deal => deal.id === second.id)!.status, 'offered');
assert.equal(game.travelPromises!.filter(row => row.sourceDeal === second.id).length, 0);
assert.equal(status(game, f.target, first.id), 'pending');
const completed = settle(act(game, f.target, f.shipmentAction));
assert.equal(status(completed, f.target, first.id), 'fulfilled');
});
void test('a pending travel term prevents final consent to a separate current-turn deferred bribe', () => {
const f = fixture(), offered = offer(f.game, f.asker, f.target,
{ kind: 'ship', territory: f.shipmentTerritory, minimum: 1, answer: true });
let game = accept(offered.game, f.target, offered.id);
game = act(game, f.asker, { type: 'dealOffer', target: f.target, terms: 'A separate payment this turn.',
payment: { kind: 'deferredBribe', payer: f.target, recipient: f.asker, amount: 1, dueTurn: game.turn } });
const payment = game.deals!.at(-1)!;
assert.notEqual(payment.id, offered.id);
assert.equal(payment.status, 'offered');
const before = structuredClone(game);
reject(game, f.target, { type: 'dealRespond', deal: payment.id, accept: true });
assert.deepEqual(game, before);
assert.equal(game.deals!.find(deal => deal.id === offered.id)!.status, 'active');
assert.equal(game.deals!.find(deal => deal.id === payment.id)!.status, 'offered');
assert.equal(status(game, f.target, offered.id), 'pending');
assert.equal(receipt(game, offered.id).released, false);
const completed = settle(act(game, f.target, f.shipmentAction));
assert.equal(status(completed, f.target, offered.id), 'fulfilled');
});
void test('final travel consent rejects a target with a live native Truthtrance bribe promise', () => {
const f = createTruthBribeFixture();
const term: Term = { kind: 'ship', territory: 'polar_sink', minimum: 1, answer: true };
// Control: the same original timing supports the travel deal by itself.
const control = offer(f.game, f.asker, f.target, term);
assert.equal(status(accept(control.game, f.target, control.id), f.target, control.id), 'pending');
const bound = bindTruthBribe(f, 'yes').game;
assert.ok(bound.bribePromises?.some(row => row.player === f.target && !row.fulfilled && !row.released));
const offered = offer(bound, f.asker, f.target, term);
const before = structuredClone(offered.game);
reject(offered.game, f.target, { type: 'dealRespond', deal: offered.id, accept: true });
assert.deepEqual(offered.game, before);
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);
assert.ok(offered.game.bribePromises?.some(row => row.player === f.target && !row.fulfilled && !row.released));
});
void test('a pending public travel deal leaves simple and mixed bribe questions askable with only Unknown', () => {
const f = createTruthMixedBribeFixture();
const offered = offer(f.game, f.asker, f.target,
{ kind: 'ship', territory: 'polar_sink', minimum: 1, answer: true });
const accepted = accept(offered.game, f.target, offered.id);
const asking = declareTruthMixedBribe(f, accepted).game;
const simple: Action = { type: 'truthAsk', question: { kind: 'bribe', target: f.target,
turn: asking.turn, recipient: f.asker, minimum: 3 } };
for (const [question, kind] of [[simple, 'simple'], [f.questionAction, 'mixed']] as const) {
const asked = act(asking, f.asker, question), target = viewGame(asked, f.target);
assert.deepEqual(kind === 'simple' ? target.truthBribeAnswers : target.truthMixedBribeAnswers, ['unknown']);
reject(asked, f.target, { type: 'truthAnswer', answer: 'yes' });
reject(asked, f.target, { type: 'truthAnswer', answer: 'no' });
const answered = act(asked, f.target, { type: 'truthAnswer', answer: 'unknown' });
assert.equal(answered.truthHistory!.at(-1)!.answer, 'unknown');
assert.equal(answered.bribePromises?.length ?? 0, 0);
assert.equal(answered.bribeExpressionPromises?.length ?? 0, 0);
assert.equal(answered.mixedBribeQuestions?.length ?? 0, 0);
assert.equal(status(answered, f.target, offered.id), 'pending');
assert.equal(receipt(answered, offered.id).released, false);
}
});
void test('an unanswered travel offer cannot acquire an obligation after its original turn', () => {
const f = fixture(), offered = offer(f.game, f.asker, f.target,
{ kind: 'ship', territory: f.shipmentTerritory, minimum: 1, answer: true });
const nextTurn = advance(offered.game, game => game.turn > f.game.turn);
assert.ok(nextTurn.turn > f.game.turn);
reject(nextTurn, f.target, { type: 'dealRespond', deal: offered.id, accept: true });
assert.equal(nextTurn.travelPromises?.some(row => row.sourceDeal === offered.id) ?? false, false);
assert.equal(nextTurn.deals!.find(deal => deal.id === offered.id)!.status, 'offered');
assert.deepEqual(inventory(nextTurn), f.originalCards);
});
void test('voluntary spice spending and premature ground movement cannot eliminate promised completion', () => {
const f = fixture({ hajr: false }), offered = offer(f.game, f.asker, f.target,
{ kind: 'ship', territory: f.shipmentTerritory, minimum: 1, answer: true });
let game = accept(offered.game, f.target, offered.id);
reject(game, f.target, { type: 'bribe', target: f.asker, amount: player(game, f.target).spice - 1 });
reject(game, f.target, f.currentEnd);
game = settle(act(game, f.target, f.shipmentAction));
assert.equal(status(game, f.target, offered.id), 'fulfilled');
assert.deepEqual(inventory(game), f.originalCards);
const moved = fixture({ window: 'shipped', hajr: false });
const ground = offer(moved.game, moved.asker, moved.target,
{ kind: 'move', territory: moved.movementTerritory, minimum: 1, answer: true });
let awaiting = accept(ground.game, moved.target, ground.id);
reject(awaiting, moved.target, moved.secondMove);
reject(awaiting, moved.target, moved.currentEnd);
awaiting = settle(act(awaiting, moved.target, moved.firstMove));
assert.equal(status(awaiting, moved.target, ground.id), 'fulfilled');
});
void test('a staged external loss makes an impossible agreement void without retroactive consent', () => {
const f = fixture(), offered = offer(f.game, f.asker, f.target,
{ kind: 'ship', territory: f.shipmentTerritory, minimum: 1, answer: true });
const accepted = accept(offered.game, f.target, offered.id);
// Isolate the aftermath of a foreign resource loss; this is not claimed as
// an actual opponent action in the two-player native chronology.
const externallyChanged = structuredClone(accepted);
player(externallyChanged, f.target).spice = 0;
const reconciled = normalizeAutomaticGame(externallyChanged);
assert.equal(status(reconciled, f.target, offered.id), 'void');
assert.equal(receipt(reconciled, offered.id).released, true);
assert.equal(reconciled.deals!.find(deal => deal.id === offered.id)!.status, 'active');
assert.deepEqual(inventory(reconciled), f.originalCards);
let released = act(reconciled, f.asker, { type: 'dealRelease', deal: offered.id, agree: true });
assert.equal(status(released, f.target, offered.id), 'void');
released = act(released, f.target, { type: 'dealRelease', deal: offered.id, agree: true });
assert.equal(released.deals!.find(deal => deal.id === offered.id)!.status, 'released');
assert.equal(status(released, f.asker, offered.id), 'void');
assert.equal(status(released, f.target, offered.id), 'void');
assert.notEqual(receipt(released, offered.id).releasedByDeal, true);
});
void test('all four autopilot profiles follow an accepted shipment commitment through legal actions', () => {
const f = fixture(), offered = offer(f.game, f.asker, f.target,
{ kind: 'ship', territory: f.shipmentTerritory, minimum: 1, answer: true });
const accepted = accept(offered.game, f.target, offered.id);
for (const difficulty of DIFFICULTIES) {
let game = act(accepted, f.target, { type: 'setAutopilot', difficulty });
for (let step = 0; step < 8 && status(game, f.target, offered.id) === 'pending'; step++) {
const actions = botActions(viewGame(game, f.target));
assert.ok(actions.length, `${difficulty} needs a legal continuation`);
game = settle(act(game, f.target, actions[0]));
}
assert.equal(status(game, f.target, offered.id), 'fulfilled', difficulty);
assert.deepEqual(inventory(game), f.originalCards);
}
});
+83
View File
@@ -0,0 +1,83 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
createMultipartyPublicDeal, createPublicDeal, parsePublicDealTravel, PublicDealError,
releasePublicDeal, respondPublicDeal, setPublicDealTravel, validatePublicDeals, withdrawPublicDeal,
type PublicDeal, type PublicDealSeat,
} from '../game/public-deals';
const seats: PublicDealSeat[] = [{ id: 'a' }, { id: 'b' }, { id: 'c' }];
const term = { kind: 'ship', territory: 'polar_sink', minimum: 1, answer: true } as const;
const fresh = () => createPublicDeal(seats, 'a', 'b', 'Travel this turn.', 3, 'offer');
void test('travel terms accept only exact bounded native actions and an explicit answer', () => {
for (const kind of ['ship', 'move'] as const) for (const answer of [true, false]) {
for (const minimum of [1, 20]) {
const input = { kind, territory: 'polar_sink', minimum, answer };
assert.deepEqual(parsePublicDealTravel(input), input);
assert.notEqual(parsePublicDealTravel(input), input);
}
}
for (const invalid of [null, undefined, [], {},
{ ...term, kind: 'fly' }, { ...term, territory: 'not_a_territory' },
...[0, -1, 21, 1.5, Infinity, Number.MAX_SAFE_INTEGER + 1, '1'].map(minimum => ({ ...term, minimum })),
{ ...term, answer: 'yes' }, { ...term, answer: undefined },
{ ...term, sector: 0 }, { ...term, turn: 4 }, { ...term, actor: 'a' },
]) assert.throws(() => parsePublicDealTravel(invalid), PublicDealError);
});
void test('a recipient accepts and both release an unchanged current-turn travel term', () => {
const source = fresh();
const input = { kind: 'ship' as const, territory: 'polar_sink', minimum: 1, answer: true };
const offered = setPublicDealTravel(source, input, seats);
input.minimum = 2;
assert.equal(source.travel, undefined);
assert.deepEqual(offered.travel, term);
assert.equal(offered.turn, 3);
assert.throws(() => respondPublicDeal(offered, seats, 'a', true), PublicDealError);
assert.throws(() => respondPublicDeal(offered, seats, 'c', true), PublicDealError);
const active = respondPublicDeal(offered, seats, 'b', true);
assert.equal(active.status, 'active');
assert.deepEqual(active.travel, term);
const first = releasePublicDeal(active, seats, 'a', true);
assert.equal(first.status, 'active');
const released = releasePublicDeal(first, seats, 'b', true);
assert.equal(released.status, 'released');
assert.deepEqual(released.travel, term);
const roundtrip = JSON.parse(JSON.stringify([offered, active, released])) as PublicDeal[];
// Ledger IDs must be unique; each lifecycle snapshot is validated separately.
for (const deal of roundtrip) {
const before = JSON.stringify(deal);
validatePublicDeals([deal], seats);
assert.equal(JSON.stringify(deal), before);
assert.deepEqual(deal.travel, term);
assert.equal(Object.hasOwn(deal, 'travelStatus'), false);
}
assert.deepEqual(respondPublicDeal(offered, seats, 'b', false).travel, term);
assert.deepEqual(withdrawPublicDeal(offered, seats, 'a').travel, term);
});
void test('travel is incompatible with multi-party, payment, 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])
assert.throws(() => setPublicDealTravel(deal, term, seats), PublicDealError);
for (const invalid of [
{ ...multi, travel: term }, { ...payment, travel: term },
{ ...fresh(), travel: undefined }, { ...fresh(), travel: { ...term, sector: 0 } },
{ ...offered, paymentStatus: 'pending' }, { ...offered, travelStatus: 'pending' },
]) 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');
const before = JSON.stringify([legacy, multi]);
validatePublicDeals([legacy, multi], seats);
assert.equal(JSON.stringify([legacy, multi]), before);
assert.equal(Object.hasOwn(legacy, 'travel'), false);
assert.equal(Object.hasOwn(multi, 'travel'), false);
});