Files
Dune/tests/fixture-truth-movement-expression.ts

149 lines
9.7 KiB
TypeScript

import assert from 'node:assert/strict';
import { viewGame, type Action, type Game } from '../game/engine';
import { parseMovementExpression, type MovementExpression } from '../game/movement-expression-promises';
import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions';
import {
actTruthMovement, createTruthMovementFixture, truthMovementClean,
truthMovementInventory, truthMovementPlayer, type TruthMovementStep,
} from './fixture-truth-movement';
import { createTruthMovementAdvancedFixture } from './fixture-truth-movement-advanced';
export type TruthMovementExpressionStep = TruthMovementStep;
export const actTruthMovementExpression = actTruthMovement;
export const truthMovementExpressionClean = truthMovementClean;
export const truthMovementExpressionInventory = truthMovementInventory;
export const truthMovementExpressionPlayer = truthMovementPlayer;
/** Every continuation uses the original native response/readiness controls.
* A live grouped obligation uses the same exact action the player sees. */
export function nextTruthMovementExpression(game: Game): TruthMovementExpressionStep | null {
if (game.truthtrance) return null;
for (const player of game.players) {
const guide = viewGame(game, player.id).movementExpressionPromiseGuide;
if (guide?.phase === game.phase) return { actor: player.id, action: guide.action };
}
return nextStrongholdFactionsNativeStep(game);
}
export function advanceTruthMovementExpression(game: Game, until: (state: Game) => boolean,
actions: TruthMovementExpressionStep[] = []): Game {
for (let count = 0; count < 1200; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished', 'The original grouped movement source ended before its requested control');
const next = nextTruthMovementExpression(game);
assert.ok(next, 'Supply the original grouped Truthtrance question or answer');
game = actTruthMovementExpression(game, next.actor, next.action, actions);
}
throw Error('The original grouped movement source did not reach its finite boundary');
}
export function settleTruthMovementExpression(game: Game, actions: TruthMovementExpressionStep[] = []): Game {
return advanceTruthMovementExpression(game, truthMovementExpressionClean, actions);
}
export function finishTruthMovementExpression(game: Game, target = game.players[0].id,
actions: TruthMovementExpressionStep[] = []): Game {
game = settleTruthMovementExpression(game, actions);
assert.equal(game.phase, 5); assert.equal(game.active, target);
game = actTruthMovementExpression(game, target, { type: 'endMovement' }, actions);
game = settleTruthMovementExpression(game, actions);
assert.ok(!game.movementRemaining?.includes(target), 'Only actual owner endMovement closes its interval');
return game;
}
export function completeTruthMovementExpression(game: Game, target = game.players[0].id,
actions: TruthMovementExpressionStep[] = []): Game {
const live = () => game.movementExpressionPromises?.some(promise => promise.player === target &&
promise.turn === game.turn && promise.answer && !promise.fulfilled && !promise.released);
for (let count = 0; count < 12 && live(); count++) {
game = settleTruthMovementExpression(game, actions);
const guide = viewGame(game, target).movementExpressionPromiseGuide; assert.ok(guide);
assert.equal(guide.phase, game.phase);
game = settleTruthMovementExpression(actTruthMovementExpression(game, target, guide.action, actions), actions);
}
assert.ok(!live(), 'The exact native movement guide fulfills the accepted whole expression');
return game;
}
/** Thin adapter over genuine public Basic/Advanced base33 starts. The optional
* Charity source replays only the original setup prefix from its unopened deal,
* selects the same still-unopened Spice cards, and stops at real phase2. */
export function createTruthMovementExpressionFixture(options: {
advanced?: boolean; hajr?: boolean; window?: 'active' | 'charity'; claim?: MovementExpression;
} = {}) {
const base = options.advanced
? createTruthMovementAdvancedFixture({ hajr: options.hajr })
: createTruthMovementFixture({ hajr: options.hajr });
const window = options.window ?? 'active';
let game = structuredClone(base.game), actions = structuredClone(base.actions);
let afterSetup = structuredClone(base.afterSetup), afterSetupActionCount: number | null = null;
let spiceOrder = structuredClone(base.spiceOrder);
if (window === 'charity') {
const start = base.actions.findIndex(step => step.action.type === 'start'); assert.ok(start >= 0);
actions = structuredClone(base.actions.slice(0, start + 1));
game = advanceTruthMovementExpression(structuredClone(base.beforeStartingDeal),
state => state.status === 'playing', actions);
afterSetup = structuredClone(game); afterSetupActionCount = actions.length;
spiceOrder = base.spiceOrder.map(({ position, card }) => {
const from = game.spiceDeck.findIndex((candidate, index) => index >= position &&
JSON.stringify(candidate) === JSON.stringify(card));
assert.ok(from >= position, 'Choose the same original unopened ordinary Spice card');
const selected = game.spiceDeck.splice(from, 1)[0]; game.spiceDeck.splice(position, 0, selected);
return { position, from, card: structuredClone(selected) };
});
game = advanceTruthMovementExpression(game,
state => state.turn === 1 && state.phase === 2 && truthMovementExpressionClean(state), actions);
assert.equal(truthMovementExpressionPlayer(game, base.target).shipped, false);
assert.equal(truthMovementExpressionPlayer(game, base.target).moved, 0);
assert.deepEqual(truthMovementExpressionPlayer(game, base.target).forces, { 'arrakeen:10': 10 });
}
assert.equal(game.advanced, options.advanced ?? false); assert.deepEqual(game.expansions, []);
assert.deepEqual(truthMovementExpressionInventory(game), base.originalCards);
assert.ok(truthMovementExpressionPlayer(game, base.asker).hand.some(card => card.id === base.truth));
assert.equal(game.movementExpressionPromises, undefined); assert.equal(game.truthtrance ?? null, null);
const firstMove = { type: 'move', from: 'arrakeen:10', territory: 'imperial_basin', sector: 9, amount: 1 } satisfies Action;
const secondMove = { type: 'move', from: 'arrakeen:10', territory: 'carthag', sector: 11, amount: 1 } satisfies Action;
const claim = parseMovementExpression(options.claim ?? { kind: 'and', terms: [
{ kind: 'move', territory: 'imperial_basin', minimum: 1 },
{ kind: 'move', territory: 'carthag', minimum: 1 },
] });
const question = { kind: 'movementExpression' as const, target: base.target, turn: game.turn, claim };
return { ...base, base, game, source: structuredClone(game), window, claim, question,
afterSetup, afterSetupActionCount, spiceOrder,
actions, sourceActions: actions, sourceActionCount: actions.length, sourceCount: actions.length,
sourceCounts: { inheritedActive: base.sourceActionCount, setup: afterSetupActionCount, selected: actions.length },
firstMove, secondMove, sameDestinationMove: { ...firstMove },
hajrAction: base.hajr ? { type: 'card', card: base.hajr } satisfies Action : null,
declaration: { type: 'card', card: base.truth } satisfies Action,
questionAction: { type: 'truthAsk', question } satisfies Action,
sourceDisclosure: `${base.staging.join(' ')} This grouped adapter changes only the structured question. ${window === 'charity'
? 'Its recorded source excludes the original active-window suffix: the unopened deal is replayed, the same unopened Spice selection is preserved, and actual Storm/Spice controls stop at clean Charity.'
: 'Its recorded source retains the exact original public setup, phase controls and paid one-force Polar Sink shipment.'} The future Imperial Basin9 and Carthag11 ground groups use the original Arrakeen10 army; Hajr remains the actually dealt physical card. No played clock, purse, hand, army, movement allowance, answer or progress is installed.` };
}
export type TruthMovementExpressionFixture = ReturnType<typeof createTruthMovementExpressionFixture>;
export function declareTruthMovementExpression(f: TruthMovementExpressionFixture, source = f.game) {
const before = structuredClone(source), actions: TruthMovementExpressionStep[] = [];
let game = actTruthMovementExpression(source, f.asker, f.declaration, actions);
const opened = structuredClone(game);
while (game.truthtrance?.stage === 'priority') {
const actor = game.players.find(player => !game.truthtrance!.passed.includes(player.id)); assert.ok(actor);
game = actTruthMovementExpression(game, actor.id, { type: 'truthPass' }, actions);
}
assert.equal(game.truthtrance?.stage, 'ask');
return { game, before, opened, actions, actionCount: actions.length };
}
export function askTruthMovementExpression(game: Game, asker: string, target: string, claim: MovementExpression,
actions: TruthMovementExpressionStep[] = []): Game {
return actTruthMovementExpression(game, asker, { type: 'truthAsk', question: {
kind: 'movementExpression', target, turn: game.turn, claim: parseMovementExpression(claim),
} }, actions);
}
export function answerTruthMovementExpression(game: Game, target: string, answer: 'yes' | 'no',
actions: TruthMovementExpressionStep[] = []): Game {
return actTruthMovementExpression(game, target, { type: 'truthAnswer', answer }, actions);
}
export function bindTruthMovementExpression(f: TruthMovementExpressionFixture, answer: 'yes' | 'no' = 'yes',
claim = f.claim, source = f.game) {
const declared = declareTruthMovementExpression(f, source), actions = [...declared.actions];
const asked = askTruthMovementExpression(declared.game, f.asker, f.target, claim, actions);
const game = answerTruthMovementExpression(asked, f.target, answer, actions);
return { game, declared, asked, actions, actionCount: actions.length };
}