import assert from 'node:assert/strict'; import { applyAction, createGame, initializeStrongholdFactionsGameForAudit, joinGame, newPlayer, viewGame, type Game } from '../game/engine'; import { botActions } from '../game/bots'; import { territory } from '../game/board'; import type { Card } from '../game/cards'; import type { NoFieldValue } from '../game/richese-no-field'; import type { StrongholdId } from '../game/stronghold-cards'; import { nextStrongholdFactionsNativeStep, type StrongholdFactionsNativeStep } from './fixture-stronghold-factions'; export type RicheseStrongholdsOptions = { initial?: Game; seatIds?: [string, string, string]; kind?: 'arrakeen' | 'habbanya_ridge_sietch'; noFieldValue?: NoFieldValue; markerBattle?: boolean; reserves?: number; stone?: boolean; ownerDial?: number; opponentDial?: number; support?: number; opponentSupport?: number; traitors?: 'owner' | 'both'; ownerDefense?: Card['kind']; opponentWeapon?: Card['kind']; }; export type RicheseStrongholdsFixture = { initial: Game; afterSetup: Game; beforeShipment: Game; shipmentStep: StrongholdFactionsNativeStep; afterShipment: Game; beforeFirstMentat: Game; firstMentatStep: StrongholdFactionsNativeStep; afterFirstMentat: Game; beforeReveal: Game; revealStep: StrongholdFactionsNativeStep; beforeBattle: Game; battleStep: StrongholdFactionsNativeStep; game: Game; richese: string; choam: string; opponent: string; kind: StrongholdId; acquisition: { before: Game; steps: StrongholdFactionsNativeStep[]; after: Game } | null; planActions: StrongholdFactionsNativeStep[]; staging: string[]; }; export const richeseStrongholdsPlayer = (game: Game, actor: string) => { const player = game.players.find(p => p.id === actor); assert.ok(player, `Missing native seat ${actor}`); return player; }; const clean = (game: Game) => !game.phaseOpening && !game.response && !game.decision; /** Consume only live native windows; ordinary setup choices come from real views. */ export function nextRicheseStrongholdsNativeStep(game: Game): StrongholdFactionsNativeStep | null { if (clean(game) && game.battle?.outcomeWindow && !game.battle.outcomeWindow.closed) { const window = game.battle.outcomeWindow; const actor = [window.attacker, window.defender].find(id => !window.ready.includes(id)); if (actor) return {actor, action: {type: 'battleOutcomeReady', event: window.event}}; } if (!game.phaseOpening && !game.response && game.decision) { const decision = game.decision, actor = decision.player; const event = game.richeseBidding?.event; if (decision.kind === 'richeseBlackMarket') return { actor, action: { type: 'decision', event, decline: true } }; if (decision.kind === 'richeseDeclaration') return { actor, action: { type: 'decision', event, position: 'first' } }; if (decision.kind === 'richeseCache') { const card = game.richeseCache?.find(c => c.id === 'richese-stone-burner') ?? game.richeseCache?.[0]; assert.ok(card); return { actor, action: { type: 'decision', event, card: card.id, method: 'onceAround', direction: 'counterclockwise' } }; } if (decision.kind === 'richeseUnbid') return { actor, action: { type: 'decision', event, keep: game.richeseAuction?.cardId === 'richese-stone-burner' } }; if (decision.kind === 'stoneBurner') return { actor, action: { type: 'decision', event: decision.event, mode: 'ignore' } }; } if (clean(game) && game.richeseAuction && !game.richeseAuction.outcome) { assert.ok(game.richeseAuction.active); return { actor: game.richeseAuction.active, action: { type: 'richeseBid', event: game.richeseAuction.event, amount: game.richeseAuction.method === 'silent' ? 0 : null } }; } return nextStrongholdFactionsNativeStep(game); } function step(game: Game): Game { const next = nextRicheseStrongholdsNativeStep(game); assert.ok(next, 'A native Richese continuation requires an unsealed human plan'); return applyAction(game, next.actor, next.action); } function advance(game: Game, predicate: (game: Game) => boolean, firstStormDial = 4): Game { for (let count = 0; count < 1600; count++) { if (predicate(game)) return game; assert.equal(game.status, 'playing'); const next = nextRicheseStrongholdsNativeStep(game); assert.ok(next); // Use legal original dials that leave the intended controlled battle outside the next Storm band. if (game.turn === 1 && next.action.type === 'stormDial') next.action.amount = firstStormDial; game = applyAction(game, next.actor, next.action); } throw Error('Native Richese continuation did not reach its requested boundary'); } 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'); } } function reserveBoard(game: Game) { for (const player of game.players) { player.reserves += Object.values(player.forces).reduce((sum, n) => sum + n, 0); player.forces = {}; } } function place(game: Game, actor: string, location: string, amount: number) { const player = richeseStrongholdsPlayer(game, actor); assert.ok(player.reserves >= amount && amount >= 0); player.reserves -= amount; if (amount) player.forces[location] = (player.forces[location] ?? 0) + amount; } function heldCard(game: Game, actor: string, kind: Card['kind'], staging: string[]) { const player = richeseStrongholdsPlayer(game, actor); const held = player.hand.find(c => c.kind === kind); if (held) return held.id; const at = game.deck.findIndex(c => c.kind === kind); assert.ok(at >= 0, `Missing canonical native ${kind}`); assert.ok(player.hand.length < 4); const card = game.deck.splice(at, 1)[0]; player.hand.push(card); staging.push(`Conserved ${card.id}: remaining native deck → ${actor}'s hand`); return card.id; } function traitor(game: Game, actor: string, identity: string, staging: string[]) { const player = richeseStrongholdsPlayer(game, actor); if (player.traitors.includes(identity)) return; const returned = player.traitors[0]; assert.ok(returned); const at = game.traitorReserve?.indexOf(identity) ?? -1; const donor = game.players.find(p => p.id !== actor && p.traitors.includes(identity)); assert.ok(at >= 0 || donor); if (at >= 0) game.traitorReserve!.splice(at, 1, returned); else donor!.traitors[donor!.traitors.indexOf(identity)] = returned; player.traitors[0] = identity; staging.push(`Conserved native traitor ${identity} exchanged for ${returned}`); } /** Real Advanced E2 setup, native cache producer and one-force shipment, followed * by actual first END Mentat. Only labeled conserved board/card/bank positions * are staged after setup. No phases, owners, markers or cache faces are assigned. * An original CLI initializer snapshot is continued without RNG or redealing. */ export function createRicheseStrongholdsFixture(options: RicheseStrongholdsOptions = {}): RicheseStrongholdsFixture { const kind = options.kind ?? 'habbanya_ridge_sietch'; const location = `${kind}:${territory(kind).sectors[0]}`; 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, ['choam']); assert.deepEqual(game.players.map(p => p.faction).sort(), ['choam', 'guild', 'richese']); } else { const ids = options.seatIds ?? ['richese', 'choam', 'guild']; game = createGame('RICHESESTRONGHOLDS', newPlayer(ids[0], 'Richese', 'richese'), true, ['choam']); joinGame(game, newPlayer(ids[1], 'CHOAM', 'choam')); joinGame(game, newPlayer(ids[2], 'Guild', 'guild')); } 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); for (let count = 0; game.status === 'setup' && count < 200; count++) { let next: StrongholdFactionsNativeStep | undefined; for (const player of game.players) { const view = viewGame(game, player.id); view.players.find(p => p.id === player.id)!.bot = 'Easy'; const action = botActions(view)[0]; if (action) { next = { actor: player.id, action }; break; } } assert.ok(next, `Native Richese setup stalled at ${game.setupStage}`); game = applyAction(game, next.actor, next.action); } assert.equal(game.status, 'playing'); const afterSetup = structuredClone(game); const richese = game.players.find(p => p.faction === 'richese')!.id; const choam = game.players.find(p => p.faction === 'choam')!.id; const opponent = game.players.find(p => p.faction === 'guild')!.id; // Stone comes from the actual unbid-cache acquisition producer, never a hand injection. const firstStormDial = Math.min(4, Math.floor((territory(kind).sectors[0] - 7) / 2)); assert.ok(firstStormDial >= 0); game = advance(game, g => g.decision?.kind === 'richeseCache', firstStormDial); const acquisitionBefore = structuredClone(game), acquisitionSteps: StrongholdFactionsNativeStep[] = []; for (let count = 0; !richeseStrongholdsPlayer(game, richese).hand.some(c => c.id === 'richese-stone-burner') && count < 100; count++) { const next = nextRicheseStrongholdsNativeStep(game); assert.ok(next); acquisitionSteps.push(next); game = applyAction(game, next.actor, next.action); } assert.ok(richeseStrongholdsPlayer(game, richese).hand.some(c => c.id === 'richese-stone-burner')); const acquisition = { before: acquisitionBefore, steps: acquisitionSteps, after: structuredClone(game) }; game = advance(game, g => g.phase === 5 && clean(g) && g.active === richese); reserveBoard(game); for (const player of game.players) player.spice = 20; staging.push('After real setup and cache acquisition: return original board counters to their own reserves; stage twenty-spice bank balances for original seats'); const beforeShipment = structuredClone(game); const token = richeseStrongholdsPlayer(game, richese).noField!.tokens.find(t => t.value === (options.noFieldValue ?? 5))!; const shipmentStep: StrongholdFactionsNativeStep = { actor: richese, action: { type: 'ship', noField: token.id, event: richeseStrongholdsPlayer(game, richese).noFieldEvent, territory: kind, sector: territory(kind).sectors[0] } }; game = applyAction(game, shipmentStep.actor, shipmentStep.action); game = advance(game, g => !g.response && !g.decision && !g.phaseOpening); const afterShipment = structuredClone(game); game = advance(game, g => g.phase === 8 && clean(g)); let beforeFirstMentat: Game | undefined, firstMentatStep: StrongholdFactionsNativeStep | undefined; for (let count = 0; game.turn === 1 && count < 100; count++) { const next = nextRicheseStrongholdsNativeStep(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!.owners[kind], richese); const afterFirstMentat = structuredClone(game); game = advance(game, g => g.phase === 5 && clean(g)); reserveBoard(game); const markerBattle = options.markerBattle ?? !options.stone; const reserves = options.reserves ?? 20; assert.ok(Number.isSafeInteger(reserves) && reserves >= 0 && reserves <= 20); place(game, richese, 'polar_sink:0', 20 - reserves); place(game, opponent, location, 6); for (const player of game.players) player.spice = 20; staging.push(`Before native second Movement completion: stage six opposing battle counters, ${20 - reserves} Richese counters in Polar Sink and twenty-spice bank balances; preserve real concealed marker and first-Mentat card ownership`); const beforeReveal = structuredClone(game); const revealStep: StrongholdFactionsNativeStep = { actor: richese, action: { type: 'revealNoField', event: richeseStrongholdsPlayer(game, richese).noFieldEvent, token: token.id } }; if (!markerBattle) { game = applyAction(game, richese, revealStep.action); reserveBoard(game); place(game, richese, location, 6); place(game, opponent, location, 6); staging.push('After actual voluntary materialization: conserve six ordinary physical counters per combatant at the printed Stronghold, never a mixed-marker battle'); } const defense = options.ownerDefense ? heldCard(game, richese, options.ownerDefense, staging) : null; const enemyWeapon = options.opponentWeapon ? heldCard(game, opponent, options.opponentWeapon, staging) : null; const ownLeader = richeseStrongholdsPlayer(game, richese).leaders.find(l => l.strength === 5 && !l.dead)!; const otherLeader = richeseStrongholdsPlayer(game, opponent).leaders.find(l => l.strength === 5 && !l.dead)!; assert.ok(ownLeader && otherLeader); if (options.traitors) { traitor(game, richese, otherLeader.id, staging); if (options.traitors === 'both') traitor(game, opponent, ownLeader.id, staging); } game = advance(game, g => g.phase === 6 && clean(g)); const beforeBattle = structuredClone(game), actor = game.active!; assert.ok(actor === richese || actor === opponent); const battleStep: StrongholdFactionsNativeStep = { actor, action: { type: 'chooseBattle', territory: kind, target: actor === richese ? opponent : richese } }; game = applyAction(game, actor, battleStep.action); for (let count = 0; count < 120; count++) { const next = nextRicheseStrongholdsNativeStep(game); if (!next) break; game = applyAction(game, next.actor, next.action); } assert.ok(game.battle && !game.battle.revealed && !game.decision && !game.response); const planActions: StrongholdFactionsNativeStep[] = [ { actor: richese, action: { type: 'battlePlan', dial: options.ownerDial ?? 0, support: options.support ?? 0, leader: ownLeader.id, weapon: options.stone ? 'richese-stone-burner' : null, defense } }, { actor: opponent, action: { type: 'battlePlan', dial: options.opponentDial ?? 0, support: options.opponentSupport ?? 0, leader: otherLeader.id, weapon: enemyWeapon } }, ]; return { initial, afterSetup, beforeShipment, shipmentStep, afterShipment, beforeFirstMentat, firstMentatStep, afterFirstMentat, beforeReveal, revealStep, beforeBattle, battleStep, game, richese, choam, opponent, kind, acquisition, planActions, staging }; } export function revealRicheseStrongholdsBattle(fixture: RicheseStrongholdsFixture): Game { let game = structuredClone(fixture.game); for (const plan of fixture.planActions) { game = applyAction(game, plan.actor, plan.action); while (game.response || game.decision?.kind === 'fullPlanOffer') game = step(game); } assert.ok(game.battle?.revealed); return game; } export function finishRicheseStrongholdsBattle(fixture: RicheseStrongholdsFixture, state: Game, callers: string[] = []): Game { let game = state; for (let count = 0; count < 160; count++) { if (!game.battle && !game.decision && !game.response && !game.pendingTreacheryDiscard) return game; const next = nextRicheseStrongholdsNativeStep(game); assert.ok(next); if (next.action.type === 'traitorCall') next.action.call = callers.includes(next.actor); game = applyAction(game, next.actor, next.action); } throw Error('Native Richese battle suffix did not finish'); }