import assert from 'node:assert/strict'; import { createHash } from 'node:crypto'; import { readFileSync, statSync, writeFileSync } from 'node:fs'; import { basename, dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { parseArgs } from 'node:util'; import { classicNexusModulesProfile, mixedE1E2NexusProfile, mixedE3NexusProfile, pairedE3NexusProfile, 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; const MIXED_PAIRED_NEXUS_MODULE_PROFILES = [ 'mixed-nexus', 'mixed-nexus-tech', 'mixed-nexus-stronghold', 'mixed-nexus-stronghold-tech', 'mixed-nexus-skills', 'mixed-nexus-skills-tech', 'mixed-nexus-skills-stronghold', 'mixed-nexus-skills-stronghold-tech', 'mixed-discovery-nexus', 'mixed-discovery-nexus-tech', 'mixed-discovery-nexus-stronghold', 'mixed-discovery-nexus-stronghold-tech', 'mixed-discovery-nexus-skills', 'mixed-discovery-nexus-skills-tech', 'mixed-discovery-nexus-skills-stronghold', 'mixed-discovery-nexus-skills-stronghold-tech', 'mixed-homeworld-nexus', 'mixed-homeworld-nexus-tech', 'mixed-homeworld-nexus-stronghold', 'mixed-homeworld-nexus-stronghold-tech', 'mixed-homeworld-nexus-skills', 'mixed-homeworld-nexus-skills-tech', 'mixed-homeworld-nexus-skills-stronghold', 'mixed-homeworld-nexus-skills-stronghold-tech', 'mixed-homeworld-nexus-discovery', 'mixed-homeworld-nexus-discovery-tech', 'mixed-homeworld-nexus-discovery-stronghold', 'mixed-homeworld-nexus-discovery-stronghold-tech', 'mixed-homeworld-nexus-discovery-skills', 'mixed-homeworld-nexus-discovery-skills-tech', 'mixed-homeworld-nexus-discovery-skills-stronghold', 'mixed-homeworld-nexus-discovery-skills-stronghold-tech', 'paired-e3-nexus', 'paired-e3-nexus-tech', 'paired-e3-nexus-stronghold', 'paired-e3-nexus-stronghold-tech', 'paired-e3-nexus-skills', 'paired-e3-nexus-skills-tech', 'paired-e3-nexus-skills-stronghold', 'paired-e3-nexus-skills-stronghold-tech', 'paired-e3-discovery-nexus', 'paired-e3-discovery-nexus-tech', 'paired-e3-discovery-nexus-stronghold', 'paired-e3-discovery-nexus-stronghold-tech', 'paired-e3-discovery-nexus-skills', 'paired-e3-discovery-nexus-skills-tech', 'paired-e3-discovery-nexus-skills-stronghold', 'paired-e3-discovery-nexus-skills-stronghold-tech', 'paired-e3-homeworld-nexus', 'paired-e3-homeworld-nexus-tech', 'paired-e3-homeworld-nexus-stronghold', 'paired-e3-homeworld-nexus-stronghold-tech', 'paired-e3-homeworld-nexus-skills', 'paired-e3-homeworld-nexus-skills-tech', 'paired-e3-homeworld-nexus-skills-stronghold', 'paired-e3-homeworld-nexus-skills-stronghold-tech', 'paired-e3-homeworld-nexus-discovery', 'paired-e3-homeworld-nexus-discovery-tech', 'paired-e3-homeworld-nexus-discovery-stronghold', 'paired-e3-homeworld-nexus-discovery-stronghold-tech', 'paired-e3-homeworld-nexus-discovery-skills', 'paired-e3-homeworld-nexus-discovery-skills-tech', 'paired-e3-homeworld-nexus-discovery-skills-stronghold', 'paired-e3-homeworld-nexus-discovery-skills-stronghold-tech', ] as const; const MIXED_E3_NEXUS_MODULE_PROFILES = [ 'mixed-e3-nexus', 'mixed-e3-nexus-tech', 'mixed-e3-nexus-stronghold', 'mixed-e3-nexus-stronghold-tech', 'mixed-e3-nexus-skills', 'mixed-e3-nexus-skills-tech', 'mixed-e3-nexus-skills-stronghold', 'mixed-e3-nexus-skills-stronghold-tech', 'mixed-e3-discovery-nexus', 'mixed-e3-discovery-nexus-tech', 'mixed-e3-discovery-nexus-stronghold', 'mixed-e3-discovery-nexus-stronghold-tech', 'mixed-e3-discovery-nexus-skills', 'mixed-e3-discovery-nexus-skills-tech', 'mixed-e3-discovery-nexus-skills-stronghold', 'mixed-e3-discovery-nexus-skills-stronghold-tech', 'mixed-e3-homeworld-nexus', 'mixed-e3-homeworld-nexus-tech', 'mixed-e3-homeworld-nexus-stronghold', 'mixed-e3-homeworld-nexus-stronghold-tech', 'mixed-e3-homeworld-nexus-skills', 'mixed-e3-homeworld-nexus-skills-tech', 'mixed-e3-homeworld-nexus-skills-stronghold', 'mixed-e3-homeworld-nexus-skills-stronghold-tech', 'mixed-e3-homeworld-nexus-discovery', 'mixed-e3-homeworld-nexus-discovery-tech', 'mixed-e3-homeworld-nexus-discovery-stronghold', 'mixed-e3-homeworld-nexus-discovery-stronghold-tech', 'mixed-e3-homeworld-nexus-discovery-skills', 'mixed-e3-homeworld-nexus-discovery-skills-tech', 'mixed-e3-homeworld-nexus-discovery-skills-stronghold', 'mixed-e3-homeworld-nexus-discovery-skills-stronghold-tech', 'three-family-nexus', 'three-family-nexus-tech', 'three-family-nexus-stronghold', 'three-family-nexus-stronghold-tech', 'three-family-nexus-skills', 'three-family-nexus-skills-tech', 'three-family-nexus-skills-stronghold', 'three-family-nexus-skills-stronghold-tech', 'three-family-discovery-nexus', 'three-family-discovery-nexus-tech', 'three-family-discovery-nexus-stronghold', 'three-family-discovery-nexus-stronghold-tech', 'three-family-discovery-nexus-skills', 'three-family-discovery-nexus-skills-tech', 'three-family-discovery-nexus-skills-stronghold', 'three-family-discovery-nexus-skills-stronghold-tech', 'three-family-homeworld-nexus', 'three-family-homeworld-nexus-tech', 'three-family-homeworld-nexus-stronghold', 'three-family-homeworld-nexus-stronghold-tech', 'three-family-homeworld-nexus-skills', 'three-family-homeworld-nexus-skills-tech', 'three-family-homeworld-nexus-skills-stronghold', 'three-family-homeworld-nexus-skills-stronghold-tech', 'three-family-homeworld-nexus-discovery', 'three-family-homeworld-nexus-discovery-tech', 'three-family-homeworld-nexus-discovery-stronghold', 'three-family-homeworld-nexus-discovery-stronghold-tech', 'three-family-homeworld-nexus-discovery-skills', 'three-family-homeworld-nexus-discovery-skills-tech', 'three-family-homeworld-nexus-discovery-skills-stronghold', 'three-family-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] | (typeof MIXED_PAIRED_NEXUS_MODULE_PROFILES)[number] | (typeof MIXED_E3_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 mixedE3NexusSampleProfile = (profile: string) => (MIXED_E3_NEXUS_MODULE_PROFILES as readonly string[]).includes(profile); const threeFamilyNexusSampleProfile = (profile: string) => mixedE3NexusSampleProfile(profile) && profile.startsWith('three-family-'); // These original entry names predate native-power marks; resume their saved opt-ins, never retrofit them. const originalNativeNexusSampleProfile = (profile: string) => profile === 'combined-nexus' || profile === 'combined-homeworld-nexus' || profile === 'ecaz-nexus'; const mixedPairedNexusSampleProfile = (profile: string) => (MIXED_PAIRED_NEXUS_MODULE_PROFILES as readonly string[]).includes(profile); const pairedE3NexusSampleProfile = (profile: string) => mixedPairedNexusSampleProfile(profile) && profile.startsWith('paired-e3-'); 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) || mixedPairedNexusSampleProfile(profile) || mixedE3NexusSampleProfile(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) || mixedPairedNexusSampleProfile(profile) || mixedE3NexusSampleProfile(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) => mixedE3NexusSampleProfile(profile) || mixedPairedNexusSampleProfile(profile) || 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) || mixedPairedNexusSampleProfile(profile) || mixedE3NexusSampleProfile(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) || mixedPairedNexusSampleProfile(profile) || mixedE3NexusSampleProfile(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 MIXED_NEXUS_BASE_SCENARIOS: readonly Scenario[] = (['ixians', 'tleilaxu'] as const).flatMap(ix => (['choam', 'richese'] as const).flatMap(choam => (['basic', 'advanced'] as const).flatMap(rules => [2, 3, 4, 5, 6].map((players): Scenario => ({ profile: 'mixed-nexus', ordinal: 0, rules, expansions: ['ix', 'choam'], roster: ([ix, choam, 'emperor', 'guild', 'fremen', 'beneGesserit'] as FactionId[]).slice(0, players), }))))).concat( (['basic', 'advanced'] as const).flatMap(rules => [4, 5, 6].map((players): Scenario => ({ profile: 'mixed-nexus', ordinal: 0, rules, expansions: ['ix', 'choam'], roster: (['ixians', 'tleilaxu', 'choam', 'richese', 'guild', 'fremen'] as FactionId[]).slice(0, players), })))); const PAIRED_E3_NEXUS_BASE_SCENARIOS: readonly Scenario[] = (['basic', 'advanced'] as const).flatMap(rules => [2, 3, 4, 5, 6].map((players): Scenario => ({ profile: 'paired-e3-nexus', ordinal: 0, rules, expansions: ['ecaz'], roster: (['ecaz', 'moritani', 'emperor', 'guild', 'fremen', 'beneGesserit'] as FactionId[]).slice(0, players), }))); const MIXED_PAIRED_NEXUS_MODULE_SCENARIOS: readonly Scenario[] = MIXED_PAIRED_NEXUS_MODULE_PROFILES.flatMap((profile, band) => (pairedE3NexusSampleProfile(profile) ? PAIRED_E3_NEXUS_BASE_SCENARIOS : MIXED_NEXUS_BASE_SCENARIOS) .filter(scenario => (!nexusStrongholdProfile(profile) || scenario.rules === 'advanced') && (!nexusTechProfile(profile) || scenario.roster.length >= 3)) .map((scenario, index): Scenario => ({ ...scenario, profile, ordinal: 17400 + band * 100 + index }))); const MIXED_E3_NEXUS_BASE_SCENARIOS: readonly Scenario[] = (['ecaz', 'moritani'] as const).flatMap(e3 => (['ixians', 'tleilaxu', 'choam', 'richese'] as const).flatMap(native => [2, 3, 4, 5, 6].flatMap(players => (['basic', 'advanced'] as const).map((rules): Scenario => ({ profile: 'mixed-e3-nexus', ordinal: 0, rules, expansions: ['ecaz', native === 'ixians' || native === 'tleilaxu' ? 'ix' : 'choam'], roster: ([e3, native, 'emperor', 'guild', 'fremen', 'beneGesserit'] as FactionId[]).slice(0, players), }))))).concat( (['ixians', 'tleilaxu', 'choam', 'richese'] as const).flatMap(native => [3, 4, 5, 6].flatMap(players => (['basic', 'advanced'] as const).map((rules): Scenario => ({ profile: 'mixed-e3-nexus', ordinal: 0, rules, expansions: ['ecaz', native === 'ixians' || native === 'tleilaxu' ? 'ix' : 'choam'], roster: (['ecaz', 'moritani', native, 'emperor', 'guild', 'fremen'] as FactionId[]).slice(0, players), })))), ([['ixians', 'tleilaxu'], ['choam', 'richese']] as const).flatMap(natives => [4, 5, 6].flatMap(players => (['basic', 'advanced'] as const).map((rules): Scenario => ({ profile: 'mixed-e3-nexus', ordinal: 0, rules, expansions: ['ecaz', natives[0] === 'ixians' ? 'ix' : 'choam'], roster: (['ecaz', 'moritani', ...natives, 'guild', 'fremen'] as FactionId[]).slice(0, players), })))), ); const THREE_FAMILY_NEXUS_BASE_SCENARIOS: readonly Scenario[] = (['ecaz', 'moritani'] as const).flatMap(e3 => (['ixians', 'tleilaxu'] as const).flatMap(e1 => (['choam', 'richese'] as const).flatMap(e2 => [3, 4, 5, 6].flatMap(players => (['basic', 'advanced'] as const).map((rules): Scenario => ({ profile: 'three-family-nexus', ordinal: 0, rules, expansions: ['ix', 'choam', 'ecaz'], roster: ([e3, e1, e2, 'emperor', 'guild', 'fremen'] as FactionId[]).slice(0, players), })))))).concat( (['ixians', 'tleilaxu'] as const).flatMap(e1 => (['choam', 'richese'] as const).flatMap(e2 => [4, 5, 6].flatMap(players => (['basic', 'advanced'] as const).map((rules): Scenario => ({ profile: 'three-family-nexus', ordinal: 0, rules, expansions: ['ix', 'choam', 'ecaz'], roster: (['ecaz', 'moritani', e1, e2, 'guild', 'fremen'] as FactionId[]).slice(0, players), }))))), [5, 6].flatMap(players => (['basic', 'advanced'] as const).map((rules): Scenario => ({ profile: 'three-family-nexus', ordinal: 0, rules, expansions: ['ix', 'choam', 'ecaz'], roster: (['ecaz', 'moritani', 'ixians', 'tleilaxu', 'choam', 'guild'] as FactionId[]).slice(0, players), }))), (['basic', 'advanced'] as const).map((rules): Scenario => ({ profile: 'three-family-nexus', ordinal: 0, rules, expansions: ['ix', 'choam', 'ecaz'], roster: ['ecaz', 'moritani', 'ixians', 'tleilaxu', 'choam', 'richese'], })), ); const MIXED_E3_NEXUS_MODULE_SCENARIOS: readonly Scenario[] = MIXED_E3_NEXUS_MODULE_PROFILES.flatMap((profile, band) => (threeFamilyNexusSampleProfile(profile) ? THREE_FAMILY_NEXUS_BASE_SCENARIOS : MIXED_E3_NEXUS_BASE_SCENARIOS) .filter(scenario => (!nexusStrongholdProfile(profile) || scenario.rules === 'advanced') && (!nexusTechProfile(profile) || scenario.roster.length >= 3)) .map((scenario, index): Scenario => ({ ...scenario, profile, ordinal: 23800 + band * 200 + 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) || mixedPairedNexusSampleProfile(profile) || mixedE3NexusSampleProfile(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') && (mixedE3NexusSampleProfile(scenario.profile) || pairedE3NexusSampleProfile(scenario.profile) || e3NexusSampleProfile(scenario.profile) || scenario.profile === 'moritani-assassinate' || discoveryProfile(scenario.profile) || nativeStrongholdProfile(scenario.profile) || nativeTechProfile(scenario.profile) || skillProfile(scenario.profile)); function originalNativeFactionStateMatches(game: Game, scenario: Scenario) { const noAssassination = game.moritaniAssassinatePreview === undefined && game.moritaniAssassinate === undefined && game.moritaniAssassinateResume === undefined && game.moritaniAssassinateCallEvents === undefined; // Older fixed samples had neither native power. Preserve that saved absence; // current setup supplies Occupy plus Advanced Moritani's original ability state. return (game.ecazOccupyPreview === undefined && noAssassination) || (game.ecazOccupyPreview === true && (scenario.rules === 'advanced' && scenario.roster.includes('moritani') ? game.moritaniAssassinatePreview === true && !!game.moritaniAssassinate : noAssassination)); } type TraceEntry = { attempt: number; accepted: number; player: string; turn: number; phase: number; setup: string | null; decision: string | null; response: string | null; action: Action; outcome: 'accepted' | 'rejected'; error?: string; }; type Result = { name: string; profile: Profile; rules: Rules; advanced: boolean; seed: number; resumed: boolean; actions: number; attempts: number; restores: number; turn: number; phase: number; winner: string[]; used: Record; rejected: Record; outcome: 'complete' | 'failure'; error?: string; }; function usage() { return ( 'Usage: node --import tsx tools/faction-games.ts --out NEW_PRIVATE_DIR ' + '[--seed UINT32] [--profile all|base|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|' + MIXED_E3_NEXUS_MODULE_PROFILES.join('|') + '|' + MIXED_PAIRED_NEXUS_MODULE_PROFILES.join('|') + '|' + 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' + 'Mixed E3 and three-family Nexus: mixed-e3-nexus/mixed-e3-discovery-nexus/mixed-e3-homeworld-nexus/mixed-e3-homeworld-nexus-discovery, or three-family versions, each optional -skills then -tech/-stronghold/-stronghold-tech. Selected Ecaz plus Ix and/or CHOAM with at least one actual native from EVERY selected family, classics allowed; original47/35 decks and separate native cache, all12 Nexus/all14 only with Skills. Basic/Advanced2–6, Tech3+, Strongholds Advanced, original Ecaz Occupy/Advanced Moritani assassination and ordinary native setup. Shared Duke never trains; existing capture, rounding, payment and pending-effect guards remain. New23800–36400 bands use200-wide source slots; old names/seeds unchanged. No public start or played-game conversion.\n' + 'Mixed E1/E2 and paired E3 Nexus: mixed-nexus/mixed-discovery-nexus/mixed-homeworld-nexus/mixed-homeworld-nexus-discovery, or paired-e3-nexus/paired-e3-discovery-nexus/paired-e3-homeworld-nexus/paired-e3-homeworld-nexus-discovery, each optional -skills then -tech/-stronghold/-stronghold-tech. Both original E1/E2 native families with ix+choam union47, or BOTH Ecaz+Moritani/ecaz33 with classics; all12 Nexus and all14 only when Skills selected, Basic/Advanced2–6, Tech3+, Strongholds Advanced. Original native setup, Duke/assassination/capture/rounding/income/card guards remain; no public starts or save conversion. New17400–23700 bands leave older names/seeds unchanged.\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, industry and winner/held timing remain. Separate single-native E1/E2, mixed E1/E2, paired E3 and no-Skills standalone E3 families above retain their own exact roster/effect predicates. Three-family/other E3 mixtures, 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 (mixedE3NexusSampleProfile(scenario.profile) || mixedPairedNexusSampleProfile(scenario.profile) || 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 (pairedE3NexusSampleProfile(scenario.profile) && !skillProfile(scenario.profile)) return initializePairedNexusGameForAudit(game); 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) && !mixedE3NexusProfile(game) && !mixedE1E2NexusProfile(game) && !pairedE3NexusProfile(game) && !singleE1E2NexusProfile(game) && !standaloneE3NexusProfile(game)) ) throw new Error('--resume sample scenarios exclude unsupported optional modules.'); const sourceName = basename(path), namedSource = sourceName.startsWith('failed-'); const scenario = [...SCENARIOS, ...MIXED_E3_NEXUS_MODULE_SCENARIOS, ...MIXED_PAIRED_NEXUS_MODULE_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) => (!namedSource || sourceName === `failed-${scenarioName(candidate)}.json`) && (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 && (['ecaz', 'combined', 'ecaz-roster', 'ecaz-treachery'].includes(candidate.profile) ? originalNativeFactionStateMatches(game, candidate) : (originalNativeNexusSampleProfile(candidate.profile) ? !!game.moritaniAssassinatePreview === !!game.moritaniAssassinate : assassinationScenario(candidate) === !!game.moritaniAssassinatePreview && assassinationScenario(candidate) === !!game.moritaniAssassinate) && (originalNativeNexusSampleProfile(candidate.profile) || (candidate.profile === 'ecaz-occupy' || ((mixedE3NexusSampleProfile(candidate.profile) || pairedE3NexusSampleProfile(candidate.profile) || e3NexusSampleProfile(candidate.profile) || homeworldModuleSampleProfile(candidate.profile) || nativeDiscoveryNexusSkillsProfile(candidate.profile) || nativeDiscoverySkillsProfile(candidate.profile) || nativeTechProfile(candidate.profile) || discoveryProfile(candidate.profile)) && candidate.roster.includes('ecaz'))) === !!game.ecazOccupyPreview)) && 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 = {}; const rejected: Record = {}; let actions = 0; let restores = 0; try { verifySampleCustody(game, expected); while (actions < maxActions && game.status !== 'finished') { let next: Game | undefined; for (const player of game.players) { for (const action of botActions(viewGame(game, player.id))) { const context = { attempt: trace.length, accepted: actions, player: player.id, turn: game.turn, phase: game.phase, setup: game.setupStage ?? null, decision: game.decision?.kind ?? null, response: game.response?.kind ?? null, action, }; const unchanged = JSON.stringify(game); try { next = applyAction(game, player.id, action); const label = action.type + (action.type === 'decision' ? `:${game.decision?.kind ?? 'none'}` : ''); used[label] = (used[label] ?? 0) + 1; trace.push({ ...context, outcome: 'accepted' }); actions++; break; } catch (error) { assert.equal( JSON.stringify(game), unchanged, 'rejected-action immutability', ); if (!(error instanceof RuleError)) throw error; const message = error.message; const label = `${action.type}: ${message}`; rejected[label] = (rejected[label] ?? 0) + 1; trace.push({ ...context, outcome: 'rejected', error: message }); } } if (next) break; } if (!next) throw new Error( `No legal candidate: ${JSON.stringify({ status: game.status, setup: game.setupStage, phase: game.phase, decision: game.decision?.kind, response: game.response?.kind, preparation: game.battle?.preparation, })}`, ); 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 (threeFamilyNexusSampleProfile(profile) && players === 2) throw new Error('Three-family Nexus samples require three through 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 = mixedE3NexusSampleProfile(profile) ? MIXED_E3_NEXUS_MODULE_SCENARIOS : mixedPairedNexusSampleProfile(profile) ? MIXED_PAIRED_NEXUS_MODULE_SCENARIOS : 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: (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; });