diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index f89d2b6d..43dfcdbb 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,9 @@ # Dune implementation status +## 27 September 2026 — Ecaz/Moritani with Nexus Cards + +The paired-faction Nexus audit setup now includes genuine Ecaz/Moritani games, retaining the CHOAM/Richese profile and both public start gates. At seed 20260927 all ten two-through-six-seat Basic/Advanced [samples](VERIFICATION_WORKFLOW.md#reusable-base-and-faction-sample-games) completed: 3,827 accepted actions, 98 JSON restores, five unallied card choices and no rejected actions. A three-seat Basic trace reaches Ecaz Ambassador decisions, battles and Nexus drawing; an interrupted four-seat Advanced game resumes as its own Ecaz/Nexus roster. The source-bound report is `/tmp/dune-ecaz-nexus-checkpoint-20260927/report.json`. None of those draws activated a Nexus card effect; card-family and combined-mode completion remain gated. + ## 27 September 2026 — CHOAM/Richese with Nexus Cards The offline [sample runner](VERIFICATION_WORKFLOW.md#reusable-base-and-faction-sample-games) now admits genuine CHOAM/Richese setup with the physical Nexus module at two through six seats in Basic and Advanced. At seed 20260928 all ten games completed with 3,481 accepted actions, 90 periodic JSON restores, no rejected actions and one unallied Nexus Card choice. A separate seed 20260927 completed nine games with three choices, then stopped in six-seat Advanced Bidding after 1,863 accepted actions at the existing unresolved exhausted-Richese-cache normal-auction-count guard; the failed report is `/tmp/dune-choam-nexus-trial-20260927/report.json`. The passing source-bound checkpoint report is `/tmp/dune-choam-nexus-checkpoint-20260928/report.json`. No drawn card effect happened in these samples, so they do not certify the CHOAM Betrayal panel, all Nexus effects or public module starts. diff --git a/docs/VERIFICATION_WORKFLOW.md b/docs/VERIFICATION_WORKFLOW.md index 506ad972..34224302 100644 --- a/docs/VERIFICATION_WORKFLOW.md +++ b/docs/VERIFICATION_WORKFLOW.md @@ -105,6 +105,7 @@ node --import tsx tools/faction-games.ts --out /tmp/dune-new-choam-roster --prof node --import tsx tools/faction-games.ts --out /tmp/dune-new-ecaz-roster --profile ecaz-roster node --import tsx tools/faction-games.ts --out /tmp/dune-new-ix-roster --profile ix-roster node --import tsx tools/faction-games.ts --out /tmp/dune-new-choam-nexus --profile choam-nexus +node --import tsx tools/faction-games.ts --out /tmp/dune-new-ecaz-nexus --profile ecaz-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 ``` @@ -113,19 +114,20 @@ 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 the base, Homeworld, Nexus, combined -`homeworld-nexus`, `choam-roster`, `ecaz-roster`, `ix-roster` or `choam-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 they are the only rules-permitted player-count -configurations. Each optional module profile runs two through six base factions -under Basic and Advanced using the actual setup seams. The three expansion-roster -profiles also run Basic and Advanced with their respective physical expansion -enabled: CHOAM/Richese start with CHOAM then Richese, Ecaz/Moritani with -Moritani then Ecaz, Ixian/Tleilaxu with Ixians then Tleilaxu; subsequent seats -extend each to six without changing existing fixed four-seat samples. Their -seed offsets are 68–77, 78–87 and 88–97. `choam-nexus` reuses the CHOAM/Richese -roster with both the physical expansion and Nexus Cards enabled at offsets -98–107; it does not alter the fixed six default samples. +`homeworld-nexus`, `choam-roster`, `ecaz-roster`, `ix-roster`, `choam-nexus` +or `ecaz-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 the only rules-permitted +player-count configurations. Each optional module profile runs two through +six base factions under Basic and Advanced using the actual setup seams. The +three expansion-roster profiles also run Basic and Advanced with their +respective physical expansion enabled: CHOAM/Richese start with CHOAM then +Richese, Ecaz/Moritani with Moritani then Ecaz, Ixian/Tleilaxu with Ixians +then Tleilaxu. Subsequent seats extend each to six without changing existing +fixed four-seat samples. Their seed offsets are 68–77, 78–87 and 88–97. +`choam-nexus` and `ecaz-nexus` reuse the respective expansion rosters with Nexus +Cards enabled at seed offsets 98–107 and 108–117, respectively. The original +six default samples are unchanged. Output names include the player count. Homeworld's original four-seat seed offsets remain 36–37; its other rosters use 48–51 and 54–57. Nexus's six-seat offsets remain 38–39; its smaller rosters use 40–47. The combined module uses diff --git a/game/engine.ts b/game/engine.ts index bac9c75a..0e1012f9 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -6360,15 +6360,20 @@ export function initializeNexusGameForAudit(state: Game): Game { 'Enable Nexus cards in a fresh audit lobby first.'); return initializeSetupGameForAudit(state, !!state.homeworlds, true); } -/** Offline CHOAM/Richese with Nexus Cards; public expansion/module starts remain gated. */ -export function initializeChoamNexusGameForAudit(state: Game): Game { - requireRule(state.expansions.length === 1 && state.expansions[0] === 'choam' && - state.players.some(player => player.faction === 'choam') && - state.players.some(player => player.faction === 'richese'), - 'The CHOAM Nexus sample requires both expansion factions and their deck.'); +/** Offline paired-faction Nexus setup; public expansion/module starts remain gated. */ +export function initializePairedNexusGameForAudit(state: Game): Game { + const expansion = state.expansions[0]; + requireRule(state.expansions.length === 1 && + ((expansion === 'choam' && + state.players.some(player => player.faction === 'choam') && + state.players.some(player => player.faction === 'richese')) || + (expansion === 'ecaz' && + state.players.some(player => player.faction === 'ecaz') && + state.players.some(player => player.faction === 'moritani'))), + 'The paired Nexus sample requires both selected expansion factions and their deck.'); requireRule(!!state.nexusCards && state.nexusCards.cards === null && state.nexusCards.phase === null && !state.homeworlds && !state.leaderSkills && !state.discoveryEnabled && !state.ecazTreachery, - 'Enable only Nexus Cards in a fresh CHOAM expansion lobby.'); + 'Enable only Nexus Cards in a fresh paired expansion lobby.'); return initializeSetupGameForAudit(state, false, true, false, false, false, false, true); } @@ -6464,7 +6469,7 @@ function initializeSetupGameForAudit(state: Game, homeworlds: boolean, nexus = f ), ), factions && nexus - ? 'The CHOAM Nexus audit needs a fresh expansion lobby without other optional modules.' + ? 'The paired expansion Nexus audit needs a fresh lobby without other optional modules.' : factions ? 'The faction prototype supports base factions and the selected expansion factions without optional modules.' : choam diff --git a/tests/faction-games-cli.test.ts b/tests/faction-games-cli.test.ts index f6f8e5e3..cfc07575 100644 --- a/tests/faction-games-cli.test.ts +++ b/tests/faction-games-cli.test.ts @@ -255,7 +255,7 @@ void test('combined modules reach saved Nexus draws and actual Homeworld shipmen void test('expansion-roster snapshots resume their own Advanced games rather than the fixed four-seat sample', (t) => { const area = temporary(t); - for (const [profile, seed] of [['choam-roster', '20260928'], ['ecaz-roster', '20260927'], ['ix-roster', '20260927'], ['choam-nexus', '20260928']] as const) { + for (const [profile, seed] of [['choam-roster', '20260928'], ['ecaz-roster', '20260927'], ['ix-roster', '20260927'], ['choam-nexus', '20260928'], ['ecaz-nexus', '20260927']] as const) { const name = `${profile}-4-advanced`; const initial = join(area, `${profile}-initial`); assert.equal(run(initial, '--profile', profile, '--players', '4', '--rules', 'advanced', '--seed', seed, '--max-actions', '1').status, 1); @@ -331,6 +331,26 @@ void test('CHOAM/Richese Nexus game reaches an unallied card choice and restores assert.ok(game.restores > 0); }); +void test('Ecaz/Moritani Nexus game draws cards and resolves Ambassador battles through JSON restores', (t) => { + const out = join(temporary(t), 'ecaz-nexus-complete'); + const result = run(out, '--profile', 'ecaz-nexus', '--players', '3', '--rules', 'basic', '--seed', '20260927'); + assert.equal(result.status, 0, result.stderr); + const report = json(join(out, 'report.json')); + const game = json<{ results: (CliResult & { + used: Record; + rejected: Record; + restores: number; + })[] }>(join(out, 'results.json')).results[0]; + assert.equal(report.status, 'passed'); + assert.equal(report.sourceUnchanged, true); + assert.equal(game.name, 'ecaz-nexus-3-basic'); + assert.equal(game.outcome, 'complete'); + assert.ok(game.used.nexusCardChoice > 0); + assert.ok(game.used['decision:ecazAmbassador'] > 0); + assert.deepEqual(game.rejected, {}); + assert.ok(game.restores > 0); +}); + 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) { diff --git a/tools/faction-games.ts b/tools/faction-games.ts index 5d2f320a..b67a9e74 100644 --- a/tools/faction-games.ts +++ b/tools/faction-games.ts @@ -10,10 +10,10 @@ import { applyAction, createGame, initializeBaseGameForAudit, - initializeChoamNexusGameForAudit, initializeFactionExpansionsGameForAudit, initializeHomeworldGameForAudit, initializeNexusGameForAudit, + initializePairedNexusGameForAudit, initializeLeaderSkillsGameForAudit, joinGame, newPlayer, @@ -29,7 +29,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' | 'homeworld' | 'nexus' | 'homeworld-nexus' | 'choam-roster' | 'ecaz-roster' | 'ix-roster' | 'choam-nexus' | 'moritani-skills' | 'tleilaxu-skills' | 'ix-skills' | 'choam-skills'; +type Profile = 'base' | 'choam' | 'ecaz' | 'combined' | 'homeworld' | 'nexus' | 'homeworld-nexus' | 'choam-roster' | 'ecaz-roster' | 'ix-roster' | 'choam-nexus' | 'ecaz-nexus' | 'moritani-skills' | 'tleilaxu-skills' | 'ix-skills' | 'choam-skills'; type Rules = 'basic' | 'advanced'; type Scenario = { @@ -144,9 +144,13 @@ const EXPANSION_ROSTER_SCENARIOS: readonly Scenario[] = [ })), ), ); -const CHOAM_NEXUS_SCENARIOS: readonly Scenario[] = EXPANSION_ROSTER_SCENARIOS - .filter(scenario => scenario.profile === 'choam-roster') - .map(scenario => ({ ...scenario, profile: 'choam-nexus', ordinal: scenario.ordinal + 30 })); +const PAIRED_NEXUS_SCENARIOS: readonly Scenario[] = EXPANSION_ROSTER_SCENARIOS + .filter(scenario => scenario.profile === 'choam-roster' || scenario.profile === 'ecaz-roster') + .map(scenario => ({ + ...scenario, + profile: scenario.profile === 'choam-roster' ? 'choam-nexus' : 'ecaz-nexus', + ordinal: scenario.ordinal + 30, + })); const MORITANI_SKILLS_ROSTER: readonly FactionId[] = [ @@ -219,10 +223,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|homeworld|nexus|homeworld-nexus|choam-roster|ecaz-roster|ix-roster|choam-nexus|moritani-skills|tleilaxu-skills|ix-skills|choam-skills] ' + + '[--seed UINT32] [--profile all|base|choam|ecaz|combined|homeworld|nexus|homeworld-nexus|choam-roster|ecaz-roster|ix-roster|choam-nexus|ecaz-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, Homeworld, Nexus, combined Homeworld-Nexus, expansion roster, CHOAM Nexus and skill profiles default to their 2–6-player samples. --players requires one of these profiles. Output must be a new private directory outside the checkout.' + 'Runs genuine setup and gameplay offline. Default/all keeps the six expansion samples; base, Homeworld, Nexus, combined Homeworld-Nexus, expansion roster, paired expansion Nexus and skill profiles default to their 2–6-player samples. --players requires one of these profiles. Output must be a new private directory outside the checkout.' ); } @@ -254,15 +258,15 @@ function positive(value: string | undefined) { function scenarioName(scenario: Scenario) { return scenario.profile === 'base' || scenario.profile === 'homeworld' || scenario.profile === 'nexus' || scenario.profile === 'homeworld-nexus' || - scenario.profile === 'choam-roster' || scenario.profile === 'ecaz-roster' || scenario.profile === 'ix-roster' || scenario.profile === 'choam-nexus' || skillProfile(scenario.profile) + scenario.profile === 'choam-roster' || scenario.profile === 'ecaz-roster' || scenario.profile === 'ix-roster' || scenario.profile === 'choam-nexus' || scenario.profile === 'ecaz-nexus' || skillProfile(scenario.profile) ? `${scenario.profile}-${scenario.roster.length}-${scenario.rules}` : `${scenario.profile}-${scenario.rules}`; } function parseProfile(value: string | undefined) { const profile = value ?? 'all'; - if (!['all', 'base', 'choam', 'ecaz', 'combined', 'homeworld', 'nexus', 'homeworld-nexus', 'choam-roster', 'ecaz-roster', 'ix-roster', 'choam-nexus', 'moritani-skills', 'tleilaxu-skills', 'ix-skills', 'choam-skills'].includes(profile)) - throw new Error('--profile must be all, base, choam, ecaz, combined, homeworld, nexus, homeworld-nexus, choam-roster, ecaz-roster, ix-roster, choam-nexus, moritani-skills, tleilaxu-skills, ix-skills or choam-skills.'); + if (!['all', 'base', 'choam', 'ecaz', 'combined', 'homeworld', 'nexus', 'homeworld-nexus', 'choam-roster', 'ecaz-roster', 'ix-roster', 'choam-nexus', 'ecaz-nexus', 'moritani-skills', 'tleilaxu-skills', 'ix-skills', 'choam-skills'].includes(profile)) + throw new Error('--profile must be all, base, choam, ecaz, combined, homeworld, nexus, homeworld-nexus, choam-roster, ecaz-roster, ix-roster, choam-nexus, ecaz-nexus, moritani-skills, tleilaxu-skills, ix-skills or choam-skills.'); return profile as Profile | 'all'; } @@ -308,10 +312,10 @@ function freshGame(scenario: Scenario) { } if (scenario.profile === 'homeworld' || scenario.profile === 'homeworld-nexus') game.homeworlds = { custody: null }; - if (scenario.profile === 'nexus' || scenario.profile === 'homeworld-nexus' || scenario.profile === 'choam-nexus') + if (scenario.profile === 'nexus' || scenario.profile === 'homeworld-nexus' || scenario.profile === 'choam-nexus' || scenario.profile === 'ecaz-nexus') game.nexusCards = { cards: null, phase: null }; - if (scenario.profile === 'choam-nexus') - return initializeChoamNexusGameForAudit(game); + if (scenario.profile === 'choam-nexus' || scenario.profile === 'ecaz-nexus') + return initializePairedNexusGameForAudit(game); return skillProfile(scenario.profile) ? initializeLeaderSkillsGameForAudit(game) : scenario.profile === 'homeworld' @@ -370,10 +374,10 @@ function resumedGame(path: string) { game.moritaniAssassinateCallEvents ) throw new Error('--resume sample scenarios exclude unsupported optional modules.'); - const scenario = [...SCENARIOS, ...BASE_SCENARIOS, ...MODULE_SCENARIOS, ...EXPANSION_ROSTER_SCENARIOS, ...CHOAM_NEXUS_SCENARIOS, ...MORITANI_SKILLS_SCENARIOS, ...TLEILAXU_SKILLS_SCENARIOS, ...IX_SKILLS_SCENARIOS, ...CHOAM_SKILLS_SCENARIOS].find( + const scenario = [...SCENARIOS, ...BASE_SCENARIOS, ...MODULE_SCENARIOS, ...EXPANSION_ROSTER_SCENARIOS, ...PAIRED_NEXUS_SCENARIOS, ...MORITANI_SKILLS_SCENARIOS, ...TLEILAXU_SKILLS_SCENARIOS, ...IX_SKILLS_SCENARIOS, ...CHOAM_SKILLS_SCENARIOS].find( (candidate) => (candidate.profile === 'homeworld' || candidate.profile === 'homeworld-nexus') === !!game.homeworlds && - (candidate.profile === 'nexus' || candidate.profile === 'homeworld-nexus' || candidate.profile === 'choam-nexus') === !!game.nexusCards && + (candidate.profile === 'nexus' || candidate.profile === 'homeworld-nexus' || candidate.profile === 'choam-nexus' || candidate.profile === 'ecaz-nexus') === !!game.nexusCards && skillProfile(candidate.profile) === !!game.leaderSkills && candidate.rules === (game.advanced ? 'advanced' : 'basic') && JSON.stringify(candidate.expansions) === @@ -567,15 +571,15 @@ async function main() { const profile = parseProfile(values.profile); const rules = parseRules(values.rules); const players = parsePlayers(values.players); - if (supplied('players') && !['base', 'homeworld', 'nexus', 'homeworld-nexus', 'choam-roster', 'ecaz-roster', 'ix-roster', 'choam-nexus'].includes(profile) && !skillProfile(profile)) - throw new Error('--players requires --profile base, homeworld, nexus, homeworld-nexus, choam-roster, ecaz-roster, ix-roster, choam-nexus or a skill profile.'); + if (supplied('players') && !['base', 'homeworld', 'nexus', 'homeworld-nexus', 'choam-roster', 'ecaz-roster', 'ix-roster', 'choam-nexus', 'ecaz-nexus'].includes(profile) && !skillProfile(profile)) + throw new Error('--players requires --profile base, homeworld, nexus, homeworld-nexus, choam-roster, ecaz-roster, ix-roster, choam-nexus, ecaz-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; const requestedSamples = profile === 'homeworld' || profile === 'nexus' || profile === 'homeworld-nexus' ? MODULE_SCENARIOS : profile === 'choam-roster' || profile === 'ecaz-roster' || profile === 'ix-roster' ? EXPANSION_ROSTER_SCENARIOS - : profile === 'choam-nexus' ? CHOAM_NEXUS_SCENARIOS + : profile === 'choam-nexus' || profile === 'ecaz-nexus' ? PAIRED_NEXUS_SCENARIOS : profile === 'base' ? BASE_SCENARIOS : profile === 'moritani-skills' ? MORITANI_SKILLS_SCENARIOS : profile === 'tleilaxu-skills' ? TLEILAXU_SKILLS_SCENARIOS