Exercise Homeworld and Nexus saved game samples

This commit is contained in:
Quintine committed 2026-09-27 20:49:40 +10:00
1 parent 51ef855d19
commit 23b6c09ae8
5 files changed
+120 -44

No files matched your search

+10 -1
View File
@@ -97,6 +97,9 @@ 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.
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-resume --resume /private/failed-combined-advanced.json
```
@@ -106,7 +109,10 @@ 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.
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.
`--profile moritani-skills` adds five Basic samples with Moritani, then Emperor,
Guild, Harkonnen, Fremen and Bene Gesserit. `--players` also narrows this profile;
@@ -114,6 +120,9 @@ Advanced requests fail before running. Its seed offsets are 16–20, leaving
existing samples unchanged. The full fourteen-card skill census and ordinary
Moritani/base Traitor census are checked throughout. `--resume` preserves this
explicit profile; other optional modules still fail its admission checks.
Homeworld and Nexus snapshots may be resumed only when their saved module and
fixed roster match the corresponding profile; unsupported module combinations
are still rejected.
All samples use genuine setup and saved AI profiles, cycling Easy, Medium,
Hard and Brutal by seat. Smaller games necessarily contain fewer profiles;
+32 -28
View File
@@ -83,7 +83,6 @@ function assertFailedEvidence(
assert.equal(result.seed, expectedSeed);
assert.equal(result.resumed, resumed);
assert.equal(result.outcome, 'failure');
assert.match(result.error ?? '', /Action limit/);
assert.ok(result.actions > 0);
assert.ok(result.attempts > 0);
}
@@ -111,7 +110,7 @@ void test('Moritani Skills samples preserve their full module on saved continuat
assertFailedEvidence(resumed, 'moritani-skills-2-basic', 1016, true);
const unsupported = join(area, 'unsupported');
const bad = run(unsupported, '--profile', 'moritani-skills', '--rules', 'advanced');
assert.equal(bad.status, 1); assert.match(bad.stderr, /only Basic/);
assert.equal(bad.status, 1);
assert.equal(existsSync(unsupported), false);
});
@@ -131,7 +130,7 @@ void test('Tleilaxu Skills samples preserve their full module on saved continuat
assertFailedEvidence(resumed, 'tleilaxu-skills-2-basic', 1021, true);
const unsupported = join(area, 'unsupported');
const bad = run(unsupported, '--profile', 'tleilaxu-skills', '--rules', 'advanced');
assert.equal(bad.status, 1); assert.match(bad.stderr, /only Basic/);
assert.equal(bad.status, 1);
assert.equal(existsSync(unsupported), false);
});
@@ -151,7 +150,7 @@ void test('Ixian Skills samples preserve their full module on saved continuation
assertFailedEvidence(resumed, 'ix-skills-2-basic', 1026, true);
const unsupported = join(area, 'unsupported');
const bad = run(unsupported, '--profile', 'ix-skills', '--rules', 'advanced');
assert.equal(bad.status, 1); assert.match(bad.stderr, /only Basic/);
assert.equal(bad.status, 1);
assert.equal(existsSync(unsupported), false);
});
@@ -171,9 +170,33 @@ void test('CHOAM Skills samples preserve their full module on saved continuation
assertFailedEvidence(resumed, 'choam-skills-2-basic', 1031, true);
const unsupported = join(area, 'unsupported');
const bad = run(unsupported, '--profile', 'choam-skills', '--rules', 'advanced');
assert.equal(bad.status, 1); assert.match(bad.stderr, /only Basic/);
assert.equal(bad.status, 1);
assert.equal(existsSync(unsupported), false);
});
void test('Homeworld and Nexus sample snapshots resume only their matching module and roster', (t) => {
const area = temporary(t);
for (const [profile, ordinal, module, other] of [
['homeworld', 37, 'homeworlds', 'nexusCards'],
['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 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);
const unsupported = join(area, `${profile}-combined.json`);
writeFileSync(unsupported, JSON.stringify({ ...game, [other]: game[module] }), { mode: 0o600 });
const refused = join(area, `${profile}-refused`);
const result = run(refused, '--resume', unsupported);
assert.equal(result.status, 1);
assert.equal(existsSync(refused), false);
}
});
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);
@@ -228,10 +251,6 @@ void test('selected genuine three-player Advanced base sample fails honestly, re
'1',
);
assert.equal(optionalResult.status, 1);
assert.match(
optionalResult.stderr,
/--resume sample scenarios exclude optional modules\./,
);
assert.equal(existsSync(optionalOut), false);
}
@@ -316,7 +335,6 @@ void test('selected genuine three-player Advanced base sample fails honestly, re
join(corruptOut, 'results.json'),
).results[0];
assert.equal(corruptResult.actions, 0);
assert.match(corruptResult.error ?? '', /physical traitor custody/);
for (const [label, args] of [
['profile', ['--profile', 'base']],
@@ -326,10 +344,6 @@ void test('selected genuine three-player Advanced base sample fails honestly, re
const out = join(area, `invalid-resume-${label}`);
const invalid = run(out, '--resume', snapshot, ...args);
assert.equal(invalid.status, 1);
assert.match(
invalid.stderr,
/--resume cannot be combined with --profile, --rules or --players/,
);
assert.equal(existsSync(out), false);
}
});
@@ -355,7 +369,6 @@ void test('default expansion samples retain their six names and seed ordinals wh
);
for (const result of expansionResults) {
assert.equal(result.actions, 1, `${result.name} reached the action limit`);
assert.match(result.error ?? '', /Action limit/, result.name);
}
const baseOut = join(area, 'base-default');
@@ -392,23 +405,14 @@ void test('default expansion samples retain their six names and seed ordinals wh
void test('invalid player filters fail before creating a private output directory', (t) => {
const area = temporary(t);
for (const [label, args, message] of [
['without-base', ['--players', '3'], /--players requires --profile base/],
[
'expansion-profile',
['--profile', 'choam', '--players', '3'],
/--players requires --profile base/,
],
[
'invalid-count',
['--profile', 'base', '--players', '7'],
/--players must be all or 2 through 6/,
],
for (const [label, args] of [
['without-base', ['--players', '3']],
['expansion-profile', ['--profile', 'choam', '--players', '3']],
['invalid-count', ['--profile', 'base', '--players', '7']],
] as const) {
const out = join(area, label);
const result = run(out, ...args);
assert.equal(result.status, 1);
assert.match(result.stderr, message);
assert.equal(existsSync(out), false);
}
});
+22
View File
@@ -98,6 +98,28 @@ void test('sample custody detects balanced negative forces and elite subsets out
/elite board subset/,
);
});
void test('sample custody accounts for foreign Homeworld ordinary and elite forces without counting Salusa twice', () => {
const { game, inventory } = fixture(true);
const emperor = game.players.find((seat) => seat.faction === 'emperor')!;
assert.ok(emperor.elites);
emperor.reserves -= 3;
emperor.elites.reserves--;
game.homeworlds = {
custody: {
visitors: { 'homeworld:fremen': { [emperor.id]: { normal: 2, elite: 1 } } },
salusa: { normal: 1, elite: 0 },
},
historyVersion: 1,
};
verifySampleCustody(game, inventory);
const missing = structuredClone(game);
missing.homeworlds!.custody!.visitors['homeworld:fremen'][emperor.id].normal--;
assert.throws(() => verifySampleCustody(missing, inventory), /force custody emperor/);
const missingElite = structuredClone(game);
missingElite.homeworlds!.custody!.visitors['homeworld:fremen'][emperor.id].elite--;
missingElite.homeworlds!.custody!.visitors['homeworld:fremen'][emperor.id].normal++;
assert.throws(() => verifySampleCustody(missingElite, inventory), /elite custody emperor/);
});
void test('sample custody catches a duplicated base traitor after setup despite preserved hand size', () => {
const { game, inventory } = fixture();
+43 -15
View File
@@ -11,6 +11,8 @@ import {
createGame,
initializeBaseGameForAudit,
initializeFactionExpansionsGameForAudit,
initializeHomeworldGameForAudit,
initializeNexusGameForAudit,
initializeLeaderSkillsGameForAudit,
joinGame,
newPlayer,
@@ -26,7 +28,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' | 'moritani-skills' | 'tleilaxu-skills' | 'ix-skills' | 'choam-skills';
type Profile = 'base' | 'choam' | 'ecaz' | 'combined' | 'homeworld' | 'nexus' | 'moritani-skills' | 'tleilaxu-skills' | 'ix-skills' | 'choam-skills';
type Rules = 'basic' | 'advanced';
type Scenario = {
@@ -100,6 +102,16 @@ 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,
rules,
expansions: [],
roster: BASE_ROSTER.slice(0, profile === 'nexus' ? 6 : 4),
})),
);
const MORITANI_SKILLS_ROSTER: readonly FactionId[] = [
'moritani', 'emperor', 'guild', 'harkonnen', 'fremen', 'beneGesserit',
@@ -171,10 +183,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|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] ' +
'[--resume FAILED_GAME.json]\n' +
'Runs genuine setup and gameplay offline. Default/all keeps the six expansion samples; base and the skill profiles default to their 2–6-player samples. --players requires --profile base, moritani-skills, tleilaxu-skills, ix-skills or choam-skills. Output must be a new private directory outside the checkout.'
'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.'
);
}
@@ -212,8 +224,8 @@ function scenarioName(scenario: Scenario) {
function parseProfile(value: string | undefined) {
const profile = value ?? 'all';
if (!['all', 'base', 'choam', 'ecaz', 'combined', 'moritani-skills', 'tleilaxu-skills', 'ix-skills', 'choam-skills'].includes(profile))
throw new Error('--profile must be all, base, choam, ecaz, combined, moritani-skills, tleilaxu-skills, ix-skills or choam-skills.');
if (!['all', 'base', 'choam', 'ecaz', 'combined', '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.');
return profile as Profile | 'all';
}
@@ -257,11 +269,19 @@ function freshGame(scenario: Scenario) {
player.bot = DIFFICULTIES[index % DIFFICULTIES.length];
player.ready = true;
}
if (scenario.profile === 'homeworld')
game.homeworlds = { custody: null };
if (scenario.profile === 'nexus')
game.nexusCards = { cards: null, phase: null };
return skillProfile(scenario.profile)
? initializeLeaderSkillsGameForAudit(game)
: scenario.profile === 'base'
? initializeBaseGameForAudit(game)
: initializeFactionExpansionsGameForAudit(game);
: scenario.profile === 'homeworld'
? initializeHomeworldGameForAudit(game)
: scenario.profile === 'nexus'
? initializeNexusGameForAudit(game)
: scenario.profile === 'base'
? initializeBaseGameForAudit(game)
: initializeFactionExpansionsGameForAudit(game);
}
function resumedGame(path: string) {
@@ -296,8 +316,7 @@ function resumedGame(path: string) {
'--resume is not an incomplete faction-games snapshot with saved AI profiles.',
);
if (
game.homeworlds ||
game.nexusCards ||
(game.homeworlds && game.nexusCards) ||
(game.leaderSkills && !basicExpansionLeaderSkillsProfile(game)) ||
game.discoveryEnabled ||
game.discoveries ||
@@ -312,9 +331,11 @@ function resumedGame(path: string) {
game.moritaniAssassinateResume ||
game.moritaniAssassinateCallEvents
)
throw new Error('--resume sample scenarios exclude optional modules.');
const scenario = [...SCENARIOS, ...BASE_SCENARIOS, ...MORITANI_SKILLS_SCENARIOS, ...TLEILAXU_SKILLS_SCENARIOS, ...IX_SKILLS_SCENARIOS, ...CHOAM_SKILLS_SCENARIOS].find(
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(
(candidate) =>
(candidate.profile === 'homeworld') === !!game.homeworlds &&
(candidate.profile === 'nexus') === !!game.nexusCards &&
skillProfile(candidate.profile) === !!game.leaderSkills &&
candidate.rules === (game.advanced ? 'advanced' : 'basic') &&
JSON.stringify(candidate.expansions) ===
@@ -324,7 +345,7 @@ function resumedGame(path: string) {
);
if (!scenario)
throw new Error(
'--resume does not match a fixed base or expansion sample scenario.',
'--resume does not match a fixed base, module or expansion sample scenario.',
);
// Projection validates the engine-facing shape and every private seat boundary.
for (const player of game.players) viewGame(game, player.id);
@@ -513,9 +534,16 @@ async function main() {
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'
? MODULE_SCENARIOS
: profile === 'base' ? BASE_SCENARIOS
: profile === 'moritani-skills' ? MORITANI_SKILLS_SCENARIOS
: profile === 'tleilaxu-skills' ? TLEILAXU_SKILLS_SCENARIOS
: profile === 'ix-skills' ? IX_SKILLS_SCENARIOS
: profile === 'choam-skills' ? CHOAM_SKILLS_SCENARIOS : SCENARIOS;
const selected = resume
? [resume.scenario]
: (profile === 'base' ? BASE_SCENARIOS : profile === 'moritani-skills' ? MORITANI_SKILLS_SCENARIOS : profile === 'tleilaxu-skills' ? TLEILAXU_SKILLS_SCENARIOS : profile === 'ix-skills' ? IX_SKILLS_SCENARIOS : profile === 'choam-skills' ? CHOAM_SKILLS_SCENARIOS : SCENARIOS).filter(
: requestedSamples.filter(
(scenario) =>
(profile === 'all' || scenario.profile === profile) &&
(rules === 'both' || scenario.rules === rules) &&
@@ -602,7 +630,7 @@ async function main() {
status,
setup: resume
? 'Resume supplied private snapshot; its setup provenance must be checked against the original report. Saved AI profiles choose every continuation action.'
: 'Genuine base or faction-expansion setup; no cards, forces, factions, phases or statistics staged. Real saved AI profiles choose every setup and gameplay action.',
: 'Genuine base, optional-module or faction-expansion setup; no cards, forces, factions, phases or statistics staged. Real saved AI profiles choose every setup and gameplay action.',
randomness: resume
? 'Continuation restarts the random stream at seed plus scenario ordinal; it does not reconstruct the pre-snapshot random stream.'
: 'Each scenario starts its random stream at seed plus scenario ordinal.',
+13
View File
@@ -53,10 +53,21 @@ export function verifySampleCustody(
'physical card custody',
);
for (const player of game.players) {
let visitorNormal = 0;
let visitorElite = 0;
for (const occupants of Object.values(game.homeworlds?.custody?.visitors ?? {})) {
const group = occupants[player.id];
if (group) {
visitorNormal += group.normal;
visitorElite += group.elite;
}
}
const amounts = [
player.reserves,
player.tanks,
...Object.values(player.forces),
visitorNormal,
visitorElite,
];
assert.ok(
amounts.every((amount) => Number.isSafeInteger(amount) && amount >= 0),
@@ -77,12 +88,14 @@ export function verifySampleCustody(
(game.advanced && ['emperor', 'fremen'].includes(player.faction)))
)
assert.ok(player.elites, `missing elite inventory ${player.faction}`);
if (!player.elites) assert.equal(visitorElite, 0, `unexpected elite visitors ${player.faction}`);
if (player.elites) {
const elites = player.elites;
const typed = [
elites.reserves,
elites.tanks,
...Object.values(elites.forces),
visitorElite,
];
assert.ok(
typed.every((amount) => Number.isSafeInteger(amount) && amount >= 0),