Exercise Homeworld and Nexus saved game samples
This commit is contained in:
1 parent
51ef855d19
commit
23b6c09ae8
5 files changed
+120
-44
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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
@@ -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.',
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in new issue
Block a user