import assert from 'node:assert/strict'; import { createHash } from 'node:crypto'; import { readFileSync, statSync, writeFileSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { parseArgs } from 'node:util'; import { botActions } from '../game/bots'; import type { FactionId } from '../game/catalog'; import { applyAction, createGame, initializeBaseGameForAudit, initializeFactionExpansionsGameForAudit, initializeHomeworldGameForAudit, initializeNexusGameForAudit, initializePairedNexusGameForAudit, initializeEcazTreacheryGameForAudit, initializeMoritaniAssassinateGameForAudit, initializeCombinedNexusGameForAudit, initializeLeaderSkillsGameForAudit, joinGame, newPlayer, viewGame, RuleError, type Action, type Game, } from '../game/engine'; import { sampleInventory, verifySampleCustody } from './sample-custody'; import { basicExpansionLeaderSkillsProfile } from '../game/leader-skill-profile'; import { privateOutputDirectory, sourceSnapshot } from './verification'; const DEFAULT_SEED = 20_260_926; const DEFAULT_MAX_ACTIONS = 3_500; const DIFFICULTIES = ['Easy', 'Medium', 'Hard', 'Brutal'] as const; type Profile = 'base' | 'choam' | 'ecaz' | 'ecaz-treachery' | 'moritani-assassinate' | 'combined' | 'combined-nexus' | 'combined-homeworld-nexus' | 'homeworld' | 'nexus' | 'homeworld-nexus' | 'choam-roster' | 'ecaz-roster' | 'ix-roster' | 'choam-nexus' | 'ecaz-nexus' | 'ix-nexus' | 'moritani-skills' | 'tleilaxu-skills' | 'ix-skills' | 'choam-skills'; type Rules = 'basic' | 'advanced'; type Scenario = { ordinal: number; profile: Profile; rules: Rules; expansions: string[]; roster: FactionId[]; }; const SCENARIOS: readonly Scenario[] = [ { ordinal: 0, profile: 'choam', rules: 'basic', expansions: ['choam'], roster: ['richese', 'choam', 'emperor', 'guild'], }, { ordinal: 1, profile: 'choam', rules: 'advanced', expansions: ['choam'], roster: ['richese', 'choam', 'emperor', 'guild'], }, { ordinal: 2, profile: 'ecaz', rules: 'basic', expansions: ['ecaz'], roster: ['ecaz', 'moritani', 'atreides', 'beneGesserit'], }, { ordinal: 3, profile: 'ecaz', rules: 'advanced', expansions: ['ecaz'], roster: ['ecaz', 'moritani', 'atreides', 'beneGesserit'], }, { ordinal: 4, profile: 'combined', rules: 'basic', expansions: ['ix', 'choam', 'ecaz'], roster: ['ecaz', 'ixians', 'tleilaxu', 'choam', 'richese', 'moritani'], }, { ordinal: 5, profile: 'combined', rules: 'advanced', expansions: ['ix', 'choam', 'ecaz'], roster: ['ecaz', 'ixians', 'tleilaxu', 'choam', 'richese', 'moritani'], }, ]; // Keep the original six expansion ordinals and defaults stable. const BASE_ROSTER: readonly FactionId[] = [ 'atreides', 'harkonnen', 'fremen', 'emperor', 'guild', 'beneGesserit', ]; const BASE_SCENARIOS: readonly Scenario[] = [2, 3, 4, 5, 6].flatMap((players) => (['basic', 'advanced'] as const).map((rules, index) => ({ ordinal: 6 + (players - 2) * 2 + index, profile: 'base' as const, rules, expansions: [], roster: BASE_ROSTER.slice(0, players), })), ); const MODULE_SCENARIOS: readonly Scenario[] = [ ...[2, 3, 4, 5, 6].flatMap((players) => (['basic', 'advanced'] as const).map((rules, index) => ({ ordinal: players === 4 ? 36 + index : 48 + (players - 2) * 2 + index, profile: 'homeworld' as const, rules, expansions: [], roster: BASE_ROSTER.slice(0, players), })), ), ...[2, 3, 4, 5, 6].flatMap((players) => (['basic', 'advanced'] as const).map((rules, index) => ({ ordinal: players === 6 ? 38 + index : 40 + (players - 2) * 2 + index, profile: 'nexus' as const, rules, expansions: [], roster: BASE_ROSTER.slice(0, players), })), ), ...[2, 3, 4, 5, 6].flatMap((players) => (['basic', 'advanced'] as const).map((rules, index) => ({ ordinal: 58 + (players - 2) * 2 + index, profile: 'homeworld-nexus' as const, rules, expansions: [], roster: BASE_ROSTER.slice(0, players), })), ), ]; const EXPANSION_ROSTER_SCENARIOS: readonly Scenario[] = [ { profile: 'choam-roster' as const, expansions: ['choam'], roster: ['choam', 'richese', 'emperor', 'guild', 'harkonnen', 'fremen'] as FactionId[] }, { profile: 'ecaz-roster' as const, expansions: ['ecaz'], roster: ['moritani', 'ecaz', 'atreides', 'beneGesserit', 'harkonnen', 'guild'] as FactionId[] }, { profile: 'ix-roster' as const, expansions: ['ix'], roster: ['ixians', 'tleilaxu', 'emperor', 'guild', 'harkonnen', 'fremen'] as FactionId[] }, ].flatMap(({ profile, expansions, roster }, family) => [2, 3, 4, 5, 6].flatMap(players => (['basic', 'advanced'] as const).map((rules, index) => ({ ordinal: 68 + family * 10 + (players - 2) * 2 + index, profile, rules, expansions, roster: roster.slice(0, players), })), ), ); /** Same genuine paired roster, with the independent three-card variant enabled. */ const ECAZ_TREACHERY_SCENARIOS: readonly Scenario[] = EXPANSION_ROSTER_SCENARIOS .filter(scenario => scenario.profile === 'ecaz-roster') .map(scenario => ({ ...scenario, profile: 'ecaz-treachery', ordinal: scenario.ordinal + 58, })); const PAIRED_NEXUS_SCENARIOS: readonly Scenario[] = EXPANSION_ROSTER_SCENARIOS .map(scenario => ({ ...scenario, profile: scenario.profile.replace('-roster', '-nexus') as Profile, ordinal: scenario.ordinal + 30, })); const COMBINED_NEXUS_SCENARIOS: readonly Scenario[] = [ ...SCENARIOS.filter(scenario => scenario.profile === 'combined') .map(scenario => ({ ...scenario, profile: 'combined-nexus' as const, ordinal: scenario.ordinal + 124 })), ...(['basic', 'advanced'] as const).map((rules, index) => ({ ordinal: 130 + index, profile: 'combined-nexus' as const, rules, expansions: ['ix', 'choam', 'ecaz'], roster: ['ecaz', 'ixians', 'tleilaxu', 'choam', 'moritani'] as FactionId[], })), ]; const COMBINED_HOMEWORLD_NEXUS_SCENARIOS: readonly Scenario[] = COMBINED_NEXUS_SCENARIOS.map(scenario => ({ ...scenario, profile: 'combined-homeworld-nexus', ordinal: scenario.ordinal + 4, })); const MORITANI_SKILLS_ROSTER: readonly FactionId[] = [ 'moritani', 'emperor', 'guild', 'harkonnen', 'fremen', 'beneGesserit', ]; const MORITANI_SKILLS_SCENARIOS: readonly Scenario[] = [2, 3, 4, 5, 6].map(players => ({ ordinal: 16 + players - 2, profile: 'moritani-skills', rules: 'basic', expansions: ['ecaz'], roster: MORITANI_SKILLS_ROSTER.slice(0, players), })); const MORITANI_ASSASSINATE_ROSTER: readonly FactionId[] = [ 'moritani', 'atreides', 'emperor', 'fremen', 'guild', 'beneGesserit', ]; const MORITANI_ASSASSINATE_SCENARIOS: readonly Scenario[] = [2, 3, 4, 5, 6].map(players => ({ ordinal: 146 + players - 2, profile: 'moritani-assassinate', rules: 'advanced', expansions: ['ecaz'], roster: MORITANI_ASSASSINATE_ROSTER.slice(0, players), })); const TLEILAXU_SKILLS_SCENARIOS: readonly Scenario[] = [2, 3, 4, 5, 6].map(players => ({ ordinal: 21 + players - 2, profile: 'tleilaxu-skills', rules: 'basic', expansions: ['ix'], roster: (['tleilaxu', 'emperor', 'guild', 'harkonnen', 'fremen', 'beneGesserit'] as FactionId[]).slice(0, players), })); const IX_SKILLS_SCENARIOS: readonly Scenario[] = [2, 3, 4, 5, 6].map(players => ({ ordinal: 26 + players - 2, profile: 'ix-skills', rules: 'basic', expansions: ['ix'], roster: (['ixians', 'tleilaxu', 'emperor', 'guild', 'harkonnen', 'fremen'] as FactionId[]).slice(0, players), })); const CHOAM_SKILLS_SCENARIOS: readonly Scenario[] = [2, 3, 4, 5, 6].map(players => ({ ordinal: 31 + players - 2, profile: 'choam-skills', rules: 'basic', expansions: ['choam'], roster: (['choam', 'emperor', 'guild', 'harkonnen', 'fremen', 'beneGesserit'] as FactionId[]).slice(0, players), })); const skillProfile = (profile: string) => profile === 'moritani-skills' || profile === 'tleilaxu-skills' || profile === 'ix-skills' || profile === 'choam-skills'; type TraceEntry = { attempt: number; accepted: number; player: string; turn: number; phase: number; setup: string | null; decision: string | null; response: string | null; action: Action; outcome: 'accepted' | 'rejected'; error?: string; }; type Result = { name: string; profile: Profile; rules: Rules; advanced: boolean; seed: number; resumed: boolean; actions: number; attempts: number; restores: number; turn: number; phase: number; winner: string[]; used: Record; rejected: Record; outcome: 'complete' | 'failure'; error?: string; }; function usage() { return ( 'Usage: node --import tsx tools/faction-games.ts --out NEW_PRIVATE_DIR ' + '[--seed UINT32] [--profile all|base|choam|ecaz|ecaz-treachery|moritani-assassinate|combined|combined-nexus|combined-homeworld-nexus|homeworld|nexus|homeworld-nexus|choam-roster|ecaz-roster|ix-roster|choam-nexus|ecaz-nexus|ix-nexus|moritani-skills|tleilaxu-skills|ix-skills|choam-skills] ' + '[--rules both|basic|advanced] [--players all|2|3|4|5|6] [--max-actions POSITIVE] ' + '[--resume FAILED_GAME.json]\n' + 'Runs genuine setup and gameplay offline. Default/all keeps the six expansion samples; combined-nexus and combined-homeworld-nexus add five/six-seat Basic/Advanced all-expansion samples. Base, Homeworld, Nexus, Homeworld-Nexus, expansion roster, paired expansion Nexus, Ecaz card variant, Advanced Moritani assassination and skill profiles select their documented rosters. --players requires a supported profile. Output must be a new private directory outside the checkout.' ); } function supplied(name: string) { return process.argv .slice(2) .some((value) => value === `--${name}` || value.startsWith(`--${name}=`)); } function unsigned32(value: string | undefined) { if (value === undefined) return DEFAULT_SEED; if (!/^\d+$/.test(value)) throw new Error('--seed must be an unsigned 32-bit integer.'); const parsed = Number(value); if (!Number.isSafeInteger(parsed) || parsed < 0 || parsed > 0xffff_ffff) throw new Error('--seed must be an unsigned 32-bit integer.'); return parsed; } function positive(value: string | undefined) { if (value === undefined) return DEFAULT_MAX_ACTIONS; if (!/^\d+$/.test(value)) throw new Error('--max-actions must be a positive integer.'); const parsed = Number(value); if (!Number.isSafeInteger(parsed) || parsed <= 0) throw new Error('--max-actions must be a positive integer.'); return parsed; } function scenarioName(scenario: Scenario) { return scenario.profile === 'base' || scenario.profile === 'homeworld' || scenario.profile === 'nexus' || scenario.profile === 'homeworld-nexus' || scenario.profile === 'combined-nexus' || scenario.profile === 'combined-homeworld-nexus' || scenario.profile === 'choam-roster' || scenario.profile === 'ecaz-roster' || scenario.profile === 'ecaz-treachery' || scenario.profile === 'moritani-assassinate' || scenario.profile === 'ix-roster' || scenario.profile === 'choam-nexus' || scenario.profile === 'ecaz-nexus' || scenario.profile === 'ix-nexus' || skillProfile(scenario.profile) ? `${scenario.profile}-${scenario.roster.length}-${scenario.rules}` : `${scenario.profile}-${scenario.rules}`; } function parseProfile(value: string | undefined) { const profile = value ?? 'all'; if (!['all', 'base', 'choam', 'ecaz', 'ecaz-treachery', 'moritani-assassinate', 'combined', 'combined-nexus', 'combined-homeworld-nexus', 'homeworld', 'nexus', 'homeworld-nexus', 'choam-roster', 'ecaz-roster', 'ix-roster', 'choam-nexus', 'ecaz-nexus', 'ix-nexus', 'moritani-skills', 'tleilaxu-skills', 'ix-skills', 'choam-skills'].includes(profile)) throw new Error('--profile must be all, base, choam, ecaz, ecaz-treachery, moritani-assassinate, combined, combined-nexus, combined-homeworld-nexus, homeworld, nexus, homeworld-nexus, choam-roster, ecaz-roster, ix-roster, choam-nexus, ecaz-nexus, ix-nexus, moritani-skills, tleilaxu-skills, ix-skills or choam-skills.'); return profile as Profile | 'all'; } function parseRules(value: string | undefined) { const rules = value ?? 'both'; if (!['both', 'basic', 'advanced'].includes(rules)) throw new Error('--rules must be both, basic or advanced.'); return rules as Rules | 'both'; } function parsePlayers(value: string | undefined) { if (value === undefined || value === 'all') return 'all'; if (!/^[2-6]$/.test(value)) throw new Error('--players must be all or 2 through 6.'); return Number(value); } function privateWrite(directory: string, name: string, value: unknown) { writeFileSync( resolve(directory, name), JSON.stringify(value, null, 2) + '\n', { encoding: 'utf8', flag: 'wx', mode: 0o600, }, ); } function freshGame(scenario: Scenario) { const [first, ...rest] = scenario.roster; const game = createGame( 'FACTIONR', newPlayer(first, first, first), scenario.rules === 'advanced', scenario.expansions, ); for (const faction of rest) joinGame(game, newPlayer(faction, faction, faction)); for (const [index, player] of game.players.entries()) { player.bot = DIFFICULTIES[index % DIFFICULTIES.length]; player.ready = true; } if (scenario.profile === 'homeworld' || scenario.profile === 'homeworld-nexus' || scenario.profile === 'combined-homeworld-nexus') game.homeworlds = { custody: null }; if (scenario.profile === 'nexus' || scenario.profile === 'homeworld-nexus' || scenario.profile === 'choam-nexus' || scenario.profile === 'ecaz-nexus' || scenario.profile === 'ix-nexus' || scenario.profile === 'combined-nexus' || scenario.profile === 'combined-homeworld-nexus') game.nexusCards = { cards: null, phase: null }; if (scenario.profile === 'combined-nexus' || scenario.profile === 'combined-homeworld-nexus') return initializeCombinedNexusGameForAudit(game, scenario.profile === 'combined-homeworld-nexus'); if (scenario.profile === 'choam-nexus' || scenario.profile === 'ecaz-nexus' || scenario.profile === 'ix-nexus') return initializePairedNexusGameForAudit(game); if (scenario.profile === 'ecaz-treachery') return initializeEcazTreacheryGameForAudit(game); if (scenario.profile === 'moritani-assassinate') return initializeMoritaniAssassinateGameForAudit(game); return skillProfile(scenario.profile) ? initializeLeaderSkillsGameForAudit(game) : scenario.profile === 'homeworld' ? initializeHomeworldGameForAudit(game) : scenario.profile === 'nexus' || scenario.profile === 'homeworld-nexus' ? initializeNexusGameForAudit(game) : scenario.profile === 'base' ? initializeBaseGameForAudit(game) : initializeFactionExpansionsGameForAudit(game); } function resumedGame(path: string) { if (!statSync(path).isFile()) throw new Error('--resume must name one regular JSON Game snapshot.'); const input = readFileSync(path); const hash = createHash('sha256').update(input).digest('hex'); let value: unknown; try { value = JSON.parse(input.toString('utf8')); } catch { throw new Error('--resume must contain one JSON Game snapshot.'); } if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('--resume must contain one JSON Game snapshot.'); const game = value as Game; if ( !['setup', 'playing'].includes(game.status) || typeof game.advanced !== 'boolean' || !Array.isArray(game.expansions) || !Array.isArray(game.players) || !game.players.length || game.players.some( (player) => !player || typeof player.id !== 'string' || typeof player.faction !== 'string' || !DIFFICULTIES.includes(player.bot as (typeof DIFFICULTIES)[number]), ) ) throw new Error( '--resume is not an incomplete faction-games snapshot with saved AI profiles.', ); if ( (game.leaderSkills && !basicExpansionLeaderSkillsProfile(game)) || game.discoveryEnabled || game.discoveries || game.discoveryStash || game.greatMaker || game.techTokens || game.strongholdCards || game.mentatQuestionPreview ) throw new Error('--resume sample scenarios exclude unsupported optional modules.'); const scenario = [...SCENARIOS, ...COMBINED_NEXUS_SCENARIOS, ...COMBINED_HOMEWORLD_NEXUS_SCENARIOS, ...BASE_SCENARIOS, ...MODULE_SCENARIOS, ...EXPANSION_ROSTER_SCENARIOS, ...ECAZ_TREACHERY_SCENARIOS, ...PAIRED_NEXUS_SCENARIOS, ...MORITANI_ASSASSINATE_SCENARIOS, ...MORITANI_SKILLS_SCENARIOS, ...TLEILAXU_SKILLS_SCENARIOS, ...IX_SKILLS_SCENARIOS, ...CHOAM_SKILLS_SCENARIOS].find( (candidate) => (candidate.profile === 'homeworld' || candidate.profile === 'homeworld-nexus' || candidate.profile === 'combined-homeworld-nexus') === !!game.homeworlds && (candidate.profile === 'nexus' || candidate.profile === 'homeworld-nexus' || candidate.profile === 'choam-nexus' || candidate.profile === 'ecaz-nexus' || candidate.profile === 'ix-nexus' || candidate.profile === 'combined-nexus' || candidate.profile === 'combined-homeworld-nexus') === !!game.nexusCards && skillProfile(candidate.profile) === !!game.leaderSkills && (candidate.profile === 'ecaz-treachery') === !!game.ecazTreachery && (candidate.profile === 'moritani-assassinate') === !!game.moritaniAssassinatePreview && (candidate.profile === 'moritani-assassinate') === !!game.moritaniAssassinate && candidate.rules === (game.advanced ? 'advanced' : 'basic') && JSON.stringify(candidate.expansions) === JSON.stringify(game.expansions) && JSON.stringify(candidate.roster) === JSON.stringify(game.players.map((player) => player.faction)), ); if (!scenario) throw new Error( '--resume does not match a fixed base, module or expansion sample scenario.', ); // Projection validates the engine-facing shape and every private seat boundary. for (const player of game.players) viewGame(game, player.id); return { game, scenario, hash, path: resolve(path) }; } function simulate( initial: Game, scenario: Scenario, seed: number, maxActions: number, resumed: boolean, ) { let game = initial; const expected = sampleInventory(game); const trace: TraceEntry[] = []; const used: Record = {}; const rejected: Record = {}; let actions = 0; let restores = 0; try { verifySampleCustody(game, expected); while (actions < maxActions && game.status !== 'finished') { let next: Game | undefined; for (const player of game.players) { for (const action of botActions(viewGame(game, player.id))) { const context = { attempt: trace.length, accepted: actions, player: player.id, turn: game.turn, phase: game.phase, setup: game.setupStage ?? null, decision: game.decision?.kind ?? null, response: game.response?.kind ?? null, action, }; const unchanged = JSON.stringify(game); try { next = applyAction(game, player.id, action); const label = action.type + (action.type === 'decision' ? `:${game.decision?.kind ?? 'none'}` : ''); used[label] = (used[label] ?? 0) + 1; trace.push({ ...context, outcome: 'accepted' }); actions++; break; } catch (error) { assert.equal( JSON.stringify(game), unchanged, 'rejected-action immutability', ); if (!(error instanceof RuleError)) throw error; const message = error.message; const label = `${action.type}: ${message}`; rejected[label] = (rejected[label] ?? 0) + 1; trace.push({ ...context, outcome: 'rejected', error: message }); } } if (next) break; } if (!next) throw new Error( `No legal candidate: ${JSON.stringify({ status: game.status, setup: game.setupStage, phase: game.phase, decision: game.decision?.kind, response: game.response?.kind, preparation: game.battle?.preparation, })}`, ); game = next; verifySampleCustody(game, expected); if (actions % 37 === 0) { const restored = JSON.parse(JSON.stringify(game)) as Game; for (const player of game.players) assert.deepEqual( viewGame(restored, player.id), viewGame(game, player.id), 'restored view', ); for (const player of game.players) { const view = viewGame(restored, player.id); for (const other of view.players.filter( (seat) => seat.id !== player.id, )) for (const field of ['hand', 'spice', 'traitors', 'faceDancers']) assert.equal( field in other, false, `private rival field ${field}`, ); } game = restored; restores++; } } if (game.status !== 'finished') throw new Error('Action limit'); return { game, trace, result: { name: scenarioName(scenario), profile: scenario.profile, rules: scenario.rules, advanced: scenario.rules === 'advanced', seed, resumed, actions, attempts: trace.length, restores, turn: game.turn, phase: game.phase, winner: game.winner, used, rejected, outcome: 'complete', } satisfies Result, }; } catch (error) { return { game, trace, result: { name: scenarioName(scenario), profile: scenario.profile, rules: scenario.rules, advanced: scenario.rules === 'advanced', seed, resumed, actions, attempts: trace.length, restores, turn: game.turn, phase: game.phase, winner: game.winner, used, rejected, outcome: 'failure', error: String(error), } satisfies Result, }; } } async function main() { const { values, positionals } = parseArgs({ options: { out: { type: 'string' }, seed: { type: 'string' }, profile: { type: 'string' }, rules: { type: 'string' }, players: { type: 'string' }, 'max-actions': { type: 'string' }, resume: { type: 'string' }, help: { type: 'boolean', short: 'h' }, }, allowPositionals: true, strict: true, }); if (values.help) { console.log(usage()); return; } if (positionals.length) throw new Error(`Unexpected argument: ${positionals[0]}`); if (!values.out) throw new Error('--out is required.\n' + usage()); if ( values.resume && (supplied('profile') || supplied('rules') || supplied('players')) ) throw new Error( '--resume cannot be combined with --profile, --rules or --players.', ); const seed = unsigned32(values.seed); const maxActions = positive(values['max-actions']); const profile = parseProfile(values.profile); const rules = parseRules(values.rules); const players = parsePlayers(values.players); if (supplied('players') && !['base', 'homeworld', 'nexus', 'homeworld-nexus', 'combined-nexus', 'combined-homeworld-nexus', 'choam-roster', 'ecaz-roster', 'ecaz-treachery', 'moritani-assassinate', 'ix-roster', 'choam-nexus', 'ecaz-nexus', 'ix-nexus'].includes(profile) && !skillProfile(profile)) throw new Error('--players requires a roster or optional-module profile.'); if ((profile === 'combined-nexus' || profile === 'combined-homeworld-nexus') && players !== 'all' && ![5, 6].includes(players)) throw new Error(`--profile ${profile} supports only five or six players.`); if (skillProfile(profile) && rules === 'advanced') throw new Error('Expansion Leader Skills profiles currently support only Basic samples.'); if (profile === 'moritani-assassinate' && rules === 'basic') throw new Error('Moritani assassination samples require Advanced rules.'); const resume = values.resume ? resumedGame(values.resume) : null; const requestedSamples = profile === 'homeworld' || profile === 'nexus' || profile === 'homeworld-nexus' ? MODULE_SCENARIOS : profile === 'choam-roster' || profile === 'ecaz-roster' || profile === 'ix-roster' ? EXPANSION_ROSTER_SCENARIOS : profile === 'ecaz-treachery' ? ECAZ_TREACHERY_SCENARIOS : profile === 'moritani-assassinate' ? MORITANI_ASSASSINATE_SCENARIOS : profile === 'choam-nexus' || profile === 'ecaz-nexus' || profile === 'ix-nexus' ? PAIRED_NEXUS_SCENARIOS : profile === 'combined-nexus' ? COMBINED_NEXUS_SCENARIOS : profile === 'combined-homeworld-nexus' ? COMBINED_HOMEWORLD_NEXUS_SCENARIOS : profile === 'base' ? BASE_SCENARIOS : profile === 'moritani-skills' ? MORITANI_SKILLS_SCENARIOS : profile === 'tleilaxu-skills' ? TLEILAXU_SKILLS_SCENARIOS : profile === 'ix-skills' ? IX_SKILLS_SCENARIOS : profile === 'choam-skills' ? CHOAM_SKILLS_SCENARIOS : SCENARIOS; const selected = resume ? [resume.scenario] : requestedSamples.filter( (scenario) => (profile === 'all' || scenario.profile === profile) && (rules === 'both' || scenario.rules === rules) && (players === 'all' || scenario.roster.length === players), ); const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); const directory = privateOutputDirectory(root, values.out); const before = sourceSnapshot(root); const originalRandom = Object.getOwnPropertyDescriptor( globalThis.crypto, 'getRandomValues', ); let randomState = seed; Object.defineProperty(globalThis.crypto, 'getRandomValues', { configurable: true, value: (array: T): T => { if (!(array instanceof Uint32Array)) throw new Error( 'Faction game simulation expects Uint32Array randomness.', ); for (let index = 0; index < array.length; index++) { randomState = (randomState + 0x6d2b79f5) >>> 0; let word = Math.imul( randomState ^ (randomState >>> 15), randomState | 1, ); word ^= word + Math.imul(word ^ (word >>> 7), word | 61); array[index] = (word ^ (word >>> 14)) >>> 0; } return array; }, }); const results: Result[] = []; try { for (const scenario of selected) { const scenarioSeed = (seed + scenario.ordinal) >>> 0; randomState = scenarioSeed; const outcome = simulate( resume ? structuredClone(resume.game) : freshGame(scenario), scenario, scenarioSeed, maxActions, !!resume, ); privateWrite( directory, `trace-${outcome.result.name}.json`, outcome.trace, ); if (outcome.result.outcome === 'failure') privateWrite( directory, `failed-${outcome.result.name}.json`, outcome.game, ); results.push(outcome.result); console.log(JSON.stringify(outcome.result)); } } finally { if (originalRandom) Object.defineProperty( globalThis.crypto, 'getRandomValues', originalRandom, ); else Reflect.deleteProperty(globalThis.crypto, 'getRandomValues'); } const after = sourceSnapshot(root); const sourceUnchanged = before.commit === after.commit && before.tree === after.tree; const status = !sourceUnchanged ? 'source-changed' : results.every((result) => result.outcome === 'complete') ? 'passed' : 'failed'; privateWrite(directory, 'results.json', { format: 1, results }); privateWrite(directory, 'report.json', { format: 1, before, after, sourceUnchanged, status, setup: resume ? 'Resume supplied private snapshot; its setup provenance must be checked against the original report. Saved AI profiles choose every continuation action.' : 'Genuine base, optional-module or faction-expansion setup; no cards, forces, factions, phases or statistics staged. Real saved AI profiles choose every setup and gameplay action.', randomness: resume ? 'Continuation restarts the random stream at seed plus scenario ordinal; it does not reconstruct the pre-snapshot random stream.' : 'Each scenario starts its random stream at seed plus scenario ordinal.', options: { seed, profile: resume ? resume.scenario.profile : profile, rules: resume ? resume.scenario.rules : rules, maxActions, players: resume ? resume.scenario.roster.length : players, ...(resume ? { resume: { path: resume.path, sha256: resume.hash } } : {}), }, results: results.map( ({ used: _used, rejected: _rejected, ...result }) => result, ), }); console.log( JSON.stringify({ status, report: resolve(directory, 'report.json') }), ); if (status !== 'passed') process.exitCode = 1; } main().catch((error) => { console.error(error instanceof Error ? error.message : String(error)); process.exitCode = 1; });