Files
Dune/tools/faction-games.ts
T

1374 lines
94 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, singleE1E2NexusProfile, standaloneE3NexusProfile } from '../game/nexus-module-profile';
import { nativeFactionTechProfile } from '../game/faction-module-profile';
import { classicDiscoveryLeaderSkillsProfile } from '../game/discovery-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,
initializeHomeworldOccupationGameForAudit,
initializeDiscoveryGameForAudit,
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 { homeworldLeaderSkillsProfile, classicNexusLeaderSkillsProfile, classicTechLeaderSkillsProfile, nativeExpansionLeaderSkillsProfile, nativeTechLeaderSkillsProfile, standaloneE3NexusLeaderSkillsProfile, 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;
const HOMEWORLD_MODULE_PROFILES = [
'homeworld-skills', 'homeworld-discovery',
'homeworld-skills-tech', 'homeworld-skills-stronghold', 'homeworld-skills-stronghold-tech',
'homeworld-discovery-tech', 'homeworld-discovery-stronghold', 'homeworld-discovery-stronghold-tech',
'homeworld-discovery-skills', 'homeworld-discovery-skills-tech',
'homeworld-discovery-skills-stronghold', 'homeworld-discovery-skills-stronghold-tech',
'native-homeworld-skills', 'native-homeworld-skills-tech',
'native-homeworld-skills-stronghold', 'native-homeworld-skills-stronghold-tech',
'native-homeworld-discovery', 'native-homeworld-discovery-tech',
'native-homeworld-discovery-stronghold', 'native-homeworld-discovery-stronghold-tech',
'native-homeworld-discovery-skills', 'native-homeworld-discovery-skills-tech',
'native-homeworld-discovery-skills-stronghold', 'native-homeworld-discovery-skills-stronghold-tech',
] as const;
const HOMEWORLD_NEXUS_MODULE_PROFILES = [
'homeworld-nexus-skills', 'homeworld-nexus-skills-tech',
'homeworld-nexus-skills-stronghold', 'homeworld-nexus-skills-stronghold-tech',
'homeworld-nexus-discovery', 'homeworld-nexus-discovery-tech',
'homeworld-nexus-discovery-stronghold', 'homeworld-nexus-discovery-stronghold-tech',
'homeworld-nexus-discovery-skills', 'homeworld-nexus-discovery-skills-tech',
'homeworld-nexus-discovery-skills-stronghold', 'homeworld-nexus-discovery-skills-stronghold-tech',
'homeworld-nexus-tech', 'homeworld-nexus-stronghold', 'homeworld-nexus-stronghold-tech',
] as const;
const HOMEWORLD_NATIVE_NEXUS_MODULE_PROFILES = [
'paired-homeworld-nexus-skills', 'paired-homeworld-nexus-skills-tech',
'paired-homeworld-nexus-skills-stronghold', 'paired-homeworld-nexus-skills-stronghold-tech',
'paired-homeworld-nexus-discovery', 'paired-homeworld-nexus-discovery-tech',
'paired-homeworld-nexus-discovery-stronghold', 'paired-homeworld-nexus-discovery-stronghold-tech',
'paired-homeworld-nexus-discovery-skills', 'paired-homeworld-nexus-discovery-skills-tech',
'paired-homeworld-nexus-discovery-skills-stronghold', 'paired-homeworld-nexus-discovery-skills-stronghold-tech',
'paired-homeworld-nexus-tech', 'paired-homeworld-nexus-stronghold', 'paired-homeworld-nexus-stronghold-tech',
'e3-homeworld-nexus-skills', 'e3-homeworld-nexus-skills-tech',
'e3-homeworld-nexus-skills-stronghold', 'e3-homeworld-nexus-skills-stronghold-tech',
'e3-homeworld-nexus-discovery-skills', 'e3-homeworld-nexus-discovery-skills-tech',
'e3-homeworld-nexus-discovery-skills-stronghold', 'e3-homeworld-nexus-discovery-skills-stronghold-tech',
] as const;
const E3_NEXUS_MODULE_PROFILES = [
'e3-nexus', 'e3-nexus-tech', 'e3-nexus-stronghold', 'e3-nexus-stronghold-tech',
'e3-discovery-nexus', 'e3-discovery-nexus-tech',
'e3-discovery-nexus-stronghold', 'e3-discovery-nexus-stronghold-tech',
'e3-homeworld-nexus', 'e3-homeworld-nexus-tech',
'e3-homeworld-nexus-stronghold', 'e3-homeworld-nexus-stronghold-tech',
'e3-homeworld-nexus-discovery', 'e3-homeworld-nexus-discovery-tech',
'e3-homeworld-nexus-discovery-stronghold', 'e3-homeworld-nexus-discovery-stronghold-tech',
] as const;
const SINGLE_NATIVE_NEXUS_MODULE_PROFILES = [
'single-nexus', 'single-nexus-tech', 'single-nexus-stronghold', 'single-nexus-stronghold-tech',
'single-nexus-skills', 'single-nexus-skills-tech', 'single-nexus-skills-stronghold', 'single-nexus-skills-stronghold-tech',
'single-discovery-nexus', 'single-discovery-nexus-tech', 'single-discovery-nexus-stronghold', 'single-discovery-nexus-stronghold-tech',
'single-discovery-nexus-skills', 'single-discovery-nexus-skills-tech', 'single-discovery-nexus-skills-stronghold', 'single-discovery-nexus-skills-stronghold-tech',
'single-homeworld-nexus', 'single-homeworld-nexus-tech', 'single-homeworld-nexus-stronghold', 'single-homeworld-nexus-stronghold-tech',
'single-homeworld-nexus-skills', 'single-homeworld-nexus-skills-tech', 'single-homeworld-nexus-skills-stronghold', 'single-homeworld-nexus-skills-stronghold-tech',
'single-homeworld-nexus-discovery', 'single-homeworld-nexus-discovery-tech', 'single-homeworld-nexus-discovery-stronghold', 'single-homeworld-nexus-discovery-stronghold-tech',
'single-homeworld-nexus-discovery-skills', 'single-homeworld-nexus-discovery-skills-tech', 'single-homeworld-nexus-discovery-skills-stronghold', 'single-homeworld-nexus-discovery-skills-stronghold-tech',
] 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' | 'nexus-skills' | 'paired-nexus-skills'
| 'nexus-skills-tech' | 'nexus-skills-stronghold' | 'nexus-skills-stronghold-tech'
| 'paired-nexus-skills-tech' | 'paired-nexus-skills-stronghold' | 'paired-nexus-skills-stronghold-tech'
| 'e3-nexus-skills' | 'e3-nexus-skills-tech' | 'e3-nexus-skills-stronghold' | 'e3-nexus-skills-stronghold-tech' | 'homeworld-occupation'
| 'discovery' | 'discovery-tech' | 'discovery-skills' | 'discovery-skills-tech'
| 'discovery-stronghold' | 'discovery-stronghold-tech' | 'discovery-skills-stronghold' | 'discovery-skills-stronghold-tech'
| 'native-discovery-skills' | 'native-discovery-skills-tech' | 'native-discovery-skills-stronghold' | 'native-discovery-skills-stronghold-tech'
| 'discovery-nexus' | 'discovery-nexus-tech' | 'discovery-nexus-stronghold' | 'discovery-nexus-stronghold-tech'
| 'discovery-nexus-skills' | 'discovery-nexus-skills-tech' | 'discovery-nexus-skills-stronghold' | 'discovery-nexus-skills-stronghold-tech'
| 'paired-discovery-nexus' | 'paired-discovery-nexus-tech' | 'paired-discovery-nexus-stronghold' | 'paired-discovery-nexus-stronghold-tech'
| 'paired-discovery-nexus-skills' | 'paired-discovery-nexus-skills-tech' | 'paired-discovery-nexus-skills-stronghold' | 'paired-discovery-nexus-skills-stronghold-tech'
| 'e3-discovery-nexus-skills' | 'e3-discovery-nexus-skills-tech' | 'e3-discovery-nexus-skills-stronghold' | 'e3-discovery-nexus-skills-stronghold-tech'
| (typeof HOMEWORLD_MODULE_PROFILES)[number]
| (typeof HOMEWORLD_NEXUS_MODULE_PROFILES)[number]
| (typeof HOMEWORLD_NATIVE_NEXUS_MODULE_PROFILES)[number]
| (typeof E3_NEXUS_MODULE_PROFILES)[number]
| (typeof SINGLE_NATIVE_NEXUS_MODULE_PROFILES)[number];
const discoveryStrongholdProfile = (profile: string) => profile === 'discovery-stronghold' ||
profile === 'discovery-stronghold-tech' || profile === 'discovery-skills-stronghold' ||
profile === 'discovery-skills-stronghold-tech' || profile === 'native-discovery-skills-stronghold' ||
profile === 'native-discovery-skills-stronghold-tech';
const discoveryTechProfile = (profile: string) => profile === 'discovery-tech' ||
profile === 'discovery-skills-tech' || profile === 'discovery-stronghold-tech' ||
profile === 'discovery-skills-stronghold-tech' || profile === 'native-discovery-skills-tech' ||
profile === 'native-discovery-skills-stronghold-tech';
const nativeDiscoverySkillsProfile = (profile: string) => profile === 'native-discovery-skills' ||
profile === 'native-discovery-skills-tech' || profile === 'native-discovery-skills-stronghold' ||
profile === 'native-discovery-skills-stronghold-tech';
const discoveryNexusSkillsProfile = (profile: string) => profile === 'discovery-nexus-skills' ||
profile === 'discovery-nexus-skills-tech' || profile === 'discovery-nexus-skills-stronghold' ||
profile === 'discovery-nexus-skills-stronghold-tech';
const pairedDiscoveryNexusSampleProfile = (profile: string) => profile === 'paired-discovery-nexus' ||
profile === 'paired-discovery-nexus-tech' || profile === 'paired-discovery-nexus-stronghold' ||
profile === 'paired-discovery-nexus-stronghold-tech';
const nativeDiscoveryNexusSkillsProfile = (profile: string) =>
profile === 'paired-discovery-nexus-skills' || profile === 'paired-discovery-nexus-skills-tech' ||
profile === 'paired-discovery-nexus-skills-stronghold' || profile === 'paired-discovery-nexus-skills-stronghold-tech' ||
profile === 'e3-discovery-nexus-skills' || profile === 'e3-discovery-nexus-skills-tech' ||
profile === 'e3-discovery-nexus-skills-stronghold' || profile === 'e3-discovery-nexus-skills-stronghold-tech';
const singleNativeNexusSampleProfile = (profile: string) =>
(SINGLE_NATIVE_NEXUS_MODULE_PROFILES as readonly string[]).includes(profile);
const e3NexusSampleProfile = (profile: string) =>
(E3_NEXUS_MODULE_PROFILES as readonly string[]).includes(profile);
const nativeHomeworldNexusSampleProfile = (profile: string) =>
(HOMEWORLD_NATIVE_NEXUS_MODULE_PROFILES as readonly string[]).includes(profile);
const homeworldNexusSampleProfile = (profile: string) =>
((e3NexusSampleProfile(profile) || singleNativeNexusSampleProfile(profile)) && profile.includes('-homeworld-')) ||
(HOMEWORLD_NEXUS_MODULE_PROFILES as readonly string[]).includes(profile) || nativeHomeworldNexusSampleProfile(profile);
const homeworldModuleSampleProfile = (profile: string) =>
(HOMEWORLD_MODULE_PROFILES as readonly string[]).includes(profile) || homeworldNexusSampleProfile(profile);
const homeworldSkillsSampleProfile = (profile: string) =>
homeworldModuleSampleProfile(profile) && profile.includes('-skills');
const homeworldDiscoverySampleProfile = (profile: string) =>
homeworldModuleSampleProfile(profile) && profile.includes('-discovery');
const homeworldTechSampleProfile = (profile: string) =>
homeworldModuleSampleProfile(profile) && profile.endsWith('-tech');
const homeworldStrongholdSampleProfile = (profile: string) =>
homeworldModuleSampleProfile(profile) && profile.includes('-stronghold');
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 HOMEWORLD_CLASSIC_MODULE_SCENARIOS: readonly Scenario[] =
HOMEWORLD_MODULE_PROFILES.filter(profile => !profile.startsWith('native-')).flatMap((profile, band) =>
BASE_SCENARIOS.map((scenario, index): Scenario => ({ ...scenario, profile, ordinal: 6400 + band * 100 + index }))
.filter(scenario => (!homeworldTechSampleProfile(profile) || scenario.roster.length >= 3) &&
(!homeworldStrongholdSampleProfile(profile) || scenario.rules === 'advanced')));
const HOMEWORLD_CLASSIC_NEXUS_MODULE_SCENARIOS: readonly Scenario[] =
HOMEWORLD_NEXUS_MODULE_PROFILES.flatMap((profile, band) =>
BASE_SCENARIOS.map((scenario, index): Scenario => ({ ...scenario, profile, ordinal: 8800 + band * 100 + index }))
.filter(scenario => (!homeworldTechSampleProfile(profile) || scenario.roster.length >= 3) &&
(!homeworldStrongholdSampleProfile(profile) || scenario.rules === 'advanced')));
const NEXUS_SKILLS_SCENARIOS: readonly Scenario[] = BASE_SCENARIOS.map((scenario, index) => ({
...scenario, profile: 'nexus-skills', ordinal: 1410 + index,
}));
const NEXUS_SKILLS_MODULE_SCENARIOS: readonly Scenario[] =
(['nexus-skills-tech', 'nexus-skills-stronghold', 'nexus-skills-stronghold-tech'] as const)
.flatMap((profile, band) => NEXUS_SKILLS_SCENARIOS
.filter(scenario => (profile === 'nexus-skills-tech' || scenario.rules === 'advanced') &&
(profile === 'nexus-skills-stronghold' || scenario.roster.length >= 3))
.map((scenario, index): Scenario => ({ ...scenario, profile, ordinal: 1510 + band * 50 + index })));
const nexusSkillsModuleProfile = (profile: string) => profile === 'nexus-skills-tech' ||
profile === 'nexus-skills-stronghold' || profile === 'nexus-skills-stronghold-tech';
const pairedNexusSkillsModuleProfile = (profile: string) => profile === 'paired-nexus-skills-tech' ||
profile === 'paired-nexus-skills-stronghold' || profile === 'paired-nexus-skills-stronghold-tech';
const e3NexusSkillsProfile = (profile: string) => profile === 'e3-nexus-skills' ||
profile === 'e3-nexus-skills-tech' || profile === 'e3-nexus-skills-stronghold' || profile === 'e3-nexus-skills-stronghold-tech';
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 discoveryNexusProfile = (profile: string) =>
((e3NexusSampleProfile(profile) || singleNativeNexusSampleProfile(profile)) && profile.includes('-discovery')) || profile === 'discovery-nexus' ||
profile === 'discovery-nexus-tech' || profile === 'discovery-nexus-stronghold' ||
profile === 'discovery-nexus-stronghold-tech' || discoveryNexusSkillsProfile(profile) ||
pairedDiscoveryNexusSampleProfile(profile) || nativeDiscoveryNexusSkillsProfile(profile);
const nexusModuleProfile = (profile: string) =>
singleNativeNexusSampleProfile(profile) || e3NexusSampleProfile(profile) || homeworldNexusSampleProfile(profile) ||
profile === 'nexus-tech' || profile === 'nexus-stronghold' || profile === 'nexus-stronghold-tech' ||
discoveryNexusProfile(profile) || pairedNexusModuleProfile(profile) || nexusSkillsModuleProfile(profile) || pairedNexusSkillsModuleProfile(profile) || e3NexusSkillsProfile(profile);
const nexusTechProfile = (profile: string) =>
((e3NexusSampleProfile(profile) || singleNativeNexusSampleProfile(profile)) && profile.endsWith('-tech')) ||
((discoveryNexusSkillsProfile(profile) || pairedDiscoveryNexusSampleProfile(profile) || nativeDiscoveryNexusSkillsProfile(profile)) && profile.endsWith('tech')) ||
profile === 'discovery-nexus-tech' || profile === 'discovery-nexus-stronghold-tech' || profile === 'nexus-tech' || profile === 'nexus-stronghold-tech' ||
profile === 'paired-nexus-tech' || profile === 'paired-nexus-stronghold-tech' ||
profile === 'nexus-skills-tech' || profile === 'nexus-skills-stronghold-tech' ||
profile === 'paired-nexus-skills-tech' || profile === 'paired-nexus-skills-stronghold-tech' ||
profile === 'e3-nexus-skills-tech' || profile === 'e3-nexus-skills-stronghold-tech';
const nexusStrongholdProfile = (profile: string) =>
((e3NexusSampleProfile(profile) || singleNativeNexusSampleProfile(profile)) && profile.includes('stronghold')) ||
((discoveryNexusSkillsProfile(profile) || pairedDiscoveryNexusSampleProfile(profile) || nativeDiscoveryNexusSkillsProfile(profile)) && profile.includes('stronghold')) ||
profile === 'discovery-nexus-stronghold' || profile === 'discovery-nexus-stronghold-tech' || profile === 'nexus-stronghold' || profile === 'nexus-stronghold-tech' ||
profile === 'paired-nexus-stronghold' || profile === 'paired-nexus-stronghold-tech' ||
profile === 'nexus-skills-stronghold' || profile === 'nexus-skills-stronghold-tech' ||
profile === 'paired-nexus-skills-stronghold' || profile === 'paired-nexus-skills-stronghold-tech' ||
profile === 'e3-nexus-skills-stronghold' || profile === 'e3-nexus-skills-stronghold-tech';
const pairedNexusModuleProfile = (profile: string) => profile === 'paired-nexus-tech' ||
profile === 'paired-nexus-stronghold' || profile === 'paired-nexus-stronghold-tech' ||
pairedDiscoveryNexusSampleProfile(profile);
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_SKILLS_SCENARIOS: readonly Scenario[] = PAIRED_NEXUS_SCENARIOS
.filter(scenario => scenario.profile !== 'ecaz-nexus')
.map((scenario, index): Scenario => ({ ...scenario, profile: 'paired-nexus-skills', ordinal: 1450 + index }));
const PAIRED_NEXUS_SKILLS_MODULE_SCENARIOS: readonly Scenario[] =
(['paired-nexus-skills-tech', 'paired-nexus-skills-stronghold', 'paired-nexus-skills-stronghold-tech'] as const)
.flatMap((profile, band) => PAIRED_NEXUS_SKILLS_SCENARIOS
.filter(scenario => (profile === 'paired-nexus-skills-tech' || scenario.rules === 'advanced') &&
(profile === 'paired-nexus-skills-stronghold' || scenario.roster.length >= 3))
.map((scenario, index): Scenario => ({ ...scenario, profile, ordinal: 1700 + band * 100 + index })));
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 E3_NEXUS_SKILLS_SCENARIOS: readonly Scenario[] =
(['e3-nexus-skills', 'e3-nexus-skills-tech', 'e3-nexus-skills-stronghold', 'e3-nexus-skills-stronghold-tech'] as const)
.flatMap((profile, band) => [...ECAZ_SKILLS_SCENARIOS, ...MORITANI_SKILLS_SCENARIOS]
.filter(scenario => (profile === 'e3-nexus-skills' || profile === 'e3-nexus-skills-tech' || scenario.rules === 'advanced') &&
(profile === 'e3-nexus-skills' || profile === 'e3-nexus-skills-stronghold' || scenario.roster.length >= 3))
.map((scenario, index): Scenario => ({ ...scenario, profile, ordinal: 2100 + band * 100 + index })));
const HOMEWORLD_OCCUPATION_SCENARIOS: readonly Scenario[] = [
...MODULE_SCENARIOS.filter(scenario => scenario.profile === 'homeworld'),
...PAIRED_NEXUS_SCENARIOS.filter(scenario => scenario.profile !== 'ecaz-nexus'),
...ECAZ_SKILLS_SCENARIOS,
...MORITANI_SKILLS_SCENARIOS,
].map((scenario, index): Scenario => ({ ...scenario, profile: 'homeworld-occupation', ordinal: 2500 + index }));
const DISCOVERY_SCENARIOS: readonly Scenario[] =
(['discovery', 'discovery-tech'] as const).flatMap((profile, band) =>
[...BASE_SCENARIOS, ...PAIRED_NEXUS_SCENARIOS.filter(scenario => scenario.profile !== 'ecaz-nexus'),
...ECAZ_SKILLS_SCENARIOS, ...MORITANI_SKILLS_SCENARIOS]
.filter(scenario => profile === 'discovery' || scenario.roster.length >= 3)
.map((scenario, index): Scenario => ({
...scenario, profile, ordinal: 2700 + band * 100 + index,
})));
const DISCOVERY_SKILLS_SCENARIOS: readonly Scenario[] =
(['discovery-skills', 'discovery-skills-tech'] as const).flatMap((profile, band) =>
BASE_SCENARIOS.filter(scenario => profile === 'discovery-skills' || scenario.roster.length >= 3)
.map((scenario, index): Scenario => ({ ...scenario, profile, ordinal: 3000 + band * 100 + index })));
const DISCOVERY_NEXUS_SCENARIOS: readonly Scenario[] =
(['discovery-nexus', 'discovery-nexus-tech', 'discovery-nexus-stronghold', 'discovery-nexus-stronghold-tech'] as const)
.flatMap((profile, band) => BASE_SCENARIOS
.filter(scenario => (!profile.includes('stronghold') || scenario.rules === 'advanced') &&
(!profile.endsWith('tech') || scenario.roster.length >= 3))
.map((scenario, index): Scenario => ({ ...scenario, profile, ordinal: 3600 + band * 100 + index })));
const DISCOVERY_STRONGHOLD_SCENARIOS: readonly Scenario[] =
(['discovery-stronghold', 'discovery-stronghold-tech', 'discovery-skills-stronghold', 'discovery-skills-stronghold-tech'] as const)
.flatMap((profile, band) => (profile.includes('skills') ? BASE_SCENARIOS
: DISCOVERY_SCENARIOS.filter(scenario => scenario.profile === 'discovery'))
.filter(scenario => scenario.rules === 'advanced' &&
(!profile.endsWith('tech') || scenario.roster.length >= 3))
.map((scenario, index): Scenario => ({ ...scenario, profile, ordinal: 4000 + band * 100 + index })));
const NATIVE_DISCOVERY_SKILLS_SCENARIOS: readonly Scenario[] =
(['native-discovery-skills', 'native-discovery-skills-tech', 'native-discovery-skills-stronghold', 'native-discovery-skills-stronghold-tech'] as const)
.flatMap((profile, band) => [...IX_SKILLS_SCENARIOS, ...TLEILAXU_SKILLS_SCENARIOS,
...CHOAM_SKILLS_SCENARIOS, ...RICHESE_SKILLS_SCENARIOS,
...ECAZ_SKILLS_SCENARIOS, ...MORITANI_SKILLS_SCENARIOS]
.filter(scenario => (!profile.includes('stronghold') || scenario.rules === 'advanced') &&
(!profile.endsWith('tech') || scenario.roster.length >= 3))
.map((scenario, index): Scenario => ({ ...scenario, profile, ordinal: 4400 + band * 100 + index })));
const HOMEWORLD_NATIVE_MODULE_SCENARIOS: readonly Scenario[] =
HOMEWORLD_MODULE_PROFILES.filter(profile => profile.startsWith('native-')).flatMap((profile, band) =>
[...IX_SKILLS_SCENARIOS, ...TLEILAXU_SKILLS_SCENARIOS, ...CHOAM_SKILLS_SCENARIOS,
...RICHESE_SKILLS_SCENARIOS, ...ECAZ_SKILLS_SCENARIOS, ...MORITANI_SKILLS_SCENARIOS]
.filter(scenario => (!homeworldStrongholdSampleProfile(profile) || scenario.rules === 'advanced') &&
(!homeworldTechSampleProfile(profile) || scenario.roster.length >= 3))
.map((scenario, index): Scenario => ({ ...scenario, profile, ordinal: 7600 + band * 100 + index })));
const DISCOVERY_NEXUS_SKILLS_SCENARIOS: readonly Scenario[] =
(['discovery-nexus-skills', 'discovery-nexus-skills-tech', 'discovery-nexus-skills-stronghold', 'discovery-nexus-skills-stronghold-tech'] as const)
.flatMap((profile, band) => BASE_SCENARIOS
.filter(scenario => (!profile.includes('stronghold') || scenario.rules === 'advanced') &&
(!profile.endsWith('tech') || scenario.roster.length >= 3))
.map((scenario, index): Scenario => ({ ...scenario, profile, ordinal: 4800 + band * 100 + index })));
const PAIRED_DISCOVERY_NEXUS_SCENARIOS: readonly Scenario[] =
(['paired-discovery-nexus', 'paired-discovery-nexus-tech', 'paired-discovery-nexus-stronghold', 'paired-discovery-nexus-stronghold-tech'] as const)
.flatMap((profile, band) => PAIRED_NEXUS_SCENARIOS
.filter(scenario => scenario.profile !== 'ecaz-nexus' &&
(!profile.includes('stronghold') || scenario.rules === 'advanced') &&
(!profile.endsWith('tech') || scenario.roster.length >= 3))
.map((scenario, index): Scenario => ({ ...scenario, profile, ordinal: 5200 + band * 100 + index })));
const E3_DISCOVERY_NEXUS_SKILLS_BASE_SCENARIOS = E3_NEXUS_SKILLS_SCENARIOS
.filter(scenario => scenario.profile === 'e3-nexus-skills');
const NATIVE_DISCOVERY_NEXUS_SKILLS_SCENARIOS: readonly Scenario[] =
(['paired-discovery-nexus-skills', 'paired-discovery-nexus-skills-tech',
'paired-discovery-nexus-skills-stronghold', 'paired-discovery-nexus-skills-stronghold-tech',
'e3-discovery-nexus-skills', 'e3-discovery-nexus-skills-tech',
'e3-discovery-nexus-skills-stronghold', 'e3-discovery-nexus-skills-stronghold-tech'] as const)
.flatMap((profile, band) => (profile.startsWith('paired')
? PAIRED_NEXUS_SKILLS_SCENARIOS : E3_DISCOVERY_NEXUS_SKILLS_BASE_SCENARIOS)
.filter(scenario => (!profile.includes('stronghold') || scenario.rules === 'advanced') &&
(!profile.endsWith('tech') || scenario.roster.length >= 3))
.map((scenario, index): Scenario => ({ ...scenario, profile, ordinal: 5600 + band * 100 + index })));
const HOMEWORLD_NATIVE_NEXUS_MODULE_SCENARIOS: readonly Scenario[] =
HOMEWORLD_NATIVE_NEXUS_MODULE_PROFILES.flatMap((profile, band) =>
(profile.startsWith('paired-') ? PAIRED_NEXUS_SKILLS_SCENARIOS : E3_DISCOVERY_NEXUS_SKILLS_BASE_SCENARIOS)
.filter(scenario => (!homeworldStrongholdSampleProfile(profile) || scenario.rules === 'advanced') &&
(!homeworldTechSampleProfile(profile) || scenario.roster.length >= 3))
.map((scenario, index): Scenario => ({ ...scenario, profile, ordinal: 10300 + band * 100 + index })));
const E3_NEXUS_MODULE_SCENARIOS: readonly Scenario[] =
E3_NEXUS_MODULE_PROFILES.flatMap((profile, band) =>
E3_DISCOVERY_NEXUS_SKILLS_BASE_SCENARIOS
.filter(scenario => (!nexusStrongholdProfile(profile) || scenario.rules === 'advanced') &&
(!nexusTechProfile(profile) || scenario.roster.length >= 3))
.map((scenario, index): Scenario => ({ ...scenario, profile, ordinal: 12600 + band * 100 + index })));
const SINGLE_NATIVE_NEXUS_BASE_SCENARIOS: readonly Scenario[] =
(['ixians', 'tleilaxu', 'choam', 'richese'] as const).flatMap(native =>
(['basic', 'advanced'] as const).flatMap(rules => [2, 3, 4, 5, 6].map((players): Scenario => ({
profile: 'single-nexus', ordinal: 0, rules,
expansions: [native === 'ixians' || native === 'tleilaxu' ? 'ix' : 'choam'],
roster: ([native, 'emperor', 'guild', 'harkonnen', 'fremen', 'beneGesserit'] as FactionId[]).slice(0, players),
}))));
const SINGLE_NATIVE_NEXUS_MODULE_SCENARIOS: readonly Scenario[] =
SINGLE_NATIVE_NEXUS_MODULE_PROFILES.flatMap((profile, band) =>
SINGLE_NATIVE_NEXUS_BASE_SCENARIOS
.filter(scenario => (!nexusStrongholdProfile(profile) || scenario.rules === 'advanced') &&
(!nexusTechProfile(profile) || scenario.roster.length >= 3))
.map((scenario, index): Scenario => ({ ...scenario, profile, ordinal: 14200 + band * 100 + index })));
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 discoverySkillsProfile = (profile: string) => profile === 'discovery-skills' || profile === 'discovery-skills-tech' ||
profile === 'discovery-skills-stronghold' || profile === 'discovery-skills-stronghold-tech';
const skillProfile = (profile: string) => (singleNativeNexusSampleProfile(profile) && profile.includes('-skills')) ||
homeworldSkillsSampleProfile(profile) || nativeDiscoveryNexusSkillsProfile(profile) || discoveryNexusSkillsProfile(profile) || nativeDiscoverySkillsProfile(profile) || discoverySkillsProfile(profile) || e3NexusSkillsProfile(profile) || pairedNexusSkillsModuleProfile(profile) || nexusSkillsModuleProfile(profile) || profile === 'nexus-skills' || profile === 'paired-nexus-skills' || profile === 'moritani-skills' || profile === 'tleilaxu-skills' || profile === 'ix-skills' || profile === 'choam-skills' || profile === 'richese-skills' || profile === 'ecaz-skills' || profile === 'skills-tech' || strongholdSkillProfile(profile);
const discoveryProfile = (profile: string) => profile === 'discovery' || profile === 'discovery-tech' ||
profile === 'discovery-stronghold' || profile === 'discovery-stronghold-tech' ||
(homeworldDiscoverySampleProfile(profile) && !homeworldSkillsSampleProfile(profile));
const assassinationScenario = (scenario: Scenario) => scenario.rules === 'advanced' &&
scenario.roster.includes('moritani') &&
(e3NexusSampleProfile(scenario.profile) || scenario.profile === 'moritani-assassinate' || discoveryProfile(scenario.profile) || 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|discovery|discovery-tech|discovery-skills|discovery-skills-tech|discovery-nexus|discovery-nexus-tech|discovery-nexus-stronghold|discovery-nexus-stronghold-tech|choam|ecaz|ecaz-treachery|ecaz-occupy|moritani-assassinate|stronghold-factions|factions-tech|stronghold-factions-tech|combined|combined-nexus|combined-homeworld-nexus|homeworld|homeworld-occupation|nexus|nexus-skills|paired-nexus-skills|nexus-skills-tech|nexus-skills-stronghold|nexus-skills-stronghold-tech|paired-nexus-skills-tech|paired-nexus-skills-stronghold|paired-nexus-skills-stronghold-tech|e3-nexus-skills|e3-nexus-skills-tech|e3-nexus-skills-stronghold|e3-nexus-skills-stronghold-tech|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|' +
SINGLE_NATIVE_NEXUS_MODULE_PROFILES.join('|') + '|' + E3_NEXUS_MODULE_PROFILES.join('|') + '] ' +
'[--rules both|basic|advanced] [--players all|2|3|4|5|6] [--max-actions POSITIVE] ' +
'[--resume FAILED_GAME.json]\n' +
'Single-native E1/E2: single-nexus and single-discovery-nexus, single-homeworld-nexus and single-homeworld-nexus-discovery, each with optional -skills and -tech/-stronghold/-stronghold-tech. Exactly one original Ixians OR Tleilaxu OR CHOAM OR Richese with classics, one family deck/all12 and all14 only when Skills selected. Basic/Advanced2–6, Tech3+, Strongholds Advanced; original setup/effects/ruling guards remain. Stable new14200–17300 bands; older names/seeds unchanged.\n' +
'Standalone E3 without Skills: e3-nexus, e3-discovery-nexus, e3-homeworld-nexus and e3-homeworld-nexus-discovery, each optionally -tech, -stronghold or -stronghold-tech. Original Ecaz OR Moritani/classic ecaz33/all12 setup, Basic/Advanced2–6, Tech3+, Strongholds Advanced, no training/public starts/save conversion. Native Duke/Occupy and Moritani traitor/Terror/assassination retain their original source and ruling boundaries. Stable new12600–14100 bands leave older names/seeds unchanged.\n' +
'Native trained Discovery/Nexus profiles: paired-discovery-nexus-skills[-tech|-stronghold|-stronghold-tech] retains original paired E1/E2 setup/all14; e3-discovery-nexus-skills[-tech|-stronghold|-stronghold-tech] retains standalone Ecaz OR Moritani original setup/all14. Discovery7/8/all12, Basic/Advanced2–6, Tech3+, Strongholds Advanced; original roster/custody/pending-rule limits and old defaults/seeds remain.\n' +
'Discovery Nexus combinations: discovery-nexus-skills[-tech|-stronghold|-stronghold-tech] uses original classic all14 training; paired-discovery-nexus[-tech|-stronghold|-stronghold-tech] uses both native E1 or E2 factions without Skills. Basic/Advanced2–6, Tech3+, Strongholds Advanced, no Homeworlds/variants/previews; old profiles/defaults/seeds unchanged.\n' +
'Native skill Discovery profiles: native-discovery-skills, native-discovery-skills-tech, native-discovery-skills-stronghold, native-discovery-skills-stronghold-tech; original native skill families/rosters and setup, optional Tech3+ and Advanced Strongholds, no Nexus/Homeworlds/variants/previews. Earlier profiles/defaults/seeds remain unchanged.\n' +
'Advanced Discovery Stronghold profiles: discovery-stronghold, discovery-stronghold-tech, discovery-skills-stronghold, discovery-skills-stronghold-tech; native or classic-skill original setup, optional Tech3+, original held claims, no Nexus/Homeworlds/variants/previews. Earlier defaults and seeds are unchanged.\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.'
+ '\nHomeworld-occupation uses original classic, paired E1/E2 or standalone E3 two-through-six-seat Basic/Advanced rosters and selected decks, with original Homeworlds alone. Continuously proved, uncontested occupation enables printed bank Collection, immediate ally sharing and source-specific occupied native defenses. Departed/replaced/contested/repopulated histories retain source guards. Paid percentage receipts, exceptional borrowed powers, unrelated modules, public starts and played-game conversion remain separate.'
+ '\nNexus-skills uses classic/base33/all14 Skills/all12 Nexus, Basic/Advanced two through six seats, with original setup/posture, actual closing draws and physical effects. Native factions, Tech/Strongholds/HW, Banker/Mentat previews, borrowed Smuggler shipment arithmetic and unresolved effects remain guarded; no public start or played-game conversion.'
+ '\nPaired-nexus-skills adds all14 Skills to both original Ixians+Tleilaxu OR CHOAM+Richese, classic opponents and one family deck/all12 Nexus, Basic/Advanced2..6. Native setup, actual closing draws, typed skills/Cunning, Face Dancers and one-invoice physical marker pairs reuse original rules. Tech/Strongholds/HW, mixed families, E3, Banker/Mentat previews, borrowed Smuggler/pair companions, public starts and pending rulings remain excluded.'
+ '\nNexus-skills-tech preserves original Tech3..6 Basic/Advanced with classic/base33/all14/all12; nexus-skills-stronghold preserves original Advanced Strongholds2..6; selecting both requires3..6 Advanced. Real first-Storm/end-Mentat ownership, phase-end industry, held subsidy, typed rescue and mandatory original-winner token transfer remain. Native rosters, HW, Banker/Mentat previews, other overlays, public starts and pending rulings stay separate.'
+ '\nPaired-nexus-skills-tech preserves original Tech3..6 Basic/Advanced with both Ixians+Tleilaxu OR CHOAM+Richese and classics, one original family deck/all14/all12. Paired-nexus-skills-stronghold uses Advanced2..6; both requires3..6 Advanced. Native setup, actual closing deals, typed rescue/equal substitution, original signed marker pairs, phase-end industry, held support and original winner reward before Face Dance remain. No mixed families/E3/HW/previews/companions/public starts or new rulings.'
+ '\nE3-nexus-skills composes standalone Ecaz OR Moritani and classics, ecaz33/all14/all12, Basic/Advanced2..6. Tech requires3+; Strongholds requires Advanced; both3+ Advanced. Original Duke/assignment and Moritani assassination/normal-traitor-forfeit guards remain, including Advanced Harkonnen exclusion. Real closing draws, first-Storm/end-Mentat custody, source skill effects and original payments/rewards remain. No E3 pair/mixed families/HW/independent cards/previews/public starts or new rulings.'
+ '\nDiscovery uses fresh classic, paired E1/E2 or standalone Ecaz OR Moritani setups with original selected decks, six Discovery territory cards, Great Maker and eight physical tokens in Basic/Advanced2..6. Discovery-tech preserves unused original Tech3..6. Native setup, actual token entry, typed force paths, stash and nested location controls reuse original handlers. No Skills/HW/Nexus/Strongholds, mixed E3 families, new contested/shared-payer rulings, public starts or played-game conversion.'
+ '\nDiscovery-skills selects classic/base33/all14 Skills with original Discovery7/8, Basic/Advanced2..6. Discovery-skills-tech preserves unused original Tech3..6. Original trained posture, physical free entry, fixed-three carried movement, paid nested routes, stash capacity, skill battle rescue/rewards and distinct-party Bureaucrat invoices remain. Native families, Nexus/Strongholds/HW, Banker/Mentat previews, pending contested/shared/mixed effects, public starts and save conversion stay separate.'
+ '\nDiscovery-nexus uses classic/base33/all12 Nexus/Discovery7+8, Basic/Advanced2..6. Discovery-nexus-tech adds original Tech3+; discovery-nexus-stronghold adds Advanced Strongholds2+; both requires3+ Advanced. Original Great Maker/worm/vote/ride, settled-alliance end-Spice closing deal, end-Mentat held-card settlement, next-turn entry and source-clear borrowed force/tariff/card effects remain. No Skills/HW/native families, mixed-module Fremen Betrayal, new pending rulings, public starts or save conversion.'
+ '\nHomeworld-skills, homeworld-discovery and homeworld-discovery-skills use original classic/base33 rosters in Basic/Advanced2..6, all14 offered training when Skills is selected and Discovery7/8 when Discovery is selected. Each family may add -tech (original Tech3+), -stronghold (Advanced2+) or -stronghold-tech (Advanced3+). Original setup/cards/native force sources, physical rescue, Maker/entry, phase-end industry and source-local held effects remain. No Nexus, native families, public starts, save conversion or pending-rule changes.'
+ '\nNative-homeworld-skills, native-homeworld-discovery and native-homeworld-discovery-skills retain each existing E1/E2 or standalone Ecaz/Moritani roster/deck predicate and original Homeworld pools, Basic/Advanced2..6. Optional -tech requires3+; -stronghold requiresAdvanced2+; -stronghold-tech requiresAdvanced3+. Original private starts, all14 when selected, DS7/8 when selected, physical Suk/residual Cyborg exchange, native aftermath and held/industry/reward timing remain. Nexus, E3pairs/mixed families, unrelated previews, public starts, save conversion and pending rulings remain separate.'
+ '\nHomeworld-nexus-skills, homeworld-nexus-discovery and homeworld-nexus-discovery-skills compose original classic/base33/all12 with optional all14 training and Discovery7/8, Basic/Advanced2..6. Each family adds -tech at3+, -stronghold inAdvanced2+ or -stronghold-tech inAdvanced3+. Without Skills/Discovery, homeworld-nexus-tech, homeworld-nexus-stronghold and homeworld-nexus-stronghold-tech retain the same prerequisites. Original native Homeworld pools, real closing draws, printed revival deposits, physical Suk and phase-end industry/held rewards remain. Native families, unrelated previews, unresolved effects, public starts and save conversion stay guarded.'
+ '\nPaired-homeworld-nexus-skills, paired-homeworld-nexus-discovery and paired-homeworld-nexus-discovery-skills retain original both-native E1 or E2 roster/deck contracts, Homeworlds/all12 and optional all14/DS7+8, Basic/Advanced2..6. Families add -tech at3+, -stronghold inAdvanced2+, -stronghold-tech inAdvanced3+. Paired-homeworld-nexus-tech/-stronghold/-stronghold-tech omit Skills/Discovery. E3-homeworld-nexus-skills and e3-homeworld-nexus-discovery-skills retain standalone Ecaz OR Moritani/classics, Ecaz33/all14/all12, with the same optional prerequisites and original Advanced Harkonnen/Duke/assassination guards. Original typed custody, real closing deals, physical rescue/substitution/marker invoices, phase-end industry and winner/held timing remain. Separate single-native E1/E2 and no-Skills E3 families above retain their own roster/effect predicates. Mixed decks, E3pairs/mixed families, pending effects, public starts and save conversion stay separate.'
);
}
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 (singleNativeNexusSampleProfile(scenario.profile) || e3NexusSampleProfile(scenario.profile) || homeworldModuleSampleProfile(scenario.profile))
return `${scenario.profile}-${scenario.expansions.join('-') || 'classic'}-${scenario.roster.join('-')}-${scenario.roster.length}-${scenario.rules}`;
if (nativeDiscoverySkillsProfile(scenario.profile) || discoveryNexusSkillsProfile(scenario.profile) ||
pairedDiscoveryNexusSampleProfile(scenario.profile) || nativeDiscoveryNexusSkillsProfile(scenario.profile))
return `${scenario.profile}-${scenario.expansions.join('-')}-${scenario.roster.join('-')}-${scenario.roster.length}-${scenario.rules}`;
if (discoveryProfile(scenario.profile))
return `${scenario.profile}-${scenario.expansions[0] ?? 'classic'}-${scenario.roster[0]}-${scenario.roster.length}-${scenario.rules}`;
if (scenario.profile === 'homeworld-occupation')
return `${scenario.profile}-${scenario.roster[0]}-${scenario.roster.length}-${scenario.rules}`;
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 (e3NexusSkillsProfile(scenario.profile))
return `${scenario.profile}-${scenario.roster[0]}-${scenario.roster.length}-${scenario.rules}`;
if (pairedNexusSkillsModuleProfile(scenario.profile) || pairedNexusModuleProfile(scenario.profile) || scenario.profile === 'paired-nexus-skills') {
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 (profile === 'homeworld-occupation' || discoveryProfile(profile)) return profile;
if (!skillProfile(profile) && !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 (homeworldTechSampleProfile(scenario.profile) || discoveryTechProfile(scenario.profile) || scenario.profile === 'skills-tech' || scenario.profile === 'skills-stronghold-tech' || nativeTechProfile(scenario.profile) || nexusTechProfile(scenario.profile)) game.techTokens = createTechTokens();
if (homeworldStrongholdSampleProfile(scenario.profile) || discoveryStrongholdProfile(scenario.profile) || strongholdSkillProfile(scenario.profile) || nexusStrongholdProfile(scenario.profile)) game.strongholdCards = createStrongholdCards();
if (homeworldModuleSampleProfile(scenario.profile) || scenario.profile === 'homeworld' || scenario.profile === 'homeworld-occupation' || scenario.profile === 'homeworld-nexus' || scenario.profile === 'combined-homeworld-nexus')
game.homeworlds = { custody: null };
if (scenario.profile === 'paired-nexus-skills' || scenario.profile === 'nexus-skills' || 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 (homeworldDiscoverySampleProfile(scenario.profile) || nativeDiscoverySkillsProfile(scenario.profile) || discoverySkillsProfile(scenario.profile) || discoveryNexusProfile(scenario.profile)) game.discoveryEnabled = true;
if (nativeHomeworldNexusSampleProfile(scenario.profile) && !homeworldSkillsSampleProfile(scenario.profile))
return initializePairedNexusGameForAudit(game);
if (discoveryProfile(scenario.profile)) {
game.discoveryEnabled = true;
return homeworldNexusSampleProfile(scenario.profile)
? initializeNexusGameForAudit(game)
: initializeDiscoveryGameForAudit(game);
}
if (scenario.profile === 'homeworld-occupation')
return initializeHomeworldOccupationGameForAudit(game);
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 = (homeworldLeaderSkillsProfile(game) || classicDiscoveryLeaderSkillsProfile(game) || standaloneE3NexusLeaderSkillsProfile(game) || classicNexusLeaderSkillsProfile(game) || classicTechLeaderSkillsProfile(game) || nativeTechLeaderSkillsProfile(game) || strongholdLeaderSkillsProfile(game)) && !game.spiceBankerIncomePreview;
const discovery = game.discoveryEnabled === true && !!game.discoveries;
if (
(game.leaderSkills && !nativeExpansionLeaderSkillsProfile(game) && !composedSkills) ||
((game.discoveryEnabled || game.discoveries || game.discoveryStash || game.greatMaker) && !discovery) ||
(game.techTokens && !discovery && !composedSkills && !nativeFactionTechProfile(game) && !classicNexusModulesProfile(game) && !pairedNexusModulesProfile(game) && !singleE1E2NexusProfile(game) && !standaloneE3NexusProfile(game)) ||
game.mentatQuestionPreview
)
throw new Error('--resume sample scenarios exclude unsupported optional modules.');
const scenario = [...SCENARIOS, ...SINGLE_NATIVE_NEXUS_MODULE_SCENARIOS, ...E3_NEXUS_MODULE_SCENARIOS, ...HOMEWORLD_NATIVE_NEXUS_MODULE_SCENARIOS, ...HOMEWORLD_CLASSIC_NEXUS_MODULE_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, ...NEXUS_SKILLS_SCENARIOS, ...PAIRED_NEXUS_SKILLS_SCENARIOS, ...NEXUS_SKILLS_MODULE_SCENARIOS, ...PAIRED_NEXUS_SKILLS_MODULE_SCENARIOS, ...E3_NEXUS_SKILLS_SCENARIOS, ...HOMEWORLD_OCCUPATION_SCENARIOS, ...DISCOVERY_SCENARIOS, ...DISCOVERY_SKILLS_SCENARIOS, ...DISCOVERY_NEXUS_SCENARIOS, ...DISCOVERY_STRONGHOLD_SCENARIOS, ...NATIVE_DISCOVERY_SKILLS_SCENARIOS, ...DISCOVERY_NEXUS_SKILLS_SCENARIOS, ...PAIRED_DISCOVERY_NEXUS_SCENARIOS, ...NATIVE_DISCOVERY_NEXUS_SKILLS_SCENARIOS, ...HOMEWORLD_CLASSIC_MODULE_SCENARIOS, ...HOMEWORLD_NATIVE_MODULE_SCENARIOS].find((candidate) =>
(homeworldDiscoverySampleProfile(candidate.profile) || nativeDiscoverySkillsProfile(candidate.profile) || discoveryProfile(candidate.profile) || discoverySkillsProfile(candidate.profile) || discoveryNexusProfile(candidate.profile)) === discovery &&
(homeworldModuleSampleProfile(candidate.profile) || candidate.profile === 'homeworld' || candidate.profile === 'homeworld-occupation' || candidate.profile === 'homeworld-nexus' || candidate.profile === 'combined-homeworld-nexus') === !!game.homeworlds &&
(candidate.profile === 'paired-nexus-skills' || candidate.profile === 'nexus-skills' || 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 &&
(candidate.profile === 'homeworld-occupation') === !!game.homeworldOccupationPreview &&
skillProfile(candidate.profile) === !!game.leaderSkills &&
(homeworldTechSampleProfile(candidate.profile) || discoveryTechProfile(candidate.profile) || candidate.profile === 'skills-tech' || candidate.profile === 'skills-stronghold-tech' || nativeTechProfile(candidate.profile) || nexusTechProfile(candidate.profile)) === !!game.techTokens &&
(homeworldStrongholdSampleProfile(candidate.profile) || discoveryStrongholdProfile(candidate.profile) || 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' || ((e3NexusSampleProfile(candidate.profile) || homeworldModuleSampleProfile(candidate.profile) || nativeDiscoveryNexusSkillsProfile(candidate.profile) || nativeDiscoverySkillsProfile(candidate.profile) || nativeTechProfile(candidate.profile) || discoveryProfile(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) {
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') && !discoveryProfile(profile) && profile !== 'homeworld-occupation' && !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 ((homeworldStrongholdSampleProfile(profile) || profile === 'stronghold-factions' || strongholdSkillProfile(profile) || nexusStrongholdProfile(profile)) && rules === 'basic')
throw new Error('Stronghold samples require Advanced rules.');
if ((homeworldTechSampleProfile(profile) || nexusTechProfile(profile) || profile === 'skills-tech' || profile === 'skills-stronghold-tech') && players === 2)
throw new Error('Tech Tokens samples require three through six players.');
if ((discoveryTechProfile(profile) || (discoveryNexusProfile(profile) && nexusTechProfile(profile))) && players === 2)
throw new Error('Discovery with Tech Tokens requires three through six players.');
const resume = values.resume ? resumedGame(values.resume) : null;
const requestedSamples = singleNativeNexusSampleProfile(profile) ? SINGLE_NATIVE_NEXUS_MODULE_SCENARIOS :
e3NexusSampleProfile(profile) ? E3_NEXUS_MODULE_SCENARIOS : homeworldModuleSampleProfile(profile)
? (nativeHomeworldNexusSampleProfile(profile) ? HOMEWORLD_NATIVE_NEXUS_MODULE_SCENARIOS
: homeworldNexusSampleProfile(profile) ? HOMEWORLD_CLASSIC_NEXUS_MODULE_SCENARIOS
: profile.startsWith('native-') ? HOMEWORLD_NATIVE_MODULE_SCENARIOS : HOMEWORLD_CLASSIC_MODULE_SCENARIOS)
: nativeDiscoveryNexusSkillsProfile(profile) ? NATIVE_DISCOVERY_NEXUS_SKILLS_SCENARIOS
: discoveryNexusSkillsProfile(profile) ? DISCOVERY_NEXUS_SKILLS_SCENARIOS
: pairedDiscoveryNexusSampleProfile(profile) ? PAIRED_DISCOVERY_NEXUS_SCENARIOS
: nativeDiscoverySkillsProfile(profile) ? NATIVE_DISCOVERY_SKILLS_SCENARIOS
: discoveryStrongholdProfile(profile) ? DISCOVERY_STRONGHOLD_SCENARIOS
: discoveryNexusProfile(profile) ? DISCOVERY_NEXUS_SCENARIOS
: discoverySkillsProfile(profile) ? DISCOVERY_SKILLS_SCENARIOS
: discoveryProfile(profile) ? DISCOVERY_SCENARIOS
: profile === 'homeworld-occupation' ? HOMEWORLD_OCCUPATION_SCENARIOS
: profile === 'homeworld' || profile === 'nexus' || profile === 'homeworld-nexus'
? MODULE_SCENARIOS
: pairedNexusModuleProfile(profile) ? PAIRED_NEXUS_MODULE_SCENARIOS
: profile === 'nexus-skills' ? NEXUS_SKILLS_SCENARIOS
: profile === 'paired-nexus-skills' ? PAIRED_NEXUS_SKILLS_SCENARIOS
: pairedNexusSkillsModuleProfile(profile) ? PAIRED_NEXUS_SKILLS_MODULE_SCENARIOS
: nexusSkillsModuleProfile(profile) ? NEXUS_SKILLS_MODULE_SCENARIOS
: e3NexusSkillsProfile(profile) ? E3_NEXUS_SKILLS_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;
});