import assert from 'node:assert/strict'; import { applyAction, createGame, initializeNexusGameForAudit, joinGame, newPlayer, viewGame, type Action, type Game, type Player } from '../game/engine'; import type { FactionId } from '../game/catalog'; import type { Card } from '../game/cards'; import { validateDiscoveryState } from '../game/discoveries'; import { classicDiscoveryNexusProfile } from '../game/discovery-module-profile'; import { traitorDeck } from '../game/traitors'; import { ownedTech, type TechId } from '../game/tech-tokens'; import { nexusInventory } from './fixture-nexus-cards'; import { holdNexusSkillsModulesPaymentsCard, nextNexusSkillsModulesPaymentsNativeStep } from './fixture-nexus-skills-modules-payments'; export type DiscoveryClassicNexusEffectKind = 'emperor-revival' | 'fremen-revival' | 'guild-shipment'; export type DiscoveryClassicNexusEffectStep = { actor: string; action: Action }; export type DiscoveryClassicNexusEffectsOptions = { /** Authenticated original lobby or turn-one uncompleted native setup; never redealt. */ initial?: Game; seatIds?: string[]; advanced?: boolean; tech?: boolean; strongholds?: boolean; kind?: DiscoveryClassicNexusEffectKind; ownerFaction?: FactionId; tanks?: number; eliteTanks?: number; }; export type DiscoveryClassicNexusEffectsFixture = { initial: Game; afterSetup: Game; beforeReveal: Game; afterReveal: Game; beforeEntry: Game; afterEntry: Game; beforeClosingDraw: Game; closingDraw: DiscoveryClassicNexusEffectStep; afterClosingDraw: Game; game: Game; actor: string; opponent: string; kind: DiscoveryClassicNexusEffectKind; paymentAction: Action; revivalAction?: Action; token: string; source: string; tokenOwner: string | null; tokenCount: number; industry: TechId; actions: DiscoveryClassicNexusEffectStep[]; staging: string[]; }; export type DiscoveryClassicNexusNestedBattle = { beforeBattle: Game; game: Game; actor: string; opponent: string; weapon: Card; losingLeader: string; plans: DiscoveryClassicNexusEffectStep[]; }; const clean = (game: Game) => !game.phaseOpening && !game.response && !game.decision && !game.truthtrance; export function discoveryClassicNexusEffectsPlayer(game: Game, id: string): Player { const player = game.players.find(player => player.id === id); assert.ok(player, `Missing original authenticated seat ${id}`); return player; } /** Labelled scalar lottery: native UUID byte-array generation is not replaced. */ function originalLottery(run: () => T, scalar = 0xffffffff): T { const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues'); const native = crypto.getRandomValues.bind(crypto); crypto.getRandomValues = (array: V): V => { if (array instanceof Uint32Array && array.length === 1) array[0] = scalar; else Reflect.apply(native, crypto, [array]); return array; }; try { return run(); } finally { if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor); else Reflect.deleteProperty(crypto, 'getRandomValues'); } } export function nextDiscoveryClassicNexusEffectsStep(game: Game): DiscoveryClassicNexusEffectStep | null { if (!game.phaseOpening && !game.response) { const decision = game.decision; if (decision?.kind === 'discoveryEntry' || decision?.kind === 'greatMakerRide' || decision?.kind === 'nexusFremenCunningOffer' || decision?.kind === 'nexusFremenCunningRide') return { actor: decision.player, action: { type: 'decision', event: decision.event, accept: false } }; if (decision?.kind === 'greatMakerVote') return { actor: decision.player, action: { type: 'decision', event: decision.event, yes: false } }; if (decision?.kind === 'wormRide') return { actor: decision.player, action: { type: 'decision', accept: false } }; if (decision?.kind === 'wormProtection') return { actor: decision.player, action: { type: 'decision', accept: true } }; } return nextNexusSkillsModulesPaymentsNativeStep(game); } export function advanceDiscoveryClassicNexusEffectsStep(game: Game, actions?: DiscoveryClassicNexusEffectStep[]): Game { const next = nextDiscoveryClassicNexusEffectsStep(game); assert.ok(next, 'Original continuation stopped for a human private battle plan'); actions?.push(structuredClone(next)); return applyAction(game, next.actor, next.action); } export function advanceDiscoveryClassicNexusEffects(state: Game, predicate: (game: Game) => boolean, actions?: DiscoveryClassicNexusEffectStep[]): Game { let game = state; for (let n = 0; n < 1800; n++) { if (predicate(game)) return game; assert.notEqual(game.status, 'finished'); game = advanceDiscoveryClassicNexusEffectsStep(game, actions); } throw Error('Original Discovery/Nexus continuation did not reach its boundary'); } export function advanceDiscoveryClassicNexusEffectsToPhase(state: Game, phase: number, actions?: DiscoveryClassicNexusEffectStep[]): Game { const turn = state.turn; return advanceDiscoveryClassicNexusEffects(state, game => { assert.equal(game.turn, turn); return game.phase >= phase && clean(game); }, actions); } export function discoveryClassicNexusEffectsInventory(game: Game): void { nexusInventory(game); validateDiscoveryState(game.discoveries!); assert.deepEqual([...(game.traitorReserve ?? []), ...game.players.flatMap(player => [...player.traitors, ...player.traitorChoices])].sort(), traitorDeck(game.players, false).sort()); for (const player of game.players) if (player.elites) { assert.equal(player.elites.reserves + player.elites.tanks + Object.values(player.elites.forces).reduce((a, b) => a + b, 0), player.faction === 'fremen' ? 3 : 5); assert.ok(player.elites.reserves <= player.reserves && player.elites.tanks <= player.tanks); for (const [key, amount] of Object.entries(player.elites.forces)) assert.ok(amount <= (player.forces[key] ?? 0)); } } export function initializeDiscoveryClassicNexusEffectsSetup(options: DiscoveryClassicNexusEffectsOptions = {}): Game { const kind = options.kind ?? 'emperor-revival', faction = options.ownerFaction ?? 'atreides'; const forbidden = kind === 'guild-shipment' ? 'guild' : kind === 'fremen-revival' ? 'fremen' : 'emperor'; let game = options.initial ? structuredClone(options.initial) : createGame('DISCOVERYCLASSICNEXUSEFFECTS', newPlayer(options.seatIds?.[0] ?? 'owner', faction, faction), options.advanced ?? !!options.strongholds); if (!options.initial) { const count = options.seatIds?.length ?? 3; assert.ok(count >= 3 && count <= 5); for (const candidate of ['harkonnen', 'guild', 'fremen', 'atreides', 'beneGesserit', 'emperor'] as const) if (game.players.length < count && candidate !== forbidden && candidate !== faction) joinGame(game, newPlayer(options.seatIds?.[game.players.length] ?? candidate, candidate, candidate)); assert.equal(game.players.length, count); } assert.ok(game.players.some(player => player.faction === faction)); assert.ok(!game.players.some(player => player.faction === forbidden)); if (options.advanced !== undefined) assert.equal(game.advanced, options.advanced); const tech = options.tech ?? !!game.techTokens, strongholds = options.strongholds ?? !!game.strongholdCards; if (game.status === 'setup') { assert.equal(game.turn, 1); assert.equal(game.phase, 0); assert.equal(!!game.techTokens, tech); assert.equal(!!game.strongholdCards, strongholds); assert.ok(classicDiscoveryNexusProfile(game)); discoveryClassicNexusEffectsInventory(game); return game; } assert.equal(game.status, 'lobby'); if (tech && !game.techTokens) game = applyAction(game, game.host, { type: 'techTokens', enabled: true }); if (strongholds && !game.strongholdCards) game = applyAction(game, game.host, { type: 'strongholdCards', enabled: true }); assert.equal(!!game.techTokens, tech); assert.equal(!!game.strongholdCards, strongholds); game.discoveryEnabled = true; game.nexusCards ??= { cards: null, phase: null }; assert.ok(classicDiscoveryNexusProfile(game)); for (const player of game.players) if (!player.ready) game = applyAction(game, player.id, { type: 'ready' }); return originalLottery(() => initializeNexusGameForAudit(game)); } function reserveBoard(game: Game, staging: string[]): void { for (const player of game.players) { player.reserves += Object.values(player.forces).reduce((a, b) => a + b, 0); player.forces = {}; if (player.elites) { player.elites.reserves += Object.values(player.elites.forces).reduce((a, b) => a + b, 0); player.elites.forces = {}; } if (player.advisors) player.advisors = {}; } staging.push('Conserved all original on-board counters into their own reserves; no clock, wallet, receipt or module ownership assigned.'); } function placeOrdinary(game: Game, actor: string, key: string, amount: number, staging: string[]): void { const player = discoveryClassicNexusEffectsPlayer(game, actor); assert.ok(player.reserves - (player.elites?.reserves ?? 0) >= amount); player.reserves -= amount; player.forces[key] = (player.forces[key] ?? 0) + amount; staging.push(`Conserved ${amount} original ordinary ${actor} reserve counters to ${key}.`); } function orderSpice(game: Game, selectors: ('discovery' | 'land' | 'worm')[], staging: string[]): void { const front: Game['spiceDeck'] = []; for (const selector of selectors) { const index = game.spiceDeck.findIndex(card => selector === 'worm' ? 'worm' in card : 'territory' in card && (selector === 'discovery' ? card.discovery === 'discovery-hagga-basin' : !card.discovery && card.territory !== 'gara_kulon')); assert.ok(index >= 0, 'Only original undealt physical Spice singletons may be selected'); front.push(game.spiceDeck.splice(index, 1)[0]); } game.spiceDeck.unshift(...front); staging.push(`Ordered conserved original undealt Spice Cards: ${selectors.join(', ')}; original discard untouched.`); } /** Original setup, actual printed Discovery blow/lottery, Collection reveal, * next-turn entry and end-Spice-Blow Nexus/alliance draw all precede the effect. */ export function createDiscoveryClassicNexusEffectsFixture(options: DiscoveryClassicNexusEffectsOptions = {}): DiscoveryClassicNexusEffectsFixture { const initial = initializeDiscoveryClassicNexusEffectsSetup(options); return originalLottery(() => { const kind = options.kind ?? 'emperor-revival', actor = initial.players.find(player => player.faction === (options.ownerFaction ?? 'atreides'))!.id; const opponent = initial.players.find(player => player.id !== actor && player.faction !== 'beneGesserit')!.id; const actions: DiscoveryClassicNexusEffectStep[] = [], staging = ['Scalar lottery fixed at the last legal index; UUID bytes remain native. Physical Hiereg lottery selects the original Shrine token; not an untouched random sample.']; let game = advanceDiscoveryClassicNexusEffects(initial, game => game.status === 'playing', actions); const afterSetup = structuredClone(game); reserveBoard(game, staging); // The actual first Storm producers assign original Tech owners. game = advanceDiscoveryClassicNexusEffectsToPhase(game, 1, actions); orderSpice(game, ['discovery', 'land'], staging); const pool = game.discoveries!.tokens.filter(token => token.type === 'hiereg' && token.status === 'supply'); const lotteryIndex = pool.findIndex(token => token.face === 'shrine'); assert.ok(lotteryIndex >= 0); staging.push(`Actual printed Discovery blow lottery selects index ${lotteryIndex} of ${pool.length} original supply Hiereg tokens; original token IDs/order remain unchanged.`); game = originalLottery(() => advanceDiscoveryClassicNexusEffects(game, state => state.discoveries!.tokens.some(token => token.face === 'shrine' && token.status === 'placed'), actions), Math.floor((lotteryIndex + 0.5) * 0x100000000 / pool.length)); game = advanceDiscoveryClassicNexusEffectsToPhase(game, 5, actions); const shrine = game.discoveries!.tokens.find(token => token.face === 'shrine')!; assert.equal(shrine.status, 'placed'); assert.equal(shrine.revealedTurn, null); const source = `${shrine.territory}:${shrine.sector}`, revealer = kind === 'guild-shipment' ? opponent : actor; assert.notEqual(shrine.sector, game.storm); placeOrdinary(game, revealer, source, 2, staging); if (game.strongholdCards) placeOrdinary(game, actor, 'arrakeen:10', 1, staging); game = advanceDiscoveryClassicNexusEffectsToPhase(game, 7, actions); if (viewGame(game, revealer).discoveries!.canInspect.includes(shrine.id)) { const action: Action = { type: 'discovery', token: shrine.id, reveal: false }; actions.push({ actor: revealer, action }); game = applyAction(game, revealer, action); } const beforeReveal = structuredClone(game), reveal: Action = { type: 'discovery', token: shrine.id, reveal: true }; assert.ok(viewGame(game, revealer).discoveries!.canReveal.includes(shrine.id)); actions.push({ actor: revealer, action: reveal }); game = applyAction(game, revealer, reveal); const afterReveal = structuredClone(game); game = advanceDiscoveryClassicNexusEffects(game, game => game.decision?.kind === 'discoveryEntry', actions); assert.ok(game.decision?.kind === 'discoveryEntry'); assert.equal(game.turn, 2); assert.equal(game.decision!.player, revealer); const beforeEntry = structuredClone(game), entry: Action = { type: 'decision', event: game.decision!.event, accept: true, groups: viewGame(game, revealer).discoveryEntry!.sources }; actions.push({ actor: revealer, action: entry }); game = applyAction(game, revealer, entry); const afterEntry = structuredClone(game); assert.equal(discoveryClassicNexusEffectsPlayer(game, revealer).forces['shrine:0'], 2); game = advanceDiscoveryClassicNexusEffectsToPhase(game, 1, actions); orderSpice(game, ['worm', 'land', 'land'], staging); let allied = false; for (let n = 0; game.nexusCards?.phase?.stage !== 'drawing' && n < 240; n++) { if (game.nexus && !game.spiceWindow && !game.spiceResolution && clean(game) && !allied) { const others = game.players.filter(player => player.id !== actor); for (const [from, to] of [[others[0].id, others[1].id], [others[1].id, others[0].id]]) { const action: Action = { type: 'alliance', target: to }; actions.push({ actor: from, action }); game = applyAction(game, from, action); } allied = true; } game = advanceDiscoveryClassicNexusEffectsStep(game, actions); } assert.ok(allied); assert.equal(game.nexusCards?.phase?.stage, 'drawing'); const selected = kind === 'guild-shipment' ? 'guild' : kind === 'fremen-revival' ? 'fremen' : 'emperor'; const cards = game.nexusCards!.cards!, index = cards.deck.indexOf(selected); assert.ok(index >= 0); cards.deck.unshift(cards.deck.splice(index, 1)[0]); staging.push(`Ordered the original undealt ${selected} Nexus singleton before its actual end-Spice-Blow draw.`); const beforeClosingDraw = structuredClone(game), closingDraw: DiscoveryClassicNexusEffectStep = { actor, action: { type: 'nexusCardChoice', turn: game.turn, card: cards.hands[actor], choice: 'draw', ownRedraws: 0 } }; actions.push(closingDraw); game = applyAction(game, actor, closingDraw.action); const afterClosingDraw = structuredClone(game); assert.equal(game.nexusCards!.cards!.hands[actor], selected); game = advanceDiscoveryClassicNexusEffects(game, game => game.nexusCards?.phase?.stage !== 'drawing', actions); game = advanceDiscoveryClassicNexusEffectsToPhase(game, kind === 'guild-shipment' ? 5 : 4, actions); let paymentAction: Action, revivalAction: Action | undefined; if (kind === 'guild-shipment') { game = advanceDiscoveryClassicNexusEffects(game, game => game.active === actor && clean(game), actions); paymentAction = { type: 'ship', territory: 'shrine', sector: 0, amount: 3, elite: 0, nexus: viewGame(game, actor).nexusGuildSecretAlly!.event }; } else { const player = discoveryClassicNexusEffectsPlayer(game, actor), tanks = options.tanks ?? 6, elite = options.eliteTanks ?? 0; assert.ok(Number.isInteger(tanks) && tanks >= 0 && Number.isInteger(elite) && elite >= 0 && elite <= tanks); assert.ok(tanks - elite <= player.reserves - (player.elites?.reserves ?? 0) && elite <= (player.elites?.reserves ?? 0)); player.reserves -= tanks; player.tanks += tanks; if (player.elites) { player.elites.reserves -= elite; player.elites.tanks += elite; } staging.push(`Conserved ${tanks} original ${actor} reserve counters to Tanks, including ${elite} elites; no return or quota receipt assigned.`); const view = viewGame(game, actor); if (kind === 'emperor-revival') { paymentAction = { type: 'nexusEmperorRevive', event: view.nexusEmperorSecretAlly!.event, elite: elite ? 1 : 0 }; } else { paymentAction = { type: 'nexusFremenRevival', event: view.nexusFremenRevival!.event }; revivalAction = { type: 'revive', amount: 3, elite: elite ? 1 : 0 }; } } discoveryClassicNexusEffectsInventory(game); const industry: TechId = kind === 'guild-shipment' ? 'heighliners' : 'axlotl', tokenOwner = game.techTokens?.[industry].owner ?? null; return { initial, afterSetup, beforeReveal, afterReveal, beforeEntry, afterEntry, beforeClosingDraw, closingDraw, afterClosingDraw, game, actor, opponent, kind, paymentAction, revivalAction, token: shrine.id, source, industry, tokenOwner, tokenCount: tokenOwner ? ownedTech(game.techTokens, tokenOwner).length : 0, actions, staging }; }); } export function payDiscoveryClassicNexusEffectsFixture(fixture: DiscoveryClassicNexusEffectsFixture, game = fixture.game): Game { const activated = applyAction(game, fixture.actor, fixture.paymentAction); return fixture.revivalAction ? applyAction(activated, fixture.actor, fixture.revivalAction) : activated; } export function finishDiscoveryClassicNexusEffectsInteraction(state: Game, actions?: DiscoveryClassicNexusEffectStep[]): Game { return advanceDiscoveryClassicNexusEffects(state, game => clean(game) && !game.pendingTreacheryDiscard && !game.pendingKarama, actions); } /** Paid nested arrival is retained, then the ordinary battle producer seals real * physical cards/plans. No battle result, ownership or reward is fabricated. */ export function openDiscoveryClassicNexusNestedBattle(fixture: DiscoveryClassicNexusEffectsFixture, state: Game): DiscoveryClassicNexusNestedBattle { assert.equal(fixture.kind, 'guild-shipment'); let game = finishDiscoveryClassicNexusEffectsInteraction(state, fixture.actions); for (const player of game.players) { game.deck.push(...player.hand); player.hand = []; } fixture.staging.push('Conserved all original held Treachery Cards into the undealt deck before selecting the nested battle cards.'); const weapon = holdNexusSkillsModulesPaymentsCard(game, fixture.actor, 'projectile', fixture.staging); const owner = discoveryClassicNexusEffectsPlayer(game, fixture.actor), opponent = discoveryClassicNexusEffectsPlayer(game, fixture.opponent); const leader = owner.leaders.filter(leader => !leader.dead && !leader.usedAt).sort((a, b) => b.strength - a.strength)[0]; const losingLeader = opponent.leaders.filter(leader => !leader.dead && !leader.usedAt).sort((a, b) => a.strength - b.strength)[0]; assert.ok(leader && losingLeader); game = advanceDiscoveryClassicNexusEffectsToPhase(game, 6, fixture.actions); assert.ok(game.active === owner.id || game.active === opponent.id); const beforeBattle = structuredClone(game), choose: Action = { type: 'chooseBattle', territory: 'shrine', target: game.active === owner.id ? opponent.id : owner.id }; fixture.actions.push({ actor: game.active!, action: choose }); game = applyAction(game, game.active!, choose); game = advanceDiscoveryClassicNexusEffects(game, game => !!game.battle && clean(game) && !game.battle.preparation && game.battle.preLeader?.closed !== false, fixture.actions); const plans: DiscoveryClassicNexusEffectStep[] = [ { actor: owner.id, action: { type: 'battlePlan', leader: leader.id, dial: 0, support: 0, weapon: weapon.id } }, { actor: opponent.id, action: { type: 'battlePlan', leader: losingLeader.id, dial: 0, support: 0 } }, ]; return { beforeBattle, game, actor: owner.id, opponent: opponent.id, weapon, losingLeader: losingLeader.id, plans }; } export function finishDiscoveryClassicNexusNestedBattle(battle: DiscoveryClassicNexusNestedBattle): Game { let game = battle.game; for (const step of battle.plans) game = applyAction(game, step.actor, step.action); return advanceDiscoveryClassicNexusEffects(game, game => !game.battle && clean(game) && !game.pendingTreacheryDiscard); }