Exercise Ecaz Nexus expansion games

This commit is contained in:
Quintine committed 2026-09-27 22:56:49 +10:00
1 parent 99c6ab5863
commit d77aa5472b
5 files changed
+75 -40

No files matched your search

+4
View File
@@ -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.
+15 -13
View File
@@ -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
+13 -8
View File
@@ -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
+21 -1
View File
@@ -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<CliReport>(join(out, 'report.json'));
const game = json<{ results: (CliResult & {
used: Record<string, number>;
rejected: Record<string, number>;
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) {
+22 -18
View File
@@ -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