import assert from 'node:assert/strict'; import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game, type GameView, } from '../game/engine'; import { createTechTokens, ownedTech } from '../game/tech-tokens'; import { createStrongholdCards } from '../game/stronghold-cards'; import { RECRUITS_CARD_ID } from '../game/recruits'; import { assertRecruitsHomeworldInventory, createRecruitsHomeworldFixture, recruitsHomeworldClean, recruitsHomeworldPlayer, type RecruitsHomeworldFixture, type RecruitsHomeworldStep, } from './fixture-recruits-homeworld'; import { nextHarassHomeworldStep } from './fixture-harass-homeworld'; import type { IndependentCardModulesOptions } from './fixture-independent-card-modules'; export type WorldPhaseRecruitsOptions = { advanced?: boolean; strongholds?: boolean; seatIds?: readonly [string, string, string, string]; skills?: boolean; training?: IndependentCardModulesOptions['training']; }; export type WorldPhaseRecruitsFixture = { initial: Game; afterSetup: Game; game: Game; afterPaidReturn: Game; beforePlay: Game; afterPlay: Game; beforeFremen: Game; afterTypedReturn: Game; afterReturns: Game; beforePhaseEnd: Game; afterPhaseEnd: Game; phaseEndStep: RecruitsHomeworldStep; actors: { emperor: string; fremen: string; atreides: string; guild: string; owner: string }; actions: Record<'paidReturn' | 'readyAtreides' | 'play' | 'reopenedReturn' | 'typedReturn' | 'followupReturn', Action>; ordinary: { fremen: GameView['revival']; atreides: GameView['revival'] }; expected: { paidReturnPrice: number; reopenedReturnPrice: number; fremenReturnPrice: number; income: number; wallets: Record; doubled: { fremenFree: number; atreidesFree: number; limit: number }; afterThreshold: { freeRemaining: number; forcesRemaining: number; homeworldBonus: number }; }; nativeSteps: RecruitsHomeworldStep[]; steps: RecruitsHomeworldStep[]; staging: string[]; }; export const worldPhaseRecruitsPlayer = recruitsHomeworldPlayer; export function worldPhaseRecruitsNativePool(game: Game, actor: string) { const world = viewGame(game, actor).homeworlds?.worlds?.find(w => w.native === actor && !w.secondary); assert.ok(world, 'The original native World must retain its real custody'); return world.forces[actor]; } /** Original E/F/A/G setup, paid typed shipments and two actual battles are * supplied by the canonical World fixture. Only an undealt lobby is composed * here: no live phase, force, wallet, owner or revival receipt is retrofitted. * Its existing initializer scopes its own entropy; the first subsequent draw * is the original first-Storm shuffle of the two unassigned Tech Tokens. * All later entropy (including Advanced's native Storm deck) delegates intact. */ export function createWorldPhaseRecruitsFixture(options: WorldPhaseRecruitsOptions = {}): WorldPhaseRecruitsFixture { const advanced = options.advanced ?? false; const strongholds = options.strongholds ?? false; assert.ok(!strongholds || advanced, 'Original Stronghold Cards require Advanced'); const ids = options.seatIds ?? ['emperor', 'fremen', 'atreides', 'guild']; const [emperor, fremen, atreides, guild] = ids; let lobby = createGame('WORLDPHASERECRUITS', newPlayer(emperor, 'Emperor', 'emperor'), advanced); for (const [id, faction] of [[fremen, 'fremen'], [atreides, 'atreides'], [guild, 'guild']] as const) joinGame(lobby, newPlayer(id, faction, faction)); const lobbySteps: RecruitsHomeworldStep[] = []; if (advanced) { // First Storm0 ends at1; the native later card1..6 ends at2..7. // Actual printed circles14/17 therefore keep Atreides before Fremen, // without completing the latter before its later typed requests. for (const [actor, position] of [[atreides, 5], [fremen, 6]] as const) { const action: Action = { type: 'seatPosition', position }; lobby = applyAction(lobby, actor, action); lobbySteps.push({ actor, action }); } } lobby = applyAction(lobby, emperor, { type: 'homeworlds', enabled: true }); lobby.techTokens = createTechTokens(); if (strongholds) lobby.strongholdCards = createStrongholdCards(); const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues'); // Binding loses Crypto's generic guarantee that it returns the same view. const originalRandom = crypto.getRandomValues.bind(crypto) as (array: T) => T; let firstStormDraw = true; crypto.getRandomValues = (array: T): T => { if (!firstStormDraw) return originalRandom(array); assert.ok(array); firstStormDraw = false; new Uint8Array(array.buffer, array.byteOffset, array.byteLength).fill(255); return array; }; let source: RecruitsHomeworldFixture; try { source = createRecruitsHomeworldFixture({ advanced, initial: lobby, seatIds: ids, skills: options.skills, training: options.training }); } finally { if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor); else Reflect.deleteProperty(crypto, 'getRandomValues'); } assert.equal(firstStormDraw, false, 'The original first Storm must assign unused Tech Tokens'); let game = structuredClone(source.game); const steps: RecruitsHomeworldStep[] = []; const act = (actor: string, action: Action) => { steps.push({ actor, action: structuredClone(action) }); game = applyAction(game, actor, action); }; const settle = () => { for (let count = 0; count < 100; count++) { if (recruitsHomeworldClean(game)) return; const next = nextHarassHomeworldStep(game); assert.ok(next); act(next.actor, next.action); } throw Error('Original World revival decisions did not settle'); }; const awaitOwner = (actor: string) => { while (game.revivalOrder && game.revivalOrder.remaining[0] !== actor) { assert.ok(game.revivalOrder.remaining.includes(actor), 'The later native request must retain its pending opportunity.'); const preceding = game.revivalOrder.remaining[0]; assert.ok(preceding); act(preceding, { type: 'ready' }); settle(); } }; const actions = { paidReturn: { type: 'revive', amount: 3, elite: 0 }, readyAtreides: { type: 'ready' }, play: { type: 'card', card: RECRUITS_CARD_ID }, reopenedReturn: { type: 'revive', amount: 2, elite: 0 }, typedReturn: { type: 'revive', amount: 1, elite: 1 }, followupReturn: { type: 'revive', amount: 5, elite: 0 }, } satisfies Record; const owner = game.techTokens?.axlotl.owner; assert.ok(owner); const income = ownedTech(game.techTokens, owner).length; assert.ok(income > 0); const ordinary = { fremen: structuredClone(viewGame(game, fremen).revival), atreides: structuredClone(viewGame(game, atreides).revival), }; const wallets = Object.fromEntries(game.players.map(p => [p.id, p.spice])); awaitOwner(atreides); act(atreides, actions.paidReturn); settle(); const afterPaidReturn = structuredClone(game); if (!advanced) act(atreides, actions.readyAtreides); const beforePlay = structuredClone(game); act(emperor, actions.play); const afterPlay = structuredClone(game); act(atreides, actions.reopenedReturn); settle(); if (advanced) act(atreides, actions.readyAtreides); awaitOwner(fremen); const beforeFremen = structuredClone(game); act(fremen, actions.typedReturn); settle(); const afterTypedReturn = structuredClone(game); act(fremen, actions.followupReturn); settle(); const afterReturns = structuredClone(game); let beforePhaseEnd: Game | undefined; let phaseEndStep: RecruitsHomeworldStep | undefined; for (let count = 0; count < 100 && game.phase === 4; count++) { const next = nextHarassHomeworldStep(game); assert.ok(next); const before = structuredClone(game); act(next.actor, next.action); if (game.phase !== 4) { beforePhaseEnd = before; phaseEndStep = structuredClone(next); } } assert.ok(beforePhaseEnd && phaseEndStep, 'The original last ready must settle Revival'); assert.equal(game.phase, 5); const afterPhaseEnd = structuredClone(game); for (const boundary of [source.afterSetup, source.game, afterPaidReturn, beforePlay, afterPlay, beforeFremen, afterTypedReturn, afterReturns, beforePhaseEnd, afterPhaseEnd]) assertRecruitsHomeworldInventory(boundary); return { initial: source.initial, afterSetup: source.afterSetup, game: source.game, afterPaidReturn, beforePlay, afterPlay, beforeFremen, afterTypedReturn, afterReturns, beforePhaseEnd, phaseEndStep, afterPhaseEnd, actors: { emperor, fremen, atreides, guild, owner }, actions, ordinary, expected: { paidReturnPrice: 2, reopenedReturnPrice: 0, fremenReturnPrice: 0, income, wallets, doubled: { fremenFree: 7, atreidesFree: 4, limit: 7 }, afterThreshold: { freeRemaining: 5, forcesRemaining: 5, homeworldBonus: 0 }, }, nativeSteps: [...lobbySteps, ...source.nativeSteps], steps, staging: [...source.staging, 'Select unused original Tech Tokens and optional Advanced Strongholds in the undealt lobby, before readiness and original setup.', 'Fix only the original first-Storm unused-token shuffle; restore native later Storm entropy. Custody and held-token count are read, never assigned.', ...(advanced ? ['Select original lobby circles14 for Atreides and17 for Fremen. Finish preceding native turns; keep Atreides current through both requests before advancing to still-pending Fremen. No played queue or phase is changed.'] : []), 'Atreides pays for three original native returns before Recruits; its recorded paid counter remains paid after activation. Basic retains ready/reopening; Advanced keeps that opportunity open. Fremen then returns one typed Fedaykin and five normal counters on separate current-rate requests.', ], }; }