import assert from 'node:assert/strict'; import { viewGame, type Game } from '../game/engine'; import { baseDeck } from '../game/cards'; import { territory } from '../game/board'; import { REINFORCEMENTS_CARD } from '../game/reinforcements'; import { createClassicIndependentNexusFixture, advanceClassicIndependentNexus, type ClassicIndependentNexusStep } from './fixture-classic-independent-nexus'; import { drawClassicNexusReaction, reactionAction, reactionPlayer } from './fixture-classic-independent-nexus-reactions'; import { classicDiscoveryNexusClean as clean, nextClassicDiscoveryNexusStep, applyClassicDiscoveryNexusStep } from './fixture-discovery-classic-nexus'; export { reactionPlayer as nexusCoreStrongholdPlayer }; export type NexusCoreStrongholdOptions = { independent?: boolean; skills?: boolean }; export type NexusCoreStrongholdFixture = { game: Game; setup: Game; actions: ClassicIndependentNexusStep[]; staging: string[]; claimed: Game; beforeShipment: Game; afterShipment: Game; owner: string; opponent: string; key: string; ownerLeader: string; opponentLeader: string; weapon: string | null; defense: string | null; }; function prepare(game: Game, actions: ClassicIndependentNexusStep[]): Game { for (let count = 0; count < 120; count++) { const next = nextClassicDiscoveryNexusStep(game); if (!next) { assert.ok(game.battle && !game.battle.revealed && clean(game)); return game; } // The original decision must put the trained disc behind the shield // before it can be selected in a sealed plan. if (next.action.type === 'leaderSkillVisibility') next.action.hide = true; game = applyClassicDiscoveryNexusStep(game, next, actions); } throw Error('Original Stronghold battle did not reach its sealed-plan boundary'); } function bestLeader(game: Game, actor: string): string { const leader = reactionPlayer(game, actor).leaders.filter(disc => !disc.dead && !disc.usedAt) .sort((one, two) => two.strength - one.strength)[0]; assert.ok(leader); return leader.id; } function chooseBattle(game: Game, location: string, actions: ClassicIndependentNexusStep[]): Game { return prepare(reactionAction(game, game.active!, { type: 'chooseBattle', territory: location, target: game.active === 'emperor' ? 'harkonnen' : 'emperor' }, actions), actions); } /** Emperor genuinely ships five original reserves into empty Arrakeen on turn * one. Only unopened Spice/Nexus decks are ordered; every counter, payment, * alliance and first end-Mentat card claim comes from original actions. */ export function createNexusCoreArrakeenFixture(options: NexusCoreStrongholdOptions = {}): NexusCoreStrongholdFixture { const independent = options.independent ?? true; const original = createClassicIndependentNexusFixture({ advanced: true, independent, skills: options.skills, strongholds: true, roster: ['emperor', 'harkonnen', 'guild'], startingCards: independent ? { emperor: [REINFORCEMENTS_CARD] } : undefined }); const actions = original.actions; let game = original.game; const take = (worm: boolean) => { const index = game.spiceDeck.findIndex(card => worm ? 'worm' in card : 'territory' in card); assert.ok(index >= 0); return game.spiceDeck.splice(index, 1)[0]; }; game.spiceDeck.unshift(take(false), take(false), take(true), take(false), take(false)); game = advanceClassicIndependentNexus(game, state => state.turn === 1 && state.phase === 5 && state.active === 'emperor' && clean(state), actions); const sector = territory('arrakeen').sectors[0], key = `arrakeen:${sector}`; assert.ok(!territory('arrakeen').sectors.includes(game.storm)); game = reactionAction(game, 'emperor', { type: 'ship', territory: 'arrakeen', sector, amount: 5, elite: 0, allyPayment: 0 }, actions); game = advanceClassicIndependentNexus(game, state => state.strongholdCards?.claimedTurn === 1, actions); const claimed = structuredClone(game); assert.equal(claimed.strongholdCards!.owners.arrakeen, 'emperor'); game = advanceClassicIndependentNexus(game, state => state.turn === 2 && state.nexus && !state.spiceWindow && !state.spiceResolution && clean(state), actions); game = reactionAction(game, 'harkonnen', { type: 'alliance', target: 'guild' }, actions); game = reactionAction(game, 'guild', { type: 'alliance', target: 'harkonnen' }, actions); game = advanceClassicIndependentNexus(game, state => state.nexusCards?.phase?.stage === 'drawing' && clean(state), actions); const cards = game.nexusCards!.cards!, index = cards.deck.indexOf('emperor'); assert.ok(index >= 0); cards.deck.unshift(cards.deck.splice(index, 1)[0]); game = reactionAction(game, 'emperor', { type: 'nexusCardChoice', turn: game.turn, card: cards.hands.emperor, choice: 'draw', ownRedraws: 0 }, actions); game = advanceClassicIndependentNexus(game, state => state.phase === 5 && state.active === 'harkonnen' && clean(state), actions); assert.ok(!territory('arrakeen').sectors.includes(game.storm)); const beforeShipment = structuredClone(game); game = reactionAction(game, 'harkonnen', { type: 'ship', territory: 'arrakeen', sector, amount: 5, elite: 0, allyPayment: 0 }, actions); game = advanceClassicIndependentNexus(game, state => state.phase === 6 && clean(state), actions); const afterShipment = structuredClone(game); game = chooseBattle(game, 'arrakeen', actions); return { ...original, game, actions, claimed, beforeShipment, afterShipment, owner: 'emperor', opponent: 'harkonnen', key, ownerLeader: bestLeader(game, 'emperor'), opponentLeader: bestLeader(game, 'harkonnen'), weapon: independent ? REINFORCEMENTS_CARD : null, defense: null }; } /** Harkonnen retains its genuinely earned Carthag card. Atreides draws the * physical Harkonnen Nexus, ships one counter and plays an actually offered * Harkonnen Traitor. Optional all14 uses the original trained-disc choice. */ export function createNexusCoreCarthagFixture(options: NexusCoreStrongholdOptions = {}): NexusCoreStrongholdFixture { const poison = baseDeck().find(card => card.kind === 'poison')!.id; const shield = baseDeck().find(card => card.kind === 'shield')!.id; const original = drawClassicNexusReaction('harkonnen', 'atreides', { advanced: true, independent: options.independent ?? true, skills: options.skills, strongholds: true, training: options.skills ? { harkonnen: 'prana-bindu-adept' } : undefined, startingCards: { atreides: [poison], harkonnen: [shield] } }); const actions = original.actions; const claimed = structuredClone(original.beforeDraw); assert.equal(claimed.strongholdCards!.owners.carthag, 'harkonnen'); let game = advanceClassicIndependentNexus(original.game, state => state.phase === 5 && state.active === 'atreides' && clean(state), actions); const opponentLeader = reactionPlayer(game, 'atreides').leaders.find(leader => reactionPlayer(game, 'harkonnen').traitors.includes(leader.id))?.id; assert.ok(opponentLeader, 'Use the actual offered Traitor, never assign a leader identity'); const sector = territory('carthag').sectors[0], key = `carthag:${sector}`; assert.ok(!territory('carthag').sectors.includes(game.storm)); const beforeShipment = structuredClone(game); game = reactionAction(game, 'atreides', { type: 'ship', territory: 'carthag', sector, amount: 1, elite: 0, allyPayment: 0 }, actions); game = advanceClassicIndependentNexus(game, state => state.phase === 6 && clean(state), actions); const afterShipment = structuredClone(game); game = reactionAction(game, game.active!, { type: 'chooseBattle', territory: 'carthag', target: game.active === 'atreides' ? 'harkonnen' : 'atreides' }, actions); game = prepare(game, actions); const trained = game.leaderSkills?.assignments.find(assignment => assignment.owner === 'harkonnen'); return { ...original, game, claimed, beforeShipment, afterShipment, owner: 'harkonnen', opponent: 'atreides', key, ownerLeader: trained?.leader ?? bestLeader(game, 'harkonnen'), opponentLeader, weapon: poison, defense: shield }; } /** Return the revealed original battle, before any Traitor vote or cleanup. */ export function revealNexusCoreStrongholdBattle(fixture: NexusCoreStrongholdFixture, options: { cunning?: boolean; shield?: boolean; ownerDial?: number; opponentDial?: number } = {}): Game { let game = structuredClone(fixture.game); const arrakeen = fixture.key.startsWith('arrakeen:'); if (arrakeen && options.cunning !== false) { const offer = viewGame(game, fixture.owner).nexusSardaukar!.offer!; assert.equal(offer.blocked, null); game = reactionAction(game, fixture.owner, { type: 'nexusSardaukar', event: offer.event }); game = advanceClassicIndependentNexus(game, clean); } const ownerDial = options.ownerDial ?? (arrakeen ? 6 : 0.5); const opponentDial = options.opponentDial ?? (arrakeen ? 1 : 0); for (const actor of [game.battle!.attacker, game.battle!.defender]) { const owner = actor === fixture.owner; game = reactionAction(game, actor, { type: 'battlePlan', dial: owner ? ownerDial : opponentDial, support: owner ? arrakeen ? options.cunning === false ? ownerDial : ownerDial / 2 : 0 : opponentDial, leader: owner ? fixture.ownerLeader : fixture.opponentLeader, weapon: arrakeen ? owner ? fixture.weapon : null : owner ? null : fixture.weapon, defense: !arrakeen && owner && options.shield !== false ? fixture.defense : null }); while (game.response || game.phaseOpening || game.decision?.kind === 'fullPlanOffer') { const next = nextClassicDiscoveryNexusStep(game); assert.ok(next); game = applyClassicDiscoveryNexusStep(game, next); } } assert.ok(game.battle?.revealed); return game; } /** Original votes and native cleanup; no plan or strategy is invented. */ export function finishNexusCoreStrongholdBattle(game: Game, stopAtCleanup = false): Game { return advanceClassicIndependentNexus(game, state => (!state.battle && clean(state)) || (stopAtCleanup && state.decision?.kind === 'battleCards')); } /** Preserve the original native Harkonnen call and pending cancellation window. */ export function callNexusCoreCarthagTraitor(game: Game): Game { for (const voter of viewGame(game, 'atreides').battle!.traitorVoters) if (voter !== 'harkonnen' && game.battle!.traitorCalls[voter] === undefined) game = reactionAction(game, voter, { type: 'traitorCall', call: false }); return reactionAction(game, 'harkonnen', { type: 'traitorCall', call: true }); }