import assert from 'node:assert/strict'; import { applyAction, createGame, initializePairedNexusGameForAudit, initializeRicheseBetrayalGameForAudit, joinGame, newPlayer, type Action, type Game, type Player, } from '../game/engine'; import type { Card } from '../game/cards'; import { treacheryDeck } from '../game/cards'; import { richeseCards } from '../game/richese-cards'; import { validateNexusCards } from '../game/nexus-cards'; import { nextPairedChoamStep } from './fixture-paired-choam-nexus-modules'; import { withClassicDiscoveryNexusLottery } from './fixture-discovery-classic-nexus'; import { frontNativeHomeworldSpice } from './fixture-homeworld-native-e1e2-modules'; export type NexusEmperorSellerStep = { actor: string; action: Action }; export type NexusEmperorSellerTransition = { before: Game; step: NexusEmperorSellerStep; after: Game }; export type NexusEmperorSellerFixture = { game: Game; event: string; winner: string; seller: string; source: 'cache' | 'blackMarket'; card: Card; price: number; round: string; lot: string; wallets: Record; originalCards: string[]; actions: NexusEmperorSellerStep[]; chronology: { initial: Game; undealt: Game; afterSetup: Game; firstMentat: NexusEmperorSellerTransition; worm: Game; alliance: Game; draw: NexusEmperorSellerTransition; afterClosingDraw: Game; beforeOffer: Game; beforeWinnerBid: Game; beforeFinalBid: Game; }; }; export function nexusEmperorSellerPlayer(game: Game, id: string): Player { const player = game.players.find(candidate => candidate.id === id); assert.ok(player, `Missing original seller-lot seat ${id}`); return player; } const clean = (game: Game) => !game.phaseOpening && !game.response && !game.decision && !game.truthtrance && !game.pendingTreacheryDiscard; export function stepNexusEmperorSeller(game: Game, step: NexusEmperorSellerStep, actions?: NexusEmperorSellerStep[]): Game { actions?.push(structuredClone(step)); return withClassicDiscoveryNexusLottery(() => applyAction(game, step.actor, step.action)); } /** Consume only original owner-labelled choices; never manufacture a private plan. */ export function nextNexusEmperorSellerStep(game: Game): NexusEmperorSellerStep { if (clean(game) && game.nexusCards?.phase?.stage === 'drawing') { const actor = game.nexusCards.phase.eligible.find(id => !game.nexusCards!.phase!.done.includes(id)); assert.ok(actor); return { actor, action: { type: 'nexusCardChoice', turn: game.turn, card: game.nexusCards.cards!.hands[actor], choice: 'keep', ownRedraws: 0 } }; } if (!game.phaseOpening && !game.response && game.decision?.kind === 'nexusEmperorSellerPurchase') return { actor: game.decision.player, action: { type: 'nexusEmperorSellerPurchase', event: game.decision.event, use: false } }; const next = nextPairedChoamStep(game); assert.ok(next, 'The original seller chronology requires a real human battle plan'); return next; } export function advanceNexusEmperorSeller(game: Game, until: (game: Game) => boolean, actions?: NexusEmperorSellerStep[]): Game { for (let n = 0; n < 2000; n++) { if (until(game)) return game; assert.notEqual(game.status, 'finished'); game = stepNexusEmperorSeller(game, nextNexusEmperorSellerStep(game), actions); } throw Error('The original seller chronology did not reach its requested boundary'); } /** Original physical zones, excluding historical ordinary-auction aliases. */ export function nexusEmperorSellerCards(game: Game): string[] { return [...game.deck, ...game.discard, ...game.players.flatMap(player => player.hand), ...(game.richeseCache ?? []), ...(game.richeseRemoved ?? []), ...(game.auction?.cards.slice(game.auction.index + Number(!!game.currentAuctionSale && game.currentAuctionSale.origin === 'normal')) ?? [])].map(card => card.id).sort(); } export function nexusEmperorSellerInventory(game: Game): void { validateNexusCards(game.nexusCards!.cards!, game.players); assert.deepEqual(nexusEmperorSellerCards(game), [...treacheryDeck(['choam']), ...richeseCards()].map(card => card.id).sort()); for (const player of game.players) { assert.equal(player.reserves + player.tanks + Object.values(player.forces).reduce((sum, n) => sum + n, 0), 20); if (player.elites) assert.equal(player.elites.reserves + player.elites.tanks + Object.values(player.elites.forces).reduce((sum, n) => sum + n, 0), 3); } } /** Native Basic/Advanced paired Richese/CHOAM with classic seats. Starting cards, * first turn, later worm/alliance/closing Nexus draw and winning bid are actual * source actions. Only undealt singleton order/scoped scalar entropy is selected; * hands, balances, counters, alliances, clocks and receipts are never assigned. */ export function createNexusEmperorSellerFixture(options: { advanced?: boolean; source?: 'cache' | 'blackMarket'; winnerFaction?: 'harkonnen' | 'richese'; nexusCard?: 'emperor' | 'guild'; truthtrance?: boolean; betrayal?: 'pass' | 'bank' | 'before'; } = {}): NexusEmperorSellerFixture { const source = options.source ?? 'cache', advanced = options.advanced ?? source === 'blackMarket'; const winner = options.winnerFaction ?? 'harkonnen', seller = 'richese', price = 2; assert.ok(source !== 'blackMarket' || advanced, 'Original Black Market is Advanced only'); assert.ok(source !== 'blackMarket' || winner !== seller, 'Original Black Market excludes seller self-purchase'); const actions: NexusEmperorSellerStep[] = []; let game = createGame('NEXUSEMPERORSELLER', newPlayer(seller, 'Richese', 'richese'), advanced, ['choam']); for (const faction of ['choam', 'harkonnen', 'fremen'] as const) joinGame(game, newPlayer(faction, faction, faction)); if (options.betrayal) joinGame(game, newPlayer('guild', 'Guild', 'guild')); game.nexusCards = { cards: null, phase: null }; const initial = structuredClone(game); for (const player of game.players) game = stepNexusEmperorSeller(game, { actor: player.id, action: { type: 'ready' } }, actions); game = withClassicDiscoveryNexusLottery(() => options.betrayal ? initializeRicheseBetrayalGameForAudit(game) : initializePairedNexusGameForAudit(game)); assert.equal(game.status, 'setup'); assert.ok(game.players.every(player => player.hand.length === 0)); const prefix: Card[] = []; // A real setup-dealt Karama retains native source/bonus cancellation windows; // the quiet driver passes them rather than fabricating a response frame. for (const effect of ['worthless', options.truthtrance ? 'truthtrance' : 'worthless', 'worthless', 'worthless', 'karama']) { const index = game.deck.findIndex(card => effect === 'worthless' ? card.kind === effect : card.effect === effect); assert.ok(index >= 0); prefix.push(game.deck.splice(index, 1)[0]); } game.deck.unshift(...prefix); const undealt = structuredClone(game); game = advanceNexusEmperorSeller(game, state => state.status === 'playing', actions); const afterSetup = structuredClone(game); assert.equal(nexusEmperorSellerPlayer(game, 'harkonnen').hand.length, 2); assert.equal(nexusEmperorSellerPlayer(game, seller).hand.length, 1); game = advanceNexusEmperorSeller(game, state => state.phase === 1 && clean(state), actions); frontNativeHomeworldSpice(game, card => 'territory' in card && card.territory === 'hagga_basin', 0); if (advanced) frontNativeHomeworldSpice(game, card => 'territory' in card && card.territory === 'broken_land', 1); let firstMentat: NexusEmperorSellerTransition | undefined; while (game.turn === 1) { const before = structuredClone(game), step = nextNexusEmperorSellerStep(game); game = stepNexusEmperorSeller(game, step, actions); if (game.turn === 2) firstMentat = { before, step, after: structuredClone(game) }; } assert.ok(firstMentat); assert.equal(firstMentat.before.phase, 8); game = advanceNexusEmperorSeller(game, state => state.turn === 2 && state.phase === 1 && clean(state) && !state.spiceSequence && !state.spiceResolution && !state.spiceWindow && !state.nexus && !state.ready.length, actions); frontNativeHomeworldSpice(game, card => 'worm' in card && !card.greatMaker && !card.suppressed && !card.thumper, 0); frontNativeHomeworldSpice(game, card => 'territory' in card && card.territory === 'rock_outcroppings', 1); if (advanced) frontNativeHomeworldSpice(game, card => 'territory' in card && card.territory === 'oh_gap', 2); game = advanceNexusEmperorSeller(game, state => !!state.nexus && !state.spiceWindow && !state.spiceResolution && clean(state), actions); const worm = structuredClone(game); for (const [actor, target] of [['choam', 'fremen'], ['fremen', 'choam']]) game = stepNexusEmperorSeller(game, { actor, action: { type: 'alliance', target } }, actions); const alliance = structuredClone(game); game = advanceNexusEmperorSeller(game, state => state.nexusCards?.phase?.stage === 'drawing' && clean(state), actions); assert.ok(game.nexusCards!.phase!.eligible.includes(winner)); const cards = game.nexusCards!.cards!, index = cards.deck.indexOf(options.nexusCard ?? 'emperor'); assert.ok(index >= 0); cards.deck.unshift(cards.deck.splice(index, 1)[0]); const drawBefore = structuredClone(game), drawStep: NexusEmperorSellerStep = { actor: winner, action: { type: 'nexusCardChoice', turn: game.turn, card: cards.hands[winner], choice: 'draw', ownRedraws: 0 } }; game = stepNexusEmperorSeller(game, drawStep, actions); const draw = { before: drawBefore, step: drawStep, after: structuredClone(game) }; if (options.betrayal) { const remaining = game.nexusCards!.cards!, richese = remaining.deck.indexOf('richese'); assert.ok(richese >= 0); remaining.deck.unshift(remaining.deck.splice(richese, 1)[0]); game = stepNexusEmperorSeller(game, { actor: 'guild', action: { type: 'nexusCardChoice', turn: game.turn, card: remaining.hands.guild, choice: 'draw', ownRedraws: 0 } }, actions); } game = advanceNexusEmperorSeller(game, state => state.phase === 2 && clean(state), actions); const afterClosingDraw = structuredClone(game); assert.equal(nexusEmperorSellerPlayer(game, winner).ally, null); assert.ok(!game.players.some(player => player.faction === 'emperor')); game = advanceNexusEmperorSeller(game, state => !state.phaseOpening && !state.response && state.decision?.kind === (source === 'cache' ? 'richeseCache' : 'richeseBlackMarket'), actions); const beforeOffer = structuredClone(game); const card = structuredClone(source === 'cache' ? game.richeseCache![0] : nexusEmperorSellerPlayer(game, seller).hand[0]); const round = game.richeseBidding!.event; game = stepNexusEmperorSeller(game, { actor: seller, action: { type: 'decision', event: round, card: card.id, method: 'silent' } }, actions); game = advanceNexusEmperorSeller(game, state => !state.phaseOpening && !state.response && !state.decision, actions); const lot = game.richeseAuction!.event; let beforeFinalBid = structuredClone(game); let beforeWinnerBid: Game | undefined; while (!game.richeseAuction!.outcome) { const auction = game.richeseAuction!; const actor = auction.order.find(id => auction.eligible.includes(id) && !Object.hasOwn(auction.sealed, id)); assert.ok(actor); beforeFinalBid = structuredClone(game); if (actor === winner) beforeWinnerBid = structuredClone(game); game = stepNexusEmperorSeller(game, { actor, action: { type: 'richeseBid', event: lot, amount: actor === winner ? price : 0 } }, actions); } assert.ok(beforeWinnerBid); if (options.betrayal && options.betrayal !== 'before') { while (game.pendingRicheseBetrayal) { const receipt = game.pendingRicheseBetrayal.receipt; const actor = receipt.required.find(id => !receipt.passed.includes(id)); assert.ok(actor); game = stepNexusEmperorSeller(game, { actor, action: { type: actor === 'guild' && options.betrayal === 'bank' ? 'richeseBetrayalUse' : 'richeseBetrayalPass', event: receipt.event, } }, actions); } } if (options.betrayal === 'before') assert.ok(game.pendingRicheseBetrayal); else { assert.equal(game.decision?.kind, 'nexusEmperorSellerPurchase'); assert.equal(game.decision.player, winner); } assert.equal(game.richeseAuction!.cardId, card.id); assert.deepEqual(game.richeseAuction!.outcome, { kind: 'sold', winner, amount: price }); assert.ok(nexusEmperorSellerPlayer(game, winner).spice >= price); assert.equal(nexusEmperorSellerPlayer(game, winner).hand.some(held => held.id === card.id), false); const wallets = Object.fromEntries(game.players.map(player => [player.id, player.spice])); const originalCards = nexusEmperorSellerCards(game); nexusEmperorSellerInventory(game); return { game, event: game.decision?.kind === 'nexusEmperorSellerPurchase' ? game.decision.event : game.pendingRicheseBetrayal!.receipt.event, winner, seller, source, card, price, round, lot, wallets, originalCards, actions, chronology: { initial, undealt, afterSetup, firstMentat, worm, alliance, draw, afterClosingDraw, beforeOffer, beforeWinnerBid, beforeFinalBid } }; }