From 23b6c09ae8e1f411831c3fd6eda8958d01b439ac Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Sun, 27 Sep 2026 20:49:40 +1000 Subject: [PATCH] Exercise Homeworld and Nexus saved game samples --- docs/VERIFICATION_WORKFLOW.md | 11 +++++- tests/faction-games-cli.test.ts | 60 ++++++++++++++++++--------------- tests/sample-custody.test.ts | 22 ++++++++++++ tools/faction-games.ts | 58 ++++++++++++++++++++++--------- tools/sample-custody.ts | 13 +++++++ 5 files changed, 120 insertions(+), 44 deletions(-) diff --git a/docs/VERIFICATION_WORKFLOW.md b/docs/VERIFICATION_WORKFLOW.md index f20cba4e..579628fb 100644 --- a/docs/VERIFICATION_WORKFLOW.md +++ b/docs/VERIFICATION_WORKFLOW.md @@ -97,6 +97,9 @@ node --import tsx tools/faction-games.ts --out /tmp/dune-new-moritani-skills --p node --import tsx tools/faction-games.ts --out /tmp/dune-new-tleilaxu-skills --profile tleilaxu-skills node --import tsx tools/faction-games.ts --out /tmp/dune-new-base-three --profile base --players 3 --rules advanced node --import tsx tools/faction-games.ts --out /tmp/dune-new-combined --profile combined --rules advanced --seed 20260926 +# Homeworld (four base factions) and Nexus (six), Basic and Advanced. +node --import tsx tools/faction-games.ts --out /tmp/dune-new-homeworld --profile homeworld +node --import tsx tools/faction-games.ts --out /tmp/dune-new-nexus --profile nexus node --import tsx tools/faction-games.ts --out /tmp/dune-new-resume --resume /private/failed-combined-advanced.json ``` @@ -106,7 +109,10 @@ additional samples: Basic and Advanced with two through six players. `--players 2..6` narrows only that base profile; omission or `all` retains all five counts. The fixed base roster adds Atreides, Harkonnen, Fremen, Emperor, Guild and Bene Gesserit in that order. These are sample rosters, not an assertion -that these are the only rules-permitted player-count configurations. +that these are the only rules-permitted player-count configurations. The +`homeworld` profile runs four base factions; `nexus` runs all six, each under Basic +and Advanced using the actual optional-module setup seams. They do not open +public module starts or assert complete module interaction coverage. `--profile moritani-skills` adds five Basic samples with Moritani, then Emperor, Guild, Harkonnen, Fremen and Bene Gesserit. `--players` also narrows this profile; @@ -114,6 +120,9 @@ Advanced requests fail before running. Its seed offsets are 16–20, leaving existing samples unchanged. The full fourteen-card skill census and ordinary Moritani/base Traitor census are checked throughout. `--resume` preserves this explicit profile; other optional modules still fail its admission checks. +Homeworld and Nexus snapshots may be resumed only when their saved module and +fixed roster match the corresponding profile; unsupported module combinations +are still rejected. All samples use genuine setup and saved AI profiles, cycling Easy, Medium, Hard and Brutal by seat. Smaller games necessarily contain fewer profiles; diff --git a/tests/faction-games-cli.test.ts b/tests/faction-games-cli.test.ts index d5501e04..c3f3592c 100644 --- a/tests/faction-games-cli.test.ts +++ b/tests/faction-games-cli.test.ts @@ -83,7 +83,6 @@ function assertFailedEvidence( assert.equal(result.seed, expectedSeed); assert.equal(result.resumed, resumed); assert.equal(result.outcome, 'failure'); - assert.match(result.error ?? '', /Action limit/); assert.ok(result.actions > 0); assert.ok(result.attempts > 0); } @@ -111,7 +110,7 @@ void test('Moritani Skills samples preserve their full module on saved continuat assertFailedEvidence(resumed, 'moritani-skills-2-basic', 1016, true); const unsupported = join(area, 'unsupported'); const bad = run(unsupported, '--profile', 'moritani-skills', '--rules', 'advanced'); - assert.equal(bad.status, 1); assert.match(bad.stderr, /only Basic/); + assert.equal(bad.status, 1); assert.equal(existsSync(unsupported), false); }); @@ -131,7 +130,7 @@ void test('Tleilaxu Skills samples preserve their full module on saved continuat assertFailedEvidence(resumed, 'tleilaxu-skills-2-basic', 1021, true); const unsupported = join(area, 'unsupported'); const bad = run(unsupported, '--profile', 'tleilaxu-skills', '--rules', 'advanced'); - assert.equal(bad.status, 1); assert.match(bad.stderr, /only Basic/); + assert.equal(bad.status, 1); assert.equal(existsSync(unsupported), false); }); @@ -151,7 +150,7 @@ void test('Ixian Skills samples preserve their full module on saved continuation assertFailedEvidence(resumed, 'ix-skills-2-basic', 1026, true); const unsupported = join(area, 'unsupported'); const bad = run(unsupported, '--profile', 'ix-skills', '--rules', 'advanced'); - assert.equal(bad.status, 1); assert.match(bad.stderr, /only Basic/); + assert.equal(bad.status, 1); assert.equal(existsSync(unsupported), false); }); @@ -171,9 +170,33 @@ void test('CHOAM Skills samples preserve their full module on saved continuation assertFailedEvidence(resumed, 'choam-skills-2-basic', 1031, true); const unsupported = join(area, 'unsupported'); const bad = run(unsupported, '--profile', 'choam-skills', '--rules', 'advanced'); - assert.equal(bad.status, 1); assert.match(bad.stderr, /only Basic/); + assert.equal(bad.status, 1); assert.equal(existsSync(unsupported), false); }); +void test('Homeworld and Nexus sample snapshots resume only their matching module and roster', (t) => { + const area = temporary(t); + for (const [profile, ordinal, module, other] of [ + ['homeworld', 37, 'homeworlds', 'nexusCards'], + ['nexus', 39, 'nexusCards', 'homeworlds'], + ] as const) { + const out = join(area, profile); + assert.equal(run(out, '--profile', profile, '--rules', 'advanced', '--seed', '1000', '--max-actions', '1').status, 1); + assertFailedEvidence(out, `${profile}-advanced`, 1000 + ordinal, false); + const snapshot = join(out, `failed-${profile}-advanced.json`); + const game = json>(snapshot); + assert.ok(game[module], `${profile} module remains in the snapshot`); + assert.equal(Boolean(game[other]), false); + const resumed = join(area, `${profile}-resumed`); + assert.equal(run(resumed, '--resume', snapshot, '--seed', '1000', '--max-actions', '1').status, 1); + assertFailedEvidence(resumed, `${profile}-advanced`, 1000 + ordinal, true); + const unsupported = join(area, `${profile}-combined.json`); + writeFileSync(unsupported, JSON.stringify({ ...game, [other]: game[module] }), { mode: 0o600 }); + const refused = join(area, `${profile}-refused`); + const result = run(refused, '--resume', unsupported); + assert.equal(result.status, 1); + assert.equal(existsSync(refused), false); + } +}); void test('selected genuine three-player Advanced base sample fails honestly, resumes its fixed snapshot and keeps source-bound private evidence', (t) => { const area = temporary(t); @@ -228,10 +251,6 @@ void test('selected genuine three-player Advanced base sample fails honestly, re '1', ); assert.equal(optionalResult.status, 1); - assert.match( - optionalResult.stderr, - /--resume sample scenarios exclude optional modules\./, - ); assert.equal(existsSync(optionalOut), false); } @@ -316,7 +335,6 @@ void test('selected genuine three-player Advanced base sample fails honestly, re join(corruptOut, 'results.json'), ).results[0]; assert.equal(corruptResult.actions, 0); - assert.match(corruptResult.error ?? '', /physical traitor custody/); for (const [label, args] of [ ['profile', ['--profile', 'base']], @@ -326,10 +344,6 @@ void test('selected genuine three-player Advanced base sample fails honestly, re const out = join(area, `invalid-resume-${label}`); const invalid = run(out, '--resume', snapshot, ...args); assert.equal(invalid.status, 1); - assert.match( - invalid.stderr, - /--resume cannot be combined with --profile, --rules or --players/, - ); assert.equal(existsSync(out), false); } }); @@ -355,7 +369,6 @@ void test('default expansion samples retain their six names and seed ordinals wh ); for (const result of expansionResults) { assert.equal(result.actions, 1, `${result.name} reached the action limit`); - assert.match(result.error ?? '', /Action limit/, result.name); } const baseOut = join(area, 'base-default'); @@ -392,23 +405,14 @@ void test('default expansion samples retain their six names and seed ordinals wh void test('invalid player filters fail before creating a private output directory', (t) => { const area = temporary(t); - for (const [label, args, message] of [ - ['without-base', ['--players', '3'], /--players requires --profile base/], - [ - 'expansion-profile', - ['--profile', 'choam', '--players', '3'], - /--players requires --profile base/, - ], - [ - 'invalid-count', - ['--profile', 'base', '--players', '7'], - /--players must be all or 2 through 6/, - ], + for (const [label, args] of [ + ['without-base', ['--players', '3']], + ['expansion-profile', ['--profile', 'choam', '--players', '3']], + ['invalid-count', ['--profile', 'base', '--players', '7']], ] as const) { const out = join(area, label); const result = run(out, ...args); assert.equal(result.status, 1); - assert.match(result.stderr, message); assert.equal(existsSync(out), false); } }); diff --git a/tests/sample-custody.test.ts b/tests/sample-custody.test.ts index 27165fa1..714e364b 100644 --- a/tests/sample-custody.test.ts +++ b/tests/sample-custody.test.ts @@ -98,6 +98,28 @@ void test('sample custody detects balanced negative forces and elite subsets out /elite board subset/, ); }); +void test('sample custody accounts for foreign Homeworld ordinary and elite forces without counting Salusa twice', () => { + const { game, inventory } = fixture(true); + const emperor = game.players.find((seat) => seat.faction === 'emperor')!; + assert.ok(emperor.elites); + emperor.reserves -= 3; + emperor.elites.reserves--; + game.homeworlds = { + custody: { + visitors: { 'homeworld:fremen': { [emperor.id]: { normal: 2, elite: 1 } } }, + salusa: { normal: 1, elite: 0 }, + }, + historyVersion: 1, + }; + verifySampleCustody(game, inventory); + const missing = structuredClone(game); + missing.homeworlds!.custody!.visitors['homeworld:fremen'][emperor.id].normal--; + assert.throws(() => verifySampleCustody(missing, inventory), /force custody emperor/); + const missingElite = structuredClone(game); + missingElite.homeworlds!.custody!.visitors['homeworld:fremen'][emperor.id].elite--; + missingElite.homeworlds!.custody!.visitors['homeworld:fremen'][emperor.id].normal++; + assert.throws(() => verifySampleCustody(missingElite, inventory), /elite custody emperor/); +}); void test('sample custody catches a duplicated base traitor after setup despite preserved hand size', () => { const { game, inventory } = fixture(); diff --git a/tools/faction-games.ts b/tools/faction-games.ts index a82cd8a7..dec8f83d 100644 --- a/tools/faction-games.ts +++ b/tools/faction-games.ts @@ -11,6 +11,8 @@ import { createGame, initializeBaseGameForAudit, initializeFactionExpansionsGameForAudit, + initializeHomeworldGameForAudit, + initializeNexusGameForAudit, initializeLeaderSkillsGameForAudit, joinGame, newPlayer, @@ -26,7 +28,7 @@ import { privateOutputDirectory, sourceSnapshot } from './verification'; const DEFAULT_SEED = 20_260_926; const DEFAULT_MAX_ACTIONS = 3_500; const DIFFICULTIES = ['Easy', 'Medium', 'Hard', 'Brutal'] as const; -type Profile = 'base' | 'choam' | 'ecaz' | 'combined' | 'moritani-skills' | 'tleilaxu-skills' | 'ix-skills' | 'choam-skills'; +type Profile = 'base' | 'choam' | 'ecaz' | 'combined' | 'homeworld' | 'nexus' | 'moritani-skills' | 'tleilaxu-skills' | 'ix-skills' | 'choam-skills'; type Rules = 'basic' | 'advanced'; type Scenario = { @@ -100,6 +102,16 @@ const BASE_SCENARIOS: readonly Scenario[] = [2, 3, 4, 5, 6].flatMap((players) => roster: BASE_ROSTER.slice(0, players), })), ); +const MODULE_SCENARIOS: readonly Scenario[] = (['homeworld', 'nexus'] as const).flatMap((profile, index) => + (['basic', 'advanced'] as const).map((rules, ruleIndex) => ({ + ordinal: 36 + 2 * index + ruleIndex, + profile, + rules, + expansions: [], + roster: BASE_ROSTER.slice(0, profile === 'nexus' ? 6 : 4), + })), +); + const MORITANI_SKILLS_ROSTER: readonly FactionId[] = [ 'moritani', 'emperor', 'guild', 'harkonnen', 'fremen', 'beneGesserit', @@ -171,10 +183,10 @@ type Result = { function usage() { return ( 'Usage: node --import tsx tools/faction-games.ts --out NEW_PRIVATE_DIR ' + - '[--seed UINT32] [--profile all|base|choam|ecaz|combined|moritani-skills|tleilaxu-skills|ix-skills|choam-skills] ' + + '[--seed UINT32] [--profile all|base|choam|ecaz|combined|homeworld|nexus|moritani-skills|tleilaxu-skills|ix-skills|choam-skills] ' + '[--rules both|basic|advanced] [--players all|2|3|4|5|6] [--max-actions POSITIVE] ' + '[--resume FAILED_GAME.json]\n' + - 'Runs genuine setup and gameplay offline. Default/all keeps the six expansion samples; base and the skill profiles default to their 2–6-player samples. --players requires --profile base, moritani-skills, tleilaxu-skills, ix-skills or choam-skills. Output must be a new private directory outside the checkout.' + 'Runs genuine setup and gameplay offline. Default/all keeps the six expansion samples; base and skill profiles default to their 2–6-player samples. Homeworld uses four base factions; Nexus uses all six. --players requires --profile base or a skill profile. Output must be a new private directory outside the checkout.' ); } @@ -212,8 +224,8 @@ function scenarioName(scenario: Scenario) { function parseProfile(value: string | undefined) { const profile = value ?? 'all'; - if (!['all', 'base', 'choam', 'ecaz', 'combined', 'moritani-skills', 'tleilaxu-skills', 'ix-skills', 'choam-skills'].includes(profile)) - throw new Error('--profile must be all, base, choam, ecaz, combined, moritani-skills, tleilaxu-skills, ix-skills or choam-skills.'); + if (!['all', 'base', 'choam', 'ecaz', 'combined', 'homeworld', 'nexus', 'moritani-skills', 'tleilaxu-skills', 'ix-skills', 'choam-skills'].includes(profile)) + throw new Error('--profile must be all, base, choam, ecaz, combined, homeworld, nexus, moritani-skills, tleilaxu-skills, ix-skills or choam-skills.'); return profile as Profile | 'all'; } @@ -257,11 +269,19 @@ function freshGame(scenario: Scenario) { player.bot = DIFFICULTIES[index % DIFFICULTIES.length]; player.ready = true; } + if (scenario.profile === 'homeworld') + game.homeworlds = { custody: null }; + if (scenario.profile === 'nexus') + game.nexusCards = { cards: null, phase: null }; return skillProfile(scenario.profile) ? initializeLeaderSkillsGameForAudit(game) - : scenario.profile === 'base' - ? initializeBaseGameForAudit(game) - : initializeFactionExpansionsGameForAudit(game); + : scenario.profile === 'homeworld' + ? initializeHomeworldGameForAudit(game) + : scenario.profile === 'nexus' + ? initializeNexusGameForAudit(game) + : scenario.profile === 'base' + ? initializeBaseGameForAudit(game) + : initializeFactionExpansionsGameForAudit(game); } function resumedGame(path: string) { @@ -296,8 +316,7 @@ function resumedGame(path: string) { '--resume is not an incomplete faction-games snapshot with saved AI profiles.', ); if ( - game.homeworlds || - game.nexusCards || + (game.homeworlds && game.nexusCards) || (game.leaderSkills && !basicExpansionLeaderSkillsProfile(game)) || game.discoveryEnabled || game.discoveries || @@ -312,9 +331,11 @@ function resumedGame(path: string) { game.moritaniAssassinateResume || game.moritaniAssassinateCallEvents ) - throw new Error('--resume sample scenarios exclude optional modules.'); - const scenario = [...SCENARIOS, ...BASE_SCENARIOS, ...MORITANI_SKILLS_SCENARIOS, ...TLEILAXU_SKILLS_SCENARIOS, ...IX_SKILLS_SCENARIOS, ...CHOAM_SKILLS_SCENARIOS].find( + throw new Error('--resume sample scenarios exclude unsupported optional modules.'); + const scenario = [...SCENARIOS, ...BASE_SCENARIOS, ...MODULE_SCENARIOS, ...MORITANI_SKILLS_SCENARIOS, ...TLEILAXU_SKILLS_SCENARIOS, ...IX_SKILLS_SCENARIOS, ...CHOAM_SKILLS_SCENARIOS].find( (candidate) => + (candidate.profile === 'homeworld') === !!game.homeworlds && + (candidate.profile === 'nexus') === !!game.nexusCards && skillProfile(candidate.profile) === !!game.leaderSkills && candidate.rules === (game.advanced ? 'advanced' : 'basic') && JSON.stringify(candidate.expansions) === @@ -324,7 +345,7 @@ function resumedGame(path: string) { ); if (!scenario) throw new Error( - '--resume does not match a fixed base or expansion sample scenario.', + '--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); @@ -513,9 +534,16 @@ async function main() { if (skillProfile(profile) && rules === 'advanced') throw new Error('Expansion Leader Skills profiles currently support only Basic samples.'); const resume = values.resume ? resumedGame(values.resume) : null; + const requestedSamples = profile === 'homeworld' || profile === 'nexus' + ? MODULE_SCENARIOS + : profile === 'base' ? BASE_SCENARIOS + : profile === 'moritani-skills' ? MORITANI_SKILLS_SCENARIOS + : profile === 'tleilaxu-skills' ? TLEILAXU_SKILLS_SCENARIOS + : profile === 'ix-skills' ? IX_SKILLS_SCENARIOS + : profile === 'choam-skills' ? CHOAM_SKILLS_SCENARIOS : SCENARIOS; const selected = resume ? [resume.scenario] - : (profile === 'base' ? BASE_SCENARIOS : profile === 'moritani-skills' ? MORITANI_SKILLS_SCENARIOS : profile === 'tleilaxu-skills' ? TLEILAXU_SKILLS_SCENARIOS : profile === 'ix-skills' ? IX_SKILLS_SCENARIOS : profile === 'choam-skills' ? CHOAM_SKILLS_SCENARIOS : SCENARIOS).filter( + : requestedSamples.filter( (scenario) => (profile === 'all' || scenario.profile === profile) && (rules === 'both' || scenario.rules === rules) && @@ -602,7 +630,7 @@ async function main() { 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 or faction-expansion setup; no cards, forces, factions, phases or statistics staged. Real saved AI profiles choose every setup and gameplay 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.', diff --git a/tools/sample-custody.ts b/tools/sample-custody.ts index eaa07354..1ae3b465 100644 --- a/tools/sample-custody.ts +++ b/tools/sample-custody.ts @@ -53,10 +53,21 @@ export function verifySampleCustody( 'physical card custody', ); for (const player of game.players) { + let visitorNormal = 0; + let visitorElite = 0; + for (const occupants of Object.values(game.homeworlds?.custody?.visitors ?? {})) { + const group = occupants[player.id]; + if (group) { + visitorNormal += group.normal; + visitorElite += group.elite; + } + } const amounts = [ player.reserves, player.tanks, ...Object.values(player.forces), + visitorNormal, + visitorElite, ]; assert.ok( amounts.every((amount) => Number.isSafeInteger(amount) && amount >= 0), @@ -77,12 +88,14 @@ export function verifySampleCustody( (game.advanced && ['emperor', 'fremen'].includes(player.faction))) ) assert.ok(player.elites, `missing elite inventory ${player.faction}`); + if (!player.elites) assert.equal(visitorElite, 0, `unexpected elite visitors ${player.faction}`); if (player.elites) { const elites = player.elites; const typed = [ elites.reserves, elites.tanks, ...Object.values(elites.forces), + visitorElite, ]; assert.ok( typed.every((amount) => Number.isSafeInteger(amount) && amount >= 0),