Files
Dune/tools/faction-games.ts
T

746 lines
29 KiB
TypeScript

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<string, number>;
rejected: Record<string, number>;
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<string, number> = {};
const rejected: Record<string, number> = {};
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: <T extends ArrayBufferView | null>(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;
});