Files
Dune/tests/fixture-truth-travel.ts
T

170 lines
11 KiB
TypeScript

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 { TravelActionClaim } from '../game/travel-expression-promises';
import type { TruthFact } from '../game/truthtrance';
import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions';
import {
actTruthMixedMovement, createTruthMixedMovementFixture, truthMixedMovementClean,
truthMixedMovementInventory, truthMixedMovementPlayer, type TruthMixedMovementStep,
} from './fixture-truth-mixed-movement';
import { createTruthMovementWaitingFixture } from './fixture-truth-movement-waiting';
export type TruthTravelExpression = MixedActionExpression<TravelActionClaim>;
export type TruthTravelStep = TruthMixedMovementStep;
export const actTruthTravel = actTruthMixedMovement;
export const truthTravelClean = truthMixedMovementClean;
export const truthTravelInventory = truthMixedMovementInventory;
export const truthTravelPlayer = truthMixedMovementPlayer;
export const truthTravelFact = (fact: TruthFact): TruthTravelExpression => ({ kind: 'fact', fact });
export const truthTravelShip = (territory = 'polar_sink', minimum = 1): TruthTravelExpression =>
({ kind: 'action', action: { kind: 'ship', territory, minimum } });
export const truthTravelMove = (territory = 'imperial_basin', minimum = 1): TruthTravelExpression =>
({ kind: 'action', action: { kind: 'move', territory, minimum } });
export const truthTravelGroup = (kind: 'and' | 'or', ...terms: TruthTravelExpression[]): TruthTravelExpression =>
({ kind, terms });
/** One original public Basic base33 start. Active stops immediately before the
* source's paid Polar Sink shipment; shipped retains that one actual action.
* Waiting replays the original unopened deal reversal and native phase prefix.
* Charity is only an explicitly unsupported-window source for a boundary test.
* Every selected count comes from the corresponding recorded action array. */
export function createTruthTravelFixture(options: {
hajr?: boolean; window?: 'active' | 'waiting' | 'shipped' | 'charity'; mixed?: TruthTravelExpression;
} = {}) {
const window = options.window ?? 'active';
const activeSource = window === 'waiting' ? null : createTruthMixedMovementFixture({
hajr: options.hajr, window: window === 'charity' ? 'charity' : 'active',
});
const waitingSource = window === 'waiting' ? createTruthMovementWaitingFixture({ hajr: options.hajr }) : null;
const original = activeSource ?? waitingSource!;
let game = structuredClone(original.game), actions = structuredClone(original.actions);
let shipmentIndex: number | null = null;
if (window === 'active') {
assert.ok(activeSource);
shipmentIndex = activeSource.actions.findIndex(step => step.actor === activeSource.shipmentStep.actor &&
JSON.stringify(step.action) === JSON.stringify(activeSource.shipmentStep.action));
assert.ok(shipmentIndex >= 0, 'The source records its original paid shipment explicitly');
game = structuredClone(activeSource.beforeShipment);
actions = structuredClone(activeSource.actions.slice(0, shipmentIndex));
}
const asker = original.asker, target = original.target;
const shipmentAction: Action = waitingSource ? structuredClone(waitingSource.shipment) :
structuredClone(activeSource!.shipmentStep.action);
const firstMove: Action = waitingSource ? structuredClone(waitingSource.move) : structuredClone(activeSource!.firstMove);
const secondMove: Action = waitingSource
? { type: 'move', from: 'arrakeen:10', territory: 'imperial_basin', sector: 9, amount: 1 }
: structuredClone(activeSource!.secondMove);
const shipmentTerritory = String(shipmentAction.territory), movementTerritory = String(firstMove.territory);
const mixed = options.mixed ?? truthTravelGroup('and', truthTravelShip(shipmentTerritory), truthTravelMove(movementTerritory));
const question = { kind: 'travel' as const, target, turn: game.turn, mixed };
const hajrCard = original.setup.deck.find(card => card.effect === 'hajr'); assert.ok(hajrCard);
const projectile = original.setup.deck.find(card => card.kind === 'projectile'); assert.ok(projectile);
const hajrFact: TruthFact = { kind: 'hand', name: hajrCard.name };
const projectileFact: TruthFact = { kind: 'hand', name: projectile.name };
const spiceFact: TruthFact = { kind: 'spice', compare: 'gte', value: truthTravelPlayer(game, target).spice };
assert.equal(game.advanced, false); assert.deepEqual(game.expansions, []);
assert.ok(truthTravelClean(game)); assert.equal(game.pendingShipment ?? null, null);
assert.ok(truthTravelPlayer(game, asker).hand.some(card => card.id === original.truth));
assert.deepEqual(truthTravelInventory(game), original.originalCards);
assert.equal(game.travelPromises?.length ?? 0, 0);
return {
game, source: structuredClone(game), base: original, activeSource, waitingSource, window,
asker, target, truth: original.truth, card: original.truth, hajr: original.hajr,
setup: original.setup, lobby: original.lobby, beforeStartingDeal: original.beforeStartingDeal,
afterSetup: original.afterSetup, beforeShipment: activeSource?.beforeShipment ?? null,
originalCards: original.originalCards, originalSpice: original.originalSpice,
dealOrder: original.dealOrder, spiceOrder: original.spiceOrder, startingPositions: original.startingPositions,
actions, sourceActions: actions, sourceActionCount: actions.length, sourceCount: actions.length,
sourceCounts: { selected: actions.length, inherited: original.sourceActionCount, shipment: shipmentIndex },
mixed, question, hajrFact, projectileFact, spiceFact,
shipmentAction, shipmentStep: { actor: target, action: shipmentAction },
shipmentTerritory, movementTerritory, firstMove, secondMove, sameDestinationMove: { ...firstMove },
shipmentCost: waitingSource ? waitingSource.shipmentCost : 2,
hajrAction: original.hajr ? { type: 'card', card: original.hajr } satisfies Action : null,
declaration: { type: 'card', card: original.truth } satisfies Action,
questionAction: { type: 'truthAsk', question } satisfies Action,
currentMove: waitingSource?.currentMove ?? null,
currentEnd: { type: 'endMovement' } satisfies Action,
sourceDisclosure: `${'sourceDisclosure' in original ? original.sourceDisclosure : original.staging.join(' ')} ` +
`This travel adapter selects only the recorded ${window} native prefix and original raw structured question. ` +
'Its shipment leaves all refer to one future original reserve shipment after answering, and ground leaves refer to actual native ground groups. ' +
'The source has one actually dealt Truthtrance, so simultaneous prior scalar-plus-travel answers are outside this fixture. ' +
'No additional physical card, dealt hand, purse, force, played clock, promise, receipt or compiled fact is installed.',
};
}
export type TruthTravelFixture = ReturnType<typeof createTruthTravelFixture>;
export function declareTruthTravel(f: TruthTravelFixture, source = f.game) {
const before = structuredClone(source), actions: TruthTravelStep[] = [];
let game = actTruthTravel(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 = actTruthTravel(game, actor.id, { type: 'truthPass' }, actions);
}
assert.equal(game.truthtrance?.stage, 'ask');
return { game, before, opened, actions, actionCount: actions.length };
}
export function askTruthTravel(game: Game, asker: string, target: string, mixed: TruthTravelExpression,
actions: TruthTravelStep[] = []): Game {
return actTruthTravel(game, asker, { type: 'truthAsk', question: {
kind: 'travel', target, turn: game.turn, mixed: structuredClone(mixed),
} }, actions);
}
export function answerTruthTravel(game: Game, target: string, answer: 'yes' | 'no', actions: TruthTravelStep[] = []): Game {
return actTruthTravel(game, target, { type: 'truthAnswer', answer }, actions);
}
export function bindTruthTravel(f: TruthTravelFixture, answer: 'yes' | 'no' = 'yes', mixed = f.mixed, source = f.game) {
const declared = declareTruthTravel(f, source), actions = [...declared.actions];
const asked = askTruthTravel(declared.game, f.asker, f.target, mixed, actions);
const game = answerTruthTravel(asked, f.target, answer, actions);
return { game, declared, asked, actions, actionCount: actions.length };
}
/** The private aggregate exposes the original question and its status only. */
export function truthTravelPromise(game: Game, target = game.travelPromises?.[0]?.player ?? game.players[0].id) {
const row = viewGame(game, target).travelPromises.find(promise => promise.player === target);
assert.ok(row, 'The respondent retains the raw travel question and aggregate answer');
return row;
}
export function nextTruthTravel(game: Game): TruthTravelStep | null {
if (game.truthtrance) return null;
for (const player of game.players) {
const guide = viewGame(game, player.id).travelPromiseGuide;
if (guide) return { actor: player.id, action: guide.action };
}
return nextStrongholdFactionsNativeStep(game);
}
export function advanceTruthTravel(game: Game, until: (state: Game) => boolean, actions: TruthTravelStep[] = []): Game {
for (let count = 0; count < 800; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished', 'The original travel source ended before its requested native boundary');
const next = nextTruthTravel(game); assert.ok(next, 'Supply the original Truthtrance question or answer');
game = actTruthTravel(game, next.actor, next.action, actions);
}
throw Error('The original travel source did not reach its finite boundary');
}
export function settleTruthTravel(game: Game, actions: TruthTravelStep[] = []): Game {
return advanceTruthTravel(game, state => truthTravelClean(state) && !state.pendingShipment, actions);
}
export function completeTruthTravel(game: Game, target = game.travelPromises?.[0]?.player ?? game.players[0].id,
actions: TruthTravelStep[] = []): Game {
for (let count = 0; count < 16; count++) {
const promise = truthTravelPromise(game, target);
if (promise.fulfilled || promise.released) return game;
game = settleTruthTravel(game, actions);
const guide = viewGame(game, target).travelPromiseGuide; assert.ok(guide, 'The accepted travel answer has a native completion');
game = settleTruthTravel(actTruthTravel(game, target, guide.action, actions), actions);
}
throw Error('The exact original native travel guide did not settle the accepted aggregate');
}
export function finishTruthTravel(game: Game, target = game.travelPromises?.[0]?.player ?? game.players[0].id,
actions: TruthTravelStep[] = []): Game {
game = settleTruthTravel(game, actions);
assert.equal(game.phase, 5); assert.equal(game.active, target);
game = settleTruthTravel(actTruthTravel(game, target, { type: 'endMovement' }, actions), actions);
assert.ok(!game.movementRemaining?.includes(target), 'Only actual owner endMovement closes this native interval');
return game;
}