Files
Dune/tests/faction-games-cli.test.ts
T
Quintine d3559f65ad Connect Basic Tleilaxu to the full Leader Skills prototype
Admit genuine Basic Tleilaxu/base setup with the full fourteen-skill deck,
Ix card inventories and native three Face Dancers. Share public profile guards
across existing skill effects while retaining public-start and Advanced gates.

Record native own-leader skill offers at successful revival commitment so
automatic Tleilaxu response chains preserve the optional private draw. Connect
Rihani to unrevealed Face Dancer exchange with exact physical/status custody,
private controls and existing legal AI choices. Release completed Rihani's
live snapshot only after validated Face Dance admission; keep signed history
and current census checks. Zoal Smuggler uses opposing printed disc value at
reveal, excluding opposing skill bonuses and his separate revival value.

Add genuine setup, response/cancellation, private choice, custody, Face Dance
reset, Zoal, legal AI and authenticated concurrent recovery regressions. Add a
reproducible two-through-six-player sample profile and saved continuation.
Preserve unresolved skill effects, foreign-ghola boundaries and delayed AI tuning.

Validation: types, lint, 5,058 offline tests and production build passed.
Five full games completed with zero rejected actions and 74 JSON restores.
Browser QA CRY4DD4J used genuine setup and an explicitly conserved staged battle;
real Rihani draw/exchange and same-state refresh passed on mobile and desktop.
Screenshot capture timed out; full visual acceptance remains open. No HTTP
endpoint/auth/storage implementation changed; authenticated SQLite recovery was
verified. All 1,581 prior rooms, 3,349 seats and auxiliary records were preserved.
One new QA room/seat remains. No server restart, database reset or automation.
2026-09-21 14:08:30 +10:00

375 lines
13 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('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);
}
});