import assert from 'node:assert/strict'; import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine'; import { botActions } from '../game/bots'; import { territory } from '../game/board'; import { finishEcazOccupySetup, type EcazOccupyFixture } from './fixture-ecaz-occupy'; import { nativeShipmentSources } from '../game/homeworld-options'; export type HomeworldEcazStep = { actor: string; action: Action }; export type HomeworldEcazOccupyFixture = EcazOccupyFixture & { actions: HomeworldEcazStep[]; afterFirstMentat?: Game; strongholdTerritory?: string; }; export const homeworldEcazClean = (game: Game) => !game.phaseOpening && !game.response && !game.decision && !game.pendingTreacheryDiscard; /** Original choices only: leave optional Ambassadors unplaced and pass auctions. */ export function nextHomeworldEcazStep(game: Game): HomeworldEcazStep | null { if (game.response) return { actor: game.players.find(p => !game.response!.passed.includes(p.id))!.id, action: { type: 'passResponse' } }; if (game.decision?.kind === 'ecazPlacement') return { actor: game.decision.player, action: { type: 'decision', decline: true } }; if (game.decision?.kind === 'moritaniPlacement') return { actor: game.decision.player, action: { type: 'decision', decline: true } }; if (game.decision?.kind === 'guildShipment') return { actor: game.decision.player, action: { type: 'decision', allow: true } }; if (!game.phaseOpening && !game.decision && game.phase === 0 && game.stormPending === null) { const actor = game.stormDialers.find(id => game.stormDials[id] === undefined); if (actor) return { actor, action: { type: 'stormDial', amount: game.turn === 1 ? 0 : 1 } }; } if (!game.phaseOpening && !game.decision && game.phase === 3 && game.auction) return { actor: game.auction.active, action: { type: 'passBid' } }; for (const player of game.players) { const view = viewGame(game, player.id); view.players.find(p => p.id === player.id)!.bot = 'Easy'; const actions = botActions(view); const action = actions.find(a => a.type === 'ready') ?? actions[0]; if (action) return { actor: player.id, action }; } return null; } /** Fresh native setup (Worlds enabled by default), a real worm alliance and paid * Arrakis shipments. Only unopened Spice order is staged; armies, wallets, * phases, alliances, World custody and offered skills follow the real actions. */ export function createHomeworldEcazOccupyFixture(options: { advanced?: boolean; skills?: boolean; independent?: boolean; allyFaction?: 'emperor' | 'moritani'; tech?: boolean; strongholds?: boolean; /** No-World control uses the same genuine alliance/Arrakis route. */ homeworlds?: boolean; /** Original paid Ecaz packet; the default source keeps its four-counter army. */ ecazForces?: number; /** Select only actual offered setup choices; the captured initial stays untouched. */ finishSetup?: (initial: Game) => Game; } = {}): HomeworldEcazOccupyFixture { const advanced = options.advanced ?? true; const homeworlds = options.homeworlds ?? true; const ally = options.allyFaction ?? 'emperor'; const ecazForces = options.ecazForces ?? 4; let game = createGame('WORLDECAZOCCUPY', newPlayer('ecaz', 'Ecaz', 'ecaz'), advanced, ['ecaz']); joinGame(game, newPlayer(ally, ally === 'emperor' ? 'Emperor' : 'Moritani', ally)); joinGame(game, newPlayer('guild', 'Guild', 'guild')); const actions: HomeworldEcazStep[] = []; const act = (actor: string, action: Action) => { actions.push({ actor, action: structuredClone(action) }); game = applyAction(game, actor, action); }; const ship = (actor: string, action: Action) => { if (homeworlds) { const sources = nativeShipmentSources(viewGame(game, actor), Number(action.amount), Number(action.elite ?? 0)); assert.ok(sources, 'The original native World pools must fund the typed shipment'); action.homeworldSources = sources; } act(actor, action); }; if (homeworlds) act('ecaz', { type: 'homeworlds', enabled: true }); if (options.tech) act('ecaz', { type: 'techTokens', enabled: true }); if (options.strongholds) act('ecaz', { type: 'strongholdCards', enabled: true }); if (options.independent) act('ecaz', { type: 'moduleOption', module: 'ecazTreachery', enabled: true }); if (options.skills) act('ecaz', { type: 'moduleOption', module: 'leaderSkills', enabled: true }); for (const player of game.players) act(player.id, { type: 'ready' }); act('ecaz', advanced ? { type: 'start', advancedPreview: true } : { type: 'start' }); const initial = structuredClone(game); game = (options.finishSetup ?? finishEcazOccupySetup)(game); const afterSetup = structuredClone(game); const step = () => { const next = nextHomeworldEcazStep(game); assert.ok(next, `A native World action must exist at ${game.turn}/${game.phase}/${game.decision?.kind}`); act(next.actor, next.action); }; const reach = (until: (state: Game) => boolean) => { for (let n = 0; n < 800 && !until(game); n++) step(); assert.ok(until(game), 'The original World programme must reach its next native boundary.'); }; const orderBlow = (worm: boolean, position: number) => { const at = game.spiceDeck.findIndex((card, i) => i >= position && (worm ? 'worm' in card && !card.greatMaker && !card.suppressed : 'territory' in card && !['arrakeen', 'sietch_tabr', 'habbanya_ridge_sietch'].includes(card.territory))); assert.ok(at >= position); game.spiceDeck.splice(position, 0, game.spiceDeck.splice(at, 1)[0]); }; orderBlow(false, 0); if (advanced) orderBlow(false, 1); reach(g => g.phase === 5 && homeworldEcazClean(g)); const strongholdTerritory = options.strongholds ? 'arrakeen' : undefined; while (game.phase === 5) { if (!homeworldEcazClean(game)) { step(); continue; } const actor = game.active!; if (strongholdTerritory && actor === 'ecaz' && !game.players.find(player => player.id === actor)!.shipped) { ship(actor, { type: 'ship', territory: strongholdTerritory, sector: territory(strongholdTerritory).sectors[0], amount: ecazForces, elite: 0, allyPayment: 0, smuggler: false }); reach(homeworldEcazClean); } act(actor, { type: 'endMovement' }); } reach(g => g.turn === 2 && g.phase === 0 && !g.phaseOpening); const afterFirstMentat = options.tech || options.strongholds ? structuredClone(game) : undefined; orderBlow(true, 0); orderBlow(false, 1); if (advanced) orderBlow(false, 2); reach(g => g.phase === 1 && g.nexus && !g.spiceWindow && !g.spiceResolution && homeworldEcazClean(g)); act('ecaz', { type: 'alliance', target: ally }); act(ally, { type: 'alliance', target: 'ecaz' }); reach(g => g.phase === 5 && homeworldEcazClean(g)); const battleTerritory = strongholdTerritory ?? ['arrakeen', 'sietch_tabr', 'habbanya_ridge_sietch'] .find(id => territory(id).sectors[0] !== game.storm && !game.players.some(player => Object.entries(player.forces).some(([key, amount]) => amount > 0 && key.startsWith(`${id}:`))))!; const sector = territory(battleTerritory).sectors[0]; const location = `${battleTerritory}:${sector}`; const shipped = new Set(); while (game.phase === 5) { if (!homeworldEcazClean(game)) { step(); continue; } const actor = game.active!; if (!shipped.has(actor) && !(strongholdTerritory && actor === 'ecaz')) { ship(actor, { type: 'ship', territory: battleTerritory, sector, amount: actor === 'ecaz' ? ecazForces : actor === 'guild' ? 2 : 4, elite: actor === ally && ally === 'emperor' && (advanced || homeworlds) ? 2 : 0, allyPayment: 0 }); shipped.add(actor); reach(homeworldEcazClean); } act(actor, { type: 'endMovement' }); } reach(homeworldEcazClean); const choice = viewGame(game, game.active!).battleChoices.find(c => c.territory === battleTerritory); assert.ok(choice, 'The actually shipped coalition must have a mandatory Arrakis battle.'); act(choice.chooser, { type: 'chooseBattle', territory: battleTerritory, target: choice.attacker === choice.chooser ? choice.defender : choice.attacker }); if (!options.skills) assert.equal(game.decision?.kind, 'ecazBattleLead'); return { game, initial, afterSetup, actions, afterFirstMentat, strongholdTerritory, ecaz: 'ecaz', ally, opponent: 'guild', territory: battleTerritory, location, ecazForces }; }