import assert from 'node:assert/strict'; import { applyAction, createGame, initializeNexusGameForAudit, joinGame, newPlayer, viewGame, type Game, } from '../game/engine'; import type { FactionId } from '../game/catalog'; import { botActions } from '../game/bots'; import { createAuditorLeader, leaders, treacheryDeck } from '../game/cards'; import { traitorDeck } from '../game/traitors'; import { drawNexusCard, validateNexusCards } from '../game/nexus-cards'; import { validateNexusTraitorSnapshot } from '../game/nexus-traitor-exchange'; import { enterNexusSpice, finishNexusSpice, orderNexusSpice, } from './fixture-nexus-cards'; import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions'; export function nexusTraitorFixture( options: { ownerFaction?: FactionId; opponentFaction?: FactionId; advanced?: boolean; ix?: boolean; seatIds?: [string, string, string]; observerFaction?: FactionId; phase?: number; } = {}, ): Game { const [p, q, r] = options.seatIds ?? ['p', 'q', 'r']; let g = createGame( 'TRAITORAUDIT', newPlayer(p, 'Owner', 'harkonnen'), options.advanced ?? false, options.ix ? ['ix'] : [], ); joinGame(g, newPlayer(q, 'Opponent', 'guild')); joinGame(g, newPlayer(r, 'Observer', 'fremen')); // Final faction is fixed before the genuine component/setup initializer. g.players[0] = newPlayer(p, 'Owner', options.ownerFaction ?? 'harkonnen'); if (options.opponentFaction) g.players[1] = newPlayer(q, 'Opponent', options.opponentFaction); if (options.observerFaction) g.players[2] = newPlayer(r, 'Observer', options.observerFaction); for (const player of g.players) g = applyAction(g, player.id, { type: 'ready' }); g.nexusCards = { cards: null, phase: null }; g = initializeNexusGameForAudit(g); for (let i = 0; g.status === 'setup' && i < 80; i++) { let next: Game | undefined; for (const player of g.players) { const v = viewGame(g, player.id); v.players.find((seat) => seat.id === player.id)!.bot = 'Easy'; const action = botActions(v)[0]; if (action) { next = applyAction(g, player.id, action); break; } } assert.ok(next, `Setup stalled at ${g.setupStage}/${g.decision?.kind}`); g = next; } assert.equal(g.status, 'playing'); for (const player of g.players) g.deck.push(...player.hand.splice(0)); if (options.ix || g.players.some((p) => p.faction === 'choam')) { // Resolve actual CHOAM markets and Ix phase openings before advancing. for (let i = 0; g.phase === 0 && i < 30; i++) { let next: Game | undefined; for (const p of g.players) { const v = viewGame(g, p.id); v.players.find((seat) => seat.id === p.id)!.bot = 'Easy'; const action = botActions(v)[0]; if (action) { next = applyAction(g, p.id, action); break; } } assert.ok( next, `Storm stalled at ${g.decision?.kind}/${g.response?.kind}`, ); g = next; } assert.equal(g.phase, 1); } else g = enterNexusSpice(g); orderNexusSpice(g, ['land', 'land']); if (options.ix || g.players.some((p) => p.faction === 'choam')) { for (let i = 0; g.phase === 1 && i < 40; i++) { let next: Game | undefined; for (const p of g.players) { const v = viewGame(g, p.id); v.players.find((seat) => seat.id === p.id)!.bot = 'Easy'; const action = botActions(v)[0]; if (action) { next = applyAction(g, p.id, action); break; } } assert.ok( next, `Spice phase stalled at ${g.decision?.kind}/${g.response?.kind}`, ); g = next; } assert.equal(g.phase, 2); } else g = finishNexusSpice(g); Object.assign(g, { phase: options.phase ?? 8, ready: [], response: null, decision: null, phaseOpening: null, active: p, storm: 18, }); const cards = g.nexusCards!.cards!; cards.deck = [ 'harkonnen', ...cards.deck.filter((card) => card !== 'harkonnen'), ]; g.nexusCards!.cards = drawNexusCard(cards, p, g.players, () => 0); nexusTraitorInventory(g); return g; } export function nexusTraitorInventory(g: Game): void { validateNexusCards(g.nexusCards!.cards!, g.players); const universe = traitorDeck( g.players.map((p) => ({ leaders: [ ...leaders(p.faction), ...(g.advanced && p.faction === 'choam' ? [createAuditorLeader()] : []), ], })), g.expansions.includes('ix'), ); validateNexusTraitorSnapshot( { reserve: g.traitorReserve!, players: g.players.map((p) => ({ id: p.id, faction: p.faction, traitors: [...p.traitors], ...(p.faceDancers ? { faceDancers: structuredClone(p.faceDancers) } : {}), })), }, universe.filter(card => card !== g.ecazLoyalty?.card), ); assert.deepEqual( [...g.deck, ...g.discard, ...g.players.flatMap((p) => p.hand)] .map((c) => c.id) .sort(), treacheryDeck(g.expansions) .map((c) => c.id) .sort(), ); for (const p of g.players) assert.equal( p.reserves + p.tanks + Object.values(p.forces).reduce((sum, n) => sum + n, 0), 20, ); } export function nexusTraitorDraw(g: Game, id = g.players[0].id): Game { const offer = viewGame(g, id).nexusTraitors!.offer!; assert.equal(offer.blocked, null); return applyAction(g, id, { type: 'nexusTraitorDraw', event: offer.event, mode: offer.mode, }); } export function nexusTraitorReturn( g: Game, cards: string[], id = g.players[0].id, ): Game { return applyAction(g, id, { type: 'nexusTraitorReturn', event: viewGame(g, id).nexusTraitors!.pending!.event, cards, }); } /** Preserve every physical traitor identity while staging a known held traitor. */ export function holdNexusTraitor(g: Game, id: string, card: string): void { const owner = g.players.find((p) => p.id === id)!; if (owner.traitors.includes(card)) return; const replace = owner.traitors[0]; assert.ok(replace); const reserveIndex = g.traitorReserve!.indexOf(card); if (reserveIndex >= 0) g.traitorReserve![reserveIndex] = replace; else { const other = g.players.find((p) => p.traitors.includes(card)); assert.ok(other); other.traitors[other.traitors.indexOf(card)] = replace; } owner.traitors[0] = card; } export function nexusTraitorBattle(g: Game, reveal = true): Game { const [owner, target] = g.players; Object.assign(g, { phase: 6, active: owner.id, order: g.players.map((p) => p.id), ready: [], response: null, decision: null, phaseOpening: null, }); for (const p of g.players) { p.forces = {}; p.reserves = 20; p.tanks = 0; } for (const p of [owner, target]) { p.forces = { 'pasty_mesa:5': 5 }; p.reserves = 15; } g = applyAction(g, owner.id, { type: 'chooseBattle', territory: 'pasty_mesa', target: target.id, }); for (const id of [owner.id, target.id]) if (g.battle!.preLeader && !g.battle!.preLeader.closed) g = applyAction(g, id, { type: 'battlePreparationReady', event: g.battle!.event, }); // Consume the original absent-BG opportunity before native Prescience. // When requested, leave that native power for the caller's actual use. while (g.battle!.preparation && (reveal || g.battle!.preparation.kind === 'nexusVoice')) { const step = nextStrongholdFactionsNativeStep(g); assert.ok(step); g = applyAction(g, step.actor, step.action); } if (!reveal) return g; for (const id of [owner.id, target.id]) g = applyAction(g, id, { type: 'battlePlan', dial: 0, leader: g.players.find((p) => p.id === id)!.leaders[0].id, }); assert.equal(g.battle!.revealed, true); return g; }