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; 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; 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; }