1042 lines
55 KiB
TypeScript
1042 lines
55 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 { classicNexusModulesProfile, pairedNexusModulesProfile } from '../game/nexus-module-profile';
|
|
import { nativeFactionTechProfile } from '../game/faction-module-profile';
|
|
import { botActions } from '../game/bots';
|
|
import { treacheryDeck } from '../game/cards';
|
|
import { richeseCards } from '../game/richese-cards';
|
|
import type { FactionId } from '../game/catalog';
|
|
import {
|
|
applyAction,
|
|
createGame,
|
|
initializeBaseGameForAudit,
|
|
initializeFactionExpansionsGameForAudit,
|
|
initializeHomeworldGameForAudit,
|
|
initializeStrongholdFactionsGameForAudit,
|
|
initializeNexusGameForAudit,
|
|
initializePairedNexusGameForAudit,
|
|
initializeEcazTreacheryGameForAudit,
|
|
initializeEcazOccupyGameForAudit,
|
|
initializeMoritaniAssassinateGameForAudit,
|
|
initializeCombinedNexusGameForAudit,
|
|
initializeLeaderSkillsGameForAudit,
|
|
joinGame,
|
|
newPlayer,
|
|
viewGame,
|
|
RuleError,
|
|
type Action,
|
|
type Game,
|
|
} from '../game/engine';
|
|
import { sampleInventory, verifySampleCustody } from './sample-custody';
|
|
import { classicTechLeaderSkillsProfile, nativeExpansionLeaderSkillsProfile, nativeTechLeaderSkillsProfile, strongholdLeaderSkillsProfile } from '../game/leader-skill-profile';
|
|
import { createTechTokens } from '../game/tech-tokens';
|
|
import { createStrongholdCards } from '../game/stronghold-cards';
|
|
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' | 'ecaz-occupy' | 'moritani-assassinate' | 'stronghold-factions' | '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' | 'richese-skills' | 'ecaz-skills' | 'skills-tech' | 'skills-stronghold' | 'skills-stronghold-tech'
|
|
| 'factions-tech' | 'stronghold-factions-tech' | 'nexus-tech' | 'nexus-stronghold' | 'nexus-stronghold-tech'
|
|
| 'paired-nexus-tech' | 'paired-nexus-stronghold' | 'paired-nexus-stronghold-tech';
|
|
type Rules = 'basic' | 'advanced';
|
|
|
|
type Scenario = {
|
|
ordinal: number;
|
|
profile: Profile;
|
|
rules: Rules;
|
|
expansions: string[];
|
|
roster: FactionId[];
|
|
ecazTreachery?: boolean;
|
|
};
|
|
|
|
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 CLASSIC_NEXUS_MODULE_SCENARIOS: readonly Scenario[] =
|
|
(['nexus-tech', 'nexus-stronghold', 'nexus-stronghold-tech'] as const)
|
|
.flatMap((profile, band) => MODULE_SCENARIOS
|
|
.filter(scenario => scenario.profile === 'nexus' &&
|
|
(profile === 'nexus-tech' || scenario.rules === 'advanced') &&
|
|
(profile === 'nexus-stronghold' || scenario.roster.length >= 3))
|
|
.map((scenario, index): Scenario => ({ ...scenario, profile, ordinal: 800 + band * 100 + index })));
|
|
const nexusModuleProfile = (profile: string) =>
|
|
profile === 'nexus-tech' || profile === 'nexus-stronghold' || profile === 'nexus-stronghold-tech' || pairedNexusModuleProfile(profile);
|
|
const nexusTechProfile = (profile: string) => profile === 'nexus-tech' || profile === 'nexus-stronghold-tech' ||
|
|
profile === 'paired-nexus-tech' || profile === 'paired-nexus-stronghold-tech';
|
|
const nexusStrongholdProfile = (profile: string) => profile === 'nexus-stronghold' || profile === 'nexus-stronghold-tech' ||
|
|
profile === 'paired-nexus-stronghold' || profile === 'paired-nexus-stronghold-tech';
|
|
const pairedNexusModuleProfile = (profile: string) => profile === 'paired-nexus-tech' ||
|
|
profile === 'paired-nexus-stronghold' || profile === 'paired-nexus-stronghold-tech';
|
|
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,
|
|
}));
|
|
/** Advanced Occupy samples retain explicit independent card-variant selection. */
|
|
const ECAZ_OCCUPY_BASE_SCENARIOS: readonly Scenario[] = [
|
|
...[2, 3, 4, 5, 6].map((players): Scenario => ({
|
|
ordinal: 169 + players - 2, profile: 'ecaz-occupy', rules: 'advanced',
|
|
expansions: ['ecaz'],
|
|
roster: (['ecaz', 'fremen', 'emperor', 'beneGesserit', 'harkonnen', 'guild'] as FactionId[]).slice(0, players),
|
|
})),
|
|
...(['ixians', 'tleilaxu', 'choam'] as const).flatMap((native, family) =>
|
|
[2, 3, 4, 5, 6].map((players): Scenario => ({
|
|
ordinal: 174 + family * 5 + players - 2, profile: 'ecaz-occupy', rules: 'advanced',
|
|
expansions: ['ecaz', native === 'choam' ? 'choam' : 'ix'],
|
|
roster: (['ecaz', native, 'guild', 'fremen', 'emperor', 'beneGesserit'] as FactionId[]).slice(0, players),
|
|
}))),
|
|
...[4, 5, 6].map((players): Scenario => ({
|
|
ordinal: 189 + players - 4, profile: 'ecaz-occupy', rules: 'advanced',
|
|
expansions: ['ecaz', 'ix', 'choam'],
|
|
roster: (['ecaz', 'ixians', 'tleilaxu', 'choam', 'guild', 'fremen'] as FactionId[]).slice(0, players),
|
|
})),
|
|
];
|
|
const ECAZ_OCCUPY_SCENARIOS: readonly Scenario[] = [
|
|
...[2, 3, 4, 5, 6].map((players): Scenario => ({
|
|
ordinal: 293 + players - 2, profile: 'ecaz-occupy', rules: 'basic',
|
|
expansions: ['ecaz'],
|
|
roster: (['ecaz', 'fremen', 'emperor', 'beneGesserit', 'harkonnen', 'guild'] as FactionId[]).slice(0, players),
|
|
})),
|
|
...ECAZ_OCCUPY_BASE_SCENARIOS,
|
|
...ECAZ_OCCUPY_BASE_SCENARIOS.map((scenario, index): Scenario => ({
|
|
...scenario, ordinal: 192 + index, ecazTreachery: true,
|
|
})),
|
|
...[2, 3, 4, 5, 6].map((players): Scenario => ({
|
|
ordinal: 215 + players - 2, profile: 'ecaz-occupy', rules: 'advanced',
|
|
expansions: ['ecaz', 'choam'],
|
|
roster: (['ecaz', 'richese', 'guild', 'fremen', 'emperor', 'beneGesserit'] as FactionId[]).slice(0, players),
|
|
})),
|
|
];
|
|
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 PAIRED_NEXUS_MODULE_SCENARIOS: readonly Scenario[] =
|
|
(['paired-nexus-tech', 'paired-nexus-stronghold', 'paired-nexus-stronghold-tech'] as const)
|
|
.flatMap((profile, band) => PAIRED_NEXUS_SCENARIOS
|
|
.filter(scenario => scenario.profile !== 'ecaz-nexus' &&
|
|
(profile === 'paired-nexus-tech' || scenario.rules === 'advanced') &&
|
|
(profile === 'paired-nexus-stronghold' || scenario.roster.length >= 3))
|
|
.map((scenario, index): Scenario => ({ ...scenario, profile, ordinal: 1100 + band * 100 + index })));
|
|
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): Scenario => ({
|
|
ordinal: 16 + players - 2, profile: 'moritani-skills', rules: 'basic',
|
|
expansions: ['ecaz'], roster: MORITANI_SKILLS_ROSTER.slice(0, players),
|
|
})),
|
|
...[2, 3, 4, 5, 6].map((players): Scenario => ({
|
|
ordinal: 249 + players - 2, profile: 'moritani-skills', rules: 'advanced', expansions: ['ecaz'],
|
|
roster: (['moritani', 'atreides', 'emperor', 'guild', 'fremen', 'beneGesserit'] as FactionId[]).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): Scenario => ({
|
|
ordinal: 21 + players - 2, profile: 'tleilaxu-skills', rules: 'basic', expansions: ['ix'],
|
|
roster: (['tleilaxu', 'emperor', 'guild', 'harkonnen', 'fremen', 'beneGesserit'] as FactionId[]).slice(0, players),
|
|
})),
|
|
...[2, 3, 4, 5, 6].map((players): Scenario => ({
|
|
ordinal: 235 + players - 2, profile: 'tleilaxu-skills', rules: 'advanced', expansions: ['ix'],
|
|
roster: (['tleilaxu', 'emperor', 'guild', 'harkonnen', 'fremen', 'beneGesserit'] as FactionId[]).slice(0, players),
|
|
})),
|
|
...[2, 3, 4, 5, 6].map((players): Scenario => ({
|
|
ordinal: 240 + players - 2, profile: 'tleilaxu-skills', rules: 'advanced', expansions: ['ix'],
|
|
roster: (['tleilaxu', 'ixians', 'emperor', 'guild', 'harkonnen', 'fremen'] as FactionId[]).slice(0, players),
|
|
})),
|
|
...[3, 4, 5, 6].map((players): Scenario => ({
|
|
ordinal: 245 + players - 3, profile: 'tleilaxu-skills', rules: 'advanced', expansions: ['ix', 'choam'],
|
|
roster: (['tleilaxu', 'ixians', 'choam', 'emperor', 'guild', 'harkonnen'] as FactionId[]).slice(0, players),
|
|
})),
|
|
];
|
|
const IX_SKILLS_SCENARIOS: readonly Scenario[] = [
|
|
...[2, 3, 4, 5, 6].map((players): Scenario => ({
|
|
ordinal: 26 + players - 2, profile: 'ix-skills', rules: 'basic', expansions: ['ix'],
|
|
roster: (['ixians', 'tleilaxu', 'emperor', 'guild', 'harkonnen', 'fremen'] as FactionId[]).slice(0, players),
|
|
})),
|
|
...[2, 3, 4, 5, 6].map((players): Scenario => ({
|
|
ordinal: 220 + players - 2, profile: 'ix-skills', rules: 'advanced', expansions: ['ix'],
|
|
roster: (['ixians', 'emperor', 'guild', 'harkonnen', 'fremen', 'beneGesserit'] as FactionId[]).slice(0, players),
|
|
})),
|
|
...[2, 3, 4, 5, 6].map((players): Scenario => ({
|
|
ordinal: 230 + players - 2, profile: 'ix-skills', rules: 'advanced', expansions: ['ix', 'choam'],
|
|
roster: (['ixians', 'choam', 'emperor', 'guild', 'harkonnen', 'fremen'] as FactionId[]).slice(0, players),
|
|
})),
|
|
];
|
|
const CHOAM_SKILLS_SCENARIOS: readonly Scenario[] = [
|
|
...[2, 3, 4, 5, 6].map((players): Scenario => ({
|
|
ordinal: 31 + players - 2, profile: 'choam-skills', rules: 'basic', expansions: ['choam'],
|
|
roster: (['choam', 'emperor', 'guild', 'harkonnen', 'fremen', 'beneGesserit'] as FactionId[]).slice(0, players),
|
|
})),
|
|
...[2, 3, 4, 5, 6].map((players): Scenario => ({
|
|
ordinal: 225 + players - 2, profile: 'choam-skills', rules: 'advanced', expansions: ['choam'],
|
|
roster: (['choam', 'emperor', 'guild', 'harkonnen', 'fremen', 'beneGesserit'] as FactionId[]).slice(0, players),
|
|
})),
|
|
];
|
|
const RICHESE_SKILLS_SCENARIOS: readonly Scenario[] = [
|
|
...(['basic', 'advanced'] as const).flatMap((rules, band) => [2, 3, 4, 5, 6].map((players): Scenario => ({
|
|
ordinal: 254 + band * 5 + players - 2, profile: 'richese-skills', rules, expansions: ['choam'],
|
|
roster: (['richese', 'emperor', 'guild', 'harkonnen', 'fremen', 'beneGesserit'] as FactionId[]).slice(0, players),
|
|
}))),
|
|
...(['basic', 'advanced'] as const).flatMap((rules, band) => [2, 3, 4, 5, 6].map((players): Scenario => ({
|
|
ordinal: 264 + band * 5 + players - 2, profile: 'richese-skills', rules, expansions: ['choam'],
|
|
roster: (['richese', 'choam', 'emperor', 'guild', 'harkonnen', 'fremen'] as FactionId[]).slice(0, players),
|
|
}))),
|
|
...[3, 4, 5, 6].map((players): Scenario => ({
|
|
ordinal: 274 + players - 3, profile: 'richese-skills', rules: 'advanced', expansions: ['ix', 'choam'],
|
|
roster: (['richese', 'tleilaxu', 'choam', 'emperor', 'guild', 'harkonnen'] as FactionId[]).slice(0, players),
|
|
})),
|
|
...[2, 3, 4, 5, 6].map((players): Scenario => ({
|
|
ordinal: 278 + players - 2, profile: 'richese-skills', rules: 'advanced', expansions: ['ix', 'choam'],
|
|
roster: (['richese', 'emperor', 'guild', 'harkonnen', 'fremen', 'beneGesserit'] as FactionId[]).slice(0, players),
|
|
})),
|
|
];
|
|
const ECAZ_SKILLS_SCENARIOS: readonly Scenario[] = [
|
|
...[2, 3, 4, 5, 6].map((players): Scenario => ({
|
|
ordinal: 283 + players - 2, profile: 'ecaz-skills', rules: 'basic', expansions: ['ecaz'],
|
|
roster: (['ecaz', 'emperor', 'guild', 'harkonnen', 'fremen', 'beneGesserit'] as FactionId[]).slice(0, players),
|
|
})),
|
|
...[2, 3, 4, 5, 6].map((players): Scenario => ({
|
|
ordinal: 288 + players - 2, profile: 'ecaz-skills', rules: 'advanced', expansions: ['ecaz'],
|
|
roster: (['ecaz', 'emperor', 'guild', 'atreides', 'fremen', 'beneGesserit'] as FactionId[]).slice(0, players),
|
|
})),
|
|
];
|
|
const STRONGHOLD_FACTIONS_ROSTER: readonly FactionId[] = [
|
|
'ixians', 'choam', 'emperor', 'fremen', 'harkonnen', 'beneGesserit',
|
|
];
|
|
const STRONGHOLD_FACTIONS_SCENARIOS: readonly Scenario[] = [
|
|
...[2, 3, 4, 5, 6].map((players): Scenario => ({
|
|
ordinal: 298 + players - 2, profile: 'stronghold-factions', rules: 'advanced',
|
|
expansions: ['ecaz'],
|
|
roster: (['ecaz', 'guild', 'emperor', 'fremen', 'beneGesserit', 'harkonnen'] as FactionId[]).slice(0, players),
|
|
})),
|
|
...[2, 3, 4, 5, 6].map((players): Scenario => ({
|
|
ordinal: 303 + players - 2, profile: 'stronghold-factions', rules: 'advanced',
|
|
expansions: ['ecaz'],
|
|
roster: (['moritani', 'guild', 'emperor', 'fremen', 'beneGesserit', 'atreides'] as FactionId[]).slice(0, players),
|
|
})),
|
|
...[2, 3, 4, 5, 6].map(players => ({
|
|
ordinal: 151 + players - 2,
|
|
profile: 'stronghold-factions' as const,
|
|
rules: 'advanced' as const,
|
|
expansions: ['ix', 'choam'],
|
|
roster: STRONGHOLD_FACTIONS_ROSTER.slice(0, players),
|
|
})),
|
|
...[2, 3, 4, 5, 6].map(players => ({
|
|
ordinal: 156 + players - 2,
|
|
profile: 'stronghold-factions' as const,
|
|
rules: 'advanced' as const,
|
|
expansions: ['ix'],
|
|
roster: (['ixians', 'tleilaxu', 'emperor', 'fremen', 'harkonnen', 'beneGesserit'] as FactionId[]).slice(0, players),
|
|
})),
|
|
...[2, 3, 4, 5, 6].map(players => ({
|
|
ordinal: 161 + players - 2,
|
|
profile: 'stronghold-factions' as const,
|
|
rules: 'advanced' as const,
|
|
expansions: ['choam'],
|
|
roster: (['choam', 'richese', 'emperor', 'fremen', 'harkonnen', 'beneGesserit'] as FactionId[]).slice(0, players),
|
|
})),
|
|
...[4, 5, 6].map(players => ({
|
|
ordinal: 166 + players - 4,
|
|
profile: 'stronghold-factions' as const,
|
|
rules: 'advanced' as const,
|
|
expansions: ['ix', 'choam'],
|
|
roster: (['ixians', 'tleilaxu', 'choam', 'richese', 'emperor', 'fremen'] as FactionId[]).slice(0, players),
|
|
})),
|
|
];
|
|
const NATIVE_TECH_SCENARIOS: readonly Scenario[] = STRONGHOLD_FACTIONS_SCENARIOS
|
|
.filter(scenario => scenario.roster.length >= 3)
|
|
.flatMap((scenario, index): Scenario[] => [
|
|
{ ...scenario, ordinal: 700 + index * 3, profile: 'factions-tech', rules: 'basic' },
|
|
{ ...scenario, ordinal: 701 + index * 3, profile: 'factions-tech' },
|
|
{ ...scenario, ordinal: 702 + index * 3, profile: 'stronghold-factions-tech' },
|
|
]);
|
|
const nativeTechProfile = (profile: string) => profile === 'factions-tech' || profile === 'stronghold-factions-tech';
|
|
const nativeStrongholdProfile = (profile: string) => profile === 'stronghold-factions' || profile === 'stronghold-factions-tech';
|
|
const SKILLS_TECH_SCENARIOS: readonly Scenario[] = [
|
|
...[3, 4, 5, 6].flatMap(players => (['basic', 'advanced'] as const).map((rules, band) => ({
|
|
ordinal: 308 + (players - 3) * 2 + band, profile: 'skills-tech' as const,
|
|
rules, expansions: [], roster: BASE_ROSTER.slice(0, players),
|
|
}))),
|
|
...[...IX_SKILLS_SCENARIOS, ...TLEILAXU_SKILLS_SCENARIOS, ...CHOAM_SKILLS_SCENARIOS, ...RICHESE_SKILLS_SCENARIOS]
|
|
.filter(scenario => scenario.roster.length >= 3)
|
|
.map((scenario, index): Scenario => ({ ...scenario, ordinal: 316 + index, profile: 'skills-tech' })),
|
|
...[...MORITANI_SKILLS_SCENARIOS, ...ECAZ_SKILLS_SCENARIOS]
|
|
.filter(scenario => scenario.roster.length >= 3)
|
|
.map((scenario, index): Scenario => ({ ...scenario, ordinal: 600 + index, profile: 'skills-tech' })),
|
|
];
|
|
const SKILLS_STRONGHOLD_SCENARIOS: readonly Scenario[] =
|
|
(['skills-stronghold', 'skills-stronghold-tech'] as const).flatMap((profile, band) => [
|
|
...[2, 3, 4, 5, 6].map(players => ({
|
|
rules: 'advanced' as const, expansions: [], roster: BASE_ROSTER.slice(0, players),
|
|
})),
|
|
...[...IX_SKILLS_SCENARIOS, ...TLEILAXU_SKILLS_SCENARIOS, ...CHOAM_SKILLS_SCENARIOS, ...RICHESE_SKILLS_SCENARIOS]
|
|
.filter(scenario => scenario.rules === 'advanced'),
|
|
...[...MORITANI_SKILLS_SCENARIOS, ...ECAZ_SKILLS_SCENARIOS]
|
|
.filter(scenario => scenario.rules === 'advanced'),
|
|
].filter(scenario => profile !== 'skills-stronghold-tech' || scenario.roster.length >= 3)
|
|
.map((scenario, index): Scenario => ({ ...scenario, ordinal: 376 + band * 100 + index, profile })));
|
|
const strongholdSkillProfile = (profile: string) => profile === 'skills-stronghold' || profile === 'skills-stronghold-tech';
|
|
const skillProfile = (profile: string) => profile === 'moritani-skills' || profile === 'tleilaxu-skills' || profile === 'ix-skills' || profile === 'choam-skills' || profile === 'richese-skills' || profile === 'ecaz-skills' || profile === 'skills-tech' || strongholdSkillProfile(profile);
|
|
const assassinationScenario = (scenario: Scenario) => scenario.rules === 'advanced' &&
|
|
scenario.roster.includes('moritani') &&
|
|
(scenario.profile === 'moritani-assassinate' || nativeStrongholdProfile(scenario.profile) || nativeTechProfile(scenario.profile) || skillProfile(scenario.profile));
|
|
|
|
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|ecaz-occupy|moritani-assassinate|stronghold-factions|factions-tech|stronghold-factions-tech|combined|combined-nexus|combined-homeworld-nexus|homeworld|nexus|nexus-tech|nexus-stronghold|nexus-stronghold-tech|paired-nexus-tech|paired-nexus-stronghold|paired-nexus-stronghold-tech|homeworld-nexus|choam-roster|ecaz-roster|ix-roster|choam-nexus|ecaz-nexus|ix-nexus|moritani-skills|tleilaxu-skills|ix-skills|choam-skills|richese-skills|ecaz-skills|skills-tech|skills-stronghold|skills-stronghold-tech] ' +
|
|
'[--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. Stronghold-factions is Advanced only: Ixians + CHOAM, native Ixians + Tleilaxu, or native CHOAM + Richese with classic opponents, two through six seats; mixed four-native rosters also run at four through six seats. Selected Ix/CHOAM decks determine the canonical 47/35 Treachery Cards; Richese has a separate ten-card cache and all samples have six separate Stronghold Cards without other modules. Scenario names identify their native roster. --players requires a supported profile. Output must be a new private directory outside the checkout.' +
|
|
'\nEcaz-occupy supports Basic even-force and Advanced: native Ecaz plus classic/Ixian/Tleilaxu/CHOAM/Richese paired or mixed rosters, two through six seats; selected E3/E1/E2 sources determine ordinary33/47/35 cards. Basic uses Ecaz/classic/optional Moritani and the exact ecaz deck, with E/2 mandatory contribution and losses. An uncanceled odd Basic count remains blocked by the preserved publisher casualty-rounding conflict. Explicit independent three-card samples additionally use36/50/38. Richese marker-only or ordinary pools retain original reveal timing; own mixed No-Field dialing, whole-plan inspection and optional overlays remain guarded.' +
|
|
'\nThe four earlier native skill profiles retain their Basic samples. Advanced Ix-skills/choam-skills/tleilaxu-skills use documented native plus classic families; Advanced moritani-skills composes its original non-Harkonnen classic assassination roster. Richese-skills adds Basic/Advanced Richese or paired CHOAM tables, Advanced Richese/Tleilaxu/CHOAM and optional Ix-deck tables without native Ixians. All retain fourteen skills and original cache/native inventories. Native Ixian Technology on Richese lots, own mixed No-Field, other roster/modules and public starts retain their separate guards.'
|
|
+ '\nEcaz-skills uses native five-disc Ecaz with classic opponents, the ecaz deck and all fourteen skills in Basic/Advanced; Advanced Harkonnen is excluded by the existing shared-Duke capture boundary. Ambassador and ordinary skill paths retain native custody. Shared-Duke assignment and combined Occupy skills remain separate boundaries; no public starts or save retrofit.'
|
|
+ '\nStandalone E3 Stronghold samples additionally use native Ecaz OR Moritani with classic opponents and the exact ecaz deck, six Stronghold Cards only. Ecaz holder-only coalition effects and original Moritani assassination remain; Moritani excludes Harkonnen. E3 pairs, E1/E2 mixtures and other overlays remain guarded.'
|
|
+ '\nSkills-tech uses three-through-six unique seats in Basic/Advanced: classic/base33, supported E1/E2 native skill rosters with original ix/choam decks/cache, or standalone Ecaz/Moritani ecaz33 rosters. All fourteen Skills and three original Tech Tokens retain original setup/native owners, phase-end income, aftermath and mandatory original-winner token transfer before Face Dance. Other modules, mixed E3 families, public starts and played-game conversion stay separate.'
|
|
+ '\nSkills-stronghold uses fresh Advanced classic or supported native two-through-six-seat skill rosters with all14 skills and six original Stronghold Cards. Skills-stronghold-tech adds three original Tech Tokens and requires three-through-six seats. Original native decks/cache, real end-Mentat Stronghold ownership and printed/first-Storm Tech assignment remain; other modules, mixed E3 families, public starts and played-game conversion stay separate.'
|
|
+ '\nAll three combined skill/module profiles additionally admit standalone native Ecaz OR Moritani with classic opponents and the exact ecaz33 deck. Original five-disc Ecaz/Duke assignment and Advanced Harkonnen exclusion remain; Advanced Moritani retains its original non-Harkonnen skill-first assassination and normal-traitor forfeiture. E3 pairs, mixed-family rosters, allied Occupy skills and other overlays stay guarded.'
|
|
+ '\nFactions-tech uses native E1/E2 families or standalone Ecaz OR Moritani plus classics, three-through-six seats, Basic/Advanced, original selected decks and three Tech Tokens without Skills. Stronghold-factions-tech is its Advanced six-card Stronghold composition. Native Tech setup, real payments, typed battle rewards and selected Ecaz lead attribution remain; Basic odd Occupy, Richese cache/No-Field rulings, other modules and public starts stay guarded.'
|
|
+ '\nNexus-tech preserves classic/base33/all12 Nexus and original Tech3..6 in Basic/Advanced. Nexus-stronghold adds six original Stronghold Cards in Advanced2..6; nexus-stronghold-tech requires3..6. Original closing Nexus deals, actual free/paid source identity, phase-end Tech, held support and postbattle card/reward windows remain. Skills, Homeworlds, expansions, other overlays, unresolved effects, public starts and played-game conversion stay separate.'
|
|
+ '\nPaired-nexus-tech adds original Tech to both Ixians+Tleilaxu OR CHOAM+Richese and classic opponents, one selected deck, three-through-six seats, Basic/Advanced. Paired-nexus-stronghold is Advanced2..6; paired-nexus-stronghold-tech is Advanced3..6. Native Cunning, real closing deals, typed forces/physical Face Dancers/No-Fields, original industry/rewards and held effects remain. No mixed families, E3, Skills, other overlays, new rulings, public starts or save conversion.'
|
|
);
|
|
}
|
|
|
|
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) {
|
|
if ((scenario.profile === 'skills-tech' || strongholdSkillProfile(scenario.profile)) && scenario.expansions.length) {
|
|
const native = scenario.roster.filter(faction => ['ixians', 'tleilaxu', 'choam', 'richese', 'ecaz', 'moritani'].includes(faction));
|
|
const extraIx = scenario.expansions.includes('ix') && !native.some(faction => faction === 'ixians' || faction === 'tleilaxu');
|
|
return `${scenario.profile}-${native.join('-')}${extraIx ? '-ix-deck' : ''}-${scenario.roster.length}-${scenario.rules}`;
|
|
}
|
|
if (nativeStrongholdProfile(scenario.profile) || nativeTechProfile(scenario.profile)) {
|
|
const native = scenario.roster.filter(faction =>
|
|
['ixians', 'tleilaxu', 'choam', 'richese', 'ecaz', 'moritani'].includes(faction));
|
|
return `${scenario.profile}-${native.join('-')}-${scenario.roster.length}-${scenario.rules}`;
|
|
}
|
|
if (scenario.profile === 'ecaz-occupy') {
|
|
const native = scenario.roster.filter(faction => ['ixians', 'tleilaxu', 'choam', 'richese'].includes(faction));
|
|
if (native.length || scenario.ecazTreachery)
|
|
return `${scenario.profile}${scenario.ecazTreachery ? '-treachery' : ''}${native.length ? '-' + native.join('-') : ''}-${scenario.roster.length}-${scenario.rules}`;
|
|
}
|
|
if (scenario.profile === 'ix-skills' && scenario.rules === 'advanced' && scenario.roster.includes('choam'))
|
|
return `${scenario.profile}-ixians-choam-${scenario.roster.length}-${scenario.rules}`;
|
|
if (scenario.profile === 'tleilaxu-skills' && scenario.rules === 'advanced' && scenario.roster.includes('ixians'))
|
|
return `${scenario.profile}-${scenario.roster.filter(faction => ['tleilaxu', 'ixians', 'choam'].includes(faction)).join('-')}-${scenario.roster.length}-${scenario.rules}`;
|
|
if (scenario.profile === 'richese-skills') {
|
|
const native = scenario.roster.filter(faction => ['richese', 'tleilaxu', 'choam'].includes(faction));
|
|
const suffix = native.length > 1 ? '-' + native.join('-') : scenario.expansions.includes('ix') ? '-ix-deck' : '';
|
|
return `${scenario.profile}${suffix}-${scenario.roster.length}-${scenario.rules}`;
|
|
}
|
|
if (pairedNexusModuleProfile(scenario.profile)) {
|
|
const family = scenario.expansions[0] === 'ix' ? 'ixians-tleilaxu' : 'choam-richese';
|
|
return `${scenario.profile}-${family}-${scenario.roster.length}-${scenario.rules}`;
|
|
}
|
|
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 === 'ecaz-occupy' || scenario.profile === 'moritani-assassinate' || scenario.profile === 'ix-roster' || scenario.profile === 'choam-nexus' || scenario.profile === 'ecaz-nexus' || scenario.profile === 'ix-nexus' || skillProfile(scenario.profile) || nexusModuleProfile(scenario.profile)
|
|
? `${scenario.profile}-${scenario.roster.length}-${scenario.rules}`
|
|
: `${scenario.profile}-${scenario.rules}`;
|
|
}
|
|
|
|
function parseProfile(value: string | undefined) {
|
|
const profile = value ?? 'all';
|
|
if (!nativeTechProfile(profile) && !nexusModuleProfile(profile) && !['all', 'base', 'choam', 'ecaz', 'ecaz-treachery', 'ecaz-occupy', 'moritani-assassinate', 'stronghold-factions', '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', 'richese-skills', 'ecaz-skills', 'skills-tech', 'skills-stronghold', 'skills-stronghold-tech'].includes(profile))
|
|
throw new Error('--profile must name a documented base, module, expansion or native skill sample profile.');
|
|
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.ecazTreachery) game.ecazTreachery = true;
|
|
if (scenario.profile === 'skills-tech' || scenario.profile === 'skills-stronghold-tech' || nativeTechProfile(scenario.profile) || nexusTechProfile(scenario.profile)) game.techTokens = createTechTokens();
|
|
if (strongholdSkillProfile(scenario.profile) || nexusStrongholdProfile(scenario.profile)) game.strongholdCards = createStrongholdCards();
|
|
if (scenario.profile === 'homeworld' || scenario.profile === 'homeworld-nexus' || scenario.profile === 'combined-homeworld-nexus')
|
|
game.homeworlds = { custody: null };
|
|
if (scenario.profile === 'nexus' || nexusModuleProfile(scenario.profile) || 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' || pairedNexusModuleProfile(scenario.profile))
|
|
return initializePairedNexusGameForAudit(game);
|
|
if (scenario.profile === 'ecaz-treachery')
|
|
return initializeEcazTreacheryGameForAudit(game);
|
|
if (scenario.profile === 'ecaz-occupy')
|
|
return initializeEcazOccupyGameForAudit(game);
|
|
if (scenario.profile === 'moritani-assassinate')
|
|
return initializeMoritaniAssassinateGameForAudit(game);
|
|
if (nativeStrongholdProfile(scenario.profile))
|
|
return initializeStrongholdFactionsGameForAudit(game);
|
|
return skillProfile(scenario.profile)
|
|
? initializeLeaderSkillsGameForAudit(game)
|
|
: scenario.profile === 'homeworld'
|
|
? initializeHomeworldGameForAudit(game)
|
|
: scenario.profile === 'nexus' || nexusModuleProfile(scenario.profile) || 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.',
|
|
);
|
|
const composedSkills = (classicTechLeaderSkillsProfile(game) || nativeTechLeaderSkillsProfile(game) || strongholdLeaderSkillsProfile(game)) && !game.spiceBankerIncomePreview;
|
|
if (
|
|
(game.leaderSkills && !nativeExpansionLeaderSkillsProfile(game) && !composedSkills) ||
|
|
game.discoveryEnabled ||
|
|
game.discoveries ||
|
|
game.discoveryStash ||
|
|
game.greatMaker ||
|
|
(game.techTokens && !composedSkills && !nativeFactionTechProfile(game) && !classicNexusModulesProfile(game) && !pairedNexusModulesProfile(game)) ||
|
|
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, ...ECAZ_OCCUPY_SCENARIOS, ...PAIRED_NEXUS_SCENARIOS, ...MORITANI_ASSASSINATE_SCENARIOS, ...STRONGHOLD_FACTIONS_SCENARIOS, ...MORITANI_SKILLS_SCENARIOS, ...TLEILAXU_SKILLS_SCENARIOS, ...IX_SKILLS_SCENARIOS, ...CHOAM_SKILLS_SCENARIOS, ...RICHESE_SKILLS_SCENARIOS, ...ECAZ_SKILLS_SCENARIOS, ...SKILLS_TECH_SCENARIOS, ...SKILLS_STRONGHOLD_SCENARIOS, ...NATIVE_TECH_SCENARIOS, ...CLASSIC_NEXUS_MODULE_SCENARIOS, ...PAIRED_NEXUS_MODULE_SCENARIOS].find(
|
|
(candidate) =>
|
|
(candidate.profile === 'homeworld' || candidate.profile === 'homeworld-nexus' || candidate.profile === 'combined-homeworld-nexus') === !!game.homeworlds &&
|
|
(candidate.profile === 'nexus' || nexusModuleProfile(candidate.profile) || 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 === 'skills-tech' || candidate.profile === 'skills-stronghold-tech' || nativeTechProfile(candidate.profile) || nexusTechProfile(candidate.profile)) === !!game.techTokens &&
|
|
(nativeStrongholdProfile(candidate.profile) || strongholdSkillProfile(candidate.profile) || nexusStrongholdProfile(candidate.profile)) === !!game.strongholdCards &&
|
|
(candidate.profile === 'ecaz-treachery' || candidate.ecazTreachery === true) === !!game.ecazTreachery &&
|
|
assassinationScenario(candidate) === !!game.moritaniAssassinatePreview &&
|
|
(candidate.profile === 'ecaz-occupy' || (nativeTechProfile(candidate.profile) && candidate.roster.includes('ecaz'))) === !!game.ecazOccupyPreview &&
|
|
assassinationScenario(candidate) === !!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);
|
|
if (nativeStrongholdProfile(scenario.profile))
|
|
verifySampleCustody(game, sampleInventory(game));
|
|
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);
|
|
if (scenario.profile === 'stronghold-factions') {
|
|
const canonical = [
|
|
...treacheryDeck(scenario.expansions),
|
|
...(scenario.roster.includes('richese') ? richeseCards() : []),
|
|
].map(card => card.id).sort();
|
|
assert.deepEqual(expected.cards, canonical, 'selected native Treachery deck and separate Richese cache census');
|
|
assert.equal(new Set(expected.cards).size, canonical.length, 'unique native Treachery identities');
|
|
assert.equal(expected.strongholds?.length, 6, 'six physical Stronghold Cards');
|
|
}
|
|
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,
|
|
})}`,
|
|
);
|
|
const previous = game;
|
|
game = next;
|
|
verifySampleCustody(game, expected, previous);
|
|
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}`,
|
|
);
|
|
}
|
|
verifySampleCustody(restored, expected, game);
|
|
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') && !nexusModuleProfile(profile) && !nativeTechProfile(profile) && !['base', 'homeworld', 'nexus', 'homeworld-nexus', 'combined-nexus', 'combined-homeworld-nexus', 'choam-roster', 'ecaz-roster', 'ecaz-treachery', 'ecaz-occupy', 'moritani-assassinate', 'stronghold-factions', '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 (profile === 'moritani-assassinate' && rules === 'basic')
|
|
throw new Error('Moritani assassination samples require Advanced rules.');
|
|
if ((profile === 'stronghold-factions' || strongholdSkillProfile(profile) || nexusStrongholdProfile(profile)) && rules === 'basic')
|
|
throw new Error('Stronghold samples require Advanced rules.');
|
|
if ((profile === 'skills-tech' || profile === 'skills-stronghold-tech') && players === 2)
|
|
throw new Error('Leader Skills with Tech Tokens samples require three through six players.');
|
|
const resume = values.resume ? resumedGame(values.resume) : null;
|
|
const requestedSamples = profile === 'homeworld' || profile === 'nexus' || profile === 'homeworld-nexus'
|
|
? MODULE_SCENARIOS
|
|
: pairedNexusModuleProfile(profile) ? PAIRED_NEXUS_MODULE_SCENARIOS
|
|
: nexusModuleProfile(profile) ? CLASSIC_NEXUS_MODULE_SCENARIOS
|
|
: profile === 'choam-roster' || profile === 'ecaz-roster' || profile === 'ix-roster' ? EXPANSION_ROSTER_SCENARIOS
|
|
: profile === 'ecaz-treachery' ? ECAZ_TREACHERY_SCENARIOS
|
|
: profile === 'ecaz-occupy' ? ECAZ_OCCUPY_SCENARIOS
|
|
: profile === 'moritani-assassinate' ? MORITANI_ASSASSINATE_SCENARIOS
|
|
: profile === 'stronghold-factions' ? STRONGHOLD_FACTIONS_SCENARIOS
|
|
: nativeTechProfile(profile) ? NATIVE_TECH_SCENARIOS
|
|
: profile === 'skills-tech' ? SKILLS_TECH_SCENARIOS
|
|
: strongholdSkillProfile(profile) ? SKILLS_STRONGHOLD_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
|
|
: profile === 'richese-skills' ? RICHESE_SKILLS_SCENARIOS
|
|
: profile === 'ecaz-skills' ? ECAZ_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.',
|
|
...(selected.some(scenario => scenario.profile === 'stronghold-factions') ? {
|
|
strongholdFactions: {
|
|
families: selected.filter(scenario => scenario.profile === 'stronghold-factions').map(scenario => ({
|
|
roster: scenario.roster,
|
|
expansions: scenario.expansions,
|
|
treacheryCards: scenario.expansions.includes('ix') ? 47 : 35,
|
|
richeseCache: scenario.roster.includes('richese') ? 10 : 0,
|
|
})),
|
|
strongholdCards: 6,
|
|
custody: 'Six separate physical cards checked after every accepted action and JSON restore; end-Mentat holders persist during the next turn, independently of current board control.',
|
|
scope: 'Advanced selected Ixian/Tleilaxu and CHOAM/Richese native families with classic opponents; Stronghold Cards alone. Per-family ordinary decks and separate Richese caches remain explicit. This sample does not certify every Treachery effect, optional-module combination or deployed readiness.',
|
|
},
|
|
} : {}),
|
|
...(selected.some(scenario => scenario.profile === 'ecaz-occupy') ? {
|
|
ecazOccupy: {
|
|
families: selected.filter(scenario => scenario.profile === 'ecaz-occupy').map(scenario => ({
|
|
roster: scenario.roster,
|
|
expansions: scenario.expansions,
|
|
treacheryCards: (scenario.expansions.includes('ix') ? 47 : scenario.expansions.includes('choam') ? 35 : 33) +
|
|
(scenario.ecazTreachery ? 3 : 0),
|
|
ecazTreachery: !!scenario.ecazTreachery,
|
|
})),
|
|
scope: 'Advanced native Ecaz/classic/Ixian/Tleilaxu/CHOAM paired or mixed families, two through six seats. Selected E3/E1/E2 decks and explicit independent three-card variant samples; no Richese mixed planning or other optional overlays. Authorized revised Advanced ceil contribution/floor survivors; not a designer erratum or Basic ruling.',
|
|
},
|
|
} : {}),
|
|
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;
|
|
});
|