Files
Dune/tests/faction-games-cli.test.ts
T
Quintine 3efda2128e Connect Basic Ixians to the full Leader Skills prototype
Admit genuine Basic Ixian setup with the full fourteen-skill deck, real
starting-card choice, optional Tleilaxu/base opponents and first-Storm HMS.
Keep public starts, Advanced and other module combinations gated.

Share per-origin cyborg movement range across engine, controls and legal bots.
Preserve independent Planetologist range after native Karama cancellation.
Connect Sandmaster force routes through room-local HMS pointer adjacency.
Retain canceled movement's no-op contract while validating actual execution.

Resolve Suk rescue before substitution with explicit ambiguous cyborg rescue
origins and exact remaining Tank losses. Finish Rihani before native
substitution, bind saved physical losses and declared exchange, and resume
mandatory/optional winner disposal on allow, decline or cancellation.
Admit exact green Ix Thumper, Harvester and Amal as Planetologist substitutes.

Validation: types, lint, 5,082 offline tests and production build passed.
Five genuine 2-6 player games completed with zero rejected actions and 54
JSON restores. Authenticated in-memory concurrent starting-card recovery,
private setup, physical card/force custody and independent review passed.
Browser FLGHBSA4 completed real setup, first Storm/HMS placement and a
conserved staged Suk battle. Mobile rescue, refresh and one-for-one
substitution passed. Screenshot capture timed out; full visual acceptance
remains open. All 1,582 prior rooms, 3,350 human seats and auxiliary records
were preserved; one new QA room/seat remains. No restart, reset or automation.

AI work remains limited to legal participation until every non-AI feature
is complete, then targets roughly 75% higher-tier wins for each adjacent pair.
2026-09-21 14:36:27 +10:00

