Exercise combined Homeworld and Nexus games

This commit is contained in:
Quintine committed 2026-09-27 21:32:01 +10:00
1 parent 6124618d42
commit 90c5c7af25
3 files changed
+78 -27

No files matched your search

+22 -12
View File
@@ -100,6 +100,7 @@ node --import tsx tools/faction-games.ts --out /tmp/dune-new-combined --profile
# Homeworld and Nexus with two through six base factions, Basic and Advanced. # Homeworld and Nexus with two through six base factions, 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-homeworld-nexus --profile homeworld-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-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
``` ```
@@ -107,15 +108,16 @@ node --import tsx tools/faction-games.ts --out /tmp/dune-new-resume --resume /pr
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 the base, Homeworld or Nexus profile; omission or `--players 2..6` narrows the base, Homeworld, Nexus or combined
`all` retains all five counts. The fixed base roster adds Atreides, Harkonnen, `homeworld-nexus` profile; omission or `all` retains all five counts. The fixed
Fremen, Emperor, Guild and Bene Gesserit in that order. These are sample base roster adds Atreides, Harkonnen, Fremen, Emperor, Guild and Bene
rosters, not an assertion that these are the only rules-permitted player-count Gesserit in that order. These are sample rosters, not an assertion that these
configurations. Homeworld and Nexus each run two through six base factions are the only rules-permitted player-count configurations. Each optional
under Basic and Advanced using the actual optional-module setup seams. module profile runs two through six base factions under Basic and Advanced
Their output names include the player count. Homeworld's original four-seat using the actual setup seams. Output names include the player count.
seed offsets remain 36–37; its other rosters use 48–51 and 54–57. Nexus's Homeworld's original four-seat seed offsets remain 36–37; its other rosters
six-seat offsets remain 38–39; its smaller rosters use 40–47. These samples use 48–51 and 54–57. Nexus's six-seat offsets remain 38–39; its smaller
rosters use 40–47. The combined module uses offsets 58–67. These samples
do not open public module starts or assert complete module interaction do not open public module starts or assert complete module interaction
coverage. coverage.
@@ -135,15 +137,23 @@ Junction offers/shipments and a Caladan reinforcement decision; these
observations do not certify every Homeworld occupation or expansion observations do not certify every Homeworld occupation or expansion
interaction. interaction.
At the same seed, all ten combined Homeworld/Nexus samples completed without
rejected actions and with JSON restores. The five-seat Advanced game reached
three Nexus Card choices and eight Homeworld shipments in one physical game,
including 26 restores. This does not certify every interaction between the
two independent modules or either module's unfinished card effects.
`--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;
Advanced requests fail before running. Its seed offsets are 16–20, leaving Advanced requests fail before running. Its seed offsets are 16–20, leaving
existing samples unchanged. The full fourteen-card skill census and ordinary existing samples unchanged. The full fourteen-card skill census and ordinary
Moritani/base Traitor census are checked throughout. `--resume` preserves this Moritani/base Traitor census are checked throughout. `--resume` preserves this
explicit profile; other optional modules still fail its admission checks. explicit profile; other optional modules still fail its admission checks.
Homeworld and Nexus snapshots may be resumed only when their saved module and Homeworld, Nexus and combined snapshots resume only when their saved modules
fixed roster match the corresponding profile; unsupported module combinations and fixed rosters match a supported profile; other optional module
are still rejected. combinations remain excluded. An edited snapshot with a removed module can
match a different fixed profile, so verify its original private report
before relying on resumed provenance.
All samples use genuine setup and saved AI profiles, cycling Easy, Medium, All samples use genuine setup and saved AI profiles, cycling Easy, Medium,
Hard and Brutal by seat. Smaller games necessarily contain fewer profiles; Hard and Brutal by seat. Smaller games necessarily contain fewer profiles;
+33
View File
@@ -220,6 +220,39 @@ void test('Homeworld samples cover each two-to-six-seat roster under both rules
assertFailedEvidence(resumed, 'homeworld-2-advanced', 1049, true); assertFailedEvidence(resumed, 'homeworld-2-advanced', 1049, true);
}); });
void test('combined Homeworld and Nexus snapshots keep both physical modules across saved continuation', (t) => {
const area = temporary(t);
const out = join(area, 'combined');
assert.equal(run(out, '--profile', 'homeworld-nexus', '--players', '3', '--rules', 'advanced', '--seed', '1000', '--max-actions', '1').status, 1);
assertFailedEvidence(out, 'homeworld-nexus-3-advanced', 1061, false);
const snapshot = join(out, 'failed-homeworld-nexus-3-advanced.json');
const game = json<Record<string, unknown>>(snapshot);
assert.equal((game.players as unknown[]).length, 3);
assert.ok(game.homeworlds);
assert.ok(game.nexusCards);
const resumed = join(area, 'resumed');
assert.equal(run(resumed, '--resume', snapshot, '--seed', '1000', '--max-actions', '1').status, 1);
assertFailedEvidence(resumed, 'homeworld-nexus-3-advanced', 1061, true);
});
void test('combined modules reach saved Nexus draws and actual Homeworld shipment in one complete game', (t) => {
const out = join(temporary(t), 'complete');
const result = run(out, '--profile', 'homeworld-nexus', '--players', '5', '--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.ok(evidence.used.homeworldShip > 0);
assert.deepEqual(evidence.rejected, {});
assert.ok(evidence.restores > 0);
});
void test('three-seat Nexus samples preserve their module and roster across saved continuation', (t) => { void test('three-seat Nexus samples preserve their module and roster across saved continuation', (t) => {
const area = temporary(t); const area = temporary(t);
for (const [rules, ordinal] of [['basic', 42], ['advanced', 43]] as const) { for (const [rules, ordinal] of [['basic', 42], ['advanced', 43]] as const) {
+23 -15
View File
@@ -28,7 +28,7 @@ import { privateOutputDirectory, sourceSnapshot } from './verification';
const DEFAULT_SEED = 20_260_926; const DEFAULT_SEED = 20_260_926;
const DEFAULT_MAX_ACTIONS = 3_500; const DEFAULT_MAX_ACTIONS = 3_500;
const DIFFICULTIES = ['Easy', 'Medium', 'Hard', 'Brutal'] as const; const DIFFICULTIES = ['Easy', 'Medium', 'Hard', 'Brutal'] as const;
type Profile = 'base' | 'choam' | 'ecaz' | 'combined' | 'homeworld' | 'nexus' | 'moritani-skills' | 'tleilaxu-skills' | 'ix-skills' | 'choam-skills'; type Profile = 'base' | 'choam' | 'ecaz' | 'combined' | 'homeworld' | 'nexus' | 'homeworld-nexus' | 'moritani-skills' | 'tleilaxu-skills' | 'ix-skills' | 'choam-skills';
type Rules = 'basic' | 'advanced'; type Rules = 'basic' | 'advanced';
type Scenario = { type Scenario = {
@@ -121,6 +121,15 @@ const MODULE_SCENARIOS: readonly Scenario[] = [
roster: BASE_ROSTER.slice(0, players), roster: BASE_ROSTER.slice(0, players),
})), })),
), ),
...[2, 3, 4, 5, 6].flatMap((players) =>
(['basic', 'advanced'] as const).map((rules, index) => ({
ordinal: 58 + (players - 2) * 2 + index,
profile: 'homeworld-nexus' as const,
rules,
expansions: [],
roster: BASE_ROSTER.slice(0, players),
})),
),
]; ];
@@ -194,10 +203,10 @@ type Result = {
function usage() { function usage() {
return ( return (
'Usage: node --import tsx tools/faction-games.ts --out NEW_PRIVATE_DIR ' + 'Usage: node --import tsx tools/faction-games.ts --out NEW_PRIVATE_DIR ' +
'[--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|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, Homeworld, 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 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.'
); );
} }
@@ -228,15 +237,15 @@ function positive(value: string | undefined) {
} }
function scenarioName(scenario: Scenario) { function scenarioName(scenario: Scenario) {
return scenario.profile === 'base' || scenario.profile === 'homeworld' || scenario.profile === 'nexus' || skillProfile(scenario.profile) return scenario.profile === 'base' || scenario.profile === 'homeworld' || scenario.profile === 'nexus' || scenario.profile === 'homeworld-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}`;
} }
function parseProfile(value: string | undefined) { function parseProfile(value: string | undefined) {
const profile = value ?? 'all'; const profile = value ?? 'all';
if (!['all', 'base', 'choam', 'ecaz', 'combined', 'homeworld', 'nexus', 'moritani-skills', 'tleilaxu-skills', 'ix-skills', 'choam-skills'].includes(profile)) if (!['all', 'base', 'choam', 'ecaz', 'combined', 'homeworld', 'nexus', 'homeworld-nexus', 'moritani-skills', 'tleilaxu-skills', 'ix-skills', 'choam-skills'].includes(profile))
throw new Error('--profile must be all, base, choam, ecaz, combined, homeworld, nexus, moritani-skills, tleilaxu-skills, ix-skills or choam-skills.'); throw new Error('--profile must be all, base, choam, ecaz, combined, homeworld, nexus, homeworld-nexus, moritani-skills, tleilaxu-skills, ix-skills or choam-skills.');
return profile as Profile | 'all'; return profile as Profile | 'all';
} }
@@ -280,15 +289,15 @@ function freshGame(scenario: Scenario) {
player.bot = DIFFICULTIES[index % DIFFICULTIES.length]; player.bot = DIFFICULTIES[index % DIFFICULTIES.length];
player.ready = true; player.ready = true;
} }
if (scenario.profile === 'homeworld') if (scenario.profile === 'homeworld' || scenario.profile === 'homeworld-nexus')
game.homeworlds = { custody: null }; game.homeworlds = { custody: null };
if (scenario.profile === 'nexus') if (scenario.profile === 'nexus' || scenario.profile === 'homeworld-nexus')
game.nexusCards = { cards: null, phase: null }; game.nexusCards = { cards: null, phase: null };
return skillProfile(scenario.profile) return skillProfile(scenario.profile)
? initializeLeaderSkillsGameForAudit(game) ? initializeLeaderSkillsGameForAudit(game)
: scenario.profile === 'homeworld' : scenario.profile === 'homeworld'
? initializeHomeworldGameForAudit(game) ? initializeHomeworldGameForAudit(game)
: scenario.profile === 'nexus' : scenario.profile === 'nexus' || scenario.profile === 'homeworld-nexus'
? initializeNexusGameForAudit(game) ? initializeNexusGameForAudit(game)
: scenario.profile === 'base' : scenario.profile === 'base'
? initializeBaseGameForAudit(game) ? initializeBaseGameForAudit(game)
@@ -327,7 +336,6 @@ function resumedGame(path: string) {
'--resume is not an incomplete faction-games snapshot with saved AI profiles.', '--resume is not an incomplete faction-games snapshot with saved AI profiles.',
); );
if ( if (
(game.homeworlds && game.nexusCards) ||
(game.leaderSkills && !basicExpansionLeaderSkillsProfile(game)) || (game.leaderSkills && !basicExpansionLeaderSkillsProfile(game)) ||
game.discoveryEnabled || game.discoveryEnabled ||
game.discoveries || game.discoveries ||
@@ -345,8 +353,8 @@ function resumedGame(path: string) {
throw new Error('--resume sample scenarios exclude unsupported optional modules.'); throw new Error('--resume sample scenarios exclude unsupported optional modules.');
const scenario = [...SCENARIOS, ...BASE_SCENARIOS, ...MODULE_SCENARIOS, ...MORITANI_SKILLS_SCENARIOS, ...TLEILAXU_SKILLS_SCENARIOS, ...IX_SKILLS_SCENARIOS, ...CHOAM_SKILLS_SCENARIOS].find( const scenario = [...SCENARIOS, ...BASE_SCENARIOS, ...MODULE_SCENARIOS, ...MORITANI_SKILLS_SCENARIOS, ...TLEILAXU_SKILLS_SCENARIOS, ...IX_SKILLS_SCENARIOS, ...CHOAM_SKILLS_SCENARIOS].find(
(candidate) => (candidate) =>
(candidate.profile === 'homeworld') === !!game.homeworlds && (candidate.profile === 'homeworld' || candidate.profile === 'homeworld-nexus') === !!game.homeworlds &&
(candidate.profile === 'nexus') === !!game.nexusCards && (candidate.profile === 'nexus' || candidate.profile === 'homeworld-nexus') === !!game.nexusCards &&
skillProfile(candidate.profile) === !!game.leaderSkills && skillProfile(candidate.profile) === !!game.leaderSkills &&
candidate.rules === (game.advanced ? 'advanced' : 'basic') && candidate.rules === (game.advanced ? 'advanced' : 'basic') &&
JSON.stringify(candidate.expansions) === JSON.stringify(candidate.expansions) ===
@@ -540,12 +548,12 @@ 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') && !['base', 'homeworld', 'nexus'].includes(profile) && !skillProfile(profile)) if (supplied('players') && !['base', 'homeworld', 'nexus', 'homeworld-nexus'].includes(profile) && !skillProfile(profile))
throw new Error('--players requires --profile base, homeworld, nexus or a skill profile.'); throw new Error('--players requires --profile base, homeworld, nexus, homeworld-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;
const requestedSamples = profile === 'homeworld' || profile === 'nexus' const requestedSamples = profile === 'homeworld' || profile === 'nexus' || profile === 'homeworld-nexus'
? MODULE_SCENARIOS ? MODULE_SCENARIOS
: profile === 'base' ? BASE_SCENARIOS : profile === 'base' ? BASE_SCENARIOS
: profile === 'moritani-skills' ? MORITANI_SKILLS_SCENARIOS : profile === 'moritani-skills' ? MORITANI_SKILLS_SCENARIOS