import assert from 'node:assert/strict'; import { applyAction, captureCandidates, createGame, handLimit, initializeNexusGameForAudit, joinGame, newPlayer, viewGame, type Action, type Game, type Player, } from '../game/engine'; import type { FactionId } from '../game/catalog'; import { leaders, treacheryDeck, type Card, type Leader } from '../game/cards'; import { DUKE_VIDAL_ID, dukeController, type DukeReleasedCaptureWitness, type DukeState, } from '../game/duke-vidal'; import { traitorDeck } from '../game/traitors'; import { TERRITORIES, territory } from '../game/board'; import { NEXUS_FACTIONS } from '../game/nexus-cards'; import { nexusEcazDukeAction } from '../game/nexus-ecaz-duke-options'; import { validateAmbassadors } from '../game/ecaz-ambassadors'; import { e3NexusEcazClean, e3NexusEcazReload, nextE3NexusEcazStep, orderE3NexusEcazSpice } from './fixture-e3-nexus-ecaz'; import { withClassicDiscoveryNexusLottery } from './fixture-discovery-classic-nexus'; /** * Genuine standalone original Advanced Ecaz/Harkonnen Nexus tables (roster * Ecaz + Harkonnen + Guild + Fremen, no Skills, no optional modules) for the * shared Duke's real capture, liberation and death custody. * * Every action is issued by a live owner: the paid Ecaz Ambassador placement, * the other seat's real arrival, the real Battle declaration and sealed plans, * the real capture response, the closing Nexus draw and real card actions. * Nothing is staged: no hand, wallet, force, played card, controller, captive * marker or clock is assigned. The single scoped exception is the capture * lottery, which selects entropy for the ORIGINAL shuffle over this case's * actual emitted candidate pool (the engine's own `captureCandidates`). */ export type NexusDukeStep = { actor: string; action: Action }; export type NexusDukeTransition = { before: Game; step: NexusDukeStep; after: Game }; export type NexusDukeResolution = { before: Game; after: Game }; export type NexusDukeCaptureDeclaration = { player: string; loser: string; territory: string }; /** Exactly the fields this fixture's driver consumes, plus its own progress. */ export type NexusDukePlan = { owner: string; captor: string; entrant: string; others: string[]; /** The reusable physical Ecaz Ambassador token and where Ecaz really places it. */ token: string; destination: string; /** True only until the first placement commits; the token then returns to supply. */ place: boolean; /** Answer to a real capture offer and to the real retained/executed choice. */ capture: 'accept' | 'decline'; mode: 'keep' | 'execute'; /** Exact next real shipments this plan issues, keyed by the acting seat. */ ship: Record; /** One real shipment per seat per turn. */ shipped: Record; /** How many real Bidding lots this plan bids one spice for. */ bid: number; /** The turn whose Bidding window this plan already bid in. */ lastBid: string; }; /** A driver plan that issues no optional action; used before Ecaz's supply exists. */ export function emptyNexusDukePlan(): NexusDukePlan { return { owner: '', captor: '', entrant: '', others: [], token: '', destination: '', place: false, capture: 'accept', mode: 'keep', ship: {}, shipped: {}, bid: 0, lastBid: '' }; } export const nexusDukeClean = (game: Game): boolean => e3NexusEcazClean(game); export const nexusDukeReload = (game: Game): Game => e3NexusEcazReload(game); export function nexusDukePlayer(game: Game, id: string): Player { const player = game.players.find((candidate) => candidate.id === id); assert.ok(player, `Missing original Nexus Duke seat ${id}`); return player; } export function nexusDukeSeat(game: Game, faction: FactionId): Player { const player = game.players.find((candidate) => candidate.faction === faction); assert.ok(player, `Missing original ${faction} seat`); return player; } /** Original undealt Treachery slot selection: which physical card each private * setup deal receives. A matcher never invents a card; it only chooses among * the undealt originals. */ export type NexusDukeDeal = (faction: FactionId) => readonly (string | ((card: Card) => boolean))[]; const slot = (spec: string | ((card: Card) => boolean) | undefined) => { if (typeof spec === 'function') return spec; const wanted = spec ?? 'worthless'; return (card: Card) => card.kind === wanted || card.effect === wanted; }; const isWeapon = (card: Card | undefined): card is Card => !!card && (card.kind === 'projectile' || card.kind === 'poison'); const defaultDeal: NexusDukeDeal = (faction) => faction === 'harkonnen' ? ['worthless', 'shield'] : ['worthless']; const poisonDeal: NexusDukeDeal = (faction) => faction === 'harkonnen' ? ['worthless', 'poison'] : ['worthless']; function permutation(original: readonly T[], desired: readonly T[]): number[] { const current = [...original], rolls: number[] = []; for (let i = current.length - 1; i > 0; i--) { const j = current.indexOf(desired[i]); assert.ok(j >= 0 && j <= i, 'The selected permutation must stay inside its original array.'); rolls.push(Math.floor((j + 0.5) / (i + 1) * 0x100000000)); [current[i], current[j]] = [current[j], current[i]]; } assert.deepEqual(current, desired); return rolls; } function lottery(rolls: readonly number[], operation: () => T): T { const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues'); const native = crypto.getRandomValues.bind(crypto); let index = 0; crypto.getRandomValues = (array: V): V => { if (array instanceof Uint32Array && array.length === 1 && index < rolls.length) array[0] = rolls[index++]; else Reflect.apply(native, crypto, [array]); return array; }; try { return operation(); } finally { if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor); else Reflect.deleteProperty(crypto, 'getRandomValues'); } } /** Exact Fisher-Yates entropy that makes the engine's own `shuffle` emit `first` * in position zero of the supplied physical candidate array. */ function shuffleRolls(source: readonly T[], first: string): number[] { const found = source.find((candidate) => candidate.id === first); assert.ok(found, 'The shared Duke disc must be in the emitted candidate pool.'); return permutation(source, [found, ...source.filter((candidate) => candidate.id !== first)]); } /** Reorder only the original undealt Spice Cards for the opening land blows. */ export function primeNexusDukeSpice(game: Game): void { orderE3NexusEcazSpice(game); } export type NexusDukeSetup = { initial: Game; offered: Game; afterSetup: Game; game: Game; owner: string; captor: string; entrant: string; others: string[]; token: string; destination: string; actions: NexusDukeStep[]; }; export type NexusDukeSetupOptions = { roster?: readonly FactionId[]; deal?: NexusDukeDeal; }; function dealt(game: Game, deal: NexusDukeDeal): Game { const cards = treacheryDeck(['ecaz']), remaining = [...cards], front: string[] = []; for (const player of game.players) for (let n = 0; n < (player.faction === 'harkonnen' ? 2 : 1); n++) { const matcher = slot([...deal(player.faction)][n]); const index = remaining.findIndex((card) => matcher(card)); assert.ok(index >= 0, `The original ${player.faction} deal needs an undealt physical Treachery card.`); front.push(remaining.splice(index, 1)[0].id); } // Auction order keeps the original weapon cards first; every card is conserved. const rest = [...remaining.filter(isWeapon), ...remaining.filter((card) => !isWeapon(card))]; const rolls = [ ...permutation(NEXUS_FACTIONS, ['ecaz', ...NEXUS_FACTIONS.filter((faction) => faction !== 'ecaz')]), ...permutation(cards.map((card) => card.id), [...front, ...rest.map((card) => card.id)]), ]; return lottery(rolls, () => initializeNexusGameForAudit(game)); } /** Genuine completed standalone original setup. Stable seam for other suites. */ export function createNexusDukeCaptureSetup(options: NexusDukeSetupOptions = {}): NexusDukeSetup { const roster = options.roster ?? (['ecaz', 'harkonnen', 'guild', 'fremen'] as FactionId[]); let game = createGame('NEXUSDUKECAPTURE', newPlayer(roster[0], roster[0], roster[0]), true, ['ecaz']); for (const faction of roster.slice(1)) joinGame(game, newPlayer(faction, faction, faction)); assert.equal(game.status, 'lobby'); assert.equal(game.advanced, true); assert.deepEqual(game.expansions, ['ecaz']); assert.ok(!game.leaderSkills && !game.ecazTreachery && !game.homeworlds); assert.ok(roster.includes('ecaz') && roster.includes('harkonnen') && roster.length >= 3); const actions: NexusDukeStep[] = [], blank = emptyNexusDukePlan(); const host = game.host; for (const [type, enabled] of [['techTokens', false], ['strongholdCards', false], ['homeworlds', false]] as const) if (!!game[type] !== enabled) game = stepNexusDuke(game, blank, { actor: host, action: { type, enabled } }, actions); game.discoveryEnabled = false; game.nexusCards ??= { cards: null, phase: null }; const initial = nexusDukeReload(game); for (const player of game.players) if (!player.ready) game = stepNexusDuke(game, blank, { actor: player.id, action: { type: 'ready' } }, actions); game = dealt(game, options.deal ?? defaultDeal); const offered = nexusDukeReload(game); for (let n = 0; game.status === 'setup' && n < 240; n++) { const next = nextE3NexusEcazStep(game); assert.ok(next, 'The original setup needs its actual current owner.'); game = stepNexusDuke(game, blank, next, actions); } assert.equal(game.status, 'playing'); const owner = nexusDukeSeat(game, 'ecaz').id, captor = nexusDukeSeat(game, 'harkonnen').id; const others = game.players.filter((player) => player.id !== owner).map((player) => player.id); const guild = game.players.find((player) => player.faction === 'guild'); const entrant = (guild && guild.id !== owner ? guild.id : others.find((id) => id !== owner))!; assert.notEqual(entrant, owner); return { initial, offered, afterSetup: nexusDukeReload(game), game: nexusDukeReload(game), owner, captor, entrant, others, token: reusableToken(game), destination: ambientStronghold(game), actions }; } function reusableToken(game: Game): string { const token = game.ecazAmbassadors?.tokens.find((candidate) => candidate.effect === 'ecaz'); assert.ok(token, 'The reusable Ecaz Ambassador token must be in the original supply.'); return token.id; } /** An empty, storm-free stronghold that no faction occupies yet. */ function ambientStronghold(game: Game): string { const occupied = new Set(); for (const player of game.players) for (const key of Object.keys(player.forces)) occupied.add(key.split(':')[0]); const found = TERRITORIES.find((candidate) => candidate.type === 'stronghold' && !candidate.sectors.includes(game.storm) && !occupied.has(candidate.id)); assert.ok(found, 'The original strongholds must leave one empty placement stronghold.'); return found.id; } function shipmentSector(game: Game, id: string): number { const sectors = territory(id).sectors; return sectors.find((sector) => sector !== game.storm) ?? sectors[0]; } /** A plan for a settled table: no optional action unless the caller asks. */ export function quietPlan(game: Game, overrides: Partial = {}): NexusDukePlan { const owner = nexusDukeSeat(game, 'ecaz').id; const captor = game.players.find((player) => player.faction === 'harkonnen')?.id ?? game.players.find((player) => player.id !== owner)!.id; const others = game.players.filter((player) => player.id !== owner).map((player) => player.id); return { owner, captor, entrant: others.find((id) => id !== captor)!, others, token: reusableToken(game), destination: ambientStronghold(game), place: false, capture: 'accept', mode: 'keep', ship: {}, shipped: {}, bid: 0, lastBid: '', ...overrides, }; } /** The exact next action of the original chronology. A null result means a real * owner must still make a private choice (or seal a private Battle Plan). */ export function nextNexusDukeStep(game: Game, plan: NexusDukePlan): NexusDukeStep | null { if (!game.response && !game.phaseOpening && !game.pendingTreacheryDiscard) { const decision = game.decision; if (decision?.kind === 'captureOffer') return { actor: decision.player, action: { type: 'decision', accept: plan.capture === 'accept' } }; if (decision?.kind === 'capturedLeader') return { actor: decision.player, action: { type: 'decision', mode: plan.mode } }; if (decision?.kind === 'battleLosses') return { actor: decision.player, action: { type: 'decision', choice: 0 } }; if (decision?.kind === 'battleCards') return { actor: decision.player, action: { type: 'decision', discard: [] } }; if (decision?.kind === 'techToken') return { actor: decision.player, action: { type: 'decision', token: decision.choices[0] } }; if (decision?.kind === 'ecazPlacement') { const token = game.ecazAmbassadors?.tokens.find((candidate) => candidate.id === plan.token); return plan.place && token?.zone === 'supply' ? { actor: decision.player, action: { type: 'decision', token: plan.token, territory: plan.destination } } : { actor: decision.player, action: { type: 'decision', decline: true } }; } if (decision?.kind === 'ecazAmbassador') { const entry = game.pendingAmbassador; if (entry?.token === plan.token && entry.stage === 'offer' && entry.owner === plan.owner) return { actor: decision.player, action: { type: 'decision', event: entry.event, trigger: true, beneficiary: plan.owner, choice: 'duke' } }; } if (game.phase === 3 && game.auction && plan.bid > 0 && game.auction.active === plan.owner && plan.lastBid !== String(game.turn) && !game.auction.bidder && isWeapon(game.auction.cards[game.auction.index]) && nexusDukePlayer(game, plan.owner).hand.length < handLimit(nexusDukePlayer(game, plan.owner))) { plan.lastBid = String(game.turn); return { actor: plan.owner, action: { type: 'bid', amount: 1 } }; } const order = game.phase === 5 && game.active ? plan.ship[game.active] : undefined; if (order && nexusDukeClean(game) && plan.shipped[game.active!] !== String(game.turn)) return { actor: game.active!, action: { type: 'ship', territory: order.territory, sector: shipmentSector(game, order.territory), amount: order.amount, elite: 0, allyPayment: 0 } }; } return nextE3NexusEcazStep(game); } export function stepNexusDuke(game: Game, plan: NexusDukePlan, step: NexusDukeStep, actions?: NexusDukeStep[]): Game { actions?.push(structuredClone(step)); if (step.action.type === 'ship') plan.shipped[step.actor] = String(game.turn); const state = nexusDukeReload(game); if (state.pendingCapture && (state.response?.kind === 'capture' || state.decision?.kind === 'captureOffer' && step.action.type === 'decision' && step.action.accept === true)) { const rolls = shuffleRolls(emittedCandidates(state), DUKE_VIDAL_ID); return lottery(rolls, () => applyAction(state, step.actor, step.action)); } return withClassicDiscoveryNexusLottery(() => applyAction(state, step.actor, step.action)); } /** The case's own candidate pool, read from the engine's original admission. * The released-battle witness the capture itself published is validated here. */ function emittedCandidates(game: Game): Leader[] { const pending = game.pendingCapture; assert.ok(pending, 'The original capture declaration must still be current.'); return captureCandidates(game, pending.loser, pending.territory, releasedWitness(game)); } function releasedWitness(game: Game): DukeReleasedCaptureWitness | undefined { const pending = game.pendingCapture; if (!pending || !('duke' in pending)) return undefined; const candidate: unknown = pending.duke; if (candidate === undefined) return undefined; if (typeof candidate !== 'object' || candidate === null || Array.isArray(candidate)) throw new Error('The original capture lost its physical released-battle witness.'); const deaths = 'deaths' in candidate ? candidate.deaths : undefined; const usedAt = 'usedAt' in candidate ? candidate.usedAt : undefined; if (typeof deaths !== 'number' || !Number.isSafeInteger(deaths) || typeof usedAt !== 'string' || !usedAt) throw new Error('The original capture lost its physical released-battle witness.'); return { deaths, usedAt }; } export function advanceNexusDuke(state: Game, until: (game: Game) => boolean, plan: NexusDukePlan, actions?: NexusDukeStep[]): Game { let game = state; for (let n = 0; n < 2600; n++) { if (until(game)) return game; assert.equal(game.status, 'playing'); const next = nextNexusDukeStep(game, plan); assert.ok(next, `The original chronology needs its real owner at ${game.turn}/${game.phase}/${game.decision?.kind ?? 'open'}.`); game = stepNexusDuke(game, plan, next, actions); } throw Error('The original Nexus Duke chronology did not reach its requested window.'); } function living(game: Game, id: string): Leader[] { return nexusDukePlayer(game, id).leaders.filter((leader) => !leader.dead); } function strongest(list: readonly Leader[]): Leader { const sorted = [...list].sort((a, b) => b.strength - a.strength)[0]; assert.ok(sorted, 'The original roster must still own a living leader.'); return sorted; } function weakest(list: readonly Leader[]): Leader { const sorted = [...list].sort((a, b) => a.strength - b.strength)[0]; assert.ok(sorted, 'The original roster must still own a living leader.'); return sorted; } /** The real paid Ecaz Ambassador placement, then another seat's real arrival. */ function ambassadorAcquisition(state: Game, plan: NexusDukePlan, actions: NexusDukeStep[]): { placement: NexusDukeTransition; arrival: NexusDukeTransition & { duke: DukeState }; game: Game; } { let game = advanceNexusDuke(state, (candidate) => candidate.decision?.kind === 'ecazPlacement', plan, actions); const placementBefore = nexusDukeReload(game); const placementStep: NexusDukeStep = { actor: plan.owner, action: { type: 'decision', token: plan.token, territory: plan.destination } }; game = stepNexusDuke(game, plan, placementStep, actions); game = advanceNexusDuke(game, (candidate) => candidate.ecazAmbassadors!.tokens.find((token) => token.id === plan.token)!.zone === 'placed', plan, actions); const placement: NexusDukeTransition = { before: placementBefore, step: placementStep, after: nexusDukeReload(game) }; plan.place = false; plan.ship = { [plan.entrant]: { territory: plan.destination, amount: 1 }, [plan.owner]: { territory: 'carthag', amount: 1 }, }; game = advanceNexusDuke(game, (candidate) => candidate.decision?.kind === 'ecazAmbassador' && candidate.pendingAmbassador?.token === plan.token && candidate.pendingAmbassador.stage === 'offer', plan, actions); const arrivalBefore = nexusDukeReload(game); const arrivalStep: NexusDukeStep = { actor: plan.owner, action: { type: 'decision', event: game.pendingAmbassador!.event, trigger: true, beneficiary: plan.owner, choice: 'duke' } }; game = stepNexusDuke(game, plan, arrivalStep, actions); assert.equal(game.dukeVidal?.controller, plan.owner, 'The real Ecaz Ambassador must acquire the shared disc.'); assert.equal(game.dukeVidal?.source, 'ecaz'); const arrival = { before: arrivalBefore, step: arrivalStep, after: nexusDukeReload(game), duke: structuredClone(game.dukeVidal!) }; plan.ship = {}; return { placement, arrival, game }; } /** Real Battle declaration; both private Battle Plans still need their owners. */ export function openNexusDukeBattle(state: Game, plan: NexusDukePlan, actions: NexusDukeStep[], where: { territory: string; first: string; second: string }): NexusDukeTransition & { game: Game } { let game = advanceNexusDuke(state, (candidate) => candidate.phase === 6 && nexusDukeClean(candidate) && !!candidate.active, plan, actions); const choice = viewGame(game, game.active!).battleChoices.find((candidate) => candidate.territory === where.territory && [where.first, where.second].includes(candidate.attacker) && [where.first, where.second].includes(candidate.defender)); assert.ok(choice, `The real ${where.territory} Battle between the original seats must be offered.`); const step: NexusDukeStep = { actor: choice.chooser, action: { type: 'chooseBattle', territory: where.territory, target: choice.attacker === choice.chooser ? choice.defender : choice.attacker } }; const before = nexusDukeReload(game); game = stepNexusDuke(game, plan, step, actions); game = advanceNexusDuke(game, (candidate) => !!candidate.battle && nexusDukeClean(candidate) && !candidate.battle.preparation && candidate.battle.preLeader?.closed !== false, plan, actions); return { before, step, after: nexusDukeReload(game), game }; } export type NexusDukeSeatPlan = { actor: string; leader: string | null; dial: number; support?: number; weapon?: string; defense?: string; }; export function sealNexusDukeBattle(state: Game, plan: NexusDukePlan, actions: NexusDukeStep[], plans: readonly NexusDukeSeatPlan[]): Game { let game = state; for (const entry of plans) { const step: NexusDukeStep = { actor: entry.actor, action: { type: 'battlePlan', leader: entry.leader, dial: entry.dial, support: entry.support ?? 0, ...(entry.weapon ? { weapon: entry.weapon } : {}), ...(entry.defense ? { defense: entry.defense } : {}) } }; game = stepNexusDuke(game, plan, step, actions); if (!game.battle?.revealed) game = advanceNexusDuke(game, (candidate) => !!candidate.battle && nexusDukeClean(candidate) && !candidate.battle.preparation && candidate.battle.preLeader?.closed !== false, plan, actions); } assert.ok(game.battle?.revealed, 'The original sealed plans must reveal this Battle.'); return game; } export type NexusDukeClosing = { alliance: Game; before: Game; step: NexusDukeStep; after: Game; game: Game; }; /** The genuine closing Nexus: two other seats pact, the real Spice Blow/Nexus * runs, and unallied Ecaz spends that phase's real Nexus choice on its own card. */ export function closeNexusDukeDraw(state: Game, plan: NexusDukePlan, actions: NexusDukeStep[]): NexusDukeClosing { assert.ok(plan.others.length >= 2, 'The original pact needs two other real seats.'); let game = advanceNexusDuke(state, (candidate) => candidate.turn === 2 && candidate.phase === 1 && nexusDukeClean(candidate), plan, actions); orderE3NexusEcazSpice(game, true); game = advanceNexusDuke(game, (candidate) => candidate.nexus && !candidate.spiceWindow && !candidate.spiceResolution && nexusDukeClean(candidate), plan, actions); for (const [actor, target] of [[plan.others[0], plan.others[1]], [plan.others[1], plan.others[0]]]) game = stepNexusDuke(game, plan, { actor, action: { type: 'alliance', target } }, actions); const alliance = nexusDukeReload(game); assert.equal(nexusDukePlayer(game, plan.owner).ally, null, 'Ecaz must stay unallied for the closing draw.'); game = advanceNexusDuke(game, (candidate) => candidate.nexusCards?.phase?.stage === 'drawing' && nexusDukeClean(candidate), plan, actions); assert.ok(game.nexusCards!.phase!.eligible.includes(plan.owner)); const before = nexusDukeReload(game); const step: NexusDukeStep = { actor: plan.owner, action: { type: 'nexusCardChoice', turn: game.turn, card: game.nexusCards!.cards!.hands[plan.owner], choice: 'draw', ownRedraws: 0 } }; game = stepNexusDuke(game, plan, step, actions); game = advanceNexusDuke(game, (candidate) => candidate.nexusCards?.phase?.stage === 'complete', plan, actions); assert.equal(game.nexusCards!.cards!.hands[plan.owner], 'ecaz', 'The real closing draw must leave Ecaz holding the physical Ecaz Nexus card.'); return { alliance, before, step, after: nexusDukeReload(game), game }; } export type NexusDukeWindow = { game: Game; action: Action; event: string; plan: NexusDukePlan }; /** The first quiet Battle boundary where native Ecaz physically holds its own * Nexus card and stays unallied. Real owners keep every other choice. */ export function quietNexusDukeWindow(state: Game, options: Partial = {}, actions: NexusDukeStep[] = []): NexusDukeWindow { const plan = quietPlan(state, options); // An empty Battle phase closes automatically. Keep a real pending battle // through a legal native shipment rather than installing an artificial pause. if (!Object.keys(plan.ship).length) plan.ship = { [plan.owner]: { territory: 'carthag', amount: 1 } }; let game = state; if (game.nexusCards?.cards?.hands[plan.owner] !== 'ecaz') game = closeNexusDukeDraw(game, plan, actions).game; game = advanceNexusDuke(game, (candidate) => candidate.phase === 6 && nexusDukeClean(candidate) && !candidate.battle && candidate.nexusCards?.cards?.hands[plan.owner] === 'ecaz', plan, actions); const action = nexusEcazDukeAction(viewGame(game, plan.owner)); assert.ok(action, 'The quiet Ecaz Nexus Cunning window must be available.'); assert.ok(typeof action.event === 'string'); return { game, action, event: action.event, plan }; } export type NexusDukeCaptureVariant = 'unused' | 'used'; export type NexusDukeCaptureOptions = { variant?: NexusDukeCaptureVariant; mode?: 'keep' | 'execute'; roster?: readonly FactionId[]; deal?: NexusDukeDeal; }; export type NexusDukeCaptureFixture = { initial: Game; offered: Game; afterSetup: Game; owner: string; captor: string; entrant: string; others: string[]; token: string; destination: string; placement: NexusDukeTransition; arrival: NexusDukeTransition & { duke: DukeState }; battle: NexusDukeTransition & { territory: string }; event: string; sealed: Game; declared: NexusDukeCaptureDeclaration | null; offer: NexusDukeTransition | null; candidates: Leader[]; resolved: NexusDukeResolution | null; decision: NexusDukeTransition | null; duke: DukeState; plan: NexusDukePlan; game: Game; actions: NexusDukeStep[]; }; type InternalCaptureOptions = NexusDukeCaptureOptions & { leadDuke: boolean; poison: boolean; capture: 'accept' | 'decline'; }; function captureChronology(options: InternalCaptureOptions): NexusDukeCaptureFixture { const setup = createNexusDukeCaptureSetup({ roster: options.roster, deal: options.deal ?? (options.poison ? poisonDeal : defaultDeal) }); const actions = setup.actions; const plan: NexusDukePlan = { ...quietPlan(setup.afterSetup), place: true, capture: options.capture, mode: options.mode ?? 'keep', }; primeNexusDukeSpice(setup.afterSetup); let game = nexusDukeReload(setup.afterSetup); const acquisition = ambassadorAcquisition(game, plan, actions); game = acquisition.game; plan.ship = { [plan.owner]: { territory: 'carthag', amount: 1 } }; game = advanceNexusDuke(game, (candidate) => candidate.phase === 6 && nexusDukeClean(candidate) && !!candidate.active, plan, actions); plan.ship = {}; const battle = openNexusDukeBattle(game, plan, actions, { territory: 'carthag', first: plan.owner, second: plan.captor }); game = battle.game; const weapon = options.poison ? nexusDukePlayer(game, plan.captor).hand.find((card) => card.kind === 'poison') : undefined; if (options.poison) assert.ok(weapon, 'The real setup deal must leave Harkonnen holding the physical poison.'); game = sealNexusDukeBattle(game, plan, actions, [ { actor: plan.owner, leader: options.leadDuke ? DUKE_VIDAL_ID : weakest(living(game, plan.owner)).id, dial: 0, support: 0 }, { actor: plan.captor, leader: strongest(living(game, plan.captor)).id, dial: 2, support: 0, ...(weapon ? { weapon: weapon.id } : {}) }, ]); const sealed = nexusDukeReload(game); const battleEvent = sealed.battle?.event ?? ''; game = advanceNexusDuke(game, (candidate) => candidate.decision?.kind === 'captureOffer' || (!candidate.battle && nexusDukeClean(candidate)), plan, actions); let declared: NexusDukeCaptureDeclaration | null = null, offer: NexusDukeTransition | null = null; let resolved: NexusDukeResolution | null = null, decision: NexusDukeTransition | null = null; let candidates: Leader[] = []; if (game.decision?.kind === 'captureOffer' && options.capture === 'decline') { game = stepNexusDuke(game, plan, { actor: game.decision.player, action: { type: 'decision', accept: false } }, actions); } else if (game.decision?.kind === 'captureOffer') { const pending = game.pendingCapture!; declared = { player: pending.player, loser: pending.loser, territory: pending.territory }; candidates = emittedCandidates(game); const acceptStep: NexusDukeStep = { actor: game.decision.player, action: { type: 'decision', accept: true } }; const offerBefore = nexusDukeReload(game); game = stepNexusDuke(game, plan, acceptStep, actions); offer = { before: offerBefore, step: acceptStep, after: nexusDukeReload(game) }; const resolveBefore = nexusDukeReload(game); game = advanceNexusDuke(game, (candidate) => candidate.decision?.kind === 'capturedLeader' || (!candidate.battle && nexusDukeClean(candidate)), plan, actions); assert.ok(game.decision?.kind === 'capturedLeader', 'The original capture response must select the shared disc.'); assert.equal(game.decision.leader, DUKE_VIDAL_ID); resolved = { before: resolveBefore, after: nexusDukeReload(game) }; const modeStep: NexusDukeStep = { actor: game.decision!.player, action: { type: 'decision', mode: plan.mode } }; const modeBefore = nexusDukeReload(game); game = stepNexusDuke(game, plan, modeStep, actions); decision = { before: modeBefore, step: modeStep, after: nexusDukeReload(game) }; } game = advanceNexusDuke(game, (candidate) => !candidate.battle && !candidate.pendingCapture && nexusDukeClean(candidate), plan, actions); return { initial: setup.initial, offered: setup.offered, afterSetup: setup.afterSetup, owner: plan.owner, captor: plan.captor, entrant: plan.entrant, others: plan.others, token: plan.token, destination: plan.destination, placement: acquisition.placement, arrival: acquisition.arrival, battle: { ...battle, territory: 'carthag' }, event: battleEvent, sealed, declared, offer, candidates, resolved, decision, duke: structuredClone(game.dukeVidal!), plan, game: nexusDukeReload(game), actions, }; } /** Real Harkonnen capture of the shared disc: unused, or just used in the very * Battle that captures it. */ export function createNexusDukeCaptureFixture(options: NexusDukeCaptureOptions = {}): NexusDukeCaptureFixture { const variant = options.variant ?? 'unused'; return captureChronology({ ...options, leadDuke: variant === 'used', poison: false, capture: 'accept' }); } export type NexusDukeDeathFixture = { fixture: NexusDukeCaptureFixture; /** Settled turn-1 aftermath with the real death applied. */ game: Game; /** The real closing draw, then the quiet Battle boundary where Ecaz holds its card. */ window: Game; action: Action; owner: string; captor: string; entrant: string; deaths: number; duke: DukeState; plan: NexusDukePlan; actions: NexusDukeStep[]; }; /** Real Battle death of the shared disc: Ecaz commits the Duke, loses, and the * winning Harkonnen plan's real poison card kills it into the Tanks. */ export function createNexusDukeDeathFixture(options: NexusDukeCaptureOptions = {}): NexusDukeDeathFixture { const fixture = captureChronology({ ...options, leadDuke: true, poison: true, capture: 'decline' }); assert.equal(fixture.game.dukeVidal!.leader.dead, true, 'The real poison must kill the shared disc.'); const quiet = quietNexusDukeWindow(fixture.game, fixture.plan, fixture.actions); return { fixture, game: fixture.game, window: quiet.game, action: quiet.action, owner: fixture.owner, captor: fixture.captor, entrant: fixture.entrant, deaths: fixture.game.dukeVidal!.leader.deaths, duke: structuredClone(fixture.game.dukeVidal!), plan: fixture.plan, actions: fixture.actions }; } export type NexusDukeReclaim = { closing: NexusDukeClosing; window: Game; before: Game; step: NexusDukeStep; after: Game; duke: DukeState; }; /** Genuine Ecaz Nexus Cunning liberation of the actual captured shared disc, * reached through the real closing draw. */ export function reclaimCapturedNexusDuke(state: Game, plan: NexusDukePlan, actions: NexusDukeStep[] = [], options: { battle?: boolean } = {}): NexusDukeReclaim { assert.ok(state.dukeVidal?.leader.capturedBy, 'Ecaz Nexus Cunning must liberate a real captive.'); assert.equal(dukeController(state.dukeVidal!), plan.captor); const closing = closeNexusDukeDraw(state, plan, actions); if (options.battle) plan.ship = { [plan.owner]: { territory: 'carthag', amount: 1 } }; const quiet = quietNexusDukeWindow(closing.game, plan, actions); plan.ship = {}; plan.place = false; const step: NexusDukeStep = { actor: plan.owner, action: quiet.action }; const before = nexusDukeReload(quiet.game); const game = stepNexusDuke(quiet.game, plan, step, actions); assert.equal(dukeController(game.dukeVidal!), plan.owner); assert.equal(game.dukeVidal!.leader.capturedBy, undefined); return { closing, window: quiet.game, before, step, after: nexusDukeReload(game), duke: structuredClone(game.dukeVidal!) }; } export type NexusDukeUseRelease = { closing: Game; battle: NexusDukeTransition; game: Game; duke: DukeState; }; /** The captor really uses the retained captive in one later Battle; the original * aftermath then releases and sets the same disc aside. */ export function releaseCapturedNexusDukeByUse(state: Game, plan: NexusDukePlan, options: { territory?: string; captorForces?: number } = {}, actions: NexusDukeStep[] = []): NexusDukeUseRelease { const territoryId = options.territory ?? 'imperial_basin', captorForces = options.captorForces ?? 5; assert.equal(dukeController(state.dukeVidal!), plan.captor); plan.capture = 'decline'; const closing = closeNexusDukeDraw(state, plan, actions).game; plan.ship = { [plan.captor]: { territory: territoryId, amount: captorForces } }; let game = advanceNexusDuke(closing, (candidate) => candidate.phase === 6 && nexusDukeClean(candidate) && !!candidate.active, plan, actions); plan.ship = {}; const battle = openNexusDukeBattle(game, plan, actions, { territory: territoryId, first: plan.captor, second: plan.owner }); game = sealNexusDukeBattle(battle.game, plan, actions, [ { actor: plan.captor, leader: DUKE_VIDAL_ID, dial: 0, support: 0 }, { actor: plan.owner, leader: weakest(living(game, plan.owner)).id, dial: 0, support: 0 }, ]); game = advanceNexusDuke(game, (candidate) => !candidate.battle && !candidate.decision && nexusDukeClean(candidate), plan, actions); assert.equal(game.dukeVidal!.leader.capturedBy, undefined); return { closing, battle, game, duke: structuredClone(game.dukeVidal!) }; } export type NexusDukeLeaderDeath = { leader: string; weapon: string; turn: number }; export type NexusDukeReturn = { game: Game; deaths: NexusDukeLeaderDeath[]; returned: Game; duke: DukeState; }; function carthagForces(game: Game, id: string): number { return Object.entries(nexusDukePlayer(game, id).forces) .filter(([key]) => key.startsWith('carthag:')) .reduce((sum, [, count]) => sum + count, 0); } /** Real route to the all-native-Harkonnen-dead return: every living Harkonnen * leader dies in a real Battle Ecaz wins with a real auctioned weapon, and the * aftermath that kills the last one returns the captive shared disc. */ export function releaseCapturedNexusDukeByAllHarkDead(state: Game, plan: NexusDukePlan, actions: NexusDukeStep[] = []): NexusDukeReturn { assert.equal(dukeController(state.dukeVidal!), plan.captor); let game = closeNexusDukeDraw(state, plan, actions).game; const deaths: NexusDukeLeaderDeath[] = []; for (const target of [...nexusDukePlayer(game, plan.captor).leaders] .sort((a, b) => a.strength - b.strength)) { const turn = game.turn; plan.bid = nexusDukePlayer(game, plan.owner).hand.some(isWeapon) ? 0 : 1; plan.capture = 'decline'; plan.ship = { ...(carthagForces(game, plan.owner) ? {} : { [plan.owner]: { territory: 'carthag', amount: 1 } }), ...(carthagForces(game, plan.captor) ? {} : { [plan.captor]: { territory: 'carthag', amount: 1 } }), }; game = advanceNexusDuke(game, (candidate) => candidate.phase === 6 && nexusDukeClean(candidate) && !!candidate.active, plan, actions); plan.ship = {}; plan.bid = 0; const weapon = nexusDukePlayer(game, plan.owner).hand.find(isWeapon); assert.ok(weapon, 'The real Bidding win must leave Ecaz holding a physical weapon.'); assert.ok(carthagForces(game, plan.owner) > 0 && carthagForces(game, plan.captor) > 0, 'Both original armies must really be present in the Battle territory.'); const battle = openNexusDukeBattle(game, plan, actions, { territory: 'carthag', first: plan.owner, second: plan.captor }); game = sealNexusDukeBattle(battle.game, plan, actions, [ { actor: plan.owner, leader: strongest(living(game, plan.owner)).id, dial: 0, support: 0, weapon: weapon.id }, { actor: plan.captor, leader: target.id, dial: 0, support: 0 }, ]); game = advanceNexusDuke(game, (candidate) => !candidate.battle && !candidate.decision && nexusDukeClean(candidate), plan, actions); assert.equal(nexusDukePlayer(game, plan.captor).leaders .find((leader) => leader.id === target.id)!.dead, true, `The real ${weapon.name} must kill the committed Harkonnen leader.`); deaths.push({ leader: target.id, weapon: weapon.id, turn }); if (deaths.length < nexusDukePlayer(game, plan.captor).leaders.length) { game = advanceNexusDuke(game, (candidate) => candidate.turn > turn && candidate.phase === 1 && nexusDukeClean(candidate), plan, actions); orderE3NexusEcazSpice(game); } } assert.deepEqual(living(game, plan.captor), [], 'Every native Harkonnen leader must really be dead.'); assert.equal(game.dukeVidal!.leader.capturedBy, undefined, 'The aftermath that killed the last native leader must return the captive.'); return { game, deaths, returned: game, duke: structuredClone(game.dukeVidal!) }; } /** Shared custody invariants: one shared disc, no fabricated roster/traitor * membership, conserved physical cards and forces. */ export function assertNexusDukeCustody(game: Game): void { for (const player of game.players) { assert.deepEqual(player.leaders.map((leader) => leader.id).sort(), leaders(player.faction).map((leader) => leader.id).sort(), `${player.faction} roster`); assert.ok(!player.leaders.some((leader) => leader.id === DUKE_VIDAL_ID), 'The shared disc never joins a native roster.'); assert.equal(player.reserves + player.tanks + Object.values(player.forces).reduce((sum, count) => sum + count, 0), 20, player.faction); } const traitors = [...(game.traitorReserve ?? []), ...game.players.flatMap((player) => [...player.traitors, ...player.traitorChoices]), ...(game.ecazLoyalty?.card ? [game.ecazLoyalty.card] : []), ...(game.moritaniAssassinate?.opportunities.filter(record => record.stage === 'replaced').map(record => record.card!) ?? [])]; assert.deepEqual(traitors.sort(), traitorDeck(game.players).sort()); assert.ok(!traitors.includes(DUKE_VIDAL_ID), 'The shared disc has no Traitor identity.'); const physical = [...game.deck, ...game.discard, ...game.players.flatMap((player) => player.hand), ...(game.auction?.cards.slice(game.auction.index + (game.currentAuctionSale ? 1 : 0)) ?? [])]; assert.deepEqual(physical.map((card) => card.id).sort(), treacheryDeck(['ecaz']).map((card) => card.id).sort()); assert.equal(game.dukeVidal!.leader.id, DUKE_VIDAL_ID); assert.equal(game.dukeVidal!.leader.strength, 6); assert.equal(game.dukeVidal!.leader.faction, 'ecaz'); assert.ok(!game.players.some((player) => player.leaders.some((leader) => leader.id === DUKE_VIDAL_ID))); if (game.ecazAmbassadors) validateAmbassadors(game.ecazAmbassadors); }