Admit the genuine private setup profile for Basic Moritani with base opponents, exactly the Ecaz expansion and no other optional modules. Keep all14 physical skills, private two-card offers, starting-card/Traitor order and Moritani's final six-force placement. Public starts and complete-mode/publication gates remain. Share public profile eligibility across Banker, Diplomat, Sandmaster movement and worm riding, Smuggler shipping/collection, Bureaucrat payments, Planetologist controls/bots and Suk/Rihani/Sandmaster battle settlement. Admission reads no hidden cards or random deal. Preserve base/CHOAM boundaries and existing source questions; no full AI implementation, strategy or calibration changes. Genuine setup regressions cover all14 assignments, legal profiles and guards, Terror Assassination death and exact skill return, optional own revival draw/privacy, Sabotage, movement controls/bots, Smuggler shipment and Bureaucrat paid bribes. Battle actions cover Rihani exchange, Sandmaster spice, Banker funds, Suk casualties and Smuggler collection. Actual enemy alliances prove losing-ally retention excludes mandatory Planetologist Special and Diplomat-copy disposal. A real worm/Nexus ride collects Sandmaster spice once. Authenticated in-memory SQLite restarts and concurrent skill choices preserve one assignment, private offers, saved seats and final Moritani placement. Conserved staged positions are explicitly distinct from played phase histories. Add a reproducible moritani-skills profile to the existing faction-game runner, including fixed2-6-player Basic rosters, seed offsets16-20 and saved resume. Verify expected module presence, all14 physical skills and ordinary Traitor custody alongside existing card/force/private-view checks. Default six expansion samples and base samples retain their rosters/ordinals. No live DB is used by these complete-game samples. CLI regressions check interrupted continuation and Advanced refusal; corruption tests catch lost cards and entire module loss. Independent review caught an old all-base worm guard and a sample checker that missed whole-module deletion; both fixed with meaningful regressions and reviewed. Initial edits had a stale helper reference and final lint found an unused import; these were corrected. Types, lint, all 5034 offline tests and build pass on stable final source. Five genuine complete-game samples passed with no rejected actions:2 players189 actions/5 restores,3 players53/1,4 players862/23, 5 players116/3 and6 players1338/36. These are legality samples, not calibration or exhaustive faction/card acceptance. No endpoint/auth/storage changes; existing HTTP suite not repeated, while new authenticated storage and live browser actions exercise the newly admitted profile through production boundaries. Browser created a fresh Basic human Moritani/Easy Emperor lobby, then the existing backed-up local initializer started that named ready room without replacing seats. Human inspected both Killer Medic bands, assigned Lupino Ord, chose a private Traitor and placed six in Polar Sink after all other setup. Refresh preserved the two public skills, own hand/Traitor and6 deployed/14 reserves at pending Storm. Mobile skill panel339px, client/scroll337; viewport restored afterward. Screenshot capture timed out, so full visual/screen-reader acceptance stays open. All 1580 opening games, 3348 saved seats and auxiliary rows remain identical. One isolated QA game adds one human seat and entry receipt; its row is unchanged after setup through refresh, with all seats unchanged. SQLite integrity and foreign keys pass. No server restart, migration, reset or recurring automation. Existing unfinished Leader Skill bands and material rulings remain explicit in the new integration guide and internal checklist. Private evidence: 20260921-moritani-skills. Verified source tree: d5078661ecc2199b2bbfebd18034b16585d06410a697a78dc514bfa5b3b77a1c
355 lines
11 KiB
TypeScript
355 lines
11 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('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 base 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);
|
|
}
|
|
});
|