diff --git a/docs/VERIFICATION_WORKFLOW.md b/docs/VERIFICATION_WORKFLOW.md index 80cb3ad8..66fa1c75 100644 --- a/docs/VERIFICATION_WORKFLOW.md +++ b/docs/VERIFICATION_WORKFLOW.md @@ -97,29 +97,36 @@ 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. +# Homeworld (four base factions) and Nexus (two through 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-nexus-three --profile nexus --players 3 --rules advanced node --import tsx tools/faction-games.ts --out /tmp/dune-new-resume --resume /private/failed-combined-advanced.json ``` Default and `--profile all` retain the original six fixed CHOAM/Richese, Ecaz/Moritani and combined Basic/Advanced samples. `--profile base` selects ten 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. 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. +`--players 2..6` narrows the base or Nexus 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. The `homeworld` profile runs four base factions; `nexus` runs +two through six, each under Basic and Advanced using the actual +optional-module setup seams. Nexus output names include the player count; +the six-seat random seed offsets remain 38–39, and smaller rosters use 40–47. +They do not open public module starts or assert complete module interaction +coverage. All-AI Nexus samples now make public reciprocal offers as well as accepting them, so an actual alliance can form without a human proposer. At the 20260927 seed, each six-seat sample made six alliance actions and completed; -all six seats paired, leaving no unallied Nexus Card recipient. The focused -Nexus alliance regression exercises the subsequent draw when a seat remains -unallied. These samples do not establish complete card-effect coverage. +no Nexus Card choice occurred at those tables. The two-to-six-seat matrix +completed without rejected actions and with JSON restores throughout; the +three-seat Advanced sample and five-seat Basic sample reached actual +`nexusCardChoice` actions. The focused Nexus alliance regression exercises +the subsequent draw when a seat remains unallied. These samples do not +establish complete card-effect coverage. `--profile moritani-skills` adds five Basic samples with Moritani, then Emperor, Guild, Harkonnen, Fremen and Bene Gesserit. `--players` also narrows this profile; diff --git a/tests/faction-games-cli.test.ts b/tests/faction-games-cli.test.ts index c3f3592c..85eac84a 100644 --- a/tests/faction-games-cli.test.ts +++ b/tests/faction-games-cli.test.ts @@ -180,15 +180,17 @@ void test('Homeworld and Nexus sample snapshots resume only their matching modul ['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 name = profile === 'nexus' ? 'nexus-6-advanced' : 'homeworld-advanced'; + const playerFilter = profile === 'nexus' ? ['--players', '6'] : []; + assert.equal(run(out, '--profile', profile, '--rules', 'advanced', ...playerFilter, '--seed', '1000', '--max-actions', '1').status, 1); + assertFailedEvidence(out, name, 1000 + ordinal, false); + const snapshot = join(out, `failed-${name}.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); + assertFailedEvidence(resumed, name, 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`); @@ -198,6 +200,40 @@ void test('Homeworld and Nexus sample snapshots resume only their matching modul } }); +void test('three-seat Nexus samples preserve their module and roster across saved continuation', (t) => { + const area = temporary(t); + for (const [rules, ordinal] of [['basic', 42], ['advanced', 43]] as const) { + const out = join(area, rules); + const initial = run(out, '--profile', 'nexus', '--players', '3', '--rules', rules, '--seed', '1000', '--max-actions', '1'); + assert.equal(initial.status, 1); + assertFailedEvidence(out, `nexus-3-${rules}`, 1000 + ordinal, false); + const snapshot = join(out, `failed-nexus-3-${rules}.json`); + const game = json<{ players: unknown[]; nexusCards: unknown }>(snapshot); + assert.equal(game.players.length, 3); + assert.ok(game.nexusCards); + const resumed = join(area, `${rules}-resumed`); + assert.equal(run(resumed, '--resume', snapshot, '--seed', '1000', '--max-actions', '1').status, 1); + assertFailedEvidence(resumed, `nexus-3-${rules}`, 1000 + ordinal, true); + } +}); + +void test('a genuine three-seat Advanced Nexus game reaches unallied card choice and completes without illegal bot actions', (t) => { + const out = join(temporary(t), 'card-draw'); + const result = run(out, '--profile', 'nexus', '--players', '3', '--rules', 'advanced', '--seed', '20260927'); + assert.equal(result.status, 0, result.stderr); + const report = json(join(out, 'report.json')); + const evidence = json<{ results: (CliResult & { + used: Record; + rejected: Record; + restores: number; + })[] }>(join(out, 'results.json')).results[0]; + assert.equal(report.status, 'passed'); + assert.equal(evidence.outcome, 'complete'); + assert.ok(evidence.used.nexusCardChoice > 0); + assert.deepEqual(evidence.rejected, {}); + assert.ok(evidence.restores > 0); +}); + 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); const first = join(area, 'first'); diff --git a/tools/faction-games.ts b/tools/faction-games.ts index dec8f83d..812cb7f3 100644 --- a/tools/faction-games.ts +++ b/tools/faction-games.ts @@ -102,15 +102,24 @@ 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, +const MODULE_SCENARIOS: readonly Scenario[] = [ + ...(['basic', 'advanced'] as const).map((rules, index) => ({ + ordinal: 36 + index, + profile: 'homeworld' as const, rules, expansions: [], - roster: BASE_ROSTER.slice(0, profile === 'nexus' ? 6 : 4), + roster: BASE_ROSTER.slice(0, 4), })), -); + ...[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), + })), + ), +]; const MORITANI_SKILLS_ROSTER: readonly FactionId[] = [ @@ -186,7 +195,7 @@ function usage() { '[--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 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.' + 'Runs genuine setup and gameplay offline. Default/all keeps the six expansion samples; base, Nexus and skill profiles default to their 2–6-player samples. Homeworld uses four base factions. --players requires --profile base, nexus or a skill profile. Output must be a new private directory outside the checkout.' ); } @@ -217,7 +226,7 @@ function positive(value: string | undefined) { } function scenarioName(scenario: Scenario) { - return scenario.profile === 'base' || skillProfile(scenario.profile) + return scenario.profile === 'base' || scenario.profile === 'nexus' || skillProfile(scenario.profile) ? `${scenario.profile}-${scenario.roster.length}-${scenario.rules}` : `${scenario.profile}-${scenario.rules}`; } @@ -529,8 +538,8 @@ async function main() { const profile = parseProfile(values.profile); const rules = parseRules(values.rules); const players = parsePlayers(values.players); - if (supplied('players') && profile !== 'base' && !skillProfile(profile)) - throw new Error('--players requires --profile base, moritani-skills, tleilaxu-skills, ix-skills or choam-skills.'); + if (supplied('players') && profile !== 'base' && profile !== 'nexus' && !skillProfile(profile)) + throw new Error('--players requires --profile base, nexus or a skill profile.'); 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;