395 lines
14 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import {
existsSync,
mkdtempSync,
readFileSync,
readdirSync,
rmSync,
statSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const root = new URL('../', import.meta.url).pathname;
const cli = new URL('../tools/faction-games.ts', import.meta.url).pathname;
type CliResult = {
name: string;
seed: number;
resumed: boolean;
outcome: string;
error?: string;
actions: number;
attempts: number;
};
type CliReport = {
status: string;
sourceUnchanged: boolean;
before: { commit: string; tree: string };
after: { commit: string; tree: string };
options: {
seed: number;
profile: string;
rules: string;
maxActions: number;
players: number | 'all';
resume?: { path: string; sha256: string };
};
results: CliResult[];
};
function run(out: string, ...args: string[]) {
const env = { ...process.env };
delete env.NODE_TEST_CONTEXT;
const result = spawnSync(
process.execPath,
['--import', import.meta.resolve('tsx'), cli, '--out', out, ...args],
{ cwd: root, env, encoding: 'utf8', timeout: 30_000 },
);
assert.ifError(result.error);
return result;
}
function temporary(t: test.TestContext) {
const directory = mkdtempSync(join(tmpdir(), 'dune-faction-games-cli-'));
t.after(() => rmSync(directory, { recursive: true, force: true }));
return directory;
}
function json<T>(path: string): T {
return JSON.parse(readFileSync(path, 'utf8')) as T;
}
function assertFailedEvidence(
out: string,
name: string,
expectedSeed: number,
resumed: boolean,
) {
const report = json<CliReport>(join(out, 'report.json'));
const results = json<{ results: CliResult[] }>(join(out, 'results.json'));
assert.equal(report.status, 'failed');
assert.equal(report.sourceUnchanged, true);
assert.equal(report.before.commit, report.after.commit);
assert.equal(report.before.tree, report.after.tree);
assert.match(report.before.commit, /\S/);
assert.match(report.before.tree, /^[a-f\d]{64}$/);
assert.equal(report.results.length, 1);
assert.equal(results.results.length, 1);
for (const result of [report.results[0], results.results[0]]) {
assert.equal(result.name, name);
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);
}
const trace = json<unknown[]>(join(out, `trace-${name}.json`));
assert.ok(Array.isArray(trace) && trace.length > 0);
assert.ok(existsSync(join(out, `failed-${name}.json`)));
for (const file of readdirSync(out))
assert.equal(statSync(join(out, file)).mode & 0o777, 0o600);
return report;
}
void test('Moritani Skills samples preserve their full module on saved continuation and reject Advanced before running', (t) => {
const area = temporary(t);
const out = join(area, 'skills');
const result = run(out, '--profile', 'moritani-skills', '--players', '2', '--seed', '1000', '--max-actions', '1');
assert.equal(result.status, 1);
assertFailedEvidence(out, 'moritani-skills-2-basic', 1016, false);
const snapshot = join(out, 'failed-moritani-skills-2-basic.json');
const game = json<{ advanced: boolean; leaderSkills: { deck: string[]; offers: Record<string, { cards: string[] }>; assignments: { skill: string }[] } }>(snapshot);
assert.equal(game.advanced, false);
const physical = [...game.leaderSkills.deck, ...Object.values(game.leaderSkills.offers).flatMap(o => o.cards), ...game.leaderSkills.assignments.map(a => a.skill)];
assert.equal(physical.length, 14); assert.equal(new Set(physical).size, 14);
const resumed = join(area, 'skills-resumed');
assert.equal(run(resumed, '--resume', snapshot, '--seed', '1000', '--max-actions', '1').status, 1);
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(existsSync(unsupported), false);
});
void test('Tleilaxu Skills samples preserve their full module on saved continuation and reject Advanced before running', (t) => {
const area = temporary(t);
const out = join(area, 'skills');
const result = run(out, '--profile', 'tleilaxu-skills', '--players', '2', '--seed', '1000', '--max-actions', '1');
assert.equal(result.status, 1);
assertFailedEvidence(out, 'tleilaxu-skills-2-basic', 1021, false);
const snapshot = join(out, 'failed-tleilaxu-skills-2-basic.json');
const game = json<{ advanced: boolean; leaderSkills: { deck: string[]; offers: Record<string, { cards: string[] }>; assignments: { skill: string }[] } }>(snapshot);
assert.equal(game.advanced, false);
const physical = [...game.leaderSkills.deck, ...Object.values(game.leaderSkills.offers).flatMap(o => o.cards), ...game.leaderSkills.assignments.map(a => a.skill)];
assert.equal(physical.length, 14); assert.equal(new Set(physical).size, 14);
const resumed = join(area, 'skills-resumed');
assert.equal(run(resumed, '--resume', snapshot, '--seed', '1000', '--max-actions', '1').status, 1);
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(existsSync(unsupported), false);
});
void test('Ixian Skills samples preserve their full module on saved continuation and reject Advanced before running', (t) => {
const area = temporary(t);
const out = join(area, 'skills');
const result = run(out, '--profile', 'ix-skills', '--players', '2', '--seed', '1000', '--max-actions', '1');
assert.equal(result.status, 1);
assertFailedEvidence(out, 'ix-skills-2-basic', 1026, false);
const snapshot = join(out, 'failed-ix-skills-2-basic.json');
const game = json<{ advanced: boolean; leaderSkills: { deck: string[]; offers: Record<string, { cards: string[] }>; assignments: { skill: string }[] } }>(snapshot);
assert.equal(game.advanced, false);
const physical = [...game.leaderSkills.deck, ...Object.values(game.leaderSkills.offers).flatMap(o => o.cards), ...game.leaderSkills.assignments.map(a => a.skill)];
assert.equal(physical.length, 14); assert.equal(new Set(physical).size, 14);
const resumed = join(area, 'skills-resumed');
assert.equal(run(resumed, '--resume', snapshot, '--seed', '1000', '--max-actions', '1').status, 1);
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(existsSync(unsupported), 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);
const first = join(area, 'first');
const initial = run(
first,
'--profile',
'base',
'--players',
'3',
'--rules',
'advanced',
'--seed',
'1000',
'--max-actions',
'1',
);
assert.equal(initial.status, 1);
const firstReport = assertFailedEvidence(
first,
'base-3-advanced',
1009,
false,
);
assert.deepEqual(firstReport.options, {
seed: 1000,
profile: 'base',
rules: 'advanced',
maxActions: 1,
players: 3,
});
const snapshot = join(first, 'failed-base-3-advanced.json');
for (const field of [
'ecazTreachery',
'mentatQuestionPreview',
'moritaniAssassinatePreview',
'moritaniAssassinate',
'moritaniAssassinateResume',
'moritaniAssassinateCallEvents',
]) {
const optionalSnapshot = join(area, `optional-${field}.json`);
const optional = json<Record<string, unknown>>(snapshot);
optional[field] = true;
writeFileSync(optionalSnapshot, JSON.stringify(optional), { mode: 0o600 });
const optionalOut = join(area, `optional-${field}-resume`);
const optionalResult = run(
optionalOut,
'--resume',
optionalSnapshot,
'--max-actions',
'1',
);
assert.equal(optionalResult.status, 1);
assert.match(
optionalResult.stderr,
/--resume sample scenarios exclude optional modules\./,
);
assert.equal(existsSync(optionalOut), false);
}
const resumedOut = join(area, 'resumed');
const resumed = run(
resumedOut,
'--resume',
snapshot,
'--seed',
'1000',
'--max-actions',
'1',
);
assert.equal(resumed.status, 1);
const resumedReport = assertFailedEvidence(
resumedOut,
'base-3-advanced',
1009,
true,
);
assert.equal(resumedReport.options.profile, 'base');
assert.equal(resumedReport.options.rules, 'advanced');
assert.equal(resumedReport.options.players, 3);
assert.ok(resumedReport.options.resume);
assert.equal(resumedReport.options.resume.path, snapshot);
assert.match(resumedReport.options.resume.sha256, /^[a-f\d]{64}$/);
const custodyOut = join(area, 'custody-source');
const custodyRun = run(
custodyOut,
'--profile',
'base',
'--players',
'3',
'--rules',
'advanced',
'--max-actions',
'20',
);
assert.equal(custodyRun.status, 1);
const corruptPath = join(area, 'paired-missing-leader-and-traitor.json');
const corrupt = json<{
status: string;
players: { leaders: { id: string }[]; traitors: string[] }[];
traitorReserve?: string[];
}>(join(custodyOut, 'failed-base-3-advanced.json'));
assert.equal(corrupt.status, 'playing');
const dealt = new Set([
...(corrupt.traitorReserve ?? []),
...corrupt.players.flatMap((player) => player.traitors),
]);
const owner = corrupt.players.find((player) =>
player.leaders.some((leader) => dealt.has(leader.id)),
);
assert.ok(owner, 'fixture has a leader represented in the Traitor inventory');
const missing = owner.leaders.find((leader) => dealt.has(leader.id))!.id;
owner.leaders = owner.leaders.filter((leader) => leader.id !== missing);
let removed = false;
corrupt.traitorReserve = corrupt.traitorReserve?.filter((id) => {
if (id !== missing) return true;
removed = true;
return false;
});
for (const player of corrupt.players)
player.traitors = player.traitors.filter((id) => {
if (id !== missing) return true;
removed = true;
return false;
});
assert.equal(removed, true, 'fixture leader has one physical Traitor card');
writeFileSync(corruptPath, JSON.stringify(corrupt), { mode: 0o600 });
const corruptOut = join(area, 'corrupt-resume');
const corrupted = run(
corruptOut,
'--resume',
corruptPath,
'--max-actions',
'1',
);
assert.equal(corrupted.status, 1);
const corruptResult = json<{ results: CliResult[] }>(
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']],
['rules', ['--rules', 'advanced']],
['players', ['--players', '3']],
] as const) {
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);
}
});
void test('default expansion samples retain their six names and seed ordinals while base defaults cover every two-to-six-player roster', (t) => {
const area = temporary(t);
const expansionOut = join(area, 'expansion-default');
const expansion = run(expansionOut, '--seed', '2000', '--max-actions', '1');
assert.equal(expansion.status, 1);
const expansionResults = json<{ results: CliResult[] }>(
join(expansionOut, 'results.json'),
).results;
assert.deepEqual(
expansionResults.map((result) => [result.name, result.seed]),
[
['choam-basic', 2000],
['choam-advanced', 2001],
['ecaz-basic', 2002],
['ecaz-advanced', 2003],
['combined-basic', 2004],
['combined-advanced', 2005],
],
);
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');
const base = run(
baseOut,
'--profile',
'base',
'--seed',
'2000',
'--max-actions',
'1',
);
assert.equal(base.status, 1);
const baseReport = json<CliReport>(join(baseOut, 'report.json'));
assert.equal(baseReport.options.players, 'all');
assert.deepEqual(
json<{ results: CliResult[] }>(join(baseOut, 'results.json')).results.map(
(result) => [result.name, result.seed],
),
[
['base-2-basic', 2006],
['base-2-advanced', 2007],
['base-3-basic', 2008],
['base-3-advanced', 2009],
['base-4-basic', 2010],
['base-4-advanced', 2011],
['base-5-basic', 2012],
['base-5-advanced', 2013],
['base-6-basic', 2014],
['base-6-advanced', 2015],
],
);
});
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/,
],
] 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);
}
});