import assert from 'node:assert/strict'; import { createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine'; import type { FactionId } from '../game/catalog'; import { actBasicSemutaAmbassador as act } from './fixture-basic-semuta-ambassador'; import { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds'; import { withClassicDiscoveryNexusLottery } from './fixture-discovery-classic-nexus'; export type NativeLeaderlessFaction = Extract; export const nativeLeaderlessPlayer = (game: Game, id: string) => { const player = game.players.find(candidate => candidate.id === id); assert.ok(player); return player; }; export const nativeLeaderlessClean = (game: Game) => !game.response && !game.phaseOpening && !game.decision && !game.truthtrance && !game.pendingTreacheryDiscard; export function advanceNativeLeaderless(game: Game, until: (state: Game) => boolean): Game { for (let count = 0; count < 1600; count++) { if (until(game)) return game; assert.notEqual(game.status, 'finished'); const step = nextRicheseStrongholdsNativeStep(game); assert.ok(step, `No native continuation at ${game.status}/${game.phase}/${game.decision?.kind}`); game = act(game, step.actor, step.action); } throw Error('The native public battle did not reach the requested boundary'); } /** Fresh public selected-deck setup, original phase progression and paid * shipments create the battle. Only private leader-use/death and conserved * hand/deck history are staged before the genuine chooseBattle action. */ export function basicNativeLeaderlessFixture(faction: NativeLeaderlessFaction, options: { dead?: boolean; hero?: boolean; ghola?: boolean } = {}) { const expansions = faction === 'ixians' || faction === 'tleilaxu' ? ['ix'] : faction === 'choam' || faction === 'richese' ? ['choam'] : ['ecaz']; let game = withClassicDiscoveryNexusLottery(() => createGame(`NATIVELEADERLESS-${faction}`, newPlayer(faction, faction, faction), false, expansions)); for (const other of ['guild', 'emperor'] as const) joinGame(game, newPlayer(other, other, other)); for (const seat of game.players) game = act(game, seat.id, {type:'ready'}); game = act(game, faction, {type:'start'}); const setup = structuredClone(game); game = advanceNativeLeaderless(game, state => state.status === 'playing' && state.phase === 5 && nativeLeaderlessClean(state)); const selected = ['pasty_mesa', 'false_wall_west', 'shield_wall'].map(id => { const sectors: Record = { pasty_mesa:[5,6,7,8], false_wall_west:[16,17,18], shield_wall:[8,9] }; return {territory:id, sector:sectors[id].find(value => value !== game.storm)}; }).find(row => row.sector !== undefined && game.players.every(player => Object.keys(player.forces).every(key => !key.startsWith(`${row.territory}:`)))); assert.ok(selected && selected.sector !== undefined); const territory = selected.territory, sector = selected.sector; while (game.phase === 5) { if (!nativeLeaderlessClean(game)) { game = advanceNativeLeaderless(game, state => state.phase !== 5 || nativeLeaderlessClean(state)); continue; } const actor = game.active!; if (actor === faction || actor === 'guild') { game = act(game, actor, {type:'ship', territory, sector, amount:1, elite:0, allyPayment:0}); game = advanceNativeLeaderless(game, state => state.phase !== 5 || state.active === actor && nativeLeaderlessClean(state)); } if (game.phase === 5) game = act(game, actor, {type:'endMovement'}); } game = advanceNativeLeaderless(game, state => state.phase === 6 && nativeLeaderlessClean(state) && !state.battle && !!state.active); const player = nativeLeaderlessPlayer(game, faction); for (const leader of player.leaders) { assert.ok(!leader.dead && !leader.capturedBy); leader.usedAt = territory === 'carthag' ? 'arrakeen' : 'carthag'; } if (options.dead) { player.leaders[0].dead = true; player.leaders[0].deaths = Math.max(1, player.leaders[0].deaths ?? 0); delete player.leaders[0].usedAt; } // Exchange exact original physical cards with the remaining deck; never // create an extra card or increase the player's hand size. const swap = (handIndex: number, predicate: (card: Game['deck'][number]) => boolean) => { const index = game.deck.findIndex(predicate); assert.ok(index >= 0); [player.hand[handIndex], game.deck[index]] = [game.deck[index], player.hand[handIndex]]; }; for (let i = 0; i < player.hand.length; i++) if (player.hand[i].kind === 'hero') swap(i, card => card.kind !== 'hero' && (!options.ghola || card.effect !== 'ghola')); if (options.hero || options.ghola) { assert.ok(player.hand.length > 0); swap(0, card => options.hero ? card.kind === 'hero' : card.effect === 'ghola'); } const chooser = game.active!; const choice = viewGame(game, chooser).battleChoices.find(row => row.territory === territory && [row.attacker, row.defender].includes(faction) && [row.attacker, row.defender].includes('guild')); assert.ok(choice); const choose: Action = {type:'chooseBattle', territory, target:choice.attacker === chooser ? choice.defender : choice.attacker}; const beforeBattle = structuredClone(game); game = act(game, chooser, choose); return {game, setup, beforeBattle, faction, opponent:'guild', territory, sector, chooser, choose}; }