Combine current-turn payments with shipment and movement promises
This commit is contained in:
1 parent
8dbec05844
commit
0d72a33ee5
18 files changed
+2591
-7
No files matched your search
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user