import assert from 'node:assert/strict'; import { applyAction, createGame, initializeHomeworldGameForAudit, joinGame, newPlayer, normalizeAutomaticGame, viewGame, type Action, type Game, } from '../game/engine'; import { botActions } from '../game/bots'; import { createAmbassadors, placeAmbassador } from '../game/ecaz-ambassadors'; import { location, territory as boardTerritory } from '../game/board'; import { homeworldGameIntegrity } from '../game/homeworld-game'; import { placeTerror, type TerrorKind } from '../game/moritani-terror'; import { caladanVictoryFixture, holdVictoryCard, positionVictoryArmy, victoryPlayer, victoryReload, } from './fixture-caladan-victory'; import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions'; export const caladanArrivalPlayer = victoryPlayer; export const caladanArrivalReload = victoryReload; export type CaladanArrivalStep = { actor: string; action: Action }; /** Controlled, conserved battle source. The inherited Homeworld preview * initializer is not a public start journey. Wallets, later turn/phase and * positions, selected held original cards, and the placed original Terror token are * scenario inputs. Only the recorded battle and arrival actions are native * chronology; no victory, entry, reaction or reinforcement receipt is installed. */ export const caladanArrivalDisclosure = 'Controlled Homeworld preview setup and later battle positions/purses/cards/token placement; recorded native battle and arrival actions, not public acquisition history.'; export function actCaladanArrival( game: Game, actor: string, action: Action, actions: CaladanArrivalStep[] = [], ): Game { const result = normalizeAutomaticGame(applyAction(game, actor, action)); actions.push({ actor, action: structuredClone(action) }); return result; } /** Resolve only actual neutral native controls, preserving the decisions under * test. It never seals a plan, chooses an entry reaction or advances a new phase. */ export function advanceCaladanArrival( game: Game, actions: CaladanArrivalStep[] = [], ): Game { let g = game; for (let n = 0; n < 100; n++) { if (g.decision && ['caladanReinforcement', 'intrusion', 'moritaniTerror', 'ambassador', 'arrivalOverlap'].includes(g.decision.kind)) return g; if (!g.battle && !g.response && !g.pendingTreacheryDiscard) return g; const next = nextStrongholdFactionsNativeStep(g); if (!next) return g; g = actCaladanArrival(g, next.actor, next.action, actions); } assert.fail('The bounded native Caladan continuation did not stop.'); } export function allowCaladanArrivalResponses( game: Game, actions: CaladanArrivalStep[] = [], ): Game { let g = game; for (let n = 0; g.response && n < 40; n++) { const actor = g.players.find(p => !g.response!.passed.includes(p.id)); assert.ok(actor); g = actCaladanArrival(g, actor.id, { type: 'passResponse' }, actions); } assert.equal(g.response, null); return advanceCaladanArrival(g, actions); } export function caladanArrivalInventory(g: Game) { homeworldGameIntegrity(g); const cards = [...g.deck, ...g.discard, ...g.players.flatMap(p => p.hand)]; assert.equal(cards.length, g.expansions.includes('ix') ? 47 : 33); assert.equal(new Set(cards.map(c => c.id)).size, cards.length); for (const p of g.players) { const visitors = Object.values(g.homeworlds!.custody!.visitors).reduce( (sum, world) => sum + (world[p.id]?.normal ?? 0) + (world[p.id]?.elite ?? 0), 0); const stars = p.elites ? p.elites.reserves + p.elites.tanks + Object.values(p.elites.forces).reduce((sum, count) => sum + count, 0) : 0; assert.equal(p.reserves + p.tanks + Object.values(p.forces).reduce((sum, count) => sum + count, 0) + visitors, 20); assert.ok(stars >= 0 && p.spice >= 0); } return { cards: cards.length, leaders: g.players.flatMap(p => p.leaders).length }; } function hmsBattleSource(): Game { let g = createGame('CALADANHMSARRIVAL', newPlayer('a', 'Atreides', 'atreides'), true, ['ix']); joinGame(g, newPlayer('g', 'Guild', 'guild')); joinGame(g, newPlayer('i', 'Ixians', 'ixians')); g = applyAction(g, 'a', { type: 'homeworlds', enabled: true }); for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' }); g = initializeHomeworldGameForAudit(g); for (let n = 0; (g.status === 'setup' || !g.mobileStronghold?.location) && n < 140; n++) { const next = nextStrongholdFactionsNativeStep(g); if (next) g = applyAction(g, next.actor, next.action); else { const choice = g.players.map(p => ({actor:p.id, action:botActions(viewGame(g,p.id))[0]})).find(c => c.action); assert.ok(choice); g = applyAction(g, choice.actor, choice.action); } } assert.equal(g.status, 'playing'); assert.ok(g.mobileStronghold?.location, 'The original Ix setup must place its actual HMS.'); // Conserved later scenario positions, not a claimed native movement journey. const ix = victoryPlayer(g, 'i'); ix.reserves += Object.values(ix.forces).reduce((sum, count) => sum + count, 0); ix.forces = {}; ix.elites!.reserves += Object.values(ix.elites!.forces).reduce((sum, count) => sum + count, 0); ix.elites!.forces = {}; for (const p of g.players) { g.deck.push(...p.hand.splice(0)); p.spice = 20; } Object.assign(g, { phase:6, turn:2, storm:18, phaseOpening:null, response:null, decision:null, active:'a', ready:[], order:g.players.map(p => p.id) }); positionVictoryArmy(g, 'a', 6, 3); positionVictoryArmy(g, 'g', 17, 3); for (const id of ['a', 'g']) { const p = victoryPlayer(g, id); delete p.forces['hagga_basin:12']; p.forces['hidden_mobile_stronghold:0'] = 3; } caladanArrivalInventory(g); return g; } export function createCaladanArrivalFixture(options: { bg?: boolean; terror?: TerrorKind; hms?: boolean; ambassador?: 'first' | 'second'; } = {}) { assert.ok(!options.hms || (!options.bg && !options.terror)); let g = options.hms ? hmsBattleSource() : caladanVictoryFixture({ advanced:true, extraSeats: options.bg ? [{id:'bg',faction:'beneGesserit'}] : options.terror ? [{id:'m',faction:'moritani'}, ...(options.ambassador ? [{id:'ec',faction:'ecaz'} as const] : [])] : [], }); const territory = options.hms ? 'hidden_mobile_stronghold' : options.terror ? 'arrakeen' : 'hagga_basin'; const destination = `${territory}:${options.hms ? 0 : options.terror ? 10 : 12}`; let token: string | undefined; let heldCard: string | undefined; if (options.bg) { const bg = victoryPlayer(g, 'bg'); Object.assign(bg, {reserves:19,forces:{'hagga_basin:12':1},advisors:{}}); holdVictoryCard(g,'g','karama'); // Keep an actual preventable flip response open. } if (options.terror) { // Ecaz/Moritani native roster preview; no extra Treachery module is added. g.expansions.push('ecaz'); for (const id of ['a','g']) { const p = victoryPlayer(g,id); delete p.forces['hagga_basin:12']; p.forces['arrakeen:10'] = 3; } const original = g.moritaniTerror!.tokens.find(t => t.kind === options.terror)!; token = original.id; g.moritaniTerror = placeTerror(g.moritaniTerror!, token, territory, 1); if (options.terror === 'sabotage') { heldCard = holdVictoryCard(g,'a','shield'); holdVictoryCard(g,'m','karama'); // An actual held card supplies the optional gift. } } if (options.ambassador) { assert.ok(options.terror); // Controlled original-token supply and storm order; neither reaction has // been accepted and no later arrival/continuation receipt is installed. g.ecazAmbassadors = createAmbassadors(() => 0); const ambassador = g.ecazAmbassadors.tokens.find(token => token.zone === 'supply')!; g.ecazAmbassadors = placeAmbassador(g.ecazAmbassadors, ambassador.id, {turn:1,availableSpice:20, destination:{id:territory,stronghold:true,inStorm:false,allowed:true}}).state; g.order = options.ambassador === 'first' ? ['a','g','ec','m'] : ['a','g','m','ec']; } // A separately staged one-counter contest retains Battle phase after this // victory, so unrelated automatic Collection income is not part of entry proof. const other = location('shield_wall', boardTerritory('shield_wall').sectors[0]); const atreides = victoryPlayer(g,'a'), guildArmy = victoryPlayer(g,'g'); assert.ok(atreides.forces['polar_sink:0'] > 0 && guildArmy.reserves > 0); atreides.forces['polar_sink:0']--; atreides.forces[other] = 1; guildArmy.reserves--; guildArmy.forces[other] = 1; caladanArrivalInventory(g); const source = victoryReload(g), actions: CaladanArrivalStep[] = []; g = actCaladanArrival(g,'a',{type:'chooseBattle',territory,target:'g'},actions); g = advanceCaladanArrival(g,actions); assert.ok(g.battle && !g.battle.preparation && !g.decision && !g.response); const a = victoryPlayer(g,'a').leaders.reduce((best,l) => l.strength > best.strength ? l : best); const guild = victoryPlayer(g,'g').leaders.reduce((best,l) => l.strength < best.strength ? l : best); g = actCaladanArrival(g,'a',{type:'battlePlan',leader:a.id,dial:1,support:1},actions); g = actCaladanArrival(g,'g',{type:'battlePlan',leader:guild.id,dial:0,support:0},actions); g = advanceCaladanArrival(g,actions); assert.equal(g.decision?.kind,'caladanReinforcement'); assert.equal(g.lastBattleContext?.winner,'a'); assert.equal(victoryPlayer(g,'a').forces[destination],2); const event = g.homeworldVictoryReinforcement!.event; const offer = viewGame(g,'a').caladanReinforcement!; caladanArrivalInventory(g); return {game:g,source,actions,sourceActions:actions,sourceCount:actions.length, disclosure:caladanArrivalDisclosure,territory,destination,event,token,heldCard,offer, action:{type:'decision',event,destination,amount:1} as Action, decline:{type:'decision',event,decline:true} as Action}; }