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-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;
+40 -4
View File
@@ -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<Record<string, unknown>>(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<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) => {
const area = temporary(t);
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),
})),
);
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;