189 lines
12 KiB
TypeScript
189 lines
12 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import { viewGame, type Action, type Game } from '../game/engine';
|
|
import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions';
|
|
import { createTruthMovementEarlyFixture } from './fixture-truth-movement-early';
|
|
import {
|
|
createTruthMovementNativeEarlyFixture, nextTruthMovementNativeEarlyStep,
|
|
type TruthMovementNativeEarlyAuction, type TruthMovementNativeEarlyRoster,
|
|
} from './fixture-truth-movement-native-early';
|
|
import { createTruthMixedMovementFixture } from './fixture-truth-mixed-movement';
|
|
import { createTruthRevivalEarlyFixture } from './fixture-truth-revival-early';
|
|
import {
|
|
actTruthTravel, truthTravelClean, truthTravelFact, truthTravelGroup,
|
|
truthTravelInventory, truthTravelMove, truthTravelPlayer, truthTravelPromise,
|
|
truthTravelShip, type TruthTravelExpression, type TruthTravelStep,
|
|
} from './fixture-truth-travel';
|
|
|
|
export type TruthTravelEarlyStep = TruthTravelStep;
|
|
export const actTruthTravelEarly = actTruthTravel;
|
|
export const truthTravelEarlyClean = truthTravelClean;
|
|
export const truthTravelEarlyInventory = truthTravelInventory;
|
|
export const truthTravelEarlyPlayer = truthTravelPlayer;
|
|
export const truthTravelEarlyPromise = truthTravelPromise;
|
|
export { truthTravelFact, truthTravelGroup, truthTravelMove, truthTravelShip };
|
|
export type { TruthTravelExpression };
|
|
|
|
/** Replay current native controls. A private guide always belongs to this
|
|
* phase and event; the source's real auction and response controls do the rest. */
|
|
export function nextTruthTravelEarly(game: Game,
|
|
auction: TruthMovementNativeEarlyAuction = {}): TruthTravelEarlyStep | 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 };
|
|
}
|
|
if (game.players.some(player => ['richese', 'choam', 'ecaz', 'moritani', 'ixians', 'tleilaxu'].includes(player.faction)))
|
|
return nextTruthMovementNativeEarlyStep(game, auction);
|
|
if (!game.response && !game.phaseOpening && game.decision?.kind === 'revivalStop')
|
|
return { actor: game.decision.player, action: { type: 'decision', decline: true } };
|
|
return nextStrongholdFactionsNativeStep(game);
|
|
}
|
|
export function advanceTruthTravelEarly(game: Game, until: (state: Game) => boolean,
|
|
actions: TruthTravelEarlyStep[] = [], auction: TruthMovementNativeEarlyAuction = {}): Game {
|
|
for (let count = 0; count < 1800; count++) {
|
|
if (until(game)) return game;
|
|
assert.notEqual(game.status, 'finished', 'The original early travel source ended before its native boundary');
|
|
const next = nextTruthTravelEarly(game, auction);
|
|
assert.ok(next, 'Supply the actual Truthtrance question, answer or owned movement before continuing');
|
|
game = actTruthTravelEarly(game, next.actor, next.action, actions);
|
|
}
|
|
throw Error('The original early travel source did not reach its finite boundary');
|
|
}
|
|
export function settleTruthTravelEarly(game: Game, actions: TruthTravelEarlyStep[] = [],
|
|
auction: TruthMovementNativeEarlyAuction = {}): Game {
|
|
return advanceTruthTravelEarly(game, state => truthTravelEarlyClean(state) && !state.pendingShipment &&
|
|
!state.pendingRevival, actions, auction);
|
|
}
|
|
|
|
/** Original sources only. Earlier snapshots exclude the later factory suffix;
|
|
* unopened Spice selections are reapplied before their real phase controls.
|
|
* The earned Revival source includes a real paid shipment and Storm casualties,
|
|
* so its previous-turn shipment flag needs no controlled installation. */
|
|
export function createTruthTravelEarlyFixture(options: {
|
|
source?: 'classic' | 'hajr' | 'native' | 'revival'; phase?: 0 | 1 | 2 | 3 | 4;
|
|
advanced?: boolean; hajr?: boolean; poor?: boolean; roster?: TruthMovementNativeEarlyRoster;
|
|
targetKarama?: boolean; minimum?: number; mixed?: TruthTravelExpression;
|
|
} = {}) {
|
|
const kind = options.source ?? 'classic', phase = options.phase ?? 2;
|
|
const classicSource = kind === 'classic'
|
|
? createTruthMovementEarlyFixture({ phase: phase < 2 ? 0 : 2 }) : null;
|
|
const hajrSource = kind === 'hajr'
|
|
? createTruthMixedMovementFixture({ advanced: options.advanced, hajr: options.hajr, window: 'charity' }) : null;
|
|
const nativeSource = kind === 'native' ? createTruthMovementNativeEarlyFixture({ roster: options.roster }) : null;
|
|
const revivalSource = kind === 'revival' ? createTruthRevivalEarlyFixture({
|
|
advanced: options.advanced, phase: phase === 4 ? 3 : phase, poor: options.poor,
|
|
targetKarama: options.targetKarama,
|
|
}) : null;
|
|
const original = classicSource ?? hajrSource ?? nativeSource ?? revivalSource!;
|
|
let game = structuredClone(original.game), actions: TruthTravelEarlyStep[] = structuredClone(original.actions);
|
|
const initialPrefixCount = actions.length;
|
|
const selectedSpice: { position: number; from: number; card: Game['spiceDeck'][number] }[] = [];
|
|
if (phase < 2 && (hajrSource || nativeSource)) {
|
|
const base = hajrSource ?? nativeSource!;
|
|
assert.ok(base.afterSetupActionCount !== null);
|
|
game = structuredClone(base.afterSetup);
|
|
actions = structuredClone(base.actions.slice(0, base.afterSetupActionCount));
|
|
for (const { position, card } of base.spiceOrder) {
|
|
const from = game.spiceDeck.findIndex((candidate, index) => index >= position &&
|
|
JSON.stringify(candidate) === JSON.stringify(card));
|
|
assert.ok(from >= position, 'Select the same original still-unopened Spice card');
|
|
const selected = game.spiceDeck.splice(from, 1)[0]; game.spiceDeck.splice(position, 0, selected);
|
|
selectedSpice.push({ position, from, card: structuredClone(selected) });
|
|
}
|
|
}
|
|
if (game.phase !== phase) game = advanceTruthTravelEarly(game,
|
|
state => state.phase === phase && truthTravelEarlyClean(state), actions);
|
|
const asker = original.asker, target = original.target;
|
|
const truth = classicSource?.truth ?? hajrSource?.truth ?? nativeSource?.truth ?? revivalSource!.truth;
|
|
const originalCards = original.originalCards;
|
|
const minimum = options.minimum ?? (hajrSource ? 1 : revivalSource ? 2 : 8);
|
|
const shipmentAction: Action = hajrSource
|
|
? { type: 'ship', territory: 'polar_sink', sector: 0, amount: minimum, elite: 0, allyPayment: 0 }
|
|
: { type: 'ship', territory: 'arrakeen', sector: 10, amount: minimum, elite: 0, allyPayment: 0 };
|
|
const firstMove: Action = hajrSource
|
|
? { type: 'move', from: 'arrakeen:10', territory: 'imperial_basin', sector: 9, amount: minimum }
|
|
: { type: 'move', from: 'arrakeen:10', territory: 'carthag', sector: 11, amount: minimum };
|
|
const secondMove: Action = { type: 'move', from: 'arrakeen:10', territory: 'carthag', sector: 11, amount: minimum };
|
|
const mixed = options.mixed ?? truthTravelGroup('and',
|
|
truthTravelShip(String(shipmentAction.territory), minimum), truthTravelMove(String(firstMove.territory), minimum));
|
|
const question = { kind: 'travel' as const, target, turn: game.turn, mixed };
|
|
assert.equal(game.status, 'playing'); assert.equal(game.phase, phase);
|
|
assert.ok(truthTravelEarlyClean(game));
|
|
assert.equal(game.travelPromises?.length ?? 0, 0);
|
|
assert.deepEqual(truthTravelEarlyInventory(game), originalCards);
|
|
assert.ok(truthTravelEarlyPlayer(game, asker).hand.some(card => card.id === truth));
|
|
const sourceDisclosure = 'sourceDisclosure' in original ? original.sourceDisclosure : original.staging.join(' ');
|
|
return {
|
|
game, source: structuredClone(game), kind, phase, base: original,
|
|
classicSource, hajrSource, nativeSource, revivalSource,
|
|
asker, target, truth, card: truth, hajr: hajrSource?.hajr ?? null,
|
|
setup: original.setup, lobby: original.lobby, beforeStartingDeal: original.beforeStartingDeal,
|
|
afterSetup: original.afterSetup, originalCards, originalSpice: original.originalSpice,
|
|
dealOrder: original.dealOrder, spiceOrder: original.spiceOrder, selectedSpice,
|
|
actions, sourceActions: actions, sourceActionCount: actions.length,
|
|
sourceCounts: { inherited: initialPrefixCount, selected: actions.length,
|
|
setup: actions.findIndex(step => step.action.type === 'start') + 1,
|
|
priorShipments: actions.filter(step => step.actor === target && step.action.type === 'ship').length,
|
|
priorRevivals: actions.filter(step => step.actor === target && step.action.type === 'revive').length },
|
|
minimum, mixed, question, shipmentAction, firstMove, secondMove,
|
|
shipmentCost: minimum * (hajrSource ? 2 : 1),
|
|
declaration: { type: 'card', card: truth } satisfies Action,
|
|
questionAction: { type: 'truthAsk', question } satisfies Action,
|
|
hajrAction: hajrSource?.hajr ? { type: 'card', card: hajrSource.hajr } satisfies Action : null,
|
|
currentEnd: { type: 'endMovement' } satisfies Action,
|
|
sourceDisclosure: `${sourceDisclosure} This early travel adapter retains only the recorded ${actions.length}-action ` +
|
|
`phase${phase} prefix. The caller supplies the whole travel question, answer and all later real actions. ` +
|
|
'No card, dealt hand, wallet, army, clock, ready list, old shipment flag, promise or receipt is installed.',
|
|
};
|
|
}
|
|
export type TruthTravelEarlyFixture = ReturnType<typeof createTruthTravelEarlyFixture>;
|
|
|
|
export function declareTruthTravelEarly(f: TruthTravelEarlyFixture, source = f.game,
|
|
actions: TruthTravelEarlyStep[] = []) {
|
|
let game = actTruthTravelEarly(source, f.asker, f.declaration, actions);
|
|
const opened = structuredClone(game);
|
|
while (game.truthtrance?.stage === 'priority') {
|
|
const player = game.players.find(player => !game.truthtrance!.passed.includes(player.id)); assert.ok(player);
|
|
game = actTruthTravelEarly(game, player.id, { type: 'truthPass' }, actions);
|
|
}
|
|
assert.equal(game.truthtrance?.stage, 'ask');
|
|
return { game, opened, actions };
|
|
}
|
|
export function askTruthTravelEarly(f: TruthTravelEarlyFixture, source = f.game,
|
|
mixed = f.mixed, actions: TruthTravelEarlyStep[] = []) {
|
|
const declared = declareTruthTravelEarly(f, source, actions);
|
|
assert.ok(viewGame(declared.game, f.asker).truthTravelTargets.includes(f.target));
|
|
const game = actTruthTravelEarly(declared.game, f.asker, { type: 'truthAsk', question: {
|
|
kind: 'travel', target: f.target, turn: source.turn, mixed: structuredClone(mixed),
|
|
} }, actions);
|
|
return { game, declared, actions };
|
|
}
|
|
export function bindTruthTravelEarly(f: TruthTravelEarlyFixture, answer: 'yes' | 'no' = 'yes',
|
|
mixed = f.mixed, source = f.game, actions: TruthTravelEarlyStep[] = []) {
|
|
const asked = askTruthTravelEarly(f, source, mixed, actions);
|
|
assert.ok(viewGame(asked.game, f.target).truthTravelAnswers?.includes(answer));
|
|
const game = settleTruthTravelEarly(actTruthTravelEarly(asked.game, f.target,
|
|
{ type: 'truthAnswer', answer }, actions), actions);
|
|
return { game, asked, actions, actionCount: actions.length };
|
|
}
|
|
export function reachTruthTravelEarlyMovement(f: TruthTravelEarlyFixture, game: Game,
|
|
actions: TruthTravelEarlyStep[] = [], auction: TruthMovementNativeEarlyAuction = {}) {
|
|
return advanceTruthTravelEarly(game, state => state.phase === 5 && state.active === f.target &&
|
|
truthTravelEarlyClean(state) && !state.pendingShipment, actions, auction);
|
|
}
|
|
export function finishTruthTravelEarly(f: TruthTravelEarlyFixture, game: Game,
|
|
actions: TruthTravelEarlyStep[] = []): Game {
|
|
game = settleTruthTravelEarly(game, actions);
|
|
assert.equal(game.phase, 5); assert.equal(game.active, f.target);
|
|
game = settleTruthTravelEarly(actTruthTravelEarly(game, f.target, f.currentEnd, actions), actions);
|
|
assert.ok(!game.movementRemaining?.includes(f.target));
|
|
return game;
|
|
}
|
|
export function completeTruthTravelEarly(f: TruthTravelEarlyFixture, game: Game,
|
|
actions: TruthTravelEarlyStep[] = []): Game {
|
|
return advanceTruthTravelEarly(game, state => {
|
|
const row = truthTravelEarlyPromise(state, f.target);
|
|
return !!row.fulfilled || !!row.released;
|
|
}, actions);
|
|
}
|