import assert from 'node:assert/strict'; import { createGame, initializeSemutaGameForAudit, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine'; import { isAdvisor } from '../game/advisors'; import { homeworldGameIntegrity, homeworldTable } from '../game/homeworld-game'; import { nativeShipmentSources } from '../game/homeworld-options'; import { homeworldRevivalDeploymentChoice } from '../game/homeworld-revival-deployment-options'; import { SEMUTA_DRUG_ID } from '../game/semuta-drug'; import { withClassicDiscoveryNexusLottery } from './fixture-discovery-classic-nexus'; import { actSemutaSpiritualCost, passSemutaSpiritualCost, claimSemutaSpiritualCost, allowSemutaSpiritualCost, semutaSpiritualCostPlayer, semutaSpiritualCostInventory, } from './fixture-semuta-spiritual-cost'; import { nextSemutaAdvisorMatching } from './fixture-semuta-advisor-matching'; export type SemutaSpiritualRevivalStage = 'shipping' | 'matching' | 'arrival'; export type SemutaSpiritualRevivalStep = { actor: string; action: Action }; export const actSemutaSpiritualRevival = actSemutaSpiritualCost; export const passSemutaSpiritualRevival = passSemutaSpiritualCost; export const claimSemutaSpiritualRevival = claimSemutaSpiritualCost; export const allowSemutaSpiritualRevival = allowSemutaSpiritualCost; export const semutaSpiritualRevivalPlayer = semutaSpiritualCostPlayer; export const semutaSpiritualRevivalInventory = semutaSpiritualCostInventory; export const semutaSpiritualRevivalClean = (game: Game) => !game.response && !game.phaseOpening && !game.decision && !game.truthtrance && !game.pendingTreacheryDiscard && !game.pendingRevival && !game.pendingSpiritualAdvisor && !game.pendingArrivalReaction?.length && !game.pendingAutomaticAdvisorFlips?.length; /** Balances and advisor stance are asserted separately: the retained native * free-revival income may settle only after the original SEND completes. */ export const semutaSpiritualRevivalPhysical = (game: Game) => ({ homeworlds: game.homeworlds, players: game.players.map(p => ({ id: p.id, forces: p.forces, elites: p.elites, reserves: p.reserves, tanks: p.tanks, shipped: p.shipped, moved: p.moved, revived: p.revived, freeForcesRevived: p.freeForcesRevived, leaders: p.leaders, ally: p.ally })) }); /** Original setup, exact native Storm controls and ordinary revival choices. * SEND, Spiritual choices/counters and fresh groups stay caller-owned. */ export function nextSemutaSpiritualRevival(game: Game): SemutaSpiritualRevivalStep | null { if (game.pendingTreacheryDiscard || game.truthtrance || game.pendingSpiritualAdvisor || game.response?.spiritualAdvisorSource || game.response?.automaticAdvisorFlip) return null; if (!game.response && !game.phaseOpening) { const decision = game.decision; if (decision?.kind === 'advisor' || decision?.kind === 'intrusion' || decision?.kind === 'homeworldRevivalDeployment') return null; if (decision?.kind === 'revivalStop' || decision?.kind === 'choamFreeRevival') return { actor: decision.player, action: { type: 'decision', decline: true } }; if (game.status === 'setup' && game.setupStage === 'prediction') return { actor: 'beneGesserit', action: { type: 'predict', faction: 'tleilaxu', turn: 10 } }; if (game.status === 'playing' && game.phase === 0 && !decision && game.stormPending === null) { const actor = game.stormDialers.find(id => game.stormDials[id] === undefined); if (actor) return { actor, action: { type: 'stormDial', amount: game.turn === 1 ? actor === game.stormDialers[0] ? 11 : 0 : 1 } }; } } return nextSemutaAdvisorMatching(game); } export const nextSemutaSpiritualRevivalStep = nextSemutaSpiritualRevival; export function advanceSemutaSpiritualRevival(game: Game, until: (state: Game) => boolean, actions: SemutaSpiritualRevivalStep[] = []): Game { for (let count = 0; count < 2400; count++) { if (until(game)) return game; assert.notEqual(game.status, 'finished'); const step = nextSemutaSpiritualRevival(game); assert.ok(step, `Choose the original SEND/Spiritual/fresh control at ${game.turn}/${game.phase}/${game.decision?.kind}`); game = actSemutaSpiritualRevival(game, step.actor, step.action, actions); } throw Error('The original Semuta/Revival SEND chronology did not reach its finite boundary'); } /** Keep a restored matching chooser explicit. Otherwise finish the original * SEND queue and retained income, without another request or phase action. */ export function finishSemutaSpiritualRevival(game: Game, actions: SemutaSpiritualRevivalStep[] = []): Game { assert.equal(game.pendingTreacheryDiscard ?? null, null, 'Choose the original fresh cost first'); return advanceSemutaSpiritualRevival(game, state => (state.homeworldRevivalReturn?.stage === 'complete' && semutaSpiritualRevivalClean(state)) || (!state.response && !state.phaseOpening && state.decision?.kind === 'advisor' && state.pendingSpiritualAdvisor?.stage === 'offer'), actions); } /** Fresh Advanced R/C/BG/A/T, actual cache purchase and ordinary printed * Karama, real next-Storm loss, free revival and owned Homeworld SEND. */ export function createSemutaSpiritualRevivalFixture(options: { stage?: SemutaSpiritualRevivalStage } = {}) { const stage = options.stage ?? 'shipping', seller = 'richese', holder = 'choam', owner = 'beneGesserit'; const spender = 'atreides', reviver = 'tleilaxu', destination = 'arrakeen:10'; const territory = stage === 'shipping' ? 'arrakeen' : 'polar_sink', sector = stage === 'shipping' ? 10 : 0; const location = `${territory}:${sector}`, casualtyLocation = 'hagga_basin:13'; let game = createGame('SEMUTASPIRITUALREVIVAL', newPlayer(seller, 'Richese', 'richese'), true, ['choam', 'ix']); for (const faction of [holder, owner, spender, reviver] as const) joinGame(game, newPlayer(faction, faction, faction)); const initial = structuredClone(game), actions: SemutaSpiritualRevivalStep[] = []; const act = (actor: string, action: Action) => { game = actSemutaSpiritualRevival(game, actor, action, actions); }; const reach = (until: (state: Game) => boolean) => { game = advanceSemutaSpiritualRevival(game, until, actions); }; const transition = (run: () => void) => { const before = structuredClone(game), at = actions.length; run(); return { before, steps: structuredClone(actions.slice(at)), after: structuredClone(game) }; }; const enableHomeworlds = transition(() => { act(game.host, { type: 'homeworlds', enabled: true }); }); for (const p of game.players) act(p.id, { type: 'ready' }); const lobby = structuredClone(game); game = withClassicDiscoveryNexusLottery(() => initializeSemutaGameForAudit(game)); const setup = structuredClone(game), initialization = { before: lobby, after: setup, entry: 'original-undealt-advanced-semuta-tleilax-homeworld-preview' }; assert.equal(game.status, 'setup'); assert.equal(game.advanced, true); assert.equal(game.semutaPreview, true); assert.equal(game.homeworlds?.custody, null); assert.ok(game.players.every(p => !p.hand.length)); assert.ok(!game.nexusCards && !game.leaderSkills && !game.techTokens && !game.strongholdCards && !game.discoveryEnabled && !game.ecazTreachery); const originalInventory = semutaSpiritualRevivalInventory(game); assert.equal(originalInventory.length, 57); const printed = game.deck.find(card => card.effect === 'karama' && !card.id.startsWith('ix-')); assert.ok(printed); const ordinary = game.deck.filter(card => ['projectile', 'poison', 'shield', 'snooper'].includes(card.kind) && !card.effect).slice(0, 4); assert.equal(ordinary.length, 4); const selected = [ordinary[0], ordinary[1], ordinary[2], printed, ordinary[3]], cost = printed.id; const dealOrder = selected.map((card, position) => { const from = game.deck.findIndex(candidate => candidate.id === card.id); assert.ok(from >= position); [game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]]; return { position, from, card: card.id }; }); const beforeStartingDeal = structuredClone(game); let advisorSetup: { before: Game; steps: SemutaSpiritualRevivalStep[]; after: Game } | null = null; for (let count = 0; game.status === 'setup' && count < 150; count++) { const step = nextSemutaSpiritualRevival(game); assert.ok(step); if (step.action.type === 'advisorSetup') step.action = { type: 'advisorSetup', territory: stage === 'matching' ? 'polar_sink' : 'arrakeen', sector: stage === 'matching' ? 0 : 10 }; const before = step.action.type === 'advisorSetup' ? structuredClone(game) : null; act(step.actor, step.action); if (before) advisorSetup = { before, steps: [structuredClone(step)], after: structuredClone(game) }; } assert.equal(game.status, 'playing'); assert.ok(advisorSetup && game.homeworlds?.custody); const afterSetup = structuredClone(game); assert.deepEqual(game.players.flatMap(p => p.hand.map(card => card.id)), selected.map(card => card.id)); const setupTerritory = stage === 'matching' ? 'polar_sink' : 'arrakeen'; assert.equal(semutaSpiritualRevivalPlayer(game, owner).forces[stage === 'matching' ? 'polar_sink:0' : 'arrakeen:10'], 1); assert.equal(isAdvisor(semutaSpiritualRevivalPlayer(game, owner), setupTerritory), stage !== 'matching'); assert.equal(semutaSpiritualRevivalPlayer(game, reviver).reserves, 20); assert.equal(semutaSpiritualRevivalPlayer(game, reviver).faceDancers?.length, 3); const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort(); const spiceOrder: { position: number; from: number; card: Game['spiceDeck'][number] }[] = []; for (let position = 0; position < 4; position++) { const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card && !card.discovery && !['hagga_basin', 'arrakeen', 'polar_sink'].includes(card.territory)); assert.ok(from >= position); const card = game.spiceDeck.splice(from, 1)[0]; game.spiceDeck.splice(position, 0, card); spiceOrder.push({ position, from, card: structuredClone(card) }); } const firstStorm = transition(() => { reach(state => state.turn === 1 && state.phase === 1); }); assert.equal(game.storm, 12); let beforeBid: Game | undefined, bid: SemutaSpiritualRevivalStep | undefined, bidAt = -1; for (let count = 0; count < 900 && !semutaSpiritualRevivalPlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID); count++) { const step = nextSemutaSpiritualRevival(game); assert.ok(step); if (step.actor === holder && step.action.type === 'richeseBid' && step.action.amount === 1) { beforeBid = structuredClone(game); bid = structuredClone(step); bidAt = actions.length; } act(step.actor, step.action); } assert.ok(beforeBid && bid && bidAt >= 0); const acquired = structuredClone(game), acquisition = { before: beforeBid, bid, steps: structuredClone(actions.slice(bidAt)), after: acquired, price: 1 }; assert.equal(semutaSpiritualRevivalPlayer(beforeBid, holder).spice - semutaSpiritualRevivalPlayer(game, holder).spice, 1); reach(state => state.turn === 1 && state.phase === 5 && state.active === reviver && semutaSpiritualRevivalClean(state)); const homeworldSources = nativeShipmentSources(viewGame(game, reviver), 1, 0); assert.ok(homeworldSources); const shipmentAction: Action = { type: 'ship', territory: 'hagga_basin', sector: 13, amount: 1, elite: 0, allyPayment: 0, homeworldSources }; const shipment = transition(() => { act(reviver, shipmentAction); reach(state => !state.response && !state.phaseOpening && state.decision?.kind === 'advisor'); }); const firstSpiritualDecline = transition(() => { act(owner, { type: 'decision', accept: false }); }); assert.equal(semutaSpiritualRevivalPlayer(shipment.before, reviver).spice - semutaSpiritualRevivalPlayer(game, reviver).spice, 2); assert.equal(semutaSpiritualRevivalPlayer(game, reviver).forces[casualtyLocation], 1); reach(state => state.turn === 2 && state.phase === 0 && !state.response && !state.phaseOpening && state.stormPending === 1); const beforeStormMovement = structuredClone(game), stormSource = structuredClone(game.stormMovementSource); const stormLoss = transition(() => { reach(state => state.turn === 2 && state.phase === 1); }); assert.equal(game.storm, 13); assert.equal(semutaSpiritualRevivalPlayer(game, reviver).tanks, 1); assert.equal(semutaSpiritualRevivalPlayer(game, reviver).forces[casualtyLocation] ?? 0, 0); reach(state => state.turn === 2 && state.phase === 4 && semutaSpiritualRevivalClean(state)); const beforeRequest = structuredClone(game), revivalAction: Action = { type: 'revive', amount: 1, elite: 0, freeElite: 0 }; assert.equal(semutaSpiritualRevivalPlayer(game, reviver).reserves, 19); assert.equal(homeworldTable(game)!.find(world => world.native === reviver)!.side, 'high'); const freeRevive = transition(() => { act(reviver, revivalAction); reach(state => !state.response && !state.phaseOpening && state.decision?.kind === 'homeworldRevivalDeployment'); }); const returned = structuredClone(game), choice = homeworldRevivalDeploymentChoice(viewGame(game, reviver), destination); assert.equal(choice.blocked, null); assert.ok(choice.action); const sendAction = choice.action, send = transition(() => { act(reviver, sendAction); reach(state => !state.response && !state.phaseOpening && state.decision?.kind === 'advisor'); }); assert.ok(game.decision?.kind === 'advisor'); assert.equal(game.decision.revivalEvent, game.homeworldRevivalReturn?.event); const beforeDeclaration = structuredClone(game), originalChooser = structuredClone(game.decision); const accompany: Action = { type: 'decision', accept: true, accompany: true, amount: 1 }; const sink: Action = { type: 'decision', accept: true, accompany: false, amount: 1 }; const decline: Action = { type: 'decision', accept: false }, nativeAction = stage === 'shipping' ? accompany : sink; const declaration = transition(() => { act(owner, nativeAction); }); assert.equal(game.response?.kind, 'advisor'); assert.ok(game.response?.spiritualAdvisorSource); const shippingResponse = structuredClone(game.response), shipping = structuredClone(game); const shippingAllowance = stage === 'shipping' ? null : transition(() => { game = allowSemutaSpiritualRevival(game, actions); }); assert.equal(game.pendingSpiritualAdvisor?.stage, stage); assert.ok(game.response && game.pendingSpiritualAdvisor && game.homeworldRevivalReturn && game.homeworldRevivalProgress); const automatic = game.response.automaticAdvisorFlip; if (stage === 'arrival') assert.ok(automatic?.producer === 'spiritualArrival'); else assert.ok(game.response.spiritualAdvisorSource); assert.equal(game.homeworldRevivalReturn.stage, 'arrival'); assert.equal(game.pendingRevival ?? null, null); const source = structuredClone(game), originalResponse = structuredClone(game.response), originalFrame = structuredClone(game.pendingSpiritualAdvisor); const originalSource = structuredClone(game.response.spiritualAdvisorSource ?? (automatic?.producer === 'spiritualArrival' ? automatic.spiritual : undefined)); assert.ok(originalSource); const originalReturn = structuredClone(game.homeworldRevivalReturn), originalProgress = structuredClone(game.homeworldRevivalProgress); const originalQueue = structuredClone(game.pendingAutomaticAdvisorFlips ?? []), originalArrival = structuredClone(game.pendingArrivalReaction ?? []); assert.ok(originalArrival.some(arrival => arrival.entrant === reviver && arrival.revivalArrival?.event === originalReturn.event)); assert.equal(originalReturn.resumeResponse?.kind, 'revivalIncome'); assert.equal(originalReturn.resumeResponse?.amount, 1); assert.ok(semutaSpiritualRevivalPlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID)); assert.ok(semutaSpiritualRevivalPlayer(game, spender).hand.some(card => card.id === cost)); homeworldGameIntegrity(game); assert.deepEqual(semutaSpiritualRevivalInventory(game), originalInventory); return { game, source, beforeCost: source, stage, advanced: true as const, kind: originalResponse.kind, owner, bg: owner, recipient: owner, holder, spender, atreides: spender, seller, richese: seller, reviver, tleilaxu: reviver, shipper: reviver, incomeOwner: reviver, cost, karama: cost, semuta: SEMUTA_DRUG_ID, action: { type: 'card', card: cost, mode: 'cancel' } as Action, nativeAction, accompany, sink, decline, replacementAction: stage === 'matching' ? accompany : null, originalResponse, originalFrame, originalSource, originalChooser, originalReturn, originalProgress, originalWorldParent: originalReturn, originalParent: originalReturn, originalRetainedIncome: structuredClone(originalReturn.resumeResponse), originalPendingRevival: structuredClone(game.pendingRevival ?? null), originalDeposit: structuredClone(originalFrame.deposit ?? null), originalAutomaticSource: structuredClone(automatic ?? null), originalQueue, originalArrival, originalPhysical: structuredClone(semutaSpiritualRevivalPhysical(game)), originalCustody: structuredClone(game.homeworlds!.custody), originalBalances: Object.fromEntries(game.players.map(p => [p.id, p.spice])), originalStance: structuredClone(semutaSpiritualRevivalPlayer(game, owner).advisors), territory, sector, location, destination, casualtyLocation, revivalAction, sendAction, initial, enableHomeworlds, lobby, setup, initialization, beforeStartingDeal, afterSetup, advisorSetup, deal: selected.map(card => card.id), dealOrder, originalInventory, originalSpice, spiceOrder, firstStorm, acquisition, acquired, shipmentAction, shipment, firstSpiritualDecline, beforeStormMovement, stormSource, stormLoss, beforeRequest, freeRevive, returned, send, beforeDeclaration, declaration, shipping, shippingResponse, shippingAllowance, actions, steps: actions, sourceActions: actions, sourceActionCount: actions.length, staging: [ 'Actual original Advanced R/C/BG/A/T exact CHOAM+Ix fresh Semuta initializer after the real Homeworlds lobby option. Only unopened original card/Spice positions and scalar lotteries are selected. Atreides really receives printed base Karama, Tleilaxu its original Face Dancers, and CHOAM actually buys cache1 Semuta.', 'Actual first Storm dials11+0 end at12. Tleilaxu really pays two to ship one original typed native reserve to Hagga13; BG actually declines that earlier Spiritual choice. Actual next original Storm Card1 ends at13 and destroys it. Real native phases reach turn2 Revival with High19/Tanks1; no played stock, wallet, hand, stance, clock, return, casualty or custody is installed.', 'Tleilaxu actually revives one ordinary force free, earning original High SEND and retained free-revival income1. The owned original deployment quote sends exactly that counter to Arrakeen10. The native return/progress stays at arrival, queued entry carries the original revival event, and pendingRevival is already cleared by its real completion.', stage === 'matching' ? 'BG’s original setup placed a native fighter in empty Polar. After SEND, BG actually declares fallback1 and passes the original shipping counter; stop before the separate matching placement. Denial restores the original chooser, with actual accompaniment1 into the SEND territory or decline still legal.' : stage === 'arrival' ? 'BG’s original advisorSetup is beside A10 Arrakeen. After SEND, BG actually declares free Polar1 and passes shipping. The real native reserve withdrawal/deposit opens its independent spiritualArrival alone-conversion counter; stop with the whole return/income parent retained.' : 'BG’s original advisorSetup is beside A10 Arrakeen. After SEND, BG actually declares accompaniment1 and stops at shipping before withdrawing any BG reserve. The revived T counter and original return/income remain committed.', ], }; } export type SemutaSpiritualRevivalFixture = ReturnType;