Combine current-turn payments with shipment and movement promises

This commit is contained in:
Quintine committed 2026-10-26 21:09:49 +11:00
1 parent 8dbec05844
commit 0d72a33ee5
18 files changed
+2591 -7

No files matched your search

+153
View File
@@ -0,0 +1,153 @@
import assert from 'node:assert/strict';
import { viewGame, type Action, type Game } from '../game/engine';
import type { MixedActionExpression } from '../game/mixed-action-question';
import type { TruthFact, TruthQuestion } from '../game/truthtrance';
import {
actTruthBid, nextTruthBid, truthBidClean, truthBidInventory, truthBidPlayer, type TruthBidStep,
} from './fixture-truth-bid';
import { createTruthBribeExpressionFixture } from './fixture-truth-bribe-expression';
import { nextTruthBribe } from './fixture-truth-bribe';
import { createTruthTravelFixture } from './fixture-truth-travel';
import { createTruthTravelOrderFixture } from './fixture-truth-travel-order';
export type TruthTurnActionClaim = { kind: 'ship' | 'move'; territory: string; minimum: number }
| { kind: 'payment'; recipient: string; minimum: number };
export type TruthTurnActionExpression = MixedActionExpression<TruthTurnActionClaim>;
export type TruthTurnActionStep = TruthBidStep;
export const actTruthTurnAction = actTruthBid;
export const truthTurnActionClean = truthBidClean;
export const truthTurnActionInventory = truthBidInventory;
export const truthTurnActionPlayer = truthBidPlayer;
export const truthTurnActionFact = (fact: TruthFact): TruthTurnActionExpression => ({ kind: 'fact', fact });
export const truthTurnActionPayment = (recipient: string, minimum = 2): TruthTurnActionExpression =>
({ kind: 'action', action: { kind: 'payment', recipient, minimum } });
export const truthTurnActionShip = (territory = 'polar_sink', minimum = 1): TruthTurnActionExpression =>
({ kind: 'action', action: { kind: 'ship', territory, minimum } });
export const truthTurnActionMove = (territory = 'imperial_basin', minimum = 1): TruthTurnActionExpression =>
({ kind: 'action', action: { kind: 'move', territory, minimum } });
export const truthTurnActionGroup = (kind: 'and' | 'or', ...terms: TruthTurnActionExpression[]): TruthTurnActionExpression =>
({ kind, terms });
/** These adapters select existing genuine original public sources only. The
* default keeps Harkonnen's two opening Truthtrances and Atreides' unused
* movement turn; Hajr keeps its actually dealt card, and Mentat earns the late
* payment window through original native actions. No played state is set. */
export function createTruthTurnActionFixture(options: {
source?: 'twoTruths' | 'hajr' | 'mentat'; advanced?: boolean; mixed?: TruthTurnActionExpression;
} = {}) {
const sourceKind = options.source ?? 'twoTruths';
const orderSource = sourceKind === 'twoTruths' ? createTruthTravelOrderFixture() : null;
const hajrSource = sourceKind === 'hajr' ? createTruthTravelFixture() : null;
const mentatSource = sourceKind === 'mentat' ? createTruthBribeExpressionFixture({ phase: 8, advanced: options.advanced }) : null;
const original = orderSource ?? hajrSource ?? mentatSource!;
const game = structuredClone(original.game), asker = original.asker, target = original.target;
const observer = game.players.find(player => player.id !== asker && player.id !== target)?.id ?? asker;
const truths = orderSource ? structuredClone(orderSource.truths) : mentatSource
? structuredClone(mentatSource.truths) : [hajrSource!.truth];
const recipient = observer;
const mixed = structuredClone(options.mixed ?? (sourceKind === 'mentat'
? truthTurnActionGroup('or', truthTurnActionPayment(recipient), truthTurnActionMove())
: truthTurnActionGroup('and', truthTurnActionPayment(recipient), truthTurnActionShip(), truthTurnActionMove())));
const question = { kind: 'turnActions', target, turn: game.turn, mixed } satisfies TruthQuestion;
const actions = structuredClone(original.sourceActions);
const shipment = orderSource?.shipment ?? hajrSource?.shipmentAction ??
{ type: 'ship', territory: 'polar_sink', sector: 0, amount: 1, elite: 0, allyPayment: 0 } satisfies Action;
const move = orderSource?.move ?? hajrSource?.firstMove ??
{ type: 'move', from: 'arrakeen:10', territory: 'imperial_basin', sector: 9, amount: 1 } satisfies Action;
const spiceFact: TruthFact = { kind: 'spice', compare: 'gte', value: truthBidPlayer(game, target).spice };
assert.ok(truthBidClean(game)); assert.equal(game.pendingShipment ?? null, null);
assert.equal(truthBidPlayer(game, recipient).ally, null);
assert.ok(truths.every(truth => truthBidPlayer(game, asker).hand.some(card => card.id === truth)));
assert.deepEqual(truthBidInventory(game), original.originalCards);
assert.equal(game.turnActionPromises?.length ?? 0, 0);
return {
game, source: structuredClone(game), sourceKind, orderSource, hajrSource, mentatSource,
asker, target, observer, recipient, truths, truth: truths[0], mixed, question, spiceFact,
setup: original.setup, afterSetup: original.afterSetup, beforeStartingDeal: original.beforeStartingDeal,
originalCards: original.originalCards, originalSpice: original.originalSpice,
dealOrder: original.dealOrder, spiceOrder: original.spiceOrder,
actions, sourceActions: actions, sourceActionCount: actions.length, sourceCount: actions.length,
sourceCounts: { selected: actions.length,
inherited: orderSource?.sourceCounts.inheritedBidding ?? hajrSource?.sourceCounts.inherited ?? mentatSource!.sourceActionCount,
nativeContinuation: orderSource?.sourceCounts.nativeContinuation ?? 0 },
shipment: structuredClone(shipment), move: structuredClone(move), shipmentCost: 2,
payment: { type: 'bribe', target: recipient, amount: 2 } satisfies Action,
end: { type: 'endMovement' } satisfies Action,
hajr: hajrSource?.hajr ?? null, hajrAction: hajrSource?.hajrAction ?? null,
sourceDisclosure: `${original.sourceDisclosure} This turn-action adapter changes only the raw question. ` +
'Future ordinary payments, the original reserve shipment and physical movement groups retain their real native actions. ' +
'All counts derive from recorded arrays; no extra card, hand, wallet, army, clock, promise, receipt or compiled fact is installed.',
};
}
export type TruthTurnActionFixture = ReturnType<typeof createTruthTurnActionFixture>;
export function declareTruthTurnAction(f: TruthTurnActionFixture, source = f.game, copy: 0 | 1 = 0,
actions: TruthTurnActionStep[] = []) {
assert.ok(f.truths[copy], 'The source must actually hold this distinct printed Truthtrance');
const before = structuredClone(source), from = actions.length;
let game = actTruthTurnAction(source, f.asker, { type: 'card', card: f.truths[copy] }, actions);
while (game.truthtrance?.stage === 'priority') {
const actor = game.players.find(player => !game.truthtrance!.passed.includes(player.id)); assert.ok(actor);
game = actTruthTurnAction(game, actor.id, { type: 'truthPass' }, actions);
}
assert.equal(game.truthtrance?.stage, 'ask');
return { game, before, actions, steps: structuredClone(actions.slice(from)), actionCount: actions.length - from };
}
export function askTruthTurnAction(game: Game, asker: string, target: string, mixed: TruthTurnActionExpression,
actions: TruthTurnActionStep[] = []): Game {
return actTruthTurnAction(game, asker, { type: 'truthAsk', question: {
kind: 'turnActions', target, turn: game.turn, mixed: structuredClone(mixed),
} }, actions);
}
export function answerTruthTurnAction(game: Game, target: string, answer: 'yes' | 'no',
actions: TruthTurnActionStep[] = []): Game {
return actTruthTurnAction(game, target, { type: 'truthAnswer', answer }, actions);
}
export function bindTruthTurnAction(f: TruthTurnActionFixture, answer: 'yes' | 'no' = 'yes', mixed = f.mixed,
source = f.game, copy: 0 | 1 = 0, actions: TruthTurnActionStep[] = []) {
const from = actions.length, declared = declareTruthTurnAction(f, source, copy, actions);
const asked = askTruthTurnAction(declared.game, f.asker, f.target, mixed, actions);
const game = answerTruthTurnAction(asked, f.target, answer, actions);
return { game, declared, asked, actions, actionCount: actions.length - from };
}
export function truthTurnActionPromise(game: Game, target = game.turnActionPromises?.[0]?.player ?? game.players[0].id) {
const row = viewGame(game, target).turnActionPromises.find(promise => promise.player === target);
assert.ok(row, 'The respondent retains the original whole turn-action question and status'); return row;
}
export function nextTruthTurnAction(game: Game): TruthTurnActionStep | null {
if (game.truthtrance) return null;
for (const player of game.players) {
const guide = viewGame(game, player.id).turnActionGuide;
if (guide) return { actor: player.id, action: guide.action };
}
return nextTruthBribe(game);
}
export function advanceTruthTurnAction(game: Game, until: (state: Game) => boolean,
actions: TruthTurnActionStep[] = [], nativeOnly = false): Game {
for (let count = 0; count < 1200; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished', 'The genuine source ended before its requested native boundary');
const next = nativeOnly ? nextTruthBid(game) : nextTruthTurnAction(game);
assert.ok(next, 'Supply the actual original Truthtrance question, answer or owned payment choice');
game = actTruthTurnAction(game, next.actor, next.action, actions);
}
throw Error('The genuine turn-action source did not reach its finite boundary');
}
export function settleTruthTurnAction(game: Game, actions: TruthTurnActionStep[] = []): Game {
return advanceTruthTurnAction(game, state => truthBidClean(state) && !state.pendingShipment, actions);
}
export function completeTruthTurnAction(game: Game, target = game.turnActionPromises?.[0]?.player ?? game.players[0].id,
actions: TruthTurnActionStep[] = []): Game {
for (let count = 0; count < 24; count++) {
const promise = truthTurnActionPromise(game, target);
if (promise.fulfilled || promise.released) return game;
game = settleTruthTurnAction(game, actions);
const guide = viewGame(game, target).turnActionGuide; assert.ok(guide, 'The accepted whole answer has an actual native guide');
game = settleTruthTurnAction(actTruthTurnAction(game, target, guide.action, actions), actions);
}
throw Error('The exact native turn-action guide did not settle the accepted whole answer');
}
export function finishTruthTurnAction(game: Game, actions: TruthTurnActionStep[] = []): Game {
const turn = game.turn;
return advanceTruthTurnAction(game, state => state.turn === turn + 1 && state.phase === 0 && truthBidClean(state), actions);
}
+411
View File
@@ -0,0 +1,411 @@
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 { TurnActionFields, turnActionInputError } from '../components/turn-action-fields';
import { TurnActionAnswers, TurnActionGuideButton, TurnActionPromises } from '../components/turn-action-promises';
import { TruthFactFields } from '../components/mixed-shipment-fields';
import { createGame, joinGame, newPlayer, viewGame, type Action, type GameView } from '../game/engine';
import type { MixedActionExpression } from '../game/mixed-action-question';
import { createRevivalOrder } from '../game/revival-order';
import { createRicheseAuction, projectRicheseAuction } from '../game/richese-auction';
import type { TurnActionLeaf } from '../game/turn-action-promises';
import type { TruthFact } from '../game/truthtrance';
type Expression = MixedActionExpression<TurnActionLeaf>;
type ElementProps = { children?: ReactNode; [key: string]: unknown };
type Element = ReactElement<ElementProps>;
type Props = Parameters<typeof TurnActionGuideButton>[0];
const event = 'controlled-turn-action';
const fact = (value: TruthFact = { kind: 'hand', name: 'Shield' }): Expression => ({ kind: 'fact', fact: value });
const physical = (kind: 'ship' | 'move' = 'ship', minimum = 1, territory = 'carthag'): Expression =>
({ kind: 'action', action: { kind, territory, minimum } });
const payment = (minimum = 3, recipient = 'r'): Expression => ({ kind: 'action', action: { kind: 'payment', recipient, minimum } });
const claim = (): Expression => ({ kind: 'or', terms: [payment(), { kind: 'and', terms: [fact(), physical('ship', 3), physical('move', 4, 'arrakeen')] }] });
function elements(node: ReactNode): Element[] {
if (Array.isArray(node)) return node.flatMap(elements);
if (!isValidElement<ElementProps>(node)) return [];
return [node, ...elements(node.props.children)];
}
function find(tree: ReactNode, key: string, value: string): Element {
const found = elements(tree).find(element => element.props[key] === value);
assert.ok(found, `${key}=${value}`); return found;
}
const control = (tree: ReactNode, key: string) => find(tree, 'data-turn-action-control', key);
const click = (button: Element) => (button.props.onClick as () => void)();
function select(tree: ReactNode, path: string, field: string, value: string) {
(find(tree, 'id', `truth-turn-action-${path}-${field}`).props.onChange as
(event: { currentTarget: { value: string } }) => void)({ currentTarget: { value } });
}
function minimum(tree: ReactNode, path: string, valueAsNumber: number) {
(find(tree, 'id', `truth-turn-action-${path}-minimum`).props.onChange as
(event: { currentTarget: { valueAsNumber: number } }) => void)({ currentTarget: { valueAsNumber } });
}
function editFact(tree: ReactNode, path: string, value: TruthFact) {
const scope = find(tree, 'data-turn-action-fact-path', path);
const fields = elements(scope).find(element => element.type === TruthFactFields); assert.ok(fields);
(fields.props.onChange as (next: TruthFact) => void)(value);
}
/** Controlled projections verify current UI ownership and exact dispatch only;
* they do not establish native answer feasibility, payment or force receipts. */
function projected(phase = 5, next: Action = { type: 'move', forces: { 'arrakeen:10': 3, 'arrakeen:11': 1 },
eliteForces: { 'arrakeen:10': 1 }, territory: 'carthag', sector: 11 }): GameView {
const source = createGame('TURNACTIONCONTROLS', newPlayer('p', 'Respondent', 'emperor'));
joinGame(source, newPlayer('a', 'Asker', 'atreides'));
joinGame(source, newPlayer('r', 'Recipient', 'harkonnen'));
const game = viewGame(source, 'p');
game.status = 'playing'; game.turn = 2; game.phase = phase; game.active = 'p'; game.ready = [];
game.bribeOptions = { available: 8, incoming: 0, blocked: null,
targets: [{ id: 'r', maximum: 6, limitation: null }, { id: 'a', maximum: 8, limitation: null }] };
game.turnActionPromises = [{ event, player: 'p', asker: 'a', turn: 2, mixed: claim(), answer: true, fulfilled: false, released: false }];
game.turnActionGuide = { event, action: { ...next, turnActionPromiseEvent: event, turnActionPromisePhase: phase } };
game.truthTurnActionAnswers = null;
return game;
}
function auction(next: Action = { type: 'passBid' }): GameView {
const game = projected(3, next);
game.auction = { bid: 7, bidder: next.type === 'decision' ? 'p' : 'a', active: 'p', remaining: 2, inspectionBlocked: null, card: null };
if (next.type === 'decision') { game.auction.active = 'a'; game.decision = { kind: 'auctionPayment', player: 'p' }; }
return game;
}
function revival(next: Action = { type: 'revive', amount: 3, elite: 1 }): GameView {
const game = projected(4, next);
game.revivalOrder = createRevivalOrder({ turn: 2, order: ['p', 'a', 'r'], ready: [] });
return game;
}
function richese(method: 'silent' | 'onceAround'): GameView {
const native = createRicheseAuction({ event: 'controlled-native-lot', cardId: 'controlled-native-card', owner: 'a', source: 'cache',
method, eligible: ['p', 'a', 'r'], order: ['p', 'a', 'r'], tieOrder: ['p', 'a', 'r'] });
const game = projected(3, { type: 'richeseBid', event: native.event, amount: method === 'silent' ? 0 : null, allyPayment: 0 });
game.richeseAuction = { ...projectRicheseAuction(native, 'p'), card: null, claim: null,
ownAllyPayment: 0, ownAvailable: 4, allyAvailable: 0, peekKnown: false, inspectionBlocked: null };
return game;
}
function editor(initial: Expression, disabled = false, game = projected()) {
let value = initial, changes = 0;
return {
get value() { return value; }, get changes() { return changes; },
tree: () => TurnActionFields({ value, game, target: 'p', disabled, onChange(next) { value = next; changes++; } }),
html: () => renderToStaticMarkup(createElement(TurnActionFields, { value, game, target: 'p', disabled, onChange() {} })),
error: () => turnActionInputError(value, game, 'p'),
};
}
function guide(props: Props) { const button = TurnActionGuideButton(props); assert.ok(button); return button; }
const press = (button: ReturnType<typeof guide>) => button.props.onClick();
const markup = (game: GameView) => renderToStaticMarkup(createElement(TurnActionPromises, { game, busy: false, act() {} }));
function answering(phase = 5): GameView {
const game = projected(phase); game.turnActionGuide = null;
game.truthtrance = { stage: 'answer', queue: [{ player: 'a', card: 'controlled-truth-card' }], passed: [],
question: { kind: 'turnActions', target: 'p', turn: 2, mixed: claim() } };
game.truthTurnActionAnswers = ['yes', 'no', 'unknown'];
return game;
}
void test('turn-action editor keeps all action families and optional facts in the original whole expression', () => {
const e = editor(physical('ship', 3)); assert.equal(e.error(), null);
select(e.tree(), '1', 'action', 'move'); select(e.tree(), '1', 'territory', 'arrakeen'); minimum(e.tree(), '1', 5);
assert.deepEqual(e.value, physical('move', 5, 'arrakeen'));
select(e.tree(), '1', 'action', 'payment'); assert.ok(e.error()); select(e.tree(), '1', 'recipient', 'r');
assert.deepEqual(e.value, payment(5));
click(control(e.tree(), 'or-1')); select(e.tree(), '1.2', 'timing', 'action');
select(e.tree(), '1.2', 'action', 'move'); minimum(e.tree(), '1.2', 4);
assert.deepEqual(e.value, { kind: 'or', terms: [payment(5), physical('move', 4)] }); assert.equal(e.error(), null);
const html = e.html();
for (const text of [/Make an ordinary payment/, /Ship from reserves/, /Make ground movement/, /sum future real bribes/,
/single future original reserve shipment/, /smaller moves never add together/, /No makes the whole expression false/,
/payment conditions last until the actual turn ends/]) assert.match(html, text);
click(control(e.tree(), 'remove-1.1')); assert.deepEqual(e.value, physical('move', 4));
select(e.tree(), '1', 'timing', 'fact'); assert.ok(e.error(), 'The expression still requires a future action.');
});
void test('turn-action editor preserves sixteen clauses and four shared grouping levels without truncation', () => {
const e = editor({ kind: 'or', terms: [payment(), physical('move', 4)] });
for (let count = 3; count <= 16; count++) { click(control(e.tree(), 'add-action-1')); minimum(e.tree(), `1.${count}`, count); }
assert.equal(e.error(), null); assert.match(e.html(), /16 \/ 16 conditions/);
const full = structuredClone(e.value), changes = e.changes;
for (const key of ['add-action-1', 'add-fact-1', 'nested-and-1', 'nested-or-1', 'and-1.1', 'or-1']) {
assert.equal(control(e.tree(), key).props.disabled, true); click(control(e.tree(), key));
}
assert.deepEqual(e.value, full); assert.equal(e.changes, changes);
const deep = editor({ kind: 'and', terms: [fact(), payment()] });
click(control(deep.tree(), 'or-1.2')); click(control(deep.tree(), 'and-1.2.1')); click(control(deep.tree(), 'or-1.2.1.1'));
assert.match(deep.html(), /4 \/ 4 levels of grouping/);
assert.equal(control(deep.tree(), 'and-1.2.1.1.1').props.disabled, true);
editFact(deep.tree(), '1.1', { kind: 'spice', compare: 'gte', value: 3 }); assert.equal(deep.error(), null);
click(control(deep.tree(), 'remove-1.2.1.1.2')); assert.match(deep.html(), /3 \/ 4 levels of grouping/);
});
void test('turn-action inputs keep invalid drafts repairable and reject disabled callbacks', () => {
for (const expression of [physical(), payment()]) {
for (const invalid of [NaN, 0, -1, 1.5, Infinity, Number.MAX_SAFE_INTEGER + 1]) {
const e = editor(expression); minimum(e.tree(), '1', invalid); assert.ok(e.error());
if (!Number.isFinite(invalid)) assert.match(e.html(), /value=""/);
minimum(e.tree(), '1', 20); assert.equal(e.error(), null);
}
}
const e = editor(physical()); minimum(e.tree(), '1', 21); assert.ok(e.error());
select(e.tree(), '1', 'action', 'payment'); select(e.tree(), '1', 'recipient', 'r'); assert.equal(e.error(), null);
minimum(e.tree(), '1', Number.MAX_SAFE_INTEGER); assert.equal(e.error(), null);
select(e.tree(), '1', 'action', 'ship'); assert.ok(e.error()); minimum(e.tree(), '1', 1);
select(e.tree(), '1', 'territory', 'invented-place'); assert.ok(e.error()); assert.match(e.html(), /aria-invalid="true"/);
const frozen = editor(claim(), true), original = structuredClone(frozen.value);
select(frozen.tree(), '1', 'meaning', 'and'); minimum(frozen.tree(), '1.1', 7);
select(frozen.tree(), '1.1', 'action', 'move'); select(frozen.tree(), '1.1', 'recipient', 'a');
editFact(frozen.tree(), '1.2.1', { kind: 'spice', compare: 'gte', value: 9 });
for (const key of ['and-1', 'add-action-1', 'remove-1.1', 'nested-or-1']) click(control(frozen.tree(), key));
assert.equal(frozen.changes, 0); assert.deepEqual(frozen.value, original);
});
void test('turn-action fields use public recipients and printed destinations without private eligibility reads', () => {
const game = projected(); game.players.find(player => player.id === 'p')!.ally = 'a';
for (const player of game.players) for (const key of ['hand', 'traitors', 'faceDancers', 'spice', 'forces', 'reserves'])
Object.defineProperty(player, key, { get() { throw Error(`No ${key} inspection`); }, configurable: true });
for (const expression of [payment(), physical('ship', 20, 'arrakeen'), { kind: 'and', terms: [fact({ kind: 'stormForecast', compare: 'gte', value: 3 }), physical('move')] } as Expression]) {
const e = editor(expression, false, game); assert.equal(e.error(), null);
assert.match(e.html(), /Only the aggregate answer is shared/);
}
const e = editor(payment(), false, game); const choices = find(e.tree(), 'id', 'truth-turn-action-1-recipient');
assert.deepEqual(elements(choices).filter(element => element.type === 'option').map(element => element.props.value), ['', 'r']);
select(e.tree(), '1', 'recipient', 'a'); assert.deepEqual(e.value, payment());
for (const recipient of ['p', 'a', 'missing']) assert.ok(turnActionInputError(payment(3, recipient), game, 'p'));
assert.match(editor(physical(), false, game).html(), /Carthag/);
});
void test('turn-action history retains original own whole questions and terminal status without private compiled branches', () => {
const game = projected(); game.turnActionGuide = null;
game.turnActionPromises.push(
{ ...game.turnActionPromises[0], event: 'controlled-no', answer: false },
{ ...game.turnActionPromises[0], event: 'controlled-complete', turn: 1, fulfilled: true },
{ ...game.turnActionPromises[0], event: 'controlled-release', released: true },
{ ...game.turnActionPromises[0], event: 'controlled-foreign', player: 'a', mixed: { kind: 'and', terms: [fact({ kind: 'hand', name: 'Poison Blade' }), physical('ship', 19, 'sietch_tabr')] } },
);
for (const promise of game.turnActionPromises) for (const key of ['compiled', 'binding', 'factAnswers', 'residual', 'route', 'payments', 'progress'])
Object.defineProperty(promise, key, { get() { throw Error(`No private ${key} inspection`); }, configurable: true });
const html = markup(game);
for (const text of [/Turn 1/, /You answered Yes/, /You answered No/, /you hold Shield/, / OR /, / AND /,
/Fulfilled: this whole commitment is complete/, /Released: this whole commitment is no longer possible/,
/No negates the whole expression, not each condition separately/, /Separate No answers each create their own restriction/,
/maximum physical forces in one actual future move/, /Individual fact answers and any reduced future branch remain private/,
/ordinary payments remain possible until the actual turn ends/]) assert.match(html, text);
assert.doesNotMatch(html, /Poison Blade|19 physical|controlled-foreign|controlled-complete|factAnswers/);
assert.equal(TurnActionPromises({ game: { ...game, me: 'missing' }, busy: false, act() {} }), null);
game.turnActionPromises = game.turnActionPromises.filter(promise => promise.fulfilled || promise.released);
assert.doesNotMatch(markup(game), /<button|Your Movement turn is still ahead/);
});
void test('turn-action guide forwards exact shipment, movement, preparation and own movement-end packets', () => {
const actions: Action[] = [
projected().turnActionGuide!.action,
{ type: 'ship', territory: 'arrakeen', sector: 10, amount: 4, elite: 1, allyPayment: 0 },
{ type: 'guildShip', from: 'reserves', territory: 'carthag', sector: 11, amount: 6, elite: 0, allyPayment: 3 },
{ type: 'move', territory: 'carthag', sector: 11, forces: { 'arrakeen:10': 4 }, eliteForces: {},
movementCard: 'controlled-ornithopter', ornithopter: 'twoGroups', ornithopterEvent: 'controlled-flight' },
{ type: 'card', card: 'controlled-hajr' }, { type: 'prepareAdvisors', territory: 'arrakeen' },
{ type: 'revealNoField', territory: 'arrakeen', sector: 10 }, { type: 'endMovement' },
];
for (const action of actions) {
const game = projected(5, action), sent: Action[] = [], button = guide({ game, busy: false, act(value) { sent.push(value); } });
assert.equal(button.props.disabled, false); press(button);
assert.deepEqual(sent, [game.turnActionGuide!.action]); assert.equal(sent[0], game.turnActionGuide!.action);
assert.match(markup(game), /data-testid="turn-action-promise-guide"/);
}
const game = projected(5, { type: 'endMovement' });
game.turnActionPromises[0].mixed = { kind: 'and', terms: [physical('move', 4), payment()] };
assert.match(markup(game), /Finish movement while preserving any later payment conditions/);
game.active = 'a'; game.turnActionGuide = { event, action: { type: 'bribe', target: 'r', amount: 3,
turnActionPromiseEvent: event, turnActionPromisePhase: 5 } };
assert.doesNotMatch(markup(game), /Your Movement turn is still ahead/);
const sent: Action[] = []; press(guide({ game, busy: false, act(value) { sent.push(value); } }));
assert.equal(sent[0], game.turnActionGuide.action);
});
void test('turn-action ordinary-payment guide retains native out-of-turn timing and quiet Mentat readiness', () => {
for (let phase = 0; phase <= 8; phase++) {
const game = projected(phase, { type: 'bribe', target: 'r', amount: 3 }); game.active = 'a'; game.ready = ['p'];
const sent: Action[] = [], button = guide({ game, busy: false, act(value) { sent.push(value); } });
assert.equal(button.props.disabled, false); press(button); assert.equal(sent[0], game.turnActionGuide!.action);
assert.match(markup(game), /Pay 3 spice to Recipient while honoring your whole answer/);
if (phase === 8) assert.match(markup(game), /Late recipients collect next turn/);
}
const game = projected(8, { type: 'ready' }); game.active = null;
const sent: Action[] = [], button = guide({ game, busy: false, act(value) { sent.push(value); } });
assert.equal(button.props.disabled, false); press(button); assert.equal(sent[0], game.turnActionGuide!.action);
assert.match(markup(game), /Ready to end this turn while honoring your whole answer/);
game.ready.push('p'); press(button); assert.equal(sent.length, 1);
for (const phase of [1, 7]) {
const current = projected(phase, { type: 'ready' }); current.nexus = true; current.spiceWindow = null;
const dispatched: Action[] = [], ready = guide({ game: current, busy: false, act(value) { dispatched.push(value); } });
assert.equal(ready.props.disabled, false); press(ready); assert.equal(dispatched[0], current.turnActionGuide!.action);
if (phase === 1) { current.nexus = false; press(ready); assert.equal(dispatched.length, 1); }
}
for (const change of [
(current: GameView) => { current.bribeOptions.blocked = 'Complete the current decision.'; },
(current: GameView) => { current.bribeOptions.blocked = ''; },
(current: GameView) => { current.bribeOptions.available = 2; },
(current: GameView) => { current.bribeOptions.available = NaN; },
(current: GameView) => { current.bribeOptions.targets[0].maximum = 2; },
(current: GameView) => { current.bribeOptions.targets = []; },
(current: GameView) => { current.players.find(player => player.id === 'p')!.ally = 'r'; },
(current: GameView) => { current.players = current.players.filter(player => player.id !== 'r'); },
]) {
const current = projected(3, { type: 'bribe', target: 'r', amount: 3 }), dispatched: Action[] = [];
const original = guide({ game: current, busy: false, act(value) { dispatched.push(value); } }); change(current); press(original);
assert.deepEqual(dispatched, []);
}
});
void test('turn-action current early native opportunities retain exact preparation, bids and owned cash or Karama invoices', () => {
const cases: GameView[] = [
projected(2, { type: 'charity' }), projected(2, { type: 'ready' }), projected(2, { type: 'card', card: 'controlled-preparation', amount: 3 }),
auction({ type: 'bid', amount: 8, allyPayment: 0 }), auction(),
auction({ type: 'decision', karama: false }), auction({ type: 'decision', karama: true, card: 'controlled-owned-karama' }),
revival(), revival({ type: 'ready' }), revival({ type: 'reviveLeader', leader: 'controlled-leader', early: true, allyPayment: 2 }),
revival({ type: 'acceptLeaderRevival', leaderRevivalPrice: 3 }), revival({ type: 'reviveKwisatz' }),
revival({ type: 'card', card: 'controlled-ghola' }), richese('silent'), richese('onceAround'),
];
for (const game of cases) {
const sent: Action[] = [], button = guide({ game, busy: false, act(value) { sent.push(value); } });
assert.equal(button.props.disabled, false); press(button); assert.equal(sent[0], game.turnActionGuide!.action);
}
assert.match(markup(auction({ type: 'bid', amount: 8, allyPayment: 0 })), /Place the required bid of 8/);
assert.match(markup(auction({ type: 'decision', karama: true, card: 'controlled-owned-karama' })), /Use the quoted Karama for the current auction payment/);
});
void test('turn-action current paid Box choices are exact through phases zero to eight without private custody reads', () => {
for (let phase = 0; phase <= 8; phase++) {
const game = projected(phase, { type: 'decision', event: 'controlled-paid-search', card: 'controlled-recovered-hajr' });
game.decision = { kind: 'nullentropy', player: 'p' };
game.nullentropy = { card: { id: 'controlled-box', name: 'Nullentropy Box', kind: 'special', effect: 'nullentropyBox' }, blocked: null,
search: { event: 'controlled-paid-search', cards: [{ id: 'controlled-recovered-hajr', name: 'Hajr', kind: 'special', effect: 'hajr' }] } };
for (const player of game.players) for (const key of ['hand', 'traitors', 'faceDancers', 'spice', 'forces', 'reserves'])
Object.defineProperty(player, key, { get() { throw Error(`No ${key} inspection`); }, configurable: true });
const sent: Action[] = [], button = guide({ game, busy: false, act(value) { sent.push(value); } });
assert.equal(button.props.disabled, false); press(button); assert.equal(sent[0], game.turnActionGuide!.action);
assert.match(markup(game), /Complete your paid Box search/);
game.nullentropy.search = { ...game.nullentropy.search! }; press(button); assert.equal(sent.length, 1);
}
});
void test('turn-action native guides reject stale auction, invoice, Revival and hidden Richese source changes', () => {
const cases: [() => GameView, ((game: GameView) => void)[]][] = [
[() => auction({ type: 'bid', amount: 8, allyPayment: 0 }), [
game => { game.auction = { ...game.auction! }; }, game => { game.auction!.active = 'a'; },
game => { game.auction!.bid = 8; }, game => { game.auction!.remaining = 1; },
]],
[() => auction({ type: 'decision', karama: false }), [
game => { game.decision = { ...game.decision! }; }, game => { game.decision = null; },
game => { game.decision = { kind: 'auctionPayment', player: 'a' }; },
game => { game.auction!.bidder = 'a'; }, game => { game.auction!.bid = 8; }, game => { game.auction!.allyPayment = 1; },
]],
[() => revival(), [
game => { game.revivalOrder = { ...game.revivalOrder! }; }, game => { game.revivalOrder!.turn = 3; },
game => { game.revivalOrder!.remaining = ['a', 'p']; }, game => { game.active = 'a'; },
game => { game.revival = { ...game.revival }; }, game => { game.revival.orderBlocked = 'Wait for your turn.'; },
game => { game.revival.pending = true; }, game => { game.ready.push('p'); },
]],
[() => richese('silent'), [
game => { game.richeseAuction = { ...game.richeseAuction! }; }, game => { game.richeseAuction!.event = 'another-lot'; },
game => { game.richeseAuction!.eligible = ['a']; }, game => { game.richeseAuction!.submitted.push('p'); },
game => { game.richeseAuction!.outcome = { kind: 'unbid' }; },
]],
];
for (const [build, changes] of cases) for (const change of changes) {
const game = build(), sent: Action[] = [], button = guide({ game, busy: false, act(value) { sent.push(value); } });
assert.equal(button.props.disabled, false); change(game); press(button); assert.deepEqual(sent, []);
}
for (const method of ['silent', 'onceAround'] as const) {
const game = richese(method);
Object.defineProperty(game.richeseAuction!, 'sealed', { get() { throw Error('No hidden bids'); } });
Object.defineProperty(game.richeseAuction!, 'card', { get() { throw Error('No hidden lot'); } });
const sent: Action[] = []; press(guide({ game, busy: false, act(value) { sent.push(value); } })); assert.equal(sent.length, 1);
}
});
void test('turn-action guide rejects stale event, packet identity, current phase, owner and quiet-window changes', () => {
const changes: ((game: GameView) => void)[] = [
game => { game.me = 'a'; }, game => { game.turn = 3; }, game => { game.phase = 4; }, game => { game.status = 'setup'; },
game => { game.active = 'a'; }, game => { game.turnActionGuide = null; },
game => { game.turnActionGuide = { ...game.turnActionGuide! }; },
game => { game.turnActionGuide!.action = { ...game.turnActionGuide!.action }; },
game => { game.turnActionGuide!.event = 'changed'; }, game => { game.turnActionGuide!.action.turnActionPromiseEvent = 'changed'; },
game => { game.turnActionGuide!.action.turnActionPromisePhase = 4; }, game => { delete game.turnActionGuide!.action.turnActionPromisePhase; },
game => { (game.turnActionGuide!.action.forces as Record<string, number>)['arrakeen:10'] = 2; },
game => { (game.turnActionGuide!.action.eliteForces as Record<string, number>)['arrakeen:10'] = 0; },
game => { game.turnActionPromises[0].event = 'expired'; }, game => { game.turnActionPromises[0].player = 'a'; },
game => { game.turnActionPromises[0].turn = 1; }, game => { game.turnActionPromises[0].fulfilled = true; },
game => { game.turnActionPromises[0].released = true; }, game => { game.turnActionPromises = []; },
game => { game.phaseOpening = { passed: [] }; }, game => { game.automaticContinuationPending = true; },
game => { game.decision = { kind: 'auctionPayment', player: 'p' }; }, game => { game.truthtrance = answering().truthtrance; },
game => { game.response = { kind: 'guildIncome', owner: 'a', amount: 1, passed: [] }; },
game => { game.players.find(player => player.id === game.me)!.autopilot = 'Easy'; },
game => { game.roomControl = { paused: true, joinLocked: false, revision: 1, updatedAt: null }; },
game => { game.roomControl = { paused: false, closed: true, joinLocked: false, revision: 1, updatedAt: null }; },
];
for (const change of changes) {
const game = projected(), sent: Action[] = [], button = guide({ game, busy: false, act(value) { sent.push(value); } });
change(game); press(button); assert.deepEqual(sent, []);
}
const busy = { game: projected(), busy: false, act() { assert.fail('Busy guide cannot send'); } };
const busyButton = guide(busy); busy.busy = true; press(busyButton);
const replaced = { game: projected(), busy: false, act() { assert.fail('Replaced view cannot send'); } };
const old = guide(replaced); replaced.game = { ...replaced.game }; press(old);
});
void test('turn-action guides reject wrong native opportunities even when a packet is projected', () => {
const invalid: GameView[] = [];
for (const phase of [0, 1, 2, 3, 4, 6, 7, 8]) for (const action of [
{ type: 'ship', amount: 3, territory: 'carthag', sector: 11 }, { type: 'move', forces: { 'arrakeen:10': 4 }, territory: 'carthag', sector: 11 },
{ type: 'endMovement' },
] satisfies Action[]) invalid.push(projected(phase, action));
invalid.push(projected(0, { type: 'ready' }), projected(1, { type: 'ready' }), projected(6, { type: 'ready' }), projected(4, { type: 'charity' }),
projected(2, { type: 'revive', amount: 3 }), projected(3, { type: 'decision', karama: false }), auction({ type: 'decision', karama: true }),
projected(5, { type: 'guildShip', from: 'arrakeen', territory: 'carthag', amount: 3 }));
for (const game of invalid) {
const sent: Action[] = [], button = guide({ game, busy: false, act(value) { sent.push(value); } });
assert.equal(button.props.disabled, true); press(button); assert.deepEqual(sent, []);
}
});
void test('turn-action answers offer only the current respondent native choices throughout phases zero through eight', () => {
for (let phase = 0; phase <= 8; phase++) {
const game = answering(phase), sent: Action[] = [], props = { game, busy: false, act(value: Action) { sent.push(value); } };
for (const player of game.players) for (const key of ['hand', 'traitors', 'faceDancers', 'spice', 'forces', 'reserves'])
Object.defineProperty(player, key, { get() { throw Error(`No ${key} inspection`); }, configurable: true });
for (const answer of game.truthTurnActionAnswers!) click(find(TurnActionAnswers(props), 'data-turn-action-answer', answer));
assert.deepEqual(sent, ['yes', 'no', 'unknown'].map(answer => ({ type: 'truthAnswer', answer })));
game.truthTurnActionAnswers = ['no'];
const html = renderToStaticMarkup(createElement(TurnActionAnswers, { game, busy: false, act() {} }));
assert.match(html, /Answer No/); assert.doesNotMatch(html, /Answer Yes|I don’t know yet/);
assert.equal(TurnActionAnswers({ game: { ...game, me: 'a' }, busy: false, act() {} }), null);
assert.equal(TurnActionAnswers({ game: { ...game, truthTurnActionAnswers: null }, busy: false, act() {} }), null);
}
});
void test('turn-action answer callbacks reject stale target, source queue, private choices, phase and modal ownership', () => {
const changes: ((game: GameView) => void)[] = [
game => { game.me = 'a'; }, game => { game.turn = 3; }, game => { game.phase = 4; }, game => { game.status = 'setup'; },
game => { game.truthtrance = null; }, game => { game.truthtrance!.stage = 'ask'; },
game => { game.truthtrance!.question = { ...game.truthtrance!.question! }; }, game => { game.truthtrance!.question!.target = 'a'; },
game => { game.truthtrance!.queue[0] = { ...game.truthtrance!.queue[0] }; }, game => { game.truthtrance!.queue[0].card = 'another-card'; },
game => { game.truthTurnActionAnswers = ['yes']; }, game => { game.truthTurnActionAnswers!.splice(0); },
game => { game.decision = { kind: 'auctionPayment', player: 'p' }; }, game => { game.phaseOpening = { passed: [] }; },
game => { game.automaticContinuationPending = true; }, game => { game.response = { kind: 'guildIncome', owner: 'a', amount: 1, passed: [] }; },
game => { game.players.find(player => player.id === game.me)!.autopilot = 'Easy'; },
game => { game.roomControl = { paused: true, joinLocked: false, revision: 1, updatedAt: null }; },
game => { game.roomControl = { paused: false, closed: true, joinLocked: false, revision: 1, updatedAt: null }; },
];
for (const change of changes) {
const game = answering(), sent: Action[] = [], tree = TurnActionAnswers({ game, busy: false, act(value) { sent.push(value); } });
const button = find(tree, 'data-turn-action-answer', 'yes'); change(game); click(button); assert.deepEqual(sent, []);
}
for (const phase of [-1, 9, 1.5]) {
const game = answering(phase), sent: Action[] = [], button = find(TurnActionAnswers({ game, busy: false, act(value) { sent.push(value); } }), 'data-turn-action-answer', 'yes');
assert.equal(button.props.disabled, true); click(button); assert.deepEqual(sent, []);
}
const busy = { game: answering(), busy: false, act() { assert.fail('Busy answer cannot send'); } };
const button = find(TurnActionAnswers(busy), 'data-turn-action-answer', 'yes'); busy.busy = true; click(button);
const replaced = { game: answering(), busy: false, act() { assert.fail('Replaced view cannot send'); } };
const old = find(TurnActionAnswers(replaced), 'data-turn-action-answer', 'yes'); replaced.game = { ...replaced.game }; click(old);
});
+100
View File
@@ -0,0 +1,100 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { viewGame, type Action, type Game } from '../game/engine';
import type { TruthQuestion } from '../game/truthtrance';
import {
actTruthBid as act, createTruthBidFixture, truthBidInventory as inventory, truthBidPlayer as player,
type TruthBidStep,
} from './fixture-truth-bid';
import {
createTruthRevivalEarlyFixture, reachTruthRevivalEarlyOpportunity, settleTruthRevivalEarly,
} from './fixture-truth-revival-early';
/** These two sources actually deal Harkonnen both printed Truthtrances.
* Every commitment below uses its own card and native declaration/priority/
* Ask/answer; no hand, cash, force, clock or promise is installed. */
function ask(game: Game, asker: string, card: string, question: TruthQuestion, actions: TruthBidStep[]) {
game = act(game, asker, { type: 'card', card }, actions);
while (game.truthtrance?.stage === 'priority') {
const actor = game.players.find(candidate => !game.truthtrance!.passed.includes(candidate.id)); assert.ok(actor);
game = act(game, actor.id, { type: 'truthPass' }, actions);
}
return act(game, asker, { type: 'truthAsk', question }, actions);
}
function paymentQuestion(game: Game, target: string, recipient: string, minimum: number): TruthQuestion {
return { kind: 'turnActions', target, turn: game.turn,
mixed: { kind: 'action', action: { kind: 'payment', recipient, minimum } } };
}
function rejected(game: Game, actor: string, action: Action) {
const before = structuredClone(game); assert.throws(() => act(game, actor, action)); assert.deepEqual(game, before);
}
function physicalTruths(game: Game, truths: string[], originalCards: string[]) {
for (const truth of truths) assert.equal(game.discard.filter(card => card.id === truth).length, 1);
assert.deepEqual(inventory(game), originalCards);
}
for (const first of ['bid', 'aggregate'] as const)
void test(`two original Truths reject a second unfunded payment or bid Yes with ${first} accepted first`, () => {
const f = createTruthBidFixture({ window: 'active' }), actions: TruthBidStep[] = [];
assert.equal(f.game.phase, 3); assert.equal(f.game.auction?.active, f.target);
assert.equal(player(f.game, f.target).spice, 10);
assert.equal(f.sourceActionCount, f.sourceActions.length);
assert.deepEqual(player(f.game, f.asker).hand.map(card => card.id), f.truths);
const bid: TruthQuestion = { ...f.question, minimum: 10 };
const aggregate = paymentQuestion(f.game, f.target, f.asker, 10);
let game = ask(f.game, f.asker, f.truths[0], first === 'bid' ? bid : aggregate, actions);
game = act(game, f.target, { type: 'truthAnswer', answer: 'yes' }, actions);
const earlierHistory = structuredClone(game.truthHistory![0]);
const earlierRow = structuredClone(first === 'bid' ? game.bidPromises![0] : game.turnActionPromises![0]);
game = ask(game, f.asker, f.truths[1], first === 'bid' ? aggregate : bid, actions);
const answers = first === 'bid' ? viewGame(game, f.target).truthTurnActionAnswers : viewGame(game, f.target).truthBidAnswers;
assert.deepEqual(answers, ['no']); rejected(game, f.target, { type: 'truthAnswer', answer: 'yes' });
assert.deepEqual(game.truthHistory![0], earlierHistory);
assert.deepEqual(first === 'bid' ? game.bidPromises![0] : game.turnActionPromises![0], earlierRow);
assert.ok(player(game, f.asker).hand.some(card => card.id === f.truths[1]));
assert.equal(player(game, f.target).spice, 10);
game = act(game, f.target, { type: 'truthAnswer', answer: 'no' }, actions);
assert.equal(game.truthHistory!.length, 2); assert.deepEqual(game.truthHistory![0], earlierHistory);
assert.deepEqual(first === 'bid' ? game.bidPromises![0] : game.turnActionPromises![0], earlierRow);
game = act(game, f.target, first === 'bid'
? { type: 'bid', amount: 10, allyPayment: 0 }
: { type: 'bribe', target: f.asker, amount: 10 }, actions);
assert.equal(first === 'bid' ? game.bidPromises![0].fulfilled : game.turnActionPromises![0].fulfilled, true);
assert.ok(!(first === 'bid' ? game.bidPromises![0].released : game.turnActionPromises![0].released));
physicalTruths(game, f.truths, f.originalCards);
});
for (const first of ['revival', 'aggregate'] as const)
void test(`two earned native Truths reject a second unfunded payment or Revival Yes with ${first} accepted first`, () => {
// The original source paid six for a real shipment of three; the next
// Storm killed those counters. Its four remaining spice fund exactly the
// ordinary three-force return (one free, two paid), or one payment of four.
const f = createTruthRevivalEarlyFixture({ phase: 3 }), actions = structuredClone(f.sourceActions);
assert.deepEqual(f.staging, []); assert.equal(f.sourceActionCount, f.sourceActions.length);
let game = reachTruthRevivalEarlyOpportunity(f.game, f.target, actions);
assert.equal(game.turn, 2); assert.equal(game.phase, 4); assert.equal(player(game, f.target).spice, 4);
assert.equal(player(game, f.target).tanks, 3); assert.equal(player(game, f.target).reserves, 17);
assert.deepEqual(player(game, f.asker).hand.map(card => card.id), f.truths);
const revival: TruthQuestion = { kind: 'revival', target: f.target, turn: game.turn, counter: 'total', minimum: 3 };
const aggregate = paymentQuestion(game, f.target, f.asker, 4);
game = ask(game, f.asker, f.truths[0], first === 'revival' ? revival : aggregate, actions);
game = act(game, f.target, { type: 'truthAnswer', answer: 'yes' }, actions);
const earlierHistory = structuredClone(game.truthHistory![0]);
const earlierRow = structuredClone(first === 'revival' ? game.revivalPromises![0] : game.turnActionPromises![0]);
game = ask(game, f.asker, f.truths[1], first === 'revival' ? aggregate : revival, actions);
const answers = first === 'revival' ? viewGame(game, f.target).truthTurnActionAnswers : viewGame(game, f.target).truthRevivalAnswers;
assert.deepEqual(answers, ['no']); rejected(game, f.target, { type: 'truthAnswer', answer: 'yes' });
assert.deepEqual(game.truthHistory![0], earlierHistory);
assert.deepEqual(first === 'revival' ? game.revivalPromises![0] : game.turnActionPromises![0], earlierRow);
assert.ok(player(game, f.asker).hand.some(card => card.id === f.truths[1]));
game = act(game, f.target, { type: 'truthAnswer', answer: 'no' }, actions);
assert.equal(game.truthHistory!.length, 2); assert.deepEqual(game.truthHistory![0], earlierHistory);
assert.deepEqual(first === 'revival' ? game.revivalPromises![0] : game.turnActionPromises![0], earlierRow);
game = settleTruthRevivalEarly(act(game, f.target, first === 'revival'
? { type: 'revive', amount: 3, elite: 0 }
: { type: 'bribe', target: f.asker, amount: 4 }, actions), actions);
assert.equal(player(game, f.target).spice, 0);
assert.equal(first === 'revival' ? game.revivalPromises![0].fulfilled : game.turnActionPromises![0].fulfilled, true);
assert.ok(!(first === 'revival' ? game.revivalPromises![0].released : game.turnActionPromises![0].released));
physicalTruths(game, f.truths, f.originalCards);
});
+310
View File
@@ -0,0 +1,310 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { viewGame, type Action, type Game } from '../game/engine';
import type { TruthQuestion } from '../game/truthtrance';
import { truthFactAnswer } from '../game/truthtrance';
import { completeTruthBribeExpression } from './fixture-truth-bribe-expression';
import {
actTruthTurnAction as act, advanceTruthTurnAction as advance, answerTruthTurnAction as answer,
askTruthTurnAction as ask, bindTruthTurnAction as bind, completeTruthTurnAction as complete,
createTruthTurnActionFixture as fixture, declareTruthTurnAction as declare, finishTruthTurnAction as finish,
settleTruthTurnAction as settle, truthTurnActionClean as clean, truthTurnActionFact as fact,
truthTurnActionGroup as group, truthTurnActionInventory as inventory, truthTurnActionMove as move,
truthTurnActionPayment as payment, truthTurnActionPlayer as player, truthTurnActionPromise as promise,
truthTurnActionShip as ship, type TruthTurnActionFixture, type TruthTurnActionStep,
} from './fixture-truth-turn-action';
function rejected(game: Game, actor: string, action: Action) {
const before = structuredClone(game); assert.throws(() => act(game, actor, action)); assert.deepEqual(game, before);
}
function truthOnce(game: Game, truth: string) {
assert.equal(game.discard.filter(card => card.id === truth).length, 1);
}
function nativeControls(game: Game) {
return { turn: game.turn, phase: game.phase, active: game.active, order: game.order,
movementRemaining: game.movementRemaining, storm: game.storm, response: game.response ?? null,
decision: game.decision ?? null, phaseOpening: game.phaseOpening ?? null, pendingShipment: game.pendingShipment ?? null };
}
function scalarQuestion(f: TruthTurnActionFixture, recipient = f.recipient, minimum = 6): TruthQuestion {
return { kind: 'bribe', target: f.target, turn: f.game.turn, recipient, minimum };
}
function bindQuestion(f: TruthTurnActionFixture, source: Game, copy: 0 | 1, question: TruthQuestion,
response: 'yes' | 'no' = 'yes', actions: TruthTurnActionStep[] = []) {
const opened = declare(f, source, copy, actions).game;
const asked = act(opened, f.asker, { type: 'truthAsk', question }, actions);
return answer(asked, f.target, response, actions);
}
void test('native turn-action source keeps two original Truths and the real active Atreides movement prefix', () => {
const f = fixture(); assert.ok(f.orderSource);
assert.equal(f.setup.status, 'setup'); assert.equal(f.setup.deck.length, 33);
assert.ok(f.setup.players.every(candidate => candidate.hand.length === 0));
assert.equal(f.setup.deck.filter(card => card.effect === 'truthtrance').length, 2);
assert.deepEqual(player(f.afterSetup, f.asker).hand.map(card => card.id), f.truths);
assert.deepEqual(player(f.game, f.asker).hand.map(card => card.id), f.truths);
assert.equal(f.game.phase, 5); assert.equal(f.game.active, f.target); assert.equal(f.target, 'atreides');
assert.equal(f.recipient, 'emperor'); assert.equal(player(f.game, f.recipient).ally, null);
assert.equal(f.sourceActionCount, 27); assert.equal(f.sourceActionCount, f.sourceActions.length);
assert.equal(f.sourceCount, f.actions.length);
assert.equal(f.sourceCounts.inherited + f.sourceCounts.nativeContinuation, f.sourceCount);
assert.deepEqual(f.sourceActions, f.orderSource.sourceActions);
assert.equal(player(f.game, f.target).spice, 10); assert.equal(player(f.game, f.target).reserves, 10);
assert.deepEqual(player(f.game, f.target).forces, { 'arrakeen:10': 10 });
assert.equal(player(f.game, f.target).shipped, false); assert.equal(player(f.game, f.target).moved, 0);
assert.equal(f.game.truthHistory?.length ?? 0, 0); assert.equal(f.game.turnActionPromises?.length ?? 0, 0);
assert.deepEqual(inventory(f.game), f.originalCards); assert.equal(f.originalCards.length, 33);
});
void test('native AND Yes pays two real spice and separately ships and moves physical forces at original costs', () => {
const f = fixture(), source = structuredClone(f.game), recipientSpice = player(f.game, f.recipient).spice;
let game = bind(f).game;
assert.deepEqual(f.game, source); assert.deepEqual(nativeControls(game), nativeControls(source));
assert.deepEqual(promise(game, f.target).mixed, f.mixed); rejected(game, f.target, f.end);
game = act(game, f.target, f.payment);
assert.equal(player(game, f.target).spice, 8); assert.equal(player(game, f.recipient).spice, recipientSpice);
assert.equal(player(game, f.recipient).bribes, 2); assert.ok(!promise(game, f.target).fulfilled);
assert.deepEqual(game.turnActionPromises![0].payments, [{ recipient: f.recipient, amount: 2 }]);
rejected(game, f.target, f.end); game = settle(act(game, f.target, f.shipment));
assert.equal(player(game, f.target).spice, 6); assert.equal(player(game, f.target).reserves, 9);
assert.equal(player(game, f.target).forces['polar_sink:0'], 1); assert.equal(player(game, f.target).moved, 0);
assert.deepEqual(game.turnActionPromises![0].progress, []);
assert.ok(!promise(game, f.target).fulfilled); rejected(game, f.target, f.end);
game = settle(act(game, f.target, f.move));
assert.equal(player(game, f.target).forces['arrakeen:10'], 9);
assert.equal(player(game, f.target).forces['imperial_basin:9'], 1);
assert.deepEqual(game.turnActionPromises![0].progress, [{ territory: 'imperial_basin', maximum: 1 }]);
assert.equal(promise(game, f.target).fulfilled, true); assert.ok(!promise(game, f.target).released);
game = settle(act(game, f.target, f.end)); truthOnce(game, f.truth); assert.deepEqual(inventory(game), f.originalCards);
});
void test('native ordinary payments to one recipient sum while reserve shipment never supplies ground progress', () => {
const f = fixture(), mixed = group('and', payment(f.recipient, 2), ship('imperial_basin'), move());
let game = bind(f, 'yes', mixed).game;
game = act(game, f.target, { ...f.payment, amount: 1 });
game = settle(act(game, f.target, { ...f.shipment, territory: 'imperial_basin', sector: 9 }));
assert.equal(player(game, f.recipient).bribes, 1); assert.equal(player(game, f.target).forces['imperial_basin:9'], 1);
assert.equal(player(game, f.target).moved, 0); assert.ok(!promise(game, f.target).fulfilled);
game = act(game, f.target, { ...f.payment, amount: 1 });
assert.equal(player(game, f.recipient).bribes, 2); assert.ok(!promise(game, f.target).fulfilled);
assert.deepEqual(game.turnActionPromises![0].payments, [{ recipient: f.recipient, amount: 2 }]);
rejected(game, f.target, f.end); game = settle(act(game, f.target, f.move));
assert.equal(player(game, f.target).forces['imperial_basin:9'], 2);
assert.equal(promise(game, f.target).fulfilled, true); truthOnce(game, f.truth);
assert.deepEqual(inventory(game), f.originalCards);
});
void test('native repeated smaller Hajr groups retain one movement maximum rather than summed force arrivals', () => {
const f = fixture({ source: 'hajr' }); assert.ok(f.hajrAction); assert.ok(f.hajrSource);
const mixed = group('and', payment(f.recipient), ship(), move('imperial_basin', 2));
let game = bind(f, 'no', mixed).game;
game = act(game, f.target, f.payment); game = settle(act(game, f.target, f.shipment));
game = settle(act(game, f.target, f.move)); game = settle(act(game, f.target, f.hajrAction));
game = settle(act(game, f.target, f.move));
assert.equal(player(game, f.target).moved, 2); assert.equal(player(game, f.target).forces['imperial_basin:9'], 2);
assert.deepEqual(game.turnActionPromises![0].progress, [{ territory: 'imperial_basin', maximum: 1 }]);
assert.ok(!promise(game, f.target).fulfilled); assert.ok(!promise(game, f.target).released);
game = finish(game); assert.equal(promise(game, f.target).fulfilled, true);
truthOnce(game, f.truth); assert.equal(game.discard.filter(card => card.id === f.hajr).length, 1);
assert.deepEqual(inventory(game), f.originalCards);
});
void test('native OR Yes retains either manual payment or movement route without fixing its alternative', () => {
const f = fixture(), mixed = group('or', payment(f.recipient), move()), bound = bind(f, 'yes', mixed).game;
for (const action of [f.payment, f.move]) {
let game = settle(act(bound, f.target, action));
assert.equal(promise(game, f.target).fulfilled, true); assert.deepEqual(promise(game, f.target).mixed, mixed);
assert.equal(player(game, f.target).reserves, 10);
assert.equal(player(game, f.target).moved, action.type === 'move' ? 1 : 0);
assert.equal(player(game, f.recipient).bribes, action.type === 'bribe' ? 2 : 0);
game = settle(act(game, f.target, f.end)); truthOnce(game, f.truth); assert.deepEqual(inventory(game), f.originalCards);
}
});
void test('native OR cumulative payments preserve both partial and full-purse payment routes alongside shipment', () => {
const f = fixture(), mixed = group('or', payment(f.recipient, 10), ship());
const bound = bind(f, 'yes', mixed).game;
assert.equal(player(bound, f.target).spice, 10);
const options = viewGame(bound, f.target).bribeOptions;
assert.equal(options.blocked, null); assert.equal(options.available, 10);
assert.equal(options.targets.find(target => target.id === f.recipient)?.maximum, 10);
// Nine leaves one funded spice and nine progress: a final one-spice
// payment still completes this OR, even when shipment is no longer funded.
let partial = act(bound, f.target, { ...f.payment, amount: 9 });
assert.equal(player(partial, f.target).spice, 1); assert.ok(!promise(partial, f.target).fulfilled);
partial = act(partial, f.target, f.end);
partial = act(partial, f.target, { ...f.payment, amount: 1 });
assert.equal(promise(partial, f.target).fulfilled, true);
assert.deepEqual(partial.turnActionPromises![0].payments, [{ recipient: f.recipient, amount: 10 }]);
const paid = act(bound, f.target, { ...f.payment, amount: 10 });
assert.equal(player(paid, f.target).spice, 0); assert.equal(player(paid, f.recipient).bribes, 10);
assert.equal(player(paid, f.target).reserves, 10); assert.equal(player(paid, f.target).moved, 0);
assert.equal(promise(paid, f.target).fulfilled, true); assert.ok(!promise(paid, f.target).released);
const shipped = settle(act(bound, f.target, f.shipment));
assert.equal(player(shipped, f.target).spice, 8); assert.equal(player(shipped, f.target).reserves, 9);
assert.equal(player(shipped, f.target).forces['polar_sink:0'], 1);
assert.equal(player(shipped, f.recipient).bribes, 0); assert.equal(promise(shipped, f.target).fulfilled, true);
for (const state of [partial, paid, shipped]) { truthOnce(state, f.truth); assert.deepEqual(inventory(state), f.originalCards); }
});
void test('native payment OR movement Yes may close own transport then pay in late Mentat before Ready', () => {
const f = fixture(), mixed = group('or', payment(f.recipient), move());
let game = bind(f, 'yes', mixed).game;
game = settle(act(game, f.target, f.end));
assert.equal(player(game, f.target).moved, 0); assert.equal(player(game, f.target).reserves, 10);
assert.ok(!promise(game, f.target).fulfilled); assert.ok(!promise(game, f.target).released);
game = advance(game, state => state.phase === 8 && clean(state), [], true);
assert.ok(!promise(game, f.target).fulfilled); rejected(game, f.target, { type: 'ready' });
game = act(game, f.target, f.payment); assert.equal(promise(game, f.target).fulfilled, true);
game = act(game, f.target, { type: 'ready' }); assert.ok(game.ready.includes(f.target));
truthOnce(game, f.truth); assert.deepEqual(inventory(game), f.originalCards);
});
void test('native whole No keeps its payment cap after own transport closes and through actual current turn end', () => {
const f = fixture(), mixed = group('or', payment(f.recipient), move());
let game = bind(f, 'no', mixed).game;
rejected(game, f.target, f.move); rejected(game, f.target, f.payment);
game = act(game, f.target, { ...f.payment, amount: 1 }); game = settle(act(game, f.target, f.end));
assert.ok(!promise(game, f.target).fulfilled);
game = advance(game, state => state.phase === 8 && clean(state), [], true);
assert.ok(!promise(game, f.target).fulfilled); assert.ok(!promise(game, f.target).released);
rejected(game, f.target, { ...f.payment, amount: 1 });
game = finish(game); assert.equal(game.turn, 2); assert.equal(promise(game, f.target).fulfilled, true);
const oldReceipt = structuredClone(game.turnActionPromises![0]);
game = act(game, f.target, f.payment); assert.deepEqual(game.turnActionPromises![0], oldReceipt);
truthOnce(game, f.truth); assert.deepEqual(inventory(game), f.originalCards);
});
void test('controlled known Inflation Double rejects a late payment-only Yes and retains truthful No', () => {
const f = fixture({ source: 'mentat' }), controlled = structuredClone(f.game);
// Original played source and inventory; only the public timing token is a
// controlled variant, not an asserted original CHOAM journey.
controlled.inflation = { side: 'double', placedTurn: 0, updatedTurn: 0, flipped: false };
const opened = declare(f, controlled).game;
const asked = ask(opened, f.asker, f.target, f.mixed);
assert.deepEqual(viewGame(asked, f.target).truthTurnActionAnswers, ['no']);
rejected(asked, f.target, { type: 'truthAnswer', answer: 'yes' });
const game = answer(asked, f.target, 'no');
assert.equal(viewGame(game, f.target).turnActionGuide?.action.type, 'ready');
truthOnce(game, f.truth); assert.deepEqual(inventory(game), f.originalCards);
});
void test('native current-spice facts freeze at the answer before real payments reduce the purse', () => {
const f = fixture(), mixed = group('and', fact(f.spiceFact), payment(f.recipient), ship(), move());
assert.equal(truthFactAnswer(player(f.game, f.target), f.spiceFact), 'yes');
let game = bind(f, 'yes', mixed).game; game = act(game, f.target, f.payment);
assert.equal(truthFactAnswer(player(game, f.target), f.spiceFact), 'no'); assert.ok(!promise(game, f.target).fulfilled);
rejected(game, f.target, f.end); game = settle(act(game, f.target, f.shipment));
game = settle(act(game, f.target, f.move)); assert.equal(promise(game, f.target).fulfilled, true);
assert.deepEqual(promise(game, f.target).mixed, mixed); truthOnce(game, f.truth);
assert.deepEqual(inventory(game), f.originalCards);
});
for (const first of ['scalar', 'aggregate'] as const) void test(`two native Truths share one actual payment in ${first}-first acceptance order`, () => {
const f = fixture(), mixed = group('and', payment(f.recipient, 6), ship(), move());
const aggregate: TruthQuestion = { kind: 'turnActions', target: f.target, turn: f.game.turn, mixed };
const questions = first === 'scalar' ? [scalarQuestion(f), aggregate] : [aggregate, scalarQuestion(f)];
const actions: TruthTurnActionStep[] = [];
let game = bindQuestion(f, f.game, 0, questions[0], 'yes', actions);
const earlierHistory = structuredClone(game.truthHistory![0]);
game = bindQuestion(f, game, 1, questions[1], 'yes', actions);
assert.deepEqual(game.truthHistory![0], earlierHistory);
game = act(game, f.target, { ...f.payment, amount: 6 }, actions);
assert.equal(game.bribePromises![0].progress, 6); assert.equal(game.bribePromises![0].fulfilled, true);
assert.equal(player(game, f.recipient).bribes, 6); assert.equal(player(game, f.target).spice, 4);
assert.ok(!promise(game, f.target).fulfilled); game = settle(act(game, f.target, f.shipment, actions), actions);
game = settle(act(game, f.target, f.move, actions), actions);
assert.equal(player(game, f.target).spice, 2); assert.equal(promise(game, f.target).fulfilled, true);
assert.ok(!game.bribePromises![0].released); assert.equal(game.truthHistory!.length, 2);
assert.equal(actions.filter(step => step.action.type === 'bribe').length, 1);
for (const truth of f.truths) truthOnce(game, truth); assert.deepEqual(inventory(game), f.originalCards);
});
for (const first of ['scalar', 'aggregate'] as const) void test(`two native Truths reject overbooking original cash in ${first}-first acceptance order`, () => {
const f = fixture(), mixed = group('and', payment(f.asker, 1), ship(), move());
const aggregate: TruthQuestion = { kind: 'turnActions', target: f.target, turn: f.game.turn, mixed };
const scalar = scalarQuestion(f, f.recipient, 8);
let game = bindQuestion(f, f.game, 0, first === 'scalar' ? scalar : aggregate);
const history = structuredClone(game.truthHistory), earlier = structuredClone(first === 'scalar'
? game.bribePromises![0] : game.turnActionPromises![0]);
game = act(declare(f, game, 1).game, f.asker, { type: 'truthAsk', question: first === 'scalar' ? aggregate : scalar });
rejected(game, f.target, { type: 'truthAnswer', answer: 'yes' });
assert.deepEqual(game.truthHistory, history);
assert.deepEqual(first === 'scalar' ? game.bribePromises![0] : game.turnActionPromises![0], earlier);
assert.ok(player(game, f.asker).hand.some(card => card.id === f.truths[1]));
assert.equal(player(game, f.target).spice, 10); assert.deepEqual(inventory(game), f.originalCards);
});
for (const advanced of [false, true]) void test(`native ${advanced ? 'Advanced' : 'Basic'} late Mentat payer fulfills before the receiver's next opening receipt`, () => {
const f = fixture({ source: 'mentat', advanced }); assert.ok(f.mentatSource);
assert.equal(f.sourceActionCount, advanced ? 35 : 32); assert.equal(f.sourceActionCount, f.sourceActions.length);
let game = act(f.game, f.target, { type: 'ready' }); game = act(game, f.asker, { type: 'ready' });
assert.equal(game.phase, 8); const recipientSpice = player(game, f.recipient).spice;
game = bind(f, 'yes', f.mixed, game).game;
assert.ok(!game.ready.includes(f.target)); assert.ok(game.ready.includes(f.asker));
rejected(game, f.target, { type: 'ready' }); game = act(game, f.target, f.payment);
assert.equal(promise(game, f.target).fulfilled, true); assert.equal(player(game, f.target).spice, 8);
assert.equal(player(game, f.recipient).spice, recipientSpice); assert.equal(player(game, f.recipient).bribes, 2);
const receipt = structuredClone(game.turnActionPromises![0].payments);
game = act(game, f.target, { type: 'ready' });
const result = completeTruthBribeExpression(f.mentatSource, game);
assert.equal(result.nextTurn.after.turn, 2); assert.equal(result.nextTurn.after.phase, 0);
assert.equal(player(result.nextTurn.after, f.recipient).bribes, 2);
assert.equal(player(result.mentat.before, f.recipient).bribes, 2);
assert.equal(player(result.mentat.after, f.recipient).bribes, 0);
assert.equal(player(result.mentat.after, f.recipient).spice - player(result.mentat.before, f.recipient).spice, 2);
assert.deepEqual(result.game.turnActionPromises![0].payments, receipt, 'Actual later income must not count the payer action twice');
assert.equal(promise(result.game, f.target).fulfilled, true);
truthOnce(result.game, f.truth); assert.deepEqual(inventory(result.game), f.originalCards);
});
void test('native turn-action raw projection and exact current-step guide preserve owner phase and event authority', () => {
const f = fixture(), declared = declare(f);
rejected(declared.game, f.target, { type: 'truthAsk', question: f.question });
rejected(declared.game, f.asker, { type: 'truthAsk', question: { ...f.question, turn: f.game.turn + 1 } });
const asked = ask(declared.game, f.asker, f.target, f.mixed);
rejected(asked, f.asker, { type: 'truthAnswer', answer: 'yes' });
let game = answer(asked, f.target, 'yes');
const row = promise(game, f.target), required = ['event', 'player', 'asker', 'turn', 'mixed', 'answer'];
assert.ok(required.every(key => Object.keys(row).includes(key)));
assert.ok(Object.keys(row).every(key => [...required, 'fulfilled', 'released'].includes(key)));
assert.deepEqual(viewGame(game, f.target).truthHistory.at(-1)?.question, f.question);
for (const actor of [f.asker, f.observer]) {
assert.deepEqual(viewGame(game, actor).turnActionPromises, []); assert.equal(viewGame(game, actor).turnActionGuide, null);
}
const guide = viewGame(game, f.target).turnActionGuide; assert.ok(guide);
assert.equal(guide.event, row.event); assert.equal(guide.action.turnActionPromiseEvent, row.event);
assert.equal(guide.action.turnActionPromisePhase, game.phase);
rejected(game, f.asker, guide.action);
rejected(game, f.target, { ...guide.action, turnActionPromiseEvent: 'stale-native-turn-action-event' });
rejected(game, f.target, { ...guide.action, turnActionPromisePhase: 4 });
rejected(game, f.target, { ...guide.action, amount: Number(guide.action.amount) + 1 });
const wire: Action = JSON.parse(JSON.stringify(guide.action)); game = settle(act(game, f.target, wire));
rejected(game, f.target, wire); game = complete(game, f.target); rejected(game, f.target, guide.action);
assert.equal(promise(game, f.target).fulfilled, true); truthOnce(game, f.truth);
assert.deepEqual(inventory(game), f.originalCards);
});
void test('all four minimal profiles actually answer and perform the current native payment shipment and movement guides', () => {
const f = fixture(), asked = ask(declare(f).game, f.asker, f.target, f.mixed);
for (const difficulty of DIFFICULTIES) {
let game = structuredClone(asked); const actions: TruthTurnActionStep[] = [];
const answering = viewGame(game, f.target); answering.players.find(candidate => candidate.id === f.target)!.bot = difficulty;
const before = structuredClone(answering), answerAction = botActions(answering).find(action => action.type === 'truthAnswer');
assert.ok(answerAction, difficulty); assert.deepEqual(answering, before); assert.equal(answerAction.answer, 'yes', difficulty);
game = act(game, f.target, answerAction, actions);
for (let count = 0; count < 12 && !promise(game, f.target).fulfilled; count++) {
const view = viewGame(game, f.target); view.players.find(candidate => candidate.id === f.target)!.bot = difficulty;
const guide = view.turnActionGuide; assert.ok(guide, difficulty);
const action = botActions(view).find(action => action.turnActionPromiseEvent === guide.event);
assert.ok(action, difficulty); assert.deepEqual(action, guide.action);
game = settle(act(game, f.target, action, actions), actions);
}
assert.equal(promise(game, f.target).fulfilled, true, difficulty);
for (const kind of ['bribe', 'ship', 'move']) assert.ok(actions.some(step => step.action.type === kind), `${difficulty}: ${kind}`);
assert.equal(player(game, f.target).spice, 6); assert.equal(player(game, f.target).reserves, 9);
assert.equal(player(game, f.target).moved, 1); assert.equal(player(game, f.recipient).bribes, 2);
truthOnce(game, f.truth); assert.deepEqual(inventory(game), f.originalCards);
}
});
+379
View File
@@ -0,0 +1,379 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
compileMixedActionExpression,
type MixedActionExpression,
} from '../game/mixed-action-question';
import {
TURN_ACTION_MAX_DEPTH,
TURN_ACTION_MAX_LEAVES,
TurnActionError,
closeTurnActionTransport,
finishTurnActionTurn,
liveTurnActionPromises,
parseTurnActionLeaf,
recordTurnActionMovement,
recordTurnActionPayment,
recordTurnActionShipment,
turnActionBranches,
turnActionHasPayments,
turnActionValue,
validateTurnActionPromises,
type TurnActionBranchLeaf,
type TurnActionLeaf,
type TurnActionPromise,
} from '../game/turn-action-promises';
type Expression = MixedActionExpression<TurnActionLeaf>;
const seats = ['respondent', 'asker', 'recipient', 'other', 'second-recipient'];
const travel = (kind: 'ship' | 'move', territory = 'carthag', minimum = 3): Expression =>
({ kind: 'action', action: { kind, territory, minimum } });
const pay = (recipient = 'recipient', minimum = 3): Expression =>
({ kind: 'action', action: { kind: 'payment', recipient, minimum } });
const fact = (): Expression => ({ kind: 'fact', fact: { kind: 'spice', compare: 'gte', value: 5 } });
const group = (kind: 'and' | 'or', ...terms: Expression[]): Expression => ({ kind, terms });
const signedTravel = (kind: 'ship' | 'move', territory = 'carthag', minimum = 3,
answer = true): TurnActionBranchLeaf => ({ kind, territory, minimum, answer });
const signedPay = (recipient = 'recipient', minimum = 3, progress = 0,
answer = true): TurnActionBranchLeaf => ({ kind: 'payment', recipient, minimum, progress, answer });
const event = (territory = 'carthag', amount = 3) => ({ territory, amount });
const payment = (recipient = 'recipient', amount = 3) => ({ recipient, amount });
function promise(mixed: Expression, answer = true, id = 'turn-action:1'): TurnActionPromise {
return {
turn: 1, mixed, event: id, player: 'respondent', asker: 'asker', answer,
compiled: compileMixedActionExpression(mixed, () => 'yes', answer),
shipment: { pending: true, event: null }, progress: [], payments: [],
transportClosed: false, paymentClosed: false, fulfilled: false, released: false,
};
}
const branches = (record: TurnActionPromise) => [...turnActionBranches(record)];
void test('turn-action leaves retain exact native fields, safe payment minima and no payment twenty-force cap', () => {
for (const kind of ['ship', 'move'] as const) {
const leaf = { kind, territory: 'carthag', minimum: 3 };
assert.deepEqual(parseTurnActionLeaf(leaf), leaf);
assert.notEqual(parseTurnActionLeaf(leaf), leaf);
for (const invalid of [{ ...leaf, minimum: 0 }, { ...leaf, minimum: 21 },
{ ...leaf, territory: 'not-a-territory' }, { ...leaf, amount: 3 },
{ ...leaf, answer: true }, { ...leaf, recipient: 'recipient' }])
assert.throws(() => parseTurnActionLeaf(invalid), TurnActionError);
}
for (const minimum of [1, 21, 1001, Number.MAX_SAFE_INTEGER]) {
const leaf = { kind: 'payment', recipient: 'recipient', minimum };
assert.deepEqual(parseTurnActionLeaf(leaf), leaf);
assert.notEqual(parseTurnActionLeaf(leaf), leaf);
}
const leaf = { kind: 'payment', recipient: 'recipient', minimum: 3 };
for (const invalid of [null, [], {}, { ...leaf, kind: 'gift' }, { ...leaf, recipient: '' },
{ ...leaf, recipient: ' recipient' }, { ...leaf, progress: 0 }, { ...leaf, turn: 1 },
{ ...leaf, territory: 'carthag' }, { ...leaf, answer: true },
...[0, -1, 1.5, NaN, Infinity, Number.MAX_SAFE_INTEGER + 1, '3'].map(minimum => ({ ...leaf, minimum }))])
assert.throws(() => parseTurnActionLeaf(invalid), TurnActionError);
});
void test('one whole AND requires actual shipment, physical movement and ordinary payment while OR retains choices', () => {
const and = promise(group('and', travel('ship'), travel('move', 'arrakeen', 2), pay()));
const or = promise(group('or', travel('ship'), travel('move', 'arrakeen', 2), pay()), true, 'turn-action:2');
assert.deepEqual(branches(and), [[signedTravel('ship'), signedTravel('move', 'arrakeen', 2), signedPay()]]);
assert.deepEqual(branches(or), [[signedTravel('ship')], [signedTravel('move', 'arrakeen', 2)], [signedPay()]]);
const shipped = recordTurnActionShipment([and, or], 'respondent', 1, event());
assert.equal(shipped[0].fulfilled, false);
assert.equal(shipped[1].fulfilled, true);
assert.deepEqual(branches(shipped[0]), [[signedTravel('move', 'arrakeen', 2), signedPay()]]);
const moved = recordTurnActionMovement(shipped, 'respondent', 1, event('arrakeen', 2));
assert.equal(moved[0].fulfilled, false);
assert.deepEqual(branches(moved[0]), [[signedPay()]]);
const paid = recordTurnActionPayment(moved, 'respondent', 1, payment());
assert.equal(paid[0].fulfilled, true);
assert.equal(turnActionValue(paid[0]), true);
assert.deepEqual(branches(paid[0]), [[]]);
assert.equal(liveTurnActionPromises(paid, 'respondent', 1).length, 0);
assert.equal(paid[1], shipped[1], 'A terminal alternative never receives later movement or payments');
assert.deepEqual(and.progress, []);
assert.deepEqual(and.payments, []);
assert.equal(and.shipment.pending, true);
validateTurnActionPromises(paid, seats);
});
void test('whole No negates the entire AND/OR tree and actual actions may change its remaining branch', () => {
const andNo = promise(group('and', travel('ship'), group('or', travel('move', 'arrakeen', 2), pay())), false);
assert.deepEqual(branches(andNo), [[signedTravel('ship', 'carthag', 3, false)],
[signedTravel('move', 'arrakeen', 2, false), signedPay('recipient', 3, 0, false)]]);
const shipped = recordTurnActionShipment([andNo], 'respondent', 1, event());
assert.deepEqual(branches(shipped[0]), [[signedTravel('move', 'arrakeen', 2, false),
signedPay('recipient', 3, 0, false)]]);
const before = structuredClone(shipped);
assert.throws(() => recordTurnActionMovement(shipped, 'respondent', 1, event('arrakeen', 2)), TurnActionError);
assert.throws(() => recordTurnActionPayment(shipped, 'respondent', 1, payment()), TurnActionError);
assert.deepEqual(shipped, before);
const orNo = promise(group('or', travel('ship'), pay()), false);
assert.deepEqual(branches(orNo), [[signedTravel('ship', 'carthag', 3, false),
signedPay('recipient', 3, 0, false)]]);
assert.throws(() => recordTurnActionShipment([orNo], 'respondent', 1, event()), TurnActionError);
assert.throws(() => recordTurnActionPayment([orNo], 'respondent', 1, payment()), TurnActionError);
const skipped = recordTurnActionShipment([andNo], 'respondent', 1, null);
assert.ok(branches(skipped[0]).some(branch => branch.length === 0));
assert.equal(skipped[0].fulfilled, false, 'Other relevant opportunities remain open even with a decisive absent shipment');
const paid = recordTurnActionPayment(skipped, 'respondent', 1, payment());
assert.equal(paid[0].fulfilled, false);
assert.equal(finishTurnActionTurn(paid, 'respondent', 1)[0].fulfilled, true);
});
void test('payment progress sums actual payer events while movement progress retains only one maximum group', () => {
const original = promise(group('and', travel('move', 'carthag', 5), pay('recipient', 5)));
const moved = recordTurnActionMovement([original], 'respondent', 1, event('carthag', 3));
const movedAgain = recordTurnActionMovement(moved, 'respondent', 1, event('carthag', 3));
assert.equal(movedAgain[0], moved[0]);
assert.deepEqual(movedAgain[0].progress, [{ territory: 'carthag', maximum: 3 }]);
const paid = recordTurnActionPayment(movedAgain, 'respondent', 1, payment('recipient', 3));
const paidAgain = recordTurnActionPayment(paid, 'respondent', 1, payment('recipient', 2));
assert.deepEqual(paidAgain[0].payments, [payment('recipient', 5)]);
assert.equal(turnActionValue(paidAgain[0]), false);
assert.deepEqual(branches(paidAgain[0]), [[signedTravel('move', 'carthag', 5)]]);
assert.equal(recordTurnActionMovement(paidAgain, 'respondent', 1, event('carthag', 5))[0].fulfilled, true);
assert.equal(recordTurnActionMovement(paidAgain, 'respondent', 1, event('arrakeen', 20))[0], paidAgain[0]);
assert.equal(recordTurnActionPayment(paidAgain, 'respondent', 1, payment('other', 20))[0], paidAgain[0]);
});
void test('answer intervals retain separate payment receipts, movement maxima and one shared original shipment', () => {
const mixed = group('and', group('or', travel('ship', 'carthag', 5), travel('move', 'arrakeen', 5)), pay('recipient', 7));
const old = promise(mixed);
const once = recordTurnActionPayment([old], 'respondent', 1, payment('recipient', 3));
const shipped = recordTurnActionShipment(once, 'respondent', 1, event('carthag', 4));
const moved = recordTurnActionMovement(shipped, 'respondent', 1, event('arrakeen', 4));
const fresh = promise(mixed, true, 'turn-action:2');
fresh.shipment.pending = false;
const otherPlayer = { ...promise(mixed, true, 'turn-action:3'), player: 'other' };
const otherTurn = { ...promise(mixed, true, 'turn-action:4'), turn: 2 };
const list = [moved[0], fresh, otherPlayer, otherTurn];
const paid = recordTurnActionPayment(list, 'respondent', 1, payment('recipient', 2));
assert.deepEqual(paid[0].payments, [payment('recipient', 5)]);
assert.deepEqual(paid[1].payments, [payment('recipient', 2)]);
assert.deepEqual(branches(paid[0]), [[signedTravel('move', 'arrakeen', 5), signedPay('recipient', 7, 5)]]);
assert.deepEqual(branches(paid[1]), [[signedTravel('move', 'arrakeen', 5), signedPay('recipient', 7, 2)]]);
const again = recordTurnActionShipment(paid, 'respondent', 1, event('arrakeen', 20));
assert.equal(again[0].shipment.event?.amount, 4);
assert.equal(again[1].shipment.event, null);
assert.equal(again[2], otherPlayer);
assert.equal(again[3], otherTurn);
const small = recordTurnActionMovement(again, 'respondent', 1, event('arrakeen', 2));
assert.deepEqual(small[0].progress, [{ territory: 'arrakeen', maximum: 4 }]);
assert.deepEqual(small[1].progress, [{ territory: 'arrakeen', maximum: 2 }]);
});
void test('signed DNF merges only same-kind bounds within a record and prunes incompatible positive shipment destinations', () => {
const same = promise(group('and', travel('ship', 'carthag', 2), travel('ship', 'carthag', 5),
travel('move', 'carthag', 3), travel('move', 'carthag', 4), pay('recipient', 3), pay('recipient', 6)));
const partial = recordTurnActionPayment([same], 'respondent', 1, payment('recipient', 2));
assert.deepEqual(branches(partial[0]), [[signedTravel('ship', 'carthag', 5), signedTravel('move', 'carthag', 4),
signedPay('recipient', 6, 2)]]);
const no = promise(group('or', pay('recipient', 3), pay('recipient', 6), travel('move', 'carthag', 2),
travel('move', 'carthag', 5)), false);
assert.deepEqual(branches(no), [[signedPay('recipient', 3, 0, false), signedTravel('move', 'carthag', 2, false)]]);
const impossible = promise(group('and', travel('ship'), travel('ship', 'arrakeen'), pay()));
assert.deepEqual(branches(impossible), []);
const choices = promise(group('and', group('or', travel('ship'), pay()),
group('or', travel('ship', 'arrakeen', 2), travel('move', 'arrakeen', 4))));
assert.deepEqual(branches(choices), [
[signedTravel('ship'), signedTravel('move', 'arrakeen', 4)],
[signedPay(), signedTravel('ship', 'arrakeen', 2)],
[signedPay(), signedTravel('move', 'arrakeen', 4)],
]);
});
void test('transport closure preserves actual shipment and unfinished Yes payment alternatives without inventing a release', () => {
const original = promise(group('and', travel('ship'), group('or', travel('move', 'arrakeen', 2), pay())));
const shipped = recordTurnActionShipment([original], 'respondent', 1, event());
const closed = closeTurnActionTransport(shipped, 'respondent', 1);
assert.equal(closed[0].transportClosed, true);
assert.equal(closed[0].paymentClosed, false);
assert.deepEqual(closed[0].shipment, { pending: false, event: event() });
assert.deepEqual(branches(closed[0]), [[signedPay()]]);
assert.equal(closed[0].fulfilled, false);
assert.equal(closed[0].released, false);
validateTurnActionPromises(closed, seats);
assert.equal(recordTurnActionMovement(closed, 'respondent', 1, event('arrakeen', 2))[0], closed[0]);
assert.equal(recordTurnActionPayment(closed, 'respondent', 1, payment())[0].fulfilled, true);
const impossible = closeTurnActionTransport([promise(travel('move'))], 'respondent', 1);
assert.deepEqual(branches(impossible[0]), []);
assert.equal(impossible[0].released, false, 'Native closure preflight decides whether the respondent may close');
validateTurnActionPromises(impossible, seats);
});
void test('whole No remains binding after transport closes through a later ordinary payment window', () => {
const original = promise(group('or', travel('move'), pay('recipient', 5)), false);
const closed = closeTurnActionTransport([original], 'respondent', 1);
assert.equal(closed[0].fulfilled, false);
assert.deepEqual(branches(closed[0]), [[signedPay('recipient', 5, 0, false)]]);
const partial = recordTurnActionPayment(closed, 'respondent', 1, payment('recipient', 4));
assert.equal(partial[0].fulfilled, false);
assert.deepEqual(branches(partial[0]), [[signedPay('recipient', 5, 4, false)]]);
assert.throws(() => recordTurnActionPayment(partial, 'respondent', 1, payment('recipient', 1)), TurnActionError);
validateTurnActionPromises(partial, seats);
const finished = finishTurnActionTurn(partial, 'respondent', 1);
assert.equal(finished[0].fulfilled, true);
assert.equal(finished[0].paymentClosed, true);
assert.equal(finished[0].released, false);
validateTurnActionPromises(finished, seats);
const transportOnly = closeTurnActionTransport([promise(travel('move'), false)], 'respondent', 1);
assert.equal(transportOnly[0].fulfilled, true);
const shipmentOnly = recordTurnActionShipment([promise(travel('ship'), false)], 'respondent', 1, null);
assert.equal(shipmentOnly[0].fulfilled, true);
});
void test('closed payment opportunities fold false while achieved payments fold true in remaining transport branches', () => {
const mixed = group('or', travel('move'), pay());
const closedPayment = { ...promise(mixed), paymentClosed: true };
assert.deepEqual(branches(closedPayment), [[signedTravel('move')]]);
assert.equal(recordTurnActionPayment([closedPayment], 'respondent', 1, payment())[0], closedPayment);
const achievedPayment = { ...promise(group('and', travel('move'), pay())), payments: [payment()] };
assert.deepEqual(branches(achievedPayment), [[signedTravel('move')]]);
const impossible = { ...promise(pay()), paymentClosed: true };
assert.deepEqual(branches(impossible), []);
validateTurnActionPromises([closedPayment], seats);
});
void test('actual turn expiry fulfills the aggregate answer or releases it and terminal rows never update', () => {
const yes = promise(group('and', travel('ship'), pay()));
const no = promise(group('and', travel('ship'), pay()), false, 'turn-action:2');
const externallyReleased = { ...promise(pay(), true, 'turn-action:3'), released: true };
const fulfilled = recordTurnActionPayment([promise(pay(), true, 'turn-action:4')], 'respondent', 1, payment())[0];
const finished = finishTurnActionTurn([yes, no, externallyReleased, fulfilled], 'respondent', 1);
assert.equal(finished[0].released, true);
assert.equal(finished[0].fulfilled, false);
assert.equal(finished[1].fulfilled, true);
assert.equal(finished[1].released, false);
assert.equal(finished[2], externallyReleased);
assert.equal(finished[3], fulfilled);
for (const record of finished.slice(0, 2)) {
assert.equal(record.transportClosed, true);
assert.equal(record.paymentClosed, true);
assert.equal(record.shipment.pending, false);
}
for (const transform of [
(list: TurnActionPromise[]) => recordTurnActionShipment(list, 'respondent', 1, event()),
(list: TurnActionPromise[]) => recordTurnActionMovement(list, 'respondent', 1, event()),
(list: TurnActionPromise[]) => recordTurnActionPayment(list, 'respondent', 1, payment()),
(list: TurnActionPromise[]) => closeTurnActionTransport(list, 'respondent', 1),
(list: TurnActionPromise[]) => finishTurnActionTurn(list, 'respondent', 1),
]) assert.ok(transform(finished).every((record, index) => record === finished[index]));
validateTurnActionPromises(finished, seats);
});
void test('frozen known and unknown facts retain whole-answer constants and original payment scope', () => {
for (const answer of [true, false]) {
for (const factAnswer of ['yes', 'no', 'unknown'] as const) {
for (const kind of ['and', 'or'] as const) {
const mixed = group(kind, fact(), pay());
const record = promise(mixed, answer);
record.compiled = compileMixedActionExpression(mixed, () => factAnswer, answer);
assert.deepEqual(record.mixed, mixed);
assert.equal(turnActionHasPayments(record), true);
if (record.compiled.kind === 'constant') {
assert.equal(turnActionValue(record), record.compiled.value);
assert.deepEqual(branches(record), record.compiled.value === answer ? [[]] : []);
if (record.compiled.value === answer) {
record.fulfilled = true;
validateTurnActionPromises([record], seats);
}
} else assert.deepEqual(branches(record), [[signedPay('recipient', 3, 0, answer)]]);
}
}
}
assert.equal(turnActionHasPayments(promise(travel('move'))), false);
});
void test('original and private turn-action expressions enforce sixteen total leaves and four group levels', () => {
assert.equal(TURN_ACTION_MAX_LEAVES, 16);
assert.equal(TURN_ACTION_MAX_DEPTH, 4);
const sixteen = group('and', ...Array.from({ length: 16 }, () => pay()));
assert.doesNotThrow(() => turnActionValue(promise(sixteen)));
const nested = (depth: number): Expression => depth === 0 ? pay() : group('or', travel('move'), nested(depth - 1));
assert.doesNotThrow(() => turnActionValue(promise(nested(4))));
for (const mixed of [nested(5), group('or', ...Array.from({ length: 17 }, () => pay())),
group('and', group('or', ...Array.from({ length: 9 }, () => pay())),
group('or', ...Array.from({ length: 8 }, () => travel('move'))))])
assert.throws(() => turnActionValue(promise(mixed)), TurnActionError);
const manyFacts: Expression = { kind: 'fact', fact: { kind: 'and', terms: Array.from({ length: 16 }, () =>
({ kind: 'spice', compare: 'gte', value: 5 })) } };
assert.throws(() => turnActionValue(promise(group('and', manyFacts, pay()))), TurnActionError,
'The fact wrapper shares the original logical budget');
const original = promise(pay());
const compiled = { kind: 'and', terms: Array.from({ length: 17 }, () => original.compiled) };
assert.throws(() => turnActionValue({ ...original, compiled } as TurnActionPromise), TurnActionError);
const nestedPrivate = (depth: number): TurnActionPromise['compiled'] => depth === 0 ? original.compiled :
{ kind: 'or', terms: [{ kind: 'constant', value: true }, nestedPrivate(depth - 1)] };
assert.throws(() => turnActionValue({ ...original, compiled: nestedPrivate(5) }), TurnActionError,
'A decisive private constant cannot skip an over-depth later clause');
});
void test('safe payment accumulation is immutable and rejects overflow even in a nonselected alternative', () => {
const original = promise(group('or', pay('recipient', Number.MAX_SAFE_INTEGER), travel('move')));
const partial = recordTurnActionPayment([original], 'respondent', 1,
payment('recipient', Number.MAX_SAFE_INTEGER - 1));
const before = structuredClone(partial);
assert.throws(() => recordTurnActionPayment(partial, 'respondent', 1, payment('recipient', 2)), TurnActionError);
assert.deepEqual(partial, before);
const ordinary = promise(group('and', pay('recipient', 5), travel('move')), true, 'turn-action:2');
const list = [ordinary, partial[0]];
assert.throws(() => recordTurnActionPayment(list, 'respondent', 1, payment('recipient', 2)), TurnActionError);
assert.deepEqual(ordinary.payments, []);
const complete = recordTurnActionPayment(partial, 'respondent', 1, payment('recipient', 1));
assert.equal(complete[0].fulfilled, true);
assert.deepEqual(complete[0].payments, [payment('recipient', Number.MAX_SAFE_INTEGER)]);
});
void test('stored rows reject missing or extra metadata, foreign private leaves and malformed native receipts', () => {
const original = promise(group('and', travel('ship'), travel('move'), pay()));
validateTurnActionPromises([original], seats);
const corruptions: unknown[] = [
{ ...original, turn: 0 }, { ...original, event: '' }, { ...original, player: ' respondent' },
{ ...original, order: 1 }, { ...original, answer: 'yes' }, { ...original, fulfilled: undefined },
{ ...original, paymentClosed: undefined }, { ...original, transportClosed: 'yes' },
{ ...original, fulfilled: true, released: true },
{ ...original, shipment: { pending: true, event: event() } },
{ ...original, shipment: { pending: false, event: { ...event(), elite: 1 } } },
{ ...original, shipment: { pending: true, event: null }, transportClosed: true },
{ ...original, progress: [{ territory: 'carthag', maximum: 21 }] },
{ ...original, progress: [{ territory: 'arrakeen', maximum: 3 }] },
{ ...original, progress: [{ territory: 'carthag', maximum: 3, amount: 3 }] },
{ ...original, progress: [{ territory: 'carthag', maximum: 1 }, { territory: 'carthag', maximum: 2 }] },
{ ...original, payments: [payment('other')] },
{ ...original, payments: [payment(), payment()] },
{ ...original, payments: [payment('recipient', Number.MAX_SAFE_INTEGER + 1)] },
{ ...original, payments: [{ ...payment(), received: true }] },
{ ...original, compiled: { kind: 'constant', value: 'unknown' } },
{ ...original, compiled: { kind: 'constant', value: true, answer: true } },
{ ...original, compiled: { kind: 'action', action: { kind: 'payment', recipient: 'other', minimum: 3 } } },
{ ...original, compiled: { kind: 'action', action: { kind: 'move', territory: 'carthag', minimum: 4 } } },
{ ...original, compiled: { kind: 'and', terms: [] } },
{ ...original, mixed: { kind: 'constant', value: true } },
{ ...original, mixed: { kind: 'action', action: { kind: 'payment', recipient: 'recipient', minimum: 3, progress: 0 } } },
{ ...original, mixed: fact() },
];
for (const field of Object.keys(original)) {
const missing: Record<string, unknown> = { ...original };
delete missing[field];
corruptions.push(missing);
}
for (const corrupted of corruptions)
assert.throws(() => turnActionValue(corrupted as TurnActionPromise), TurnActionError);
for (const corrupted of [{ ...original, asker: 'respondent' }, { ...original, player: 'missing' },
{ ...original, fulfilled: true }, { ...original, mixed: pay('missing'), compiled: promise(pay('missing')).compiled },
{ ...original, mixed: pay('respondent'), compiled: promise(pay('respondent')).compiled },
{ ...original, answer: false, shipment: { pending: false, event: event() },
progress: [{ territory: 'carthag', maximum: 3 }], payments: [payment()] },
{ ...original, answer: false, fulfilled: true }])
assert.throws(() => validateTurnActionPromises([corrupted], seats), TurnActionError);
assert.throws(() => validateTurnActionPromises([original, original], seats), TurnActionError);
for (const invalid of [{ ...event(), amount: 21 }, { ...event(), amount: -1 },
{ ...event(), amount: 2.5 }, { ...event(), amount: '3' },
{ ...event(), territory: 'missing' }, { ...event(), elite: 1 }]) {
assert.throws(() => recordTurnActionMovement([original], 'respondent', 1, invalid as never), TurnActionError);
assert.throws(() => recordTurnActionShipment([original], 'respondent', 1, invalid as never), TurnActionError);
}
for (const invalid of [{ ...payment(), amount: 0 }, { ...payment(), amount: -1 },
{ ...payment(), amount: 1.5 }, { ...payment(), amount: Number.MAX_SAFE_INTEGER + 1 },
{ ...payment(), recipient: '' }, { ...payment(), received: true }])
assert.throws(() => recordTurnActionPayment([original], 'respondent', 1, invalid as never), TurnActionError);
});