import assert from 'node:assert/strict'; import { createGame, initializeSemutaGameForAudit, joinGame, newPlayer, type Action, type Game, } from '../game/engine'; import { SEMUTA_DRUG_ID } from '../game/semuta-drug'; import { actTruthMovement } from './fixture-truth-movement'; import { nextTruthMovementNativeEarlyStep } from './fixture-truth-movement-native-early'; import { withClassicDiscoveryNexusLottery } from './fixture-discovery-classic-nexus'; export type SemutaIxianAuctionStep = { actor: string; action: Action }; export type SemutaIxianAuctionKind = 'ixAuction' | 'ixTechnology'; export const actSemutaIxianAuction = actTruthMovement; export const semutaIxianAuctionPlayer = (game: Game, id: string) => { const player = game.players.find(player => player.id === id); assert.ok(player); return player; }; export const semutaIxianAuctionClean = (game: Game) => !game.response && !game.phaseOpening && !game.decision && !game.truthtrance && !game.pendingTreacheryDiscard; /** Original physical zones only; inspected/known pool copies are receipts. */ export function semutaIxianAuctionInventory(game: Game) { const ids = [...game.deck, ...game.discard, ...(game.richeseCache ?? []), ...(game.richeseRemoved ?? []), ...(game.ixSetupCards ?? []), ...(game.ixAuction?.cards ?? []), ...game.players.flatMap(p => p.hand), ...(game.auction?.cards.slice(game.auction.index + Number(game.currentAuctionSale?.origin === 'normal')) ?? [])] .map(card => card.id).sort(); assert.equal(new Set(ids).size, ids.length, 'Each original main/cache card has one physical zone'); return ids; } /** The original source owns no automatic Semuta decision. Native pool return, * declined optional Technology, initial setup and cache bids remain real acts. */ export function nextSemutaIxianAuctionStep(game: Game, buyer = 'choam'): SemutaIxianAuctionStep | null { if (game.pendingTreacheryDiscard || game.truthtrance) return null; if (!game.response && !game.phaseOpening) { const d = game.decision; if (d?.kind === 'ixSetup') { const card = game.ixSetupCards?.find(card => card.kind === 'shield'); assert.ok(card); return { actor: d.player, action: { type: 'decision', card: card.id } }; } if (d?.kind === 'ixAuction') { const card = game.ixAuction?.cards[0]; assert.ok(card); return { actor: d.player, action: { type: 'decision', card: card.id, position: 'bottom' } }; } if (d?.kind === 'ixTechnology') return { actor: d.player, action: { type: 'decision', decline: true } }; if (d?.kind === 'choamMarket' || d?.kind === 'choamMentat') return { actor: d.player, action: { type: 'decision', done: true } }; if (d?.kind === 'auctionPayment') return { actor: d.player, action: { type: 'decision', karama: false } }; if (!d && game.phase === 5) return { actor: game.active!, action: { type: 'endMovement' } }; } const lot = game.richeseAuction; if (semutaIxianAuctionClean(game) && lot?.cardId === SEMUTA_DRUG_ID && !lot.outcome && lot.active === buyer && lot.bid === 0) return { actor: buyer, action: { type: 'richeseBid', event: lot.event, amount: 1, allyPayment: 0 } }; return nextTruthMovementNativeEarlyStep(game, { position: 'first', method: 'onceAround', direction: 'clockwise', keepUnbid: false, card: game.richeseCache?.find(card => card.id === SEMUTA_DRUG_ID)?.id ?? game.richeseCache?.[0]?.id, }); } export function advanceSemutaIxianAuction(game: Game, until: (state: Game) => boolean, actions: SemutaIxianAuctionStep[] = [], buyer = 'choam'): Game { for (let count = 0; count < 1600; count++) { if (until(game)) return game; assert.notEqual(game.status, 'finished'); const next = nextSemutaIxianAuctionStep(game, buyer); assert.ok(next, 'Choose the actual original fresh-discard reaction before continuing'); game = actSemutaIxianAuction(game, next.actor, next.action, actions); } throw Error('The native Ixian auction programme did not reach its finite boundary'); } /** Original response acknowledgements only, one named opportunity at a time. */ export function allowSemutaIxianAuction(game: Game, kind: string, actions: SemutaIxianAuctionStep[] = []): Game { for (let count = 0; game.response?.kind === kind && count < game.players.length; count++) { const actor = game.players.find(p => !game.response!.passed.includes(p.id)); assert.ok(actor); game = actSemutaIxianAuction(game, actor.id, { type: 'passResponse' }, actions); } assert.notEqual(game.response?.kind, kind); return game; } export function passSemutaIxianAuction(game: Game, actions: SemutaIxianAuctionStep[] = []): Game { const event = game.pendingTreacheryDiscard?.batch.event; assert.ok(event); for (let count = 0; game.pendingTreacheryDiscard?.batch.event === event && count < game.players.length; count++) { const pending: NonNullable = game.pendingTreacheryDiscard!; const reaction = pending.reaction; assert.ok(reaction?.stage === 'offer'); const actor = game.players.find(p => !reaction.passed.includes(p.id)); assert.ok(actor); game = actSemutaIxianAuction(game, actor.id, { type: 'semutaPass', event }, actions); } assert.notEqual(game.pendingTreacheryDiscard?.batch.event, event); return game; } export function claimSemutaIxianAuction(game: Game, holder: string, actions: SemutaIxianAuctionStep[] = []): Game { const pending = game.pendingTreacheryDiscard; assert.ok(pending && pending.batch.entries.length === 1); const event = pending.batch.event, card = pending.batch.entries[0].card.id; game = actSemutaIxianAuction(game, holder, { type: 'semutaCommit', event }, actions); if (game.pendingTreacheryDiscard?.reaction?.stage === 'select') game = actSemutaIxianAuction(game, holder, { type: 'semutaSelect', event, card }, actions); return game; } /** After prevention, decline only a fresh optional Technology offer and allow * the original Atreides peek. Stop before any normal bid; never replay Ix use. */ export function finishSemutaIxianAuction(game: Game, actions: SemutaIxianAuctionStep[] = []): Game { assert.ok(!game.pendingTreacheryDiscard, 'Answer the original cost reaction first'); assert.ok(!['ixAuction', 'ixTechnology'].includes(game.response?.kind ?? ''), 'The original Ixian prevention must finish first'); if (!game.response && game.decision?.kind === 'ixTechnology') game = actSemutaIxianAuction(game, game.decision.player, { type: 'decision', decline: true }, actions); if (game.response?.kind === 'atreidesAuction') game = allowSemutaIxianAuction(game, 'atreidesAuction', actions); assert.ok(semutaIxianAuctionClean(game)); return game; } /** Native public Basic start, or original Advanced Semuta initializer, then * actual Ix setup/Storm/HMS and paid first cache. No played custody, force, * wallet, phase, queue, response, pool or Technology receipt is installed. */ export function createSemutaIxianAuctionFixture(options: { advanced?: boolean; kind?: SemutaIxianAuctionKind; holder?: 'choam' | 'ixians'; } = {}) { const kind = options.kind ?? 'ixAuction', advanced = options.advanced ?? kind === 'ixTechnology'; assert.ok(kind !== 'ixTechnology' || advanced, 'Native Technology is Advanced only'); const owner = 'ixians', holder = options.holder ?? 'choam', spender = 'atreides', seller = 'richese', observer = 'emperor'; let game = createGame('SEMUTAIXIANAUCTION', newPlayer(seller, 'Richese', 'richese'), advanced, ['choam', 'ix']); for (const faction of ['choam', 'ixians', 'atreides', 'emperor'] as const) joinGame(game, newPlayer(faction, faction, faction)); const actions: SemutaIxianAuctionStep[] = []; const act = (actor: string, action: Action) => { game = actSemutaIxianAuction(game, actor, action, actions); }; const reach = (until: (state: Game) => boolean) => { game = advanceSemutaIxianAuction(game, until, actions, holder); }; for (const p of game.players) act(p.id, { type: 'ready' }); const lobby = structuredClone(game); if (advanced) game = withClassicDiscoveryNexusLottery(() => initializeSemutaGameForAudit(game)); else act(game.host, { type: 'start' }); const setup = structuredClone(game), initialization = { before: lobby, after: setup, entry: advanced ? 'explicit-original-semuta-initializer' : 'public-basic-start' }; assert.equal(game.status, 'setup'); assert.equal(game.advanced, advanced); assert.equal(game.semutaPreview, true); assert.ok(game.players.every(p => !p.hand.length)); const originalInventory = semutaIxianAuctionInventory(game); assert.equal(originalInventory.length, 57); const karamas = game.deck.filter(card => card.effect === 'karama' && !card.id.startsWith('ix-')); assert.equal(karamas.length, 2); const ordinary = game.deck.filter(card => card.kind === 'projectile' && !card.effect).slice(0, 2); const shield = game.deck.find(card => card.kind === 'shield'); assert.equal(ordinary.length, 2); assert.ok(shield); // Ix actually selects its inspected Shield. The original max scalar lottery // leaves the four remaining inspected cards in their existing native order. const inspected = [ordinary[0].id, ordinary[1].id, shield.id, karamas[0].id, karamas[1].id]; const dealOrder = inspected.map((card, position) => { const from = game.deck.findIndex(candidate => candidate.id === card); assert.ok(from >= position); [game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]]; return { card, position, from }; }); const beforeStartingDeal = structuredClone(game), cost = karamas[0].id, observerKarama = karamas[1].id; let ixSetup: { before: Game; step: SemutaIxianAuctionStep; after: Game } | null = null; for (let count = 0; game.status === 'setup' && count < 150; count++) { const next = nextSemutaIxianAuctionStep(game, holder); assert.ok(next); const before = game.decision?.kind === 'ixSetup' ? structuredClone(game) : null; act(next.actor, next.action); if (before) ixSetup = { before, step: structuredClone(next), after: structuredClone(game) }; } assert.equal(game.status, 'playing'); assert.ok(ixSetup); const afterSetup = structuredClone(game); assert.deepEqual(game.players.flatMap(p => p.hand.map(card => card.id)), inspected); assert.ok(semutaIxianAuctionPlayer(game, spender).hand.some(card => card.id === cost)); const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort(), front: Game['spiceDeck'] = []; const spiceOrder: { position: number; from: number; card: Game['spiceDeck'][number] }[] = []; for (let position = 0; position < (advanced ? 2 : 1); position++) { const from = game.spiceDeck.findIndex(card => 'territory' in card && !card.discovery); assert.ok(from >= 0); const card = game.spiceDeck.splice(from, 1)[0]; front.push(card); spiceOrder.push({ position, from, card: structuredClone(card) }); } game.spiceDeck.unshift(...front); assert.deepEqual(game.spiceDeck.map(card => JSON.stringify(card)).sort(), originalSpice); let beforeBid: Game | undefined, bid: SemutaIxianAuctionStep | undefined, bidAt = -1; for (let count = 0; count < 900 && !semutaIxianAuctionPlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID); count++) { const next = nextSemutaIxianAuctionStep(game, holder); assert.ok(next); if (next.actor === holder && next.action.type === 'richeseBid' && next.action.amount === 1) { beforeBid = structuredClone(game); bid = structuredClone(next); bidAt = actions.length; } act(next.actor, next.action); } assert.ok(beforeBid && bid && bidAt >= 0); const acquired = structuredClone(game), acquisition = { before: beforeBid, bid, steps: structuredClone(actions.slice(bidAt)), after: acquired, price: 1 }; assert.ok(semutaIxianAuctionPlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID)); assert.equal(semutaIxianAuctionPlayer(beforeBid, holder).spice - semutaIxianAuctionPlayer(game, holder).spice, 1); assert.equal(semutaIxianAuctionPlayer(game, seller).spice - semutaIxianAuctionPlayer(beforeBid, seller).spice, 1); reach(state => state.response?.kind === 'ixAuction' && !state.phaseOpening && !state.pendingTreacheryDiscard); const beforePool = structuredClone(game), poolCount = game.ixAuction!.count; assert.equal(game.turn, 1); assert.deepEqual(game.ixAuction!.cards, []); assert.ok(poolCount > 0); assert.ok(!game.auction); assert.ok(game.mobileStronghold?.location); let poolSelection: { before: Game; steps: SemutaIxianAuctionStep[]; after: Game; card: string } | null = null; let technology: { before: Game; step: SemutaIxianAuctionStep; after: Game } | null = null; if (kind === 'ixTechnology') { const before = structuredClone(game), start = actions.length; game = allowSemutaIxianAuction(game, 'ixAuction', actions); assert.equal(game.decision?.kind, 'ixAuction'); const returned = game.ixAuction!.cards[0]; assert.ok(returned); act(owner, { type: 'decision', card: returned.id, position: 'bottom' }); assert.equal(game.decision?.kind, 'ixTechnology'); poolSelection = { before, steps: structuredClone(actions.slice(start)), after: structuredClone(game), card: returned.id }; const selected = semutaIxianAuctionPlayer(game, owner).hand.find(card => card.id === (holder === owner ? SEMUTA_DRUG_ID : shield.id)); assert.ok(selected); const declaration: SemutaIxianAuctionStep = { actor: owner, action: { type: 'decision', card: selected.id } }; const beforeTechnology = structuredClone(game); act(declaration.actor, declaration.action); technology = { before: beforeTechnology, step: declaration, after: structuredClone(game) }; assert.equal(game.response?.kind, 'ixTechnology'); assert.equal(game.ixTechnologyTurn, game.turn); assert.equal(game.pendingIxTechnology?.card, selected.id); } assert.equal(game.response?.kind, kind); assert.equal(game.response?.owner, owner); assert.ok(semutaIxianAuctionPlayer(game, spender).hand.some(card => card.id === cost)); assert.ok(semutaIxianAuctionPlayer(game, observer).hand.some(card => card.id === observerKarama)); assert.deepEqual(semutaIxianAuctionInventory(game), originalInventory); const source = structuredClone(game), action: Action = { type: 'card', card: cost, mode: 'cancel' }; return { game, source, beforeCost: source, kind, advanced, holder, spender, owner, ixians: owner, atreides: spender, observer, observerKarama, seller, cost, karama: cost, card: cost, semuta: SEMUTA_DRUG_ID, action, lobby, setup, initialization, beforeStartingDeal, afterSetup, inspected, dealOrder, ixSetup, spiceOrder, acquired, acquisition, beforePool, poolCount, poolSelection, technology, declaredCard: game.pendingIxTechnology?.card ?? null, selectedSemuta: kind === 'ixTechnology' && holder === owner, lotCard: game.auction?.cards[game.auction.index]?.id ?? null, originalInventory, originalCards: originalInventory, actions, steps: actions, sourceActionCount: actions.length, staging: [ 'Actual Richese/CHOAM/Ixians/Atreides/Emperor setup uses the public bare Basic start or existing explicit Advanced Semuta initializer. Original ix+choam main47/cache10 and native setup forces/HMS remain intact; no Basic game is converted after start.', `Only original unopened setup-pool positions, land Spice positions and scalar lotteries are selected. Ix actually chooses the inspected Shield; its real remainder shuffle deals both original printed Karama cards to Atreides/Emperor. ${holder} really pays cache1 for Semuta before the native first ordinary Ixian pool opens.`, kind === 'ixAuction' ? 'Stop at the native undrawn pool response: frozen count, zero drawn cards, no auction and no private pool choice. The cancellation cost and subsequent ordinary draw remain undeclared.' : `Actually allow the original Ixian pool response, return one actual inspected card to the deck bottom, then declare the ${holder === owner ? 'genuinely acquired Semuta itself' : 'selected original Shield'} exchange through the native Technology chooser. Stop after its once-use is spent but before cancellation, exchange or Atreides inspection.`, 'No played hand, wallet, army, phase, queue, receipt, pool result or once-use is assigned. Claim/pass must resolve the actual used printed cost before the original native prevention continues.', ] }; } export type SemutaIxianAuctionFixture = ReturnType;