import assert from 'node:assert/strict'; import { applyAction, createGame, initializeStrongholdFactionsGameForAudit, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine'; import { botActions } from '../game/bots'; import { MOBILE_LOCATION, MOBILE_STRONGHOLD, territory } from '../game/board'; import { isAuditorLeader, type Card } from '../game/cards'; import type { FactionId } from '../game/catalog'; import { STRONGHOLD_CARDS, strongholdControllers, type StrongholdId } from '../game/stronghold-cards'; import { quoteBattleResolution, type BattleResolutionQuote, type ResolutionCombatant } from '../game/battle-resolution-quote'; export type StrongholdFactionsNativeStep = { actor: string; action: Action }; export type StrongholdFactionsFixtureOptions = { initial?: Game; seatIds?: string[]; kind?: StrongholdId; copy?: StrongholdId; copyChoices?: 0 | 1 | 2; ownerFaction?: FactionId; attackerIsOwner?: boolean; ownerDial?: number; opponentDial?: number; support?: number; opponentSupport?: number; allyPayment?: number; choamFunding?: number; ownerSpice?: number; ownerLeader?: 'auditor'; ownerWeapon?: Card['kind']; ownerDefense?: Card['kind']; opponentWeapon?: Card['kind']; opponentDefense?: Card['kind']; traitors?: 'owner' | 'both'; counter?: boolean; mobileRoute?: string[]; }; export type StrongholdFactionsSourceFacts = { event: string; turn: number; territory: StrongholdId; attacker: string; defender: string; owner: string; copyChoices: StrongholdId[]; cardOwners: Record; currentControllers: Record; physicalForces: { player: string; forces: Record; eliteForces: Record }[]; }; export type StrongholdFactionsFixture = { initial: Game; afterSetup: Game; beforeFirstMentat: Game; firstMentatStep: StrongholdFactionsNativeStep; afterFirstMentat: Game; beforeBattle: Game; battleAction: Action; actor: string; owner: string; opponent: string; choam: string; game: Game; event: string; kind: StrongholdId; copyAction: Action | null; fundingAction: StrongholdFactionsNativeStep | null; planActions: StrongholdFactionsNativeStep[]; staging: string[]; sourceFacts: StrongholdFactionsSourceFacts; }; const seat = (game: Game, id: string) => { const player = game.players.find(p => p.id === id); assert.ok(player, `Missing original seat ${id}`); return player; }; const key = (id: StrongholdId) => id === MOBILE_STRONGHOLD ? MOBILE_LOCATION : `${id}:${territory(id).sectors[0]}`; /** Only consumes actual native decisions. A null result with a live battle means * a human must seal a plan; the helper never reads or invents that private plan. */ export function nextStrongholdFactionsNativeStep(game: Game): StrongholdFactionsNativeStep | null { if (game.status === 'finished') return null; if (game.pendingTreacheryDiscard) { const pending = game.pendingTreacheryDiscard, reaction = pending.reaction; if (reaction?.stage === 'offer') { const actor = game.players.find(p => !reaction.passed.includes(p.id)); assert.ok(actor); return { actor: actor.id, action: { type: 'semutaPass', event: pending.batch.event } }; } if (reaction?.stage === 'select') { const action = botActions(viewGame(game, reaction.player))[0]; assert.ok(action); return { actor: reaction.player, action }; } return { actor: game.host, action: { type: 'advanceBots' } }; } if (game.phaseOpening) { const actor = game.players.find(p => !game.phaseOpening!.passed.includes(p.id))?.id; assert.ok(actor); return { actor, action: { type: 'ready' } }; } if (game.response) { // The persisted pass queue defines legality; a policy recommendation is not a legal-action menu. const actor = game.players.find(player => !game.response!.passed.includes(player.id))?.id; assert.ok(actor, `No remaining original response at ${game.response.kind}`); return { actor, action: { type: 'passResponse' } }; } const decision = game.decision; if (decision) { const actor = decision.player; if (decision.kind === 'nexusFremenProtection') return { actor, action: { type: 'nexusFremenProtection', event: decision.event, use: false } }; if (decision.kind === 'nexusEmperorPayment') return { actor, action: { type: 'nexusEmperorPayment', event: decision.event, use: false } }; if (decision.kind === 'nexusEmperorSellerPurchase') return { actor, action: { type: 'nexusEmperorSellerPurchase', event: decision.event, use: false } }; if (decision.kind === 'ixSetup') return { actor, action: { type: 'decision', card: game.ixSetupCards![0].id } }; if (decision.kind === 'mobileStronghold') return { actor, action: decision.placement ? { type: 'decision', location: 'polar_sink:0' } : { type: 'decision', decline: true } }; if (decision.kind === 'ixTechnology' || decision.kind === 'ixSubstitution' || decision.kind === 'captureOffer' || decision.kind === 'fullPlanOffer' || decision.kind === 'choamTradeReply') return { actor, action: { type: 'decision', decline: true, ...(decision.kind === 'captureOffer' ? { accept: false } : {}) } }; if (decision.kind === 'choamMarket') return { actor, action: { type: 'decision', done: true } }; if (decision.kind === 'choamFreeRevival') return { actor, action: { type: 'decision', decline: true } }; if (decision.kind === 'choamBattleFunding') return { actor, action: { type: 'decision', amount: 0 } }; if (decision.kind === 'strongholdCopy') return { actor, action: { type: 'decision', event: decision.event, stronghold: decision.choices[0] } }; if (decision.kind === 'auctionPayment') return { actor, action: { type: 'decision', karama: false } }; if (decision.kind === 'guildShipment') return { actor, action: { type: 'decision', allow: true } }; if (decision.kind === 'poisonTooth') return { actor, action: { type: 'decision', activate: true } }; if (decision.kind === 'battleLosses') return { actor, action: { type: 'decision', choice: 0 } }; if (decision.kind === 'battleCards') return { actor, action: { type: 'decision', discard: [] } }; if (decision.kind === 'choamAudit') return { actor, action: { type: 'decision', event: decision.event, audit: false } }; if (decision.kind === 'choamAuditPayment') return { actor, action: { type: 'decision', event: decision.event, pay: false } }; } if (game.battle?.preLeader && !game.battle.preLeader.closed) { const actor = [game.battle.attacker, game.battle.defender].find(id => !game.battle!.preLeader!.ready.includes(id)); if (actor) return { actor, action: { type: 'battlePreparationReady', event: game.battle.event } }; } if (game.battle?.preparation?.kind === 'nexusVoice') return { actor: game.battle.preparation.owner, action: { type: 'nexusBorrowedVoice', event: game.battle.nexusBorrowedVoice!.event, use: false } }; if (game.battle?.preparation) return { actor: game.battle.preparation.owner, action: { type: 'declineBattlePower' } }; if (game.battle?.revealed) { const actor = viewGame(game, game.host).battle!.traitorVoters.find(id => game.battle!.traitorCalls[id] === undefined); if (actor) return { actor, action: { type: 'traitorCall', call: false } }; } if (game.battle && !game.battle.revealed && !game.decision) return null; if (game.status === 'playing' && !game.decision) { if (game.phase === 0 && game.stormPending === null) { const actor = game.stormDialers.find(id => game.stormDials[id] === undefined); if (actor) return { actor, action: { type: 'stormDial', amount: game.turn === 1 ? 0 : 1 } }; } if (game.phase === 3 && game.auction) return { actor: game.auction.active, action: { type: 'passBid' } }; if (game.phase === 5) return { actor: game.active!, action: { type: 'endMovement' } }; if (game.phase === 2 && game.charityOrder) { const actor = game.charityOrder.remaining[0]; return actor ? {actor,action:{type:'ready'}} : null; } if (game.phase === 4 && game.revivalOrder) { const actor = game.revivalOrder.remaining[0]; return actor ? {actor,action:{type:'ready'}} : null; } if ([1, 2, 4, 7, 8].includes(game.phase) || (game.phase === 0 && game.stormPending !== null)) { const actor = game.players.find(p => !game.ready.includes(p.id))?.id; if (actor) return { actor, action: { type: 'ready' } }; } } for (const player of game.players) { if (decision && decision.player !== player.id) continue; const view = viewGame(game, player.id); view.players.find(p => p.id === player.id)!.bot = 'Easy'; const actions = botActions(view); const action = actions.find(a => a.type === 'ready') ?? actions[0]; if (action) return { actor: player.id, action }; } throw Error(`No native Stronghold continuation at ${game.status}/${game.turn}/${game.phase}/${decision?.kind}`); } function step(game: Game): Game { const next = nextStrongholdFactionsNativeStep(game); assert.ok(next, 'Native continuation stopped for an unsealed human plan'); return applyAction(game, next.actor, next.action); } function advance(game: Game, predicate: (state: Game) => boolean): Game { for (let count = 0; count < 1800; count++) { if (predicate(game)) return game; assert.equal(game.status, 'playing'); game = step(game); } throw Error('Native Stronghold phase did not reach its requested boundary'); } const clean = (game: Game) => !game.phaseOpening && !game.response && !game.decision; /** Determinism is scoped only to the original native initializer. Saved decks, * offers and native remainder shuffles are never reordered or redealt. */ function initialize(game: Game): Game { const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues'); crypto.getRandomValues = (array: T): T => { assert.ok(array); new Uint8Array(array.buffer, array.byteOffset, array.byteLength).fill(255); return array; }; try { return initializeStrongholdFactionsGameForAudit(game); } finally { if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor); else Reflect.deleteProperty(crypto, 'getRandomValues'); } } /** Canonical conserved staging, never hand replacement or face manufacture. */ function heldCard(game: Game, actor: string, matches: (card: Card) => boolean, staging: string[], excluded: string[] = []): Card { const player = seat(game, actor); const held = player.hand.find(card => matches(card) && !excluded.includes(card.id)); if (held) return held; const index = game.deck.findIndex(card => matches(card) && !excluded.includes(card.id)); assert.ok(index >= 0, 'The required canonical card is neither held nor in the remaining native deck'); const card = game.deck.splice(index, 1)[0]; assert.ok(player.hand.length < (player.faction === 'harkonnen' ? 8 : player.faction === 'choam' ? 5 : 4)); player.hand.push(card); staging.push(`Conserved ${card.id}: remaining native deck → ${actor}'s hand`); return card; } /** Return board counters to their own reserves without changing subtype totals. */ function reserveBoard(game: Game): void { for (const player of game.players) { player.reserves += Object.values(player.forces).reduce((sum, count) => sum + count, 0); player.forces = {}; if (player.elites) { player.elites.reserves += Object.values(player.elites.forces).reduce((sum, count) => sum + count, 0); player.elites.forces = {}; } if (player.advisors) player.advisors = {}; } } function place(game: Game, actor: string, location: string, count: number, elite = 0): void { const player = seat(game, actor); assert.ok(player.reserves >= count && count >= elite && (player.elites?.reserves ?? 0) >= elite); player.reserves -= count; player.forces[location] = (player.forces[location] ?? 0) + count; if (elite) { player.elites!.reserves -= elite; player.elites!.forces[location] = (player.elites!.forces[location] ?? 0) + elite; } } function conserveTraitor(game: Game, actor: string, identity: string, staging: string[]): void { const player = seat(game, actor); if (player.traitors.includes(identity)) return; const returned = player.traitors[0]; assert.ok(returned); const index = game.traitorReserve?.indexOf(identity) ?? -1; const donor = game.players.find(p => p.id !== actor && p.traitors.includes(identity)); assert.ok(index >= 0 || donor, 'Requested canonical traitor retains native reserve or hand custody'); if (index >= 0) game.traitorReserve!.splice(index, 1, returned); else donor!.traitors[donor!.traitors.indexOf(identity)] = returned; player.traitors[0] = identity; staging.push(`Conserved traitor ${identity}: native ${donor?.id ?? 'reserve'} custody exchanged with ${actor}'s ${returned}`); } /** Genuine Advanced native setup, first full turn and actual END Mentat custody. * Only labeled conserved board/card/bank positions are staged. Never assign * owners, phases, source receipts, quotations, pending windows or responses. */ export function createStrongholdFactionsFixture(options: StrongholdFactionsFixtureOptions = {}): StrongholdFactionsFixture { const kind = options.kind ?? MOBILE_STRONGHOLD; const staging: string[] = []; let game: Game; if (options.initial) { game = structuredClone(options.initial); assert.ok(game.status === 'lobby' || game.status === 'setup' && game.turn === 1 && game.phase === 0 && game.players.every(p => p.hand.length === 0), 'Continue only the original fresh undealt CLI setup'); assert.equal(game.advanced, true); assert.deepEqual([...game.expansions].sort(), ['choam', 'ix']); assert.ok(game.players.some(p => p.faction === 'ixians') && game.players.some(p => p.faction === 'choam')); } else { const ids = options.seatIds ?? ['ix', 'choam', 'guild']; assert.ok(ids.length >= 2 && ids.length <= 6 && new Set(ids).size === ids.length); const factions: FactionId[] = ['ixians', 'choam', 'guild', 'emperor', 'fremen', 'harkonnen']; game = createGame('STRONGHOLDFACTIONS', newPlayer(ids[0], 'Ixians', factions[0]), true, ['ix', 'choam']); for (let index = 1; index < ids.length; index++) joinGame(game, newPlayer(ids[index], factions[index], factions[index])); } if (game.status === 'lobby') { for (const player of game.players) if (!player.ready) game = applyAction(game, player.id, { type: 'ready' }); game = initialize(game); } const initial = structuredClone(game); assert.equal(game.strongholdCards!.claimedTurn, 0); assert.ok(Object.values(game.strongholdCards!.owners).every(owner => owner === null)); for (let count = 0; game.status === 'setup' && count < 200; count++) game = step(game); assert.equal(game.status, 'playing'); const afterSetup = structuredClone(game); const ix = game.players.find(p => p.faction === 'ixians')!.id; const choam = game.players.find(p => p.faction === 'choam')!.id; const owner = game.players.find(p => p.faction === (options.ownerFaction ?? 'ixians'))?.id; assert.ok(owner, 'Requested owner must be an original native seat'); const opponent = game.players.find(p => p.id !== owner && p.id !== choam)?.id ?? game.players.find(p => p.id !== owner)!.id; if (options.choamFunding || options.allyPayment) assert.ok(owner !== choam && opponent !== choam, 'Native donor needs a separate CHOAM seat'); // Select real first dials, not the storm field. A later 1..6 native storm // cannot obstruct this test's single printed battle sector. for (let count = 0; !(game.phase === 8 && clean(game)) && count < 1600; count++) { const next = nextStrongholdFactionsNativeStep(game); assert.ok(next); if (next.action.type === 'stormDial' && game.turn === 1) next.action.amount = kind === 'tueks_sietch' || kind === 'habbanya_ridge_sietch' ? 4 : 0; game = applyAction(game, next.actor, next.action); } assert.equal(game.phase, 8); assert.equal(game.turn, 1); reserveBoard(game); staging.push('Before actual first END Mentat: relocate existing physical counters through their own reserves; nobody controls three strongholds'); place(game, ix, MOBILE_LOCATION, 6, 3); const firstCopy = options.copy && options.copy !== MOBILE_STRONGHOLD ? options.copy : 'arrakeen'; if (kind === MOBILE_STRONGHOLD) { if (owner !== ix) { reserveBoard(game); place(game, owner, MOBILE_LOCATION, 6); } place(game, owner, key(firstCopy), 1); } else place(game, owner, key(kind), 1); const occupied = new Set([kind, MOBILE_STRONGHOLD, ...(kind === MOBILE_STRONGHOLD ? [firstCopy] : [])]); const others = game.players.filter(p => p.id !== owner && p.id !== ix); for (const [index, card] of STRONGHOLD_CARDS.filter(card => !occupied.has(card.id)).entries()) { const recipient = others[Math.floor(index / 2)]; if (recipient) place(game, recipient.id, key(card.id), 1); } // Keep every first-turn owner below the solo victory threshold. for (const player of game.players) assert.ok(Object.keys(player.forces).length <= 2); let beforeFirstMentat: Game | undefined; let firstMentatStep: StrongholdFactionsNativeStep | undefined; for (let count = 0; game.turn === 1 && count < 200; count++) { const next = nextStrongholdFactionsNativeStep(game); assert.ok(next); const before = structuredClone(game); game = applyAction(game, next.actor, next.action); if (game.turn === 2) { beforeFirstMentat = before; firstMentatStep = next; } } assert.ok(beforeFirstMentat && firstMentatStep); assert.equal(game.status, 'playing'); assert.equal(game.strongholdCards!.claimedTurn, 1); assert.equal(game.strongholdCards!.owners[kind], owner); const afterFirstMentat = structuredClone(game); if (options.mobileRoute) { game = advance(game, state => state.decision?.kind === 'mobileStronghold'); assert.equal(game.decision?.kind, 'mobileStronghold'); game = applyAction(game, ix, { type: 'decision', route: options.mobileRoute, collect: false }); } if (options.choamFunding || options.allyPayment) { game = advance(game, state => state.phase === 1 && clean(state) && !state.spiceSequence && !state.spiceResolution && !state.spiceWindow && !state.nexus); const thumperActor = game.players.find(player => player.hand.some(card => card.effect === 'thumper'))?.id ?? owner; const thumper = heldCard(game, thumperActor, card => card.effect === 'thumper', staging); game = applyAction(game, thumperActor, { type: 'card', card: thumper.id }); game = advance(game, state => state.nexus && clean(state) && !state.spiceWindow && !state.spiceResolution); game = applyAction(game, owner, { type: 'alliance', target: choam }); game = applyAction(game, choam, { type: 'alliance', target: owner }); assert.equal(seat(game, owner).ally, choam); } game = advance(game, state => state.phase === 5 && clean(state)); reserveBoard(game); staging.push('Before native turn-two movement completion: conserve six battle counters per combatant and current copy-control counters; retain actual first-Mentat card custody'); for (const id of [owner, opponent]) place(game, id, key(kind), 6, seat(game, id).faction === 'ixians' ? 3 : 0); if (kind !== MOBILE_STRONGHOLD && ix !== owner && ix !== opponent) place(game, ix, MOBILE_LOCATION, 6, 3); if (kind === MOBILE_STRONGHOLD) { const choices = options.copyChoices ?? 2; if (choices) place(game, owner, key(firstCopy), 1); if (choices === 2) place(game, owner, key(firstCopy === 'sietch_tabr' ? 'arrakeen' : 'sietch_tabr'), 1); } for (const player of game.players) { player.spice = player.id === owner ? options.ownerSpice ?? 20 : 20; } staging.push('Bank funding staging: original seats receive explicit twenty-spice test balances (ownerSpice may set the affordability boundary); no aid or support receipt is injected'); const used: string[] = []; const card = (actor: string, type: Card['kind'] | undefined) => { if (!type) return null; const selected = heldCard(game, actor, c => c.kind === type, staging, used); used.push(selected.id); return selected.id; }; const ownerWeapon = card(owner, options.ownerWeapon); const ownerDefense = card(owner, options.ownerDefense); const opponentWeapon = card(opponent, options.opponentWeapon); const opponentDefense = card(opponent, options.opponentDefense); if (options.counter) heldCard(game, opponent, c => c.effect === 'karama', staging); const ownerLeader = seat(game, owner).leaders.find(l => options.ownerLeader === 'auditor' ? isAuditorLeader(l) : !isAuditorLeader(l) && l.strength === 5) ?? seat(game, owner).leaders.find(l => !isAuditorLeader(l)); const opponentLeader = seat(game, opponent).leaders.find(l => !isAuditorLeader(l) && l.strength === (options.ownerLeader === 'auditor' ? 2 : ownerLeader!.strength)) ?? seat(game, opponent).leaders.find(l => !isAuditorLeader(l)); assert.ok(ownerLeader && opponentLeader); if (options.ownerLeader === 'auditor') assert.ok(isAuditorLeader(ownerLeader), 'The actual native CHOAM Auditor is required'); if (options.traitors) { conserveTraitor(game, owner, opponentLeader.id, staging); if (options.traitors === 'both') conserveTraitor(game, opponent, ownerLeader.id, staging); } // Finish genuine movement; the battle queue determines its real chooser. game = advance(game, state => state.phase === 6 && clean(state)); const beforeBattle = structuredClone(game); const actor = game.active!; assert.ok([owner, opponent].includes(actor)); if (options.attackerIsOwner !== undefined) assert.equal(actor === owner, options.attackerIsOwner, 'Native storm order decides physical attacker; this option does not rewrite it'); const battleAction: Action = { type: 'chooseBattle', territory: kind, target: actor === owner ? opponent : owner }; game = applyAction(game, actor, battleAction); for (let count = 0; count < 120; count++) { if (game.decision?.kind === 'strongholdCopy') break; const next = nextStrongholdFactionsNativeStep(game); if (!next) break; if (game.decision?.kind === 'choamBattleFunding') next.action.amount = options.choamFunding ?? 0; game = applyAction(game, next.actor, next.action); } assert.ok(game.battle?.event && !game.battle.revealed); const copyAction: Action | null = game.decision?.kind === 'strongholdCopy' ? { type: 'decision', event: game.decision.event, stronghold: options.copy ?? firstCopy } : null; const planActions: StrongholdFactionsNativeStep[] = [ { actor: owner, action: { type: 'battlePlan', dial: options.ownerDial ?? 0, support: options.support ?? 0, allyPayment: options.allyPayment ?? 0, leader: ownerLeader.id, weapon: ownerWeapon, defense: ownerDefense } }, { actor: opponent, action: { type: 'battlePlan', dial: options.opponentDial ?? 0, support: options.opponentSupport ?? 0, leader: opponentLeader.id, weapon: opponentWeapon, defense: opponentDefense } }, ]; const fundingAction: StrongholdFactionsNativeStep | null = options.choamFunding || options.allyPayment ? { actor: choam, action: { type: 'decision', amount: options.choamFunding ?? options.allyPayment ?? 0 } } : null; const currentControllers = strongholdControllers(game.players, !!game.mobileStronghold?.location); const sourceFacts: StrongholdFactionsSourceFacts = { event: game.battle.event, turn: game.turn, territory: kind, attacker: game.battle.attacker, defender: game.battle.defender, owner, copyChoices: STRONGHOLD_CARDS.filter(card => card.id !== MOBILE_STRONGHOLD && currentControllers[card.id] === owner).map(card => card.id), cardOwners: { ...game.strongholdCards!.owners }, currentControllers, physicalForces: game.players.map(player => ({ player: player.id, forces: { ...player.forces }, eliteForces: { ...player.elites?.forces } })), }; return { initial, afterSetup, beforeFirstMentat, firstMentatStep, afterFirstMentat, beforeBattle, battleAction, actor, owner, opponent, choam, game, event: game.battle.event, kind, copyAction, fundingAction, planActions, staging, sourceFacts }; } /** Independent shared quote on the actual revealed source, including native * subtype pools, donor escrow, bounty and bank income as separate legs. */ export function quoteStrongholdFactionsBattle(game: Game): BattleResolutionQuote { const battle = game.battle; assert.ok(battle?.revealed); const side = (actor: string): ResolutionCombatant => { const player = seat(game, actor), view = viewGame(game, actor).battle!; const aid = player.ally ? game.aid[player.ally] : undefined; assert.ok(view.ownForces); return { id: actor, faction: player.faction, ally: player.ally, spice: player.spice, hand: player.hand, plan: battle.plans[actor], leader: player.leaders.find(l => l.id === battle.plans[actor].leader), forces: view.ownForces, stronghold: view.strongholdEffects[actor], poisonTooth: battle.poisonTooth?.[actor], lateDefense: battle.lateDefense?.[actor], stoneMode: battle.stoneBurner?.[actor], ...(aid?.recipient === actor ? { aid: { donor: player.ally!, amount: aid.amount } } : {}) }; }; return quoteBattleResolution({ advanced: game.advanced, turn: game.turn, territory: battle.territory, aggressor: viewGame(game, game.host).battle!.aggressor, attacker: side(battle.attacker), defender: side(battle.defender), voters: viewGame(game, game.host).battle!.traitorVoters.map(actor => ({ id: actor, beneficiary: [battle.attacker, battle.defender].includes(actor) ? actor : seat(game, actor).ally!, called: battle.traitorCalls[actor] ?? false, traitors: seat(game, actor).traitors })), participants: game.players, physicalCards: [...game.deck, ...game.discard, ...game.players.flatMap(p => p.hand)], pendingAuditorPresent: !!game.pendingAuditor, pendingRetentionPresent: false }); } export type StrongholdFactionsBattleQuote = BattleResolutionQuote;