Exercise Nexus games across two to six seats

This commit is contained in:
Quintine committed 2026-09-27 21:12:30 +10:00
1 parent 8045c9e3eb
commit 628c536218
3 files changed
+77 -25

No files matched your search

+18 -11
View File
@@ -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-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-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 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-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 --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 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, Default and `--profile all` retain the original six fixed CHOAM/Richese,
Ecaz/Moritani and combined Basic/Advanced samples. `--profile base` selects ten Ecaz/Moritani and combined Basic/Advanced samples. `--profile base` selects ten
additional samples: Basic and Advanced with two through six players. additional samples: Basic and Advanced with two through six players.
`--players 2..6` narrows only that base profile; omission or `all` retains all `--players 2..6` narrows the base or Nexus profile; omission or `all` retains
five counts. The fixed base roster adds Atreides, Harkonnen, Fremen, Emperor, all five counts. The fixed base roster adds Atreides, Harkonnen, Fremen,
Guild and Bene Gesserit in that order. These are sample rosters, not an assertion Emperor, Guild and Bene Gesserit in that order. These are sample rosters, not
that these are the only rules-permitted player-count configurations. The an assertion that these are the only rules-permitted player-count
`homeworld` profile runs four base factions; `nexus` runs all six, each under Basic configurations. The `homeworld` profile runs four base factions; `nexus` runs
and Advanced using the actual optional-module setup seams. They do not open two through six, each under Basic and Advanced using the actual
public module starts or assert complete module interaction coverage. 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 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 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; 20260927 seed, each six-seat sample made six alliance actions and completed;
all six seats paired, leaving no unallied Nexus Card recipient. The focused no Nexus Card choice occurred at those tables. The two-to-six-seat matrix
Nexus alliance regression exercises the subsequent draw when a seat remains completed without rejected actions and with JSON restores throughout; the
unallied. These samples do not establish complete card-effect coverage. 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, `--profile moritani-skills` adds five Basic samples with Moritani, then Emperor,
Guild, Harkonnen, Fremen and Bene Gesserit. `--players` also narrows this profile; Guild, Harkonnen, Fremen and Bene Gesserit. `--players` also narrows this profile;
+40 -4
View File
@@ -180,15 +180,17 @@ void test('Homeworld and Nexus sample snapshots resume only their matching modul
['nexus', 39, 'nexusCards', 'homeworlds'], ['nexus', 39, 'nexusCards', 'homeworlds'],
] as const) { ] as const) {
const out = join(area, profile); const out = join(area, profile);
assert.equal(run(out, '--profile', profile, '--rules', 'advanced', '--seed', '1000', '--max-actions', '1').status, 1); const name = profile === 'nexus' ? 'nexus-6-advanced' : 'homeworld-advanced';
assertFailedEvidence(out, `${profile}-advanced`, 1000 + ordinal, false); const playerFilter = profile === 'nexus' ? ['--players', '6'] : [];
const snapshot = join(out, `failed-${profile}-advanced.json`); 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<Record<string, unknown>>(snapshot); const game = json<Record<string, unknown>>(snapshot);
assert.ok(game[module], `${profile} module remains in the snapshot`); assert.ok(game[module], `${profile} module remains in the snapshot`);
assert.equal(Boolean(game[other]), false); assert.equal(Boolean(game[other]), false);
const resumed = join(area, `${profile}-resumed`); const resumed = join(area, `${profile}-resumed`);
assert.equal(run(resumed, '--resume', snapshot, '--seed', '1000', '--max-actions', '1').status, 1); 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`); const unsupported = join(area, `${profile}-combined.json`);
writeFileSync(unsupported, JSON.stringify({ ...game, [other]: game[module] }), { mode: 0o600 }); writeFileSync(unsupported, JSON.stringify({ ...game, [other]: game[module] }), { mode: 0o600 });
const refused = join(area, `${profile}-refused`); 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<CliReport>(join(out, 'report.json'));
const evidence = 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(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) => { 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 area = temporary(t);
const first = join(area, 'first'); const first = join(area, 'first');
+19 -10
View File
@@ -102,15 +102,24 @@ const BASE_SCENARIOS: readonly Scenario[] = [2, 3, 4, 5, 6].flatMap((players) =>
roster: BASE_ROSTER.slice(0, players), roster: BASE_ROSTER.slice(0, players),
})), })),
); );
const MODULE_SCENARIOS: readonly Scenario[] = (['homeworld', 'nexus'] as const).flatMap((profile, index) => const MODULE_SCENARIOS: readonly Scenario[] = [
(['basic', 'advanced'] as const).map((rules, ruleIndex) => ({ ...(['basic', 'advanced'] as const).map((rules, index) => ({
ordinal: 36 + 2 * index + ruleIndex, ordinal: 36 + index,
profile, profile: 'homeworld' as const,
rules, rules,
expansions: [], 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[] = [ 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] ' + '[--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] ' + '[--rules both|basic|advanced] [--players all|2|3|4|5|6] [--max-actions POSITIVE] ' +
'[--resume FAILED_GAME.json]\n' + '[--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) { 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.roster.length}-${scenario.rules}`
: `${scenario.profile}-${scenario.rules}`; : `${scenario.profile}-${scenario.rules}`;
} }
@@ -529,8 +538,8 @@ async function main() {
const profile = parseProfile(values.profile); const profile = parseProfile(values.profile);
const rules = parseRules(values.rules); const rules = parseRules(values.rules);
const players = parsePlayers(values.players); const players = parsePlayers(values.players);
if (supplied('players') && profile !== 'base' && !skillProfile(profile)) if (supplied('players') && profile !== 'base' && profile !== 'nexus' && !skillProfile(profile))
throw new Error('--players requires --profile base, moritani-skills, tleilaxu-skills, ix-skills or choam-skills.'); throw new Error('--players requires --profile base, nexus or a skill profile.');
if (skillProfile(profile) && rules === 'advanced') if (skillProfile(profile) && rules === 'advanced')
throw new Error('Expansion Leader Skills profiles currently support only Basic samples.'); throw new Error('Expansion Leader Skills profiles currently support only Basic samples.');
const resume = values.resume ? resumedGame(values.resume) : null; const resume = values.resume ? resumedGame(values.resume) : null;