Files
Dune/tools/faction-games.ts
T
Quintine 368a7a5144 Add reusable base game samples across player counts
The existing offline faction runner now selects genuine Basic and Advanced base games with two through six players. Original six expansion defaults and seed offsets remain stable; player-count filters require the explicit base profile. Saved AI profiles choose every setup and game action. This replaces repeated private harness copies while preserving source-bound private reports, failed traces and explicit snapshot resume.

Shared custody checks include every physical Treachery zone, nonnegative integer resources, twenty-force totals, the distinct Emperor/Fremen/Ixian elite totals and subsets, and canonical base Traitor identities after dealing. Rejected actions must leave their input unchanged; periodic JSON restoration compares all private views and excludes rival private hand/spice/Traitor/Face Dancer fields. Optional modules and separate private previews cannot be resumed as ordinary fixed scenarios. Resume still requires checking original setup provenance and intentionally restarts the selected random stream.

Independent review caught and corrected seven-cyborg accounting, save-derived Traitor expectations, missing elite records and private-preview classification. Meaningful regressions cover those failures plus strict filters, original names/seeds, failed subprocess reporting, source stability and resume. Types, lint and 4823 offline tests pass. No application, dependency, HTTP or database code changed, so build/HTTP suites were not repeated.

All ten base and six existing expansion samples completed on the final unchanged source: 6746 accepted actions, 175 periodic restores, 82 rejected candidates retained in traces. The expansion guarded-arrival fallbacks remain visible; these samples do not certify every faction, optional module, player-count rule or difficulty ordering. Exact expansion Traitor/Face Dancer custody still needs its own adapter. The earlier pilot/source-changed report remains private; the required check was repeated after the late review correction rather than accepting results from changing source.

All 1038 opening games and original seat/recovery records are unchanged; 1038 rooms remain. SQLite integrity and foreign keys pass. The existing QA table restored its saved seat and fulfilled shipment after browser refresh. The healthy server was reused without reset or restart.

The rules register now explicitly records the Moritani Secret Ally after-loss privacy blocker. A holder-only pause exposes its hidden Nexus; the existing alliance-retention helper does not resolve the shared pending response UX decision. No duplicate user question, new house rule or mode/publication gate change was introduced.

Verified source tree: 85d5a53fa72efb9704c1828526aaccba1554670307ce476f729eae9c5c4abff9
2026-09-14 06:00:38 +10:00

590 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
import { readFileSync, statSync, writeFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { parseArgs } from 'node:util';
import { botActions } from '../game/bots';
import type { FactionId } from '../game/catalog';
import {
applyAction,
createGame,
initializeBaseGameForAudit,
initializeFactionExpansionsGameForAudit,
joinGame,
newPlayer,
viewGame,
RuleError,
type Action,
type Game,
} from '../game/engine';
import { sampleInventory, verifySampleCustody } from './sample-custody';
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';
type Rules = 'basic' | 'advanced';
type Scenario = {
ordinal: number;
profile: Profile;
rules: Rules;
expansions: string[];
roster: FactionId[];
};
const SCENARIOS: readonly Scenario[] = [
{
ordinal: 0,
profile: 'choam',
rules: 'basic',
expansions: ['choam'],
roster: ['richese', 'choam', 'emperor', 'guild'],
},
{
ordinal: 1,
profile: 'choam',
rules: 'advanced',
expansions: ['choam'],
roster: ['richese', 'choam', 'emperor', 'guild'],
},
{
ordinal: 2,
profile: 'ecaz',
rules: 'basic',
expansions: ['ecaz'],
roster: ['ecaz', 'moritani', 'atreides', 'beneGesserit'],
},
{
ordinal: 3,
profile: 'ecaz',
rules: 'advanced',
expansions: ['ecaz'],
roster: ['ecaz', 'moritani', 'atreides', 'beneGesserit'],
},
{
ordinal: 4,
profile: 'combined',
rules: 'basic',
expansions: ['ix', 'choam', 'ecaz'],
roster: ['ecaz', 'ixians', 'tleilaxu', 'choam', 'richese', 'moritani'],
},
{
ordinal: 5,
profile: 'combined',
rules: 'advanced',
expansions: ['ix', 'choam', 'ecaz'],
roster: ['ecaz', 'ixians', 'tleilaxu', 'choam', 'richese', 'moritani'],
},
];
// Keep the original six expansion ordinals and defaults stable.
const BASE_ROSTER: readonly FactionId[] = [
'atreides',
'harkonnen',
'fremen',
'emperor',
'guild',
'beneGesserit',
];
const BASE_SCENARIOS: readonly Scenario[] = [2, 3, 4, 5, 6].flatMap((players) =>
(['basic', 'advanced'] as const).map((rules, index) => ({
ordinal: 6 + (players - 2) * 2 + index,
profile: 'base' as const,
rules,
expansions: [],
roster: BASE_ROSTER.slice(0, players),
})),
);
type TraceEntry = {
attempt: number;
accepted: number;
player: string;
turn: number;
phase: number;
setup: string | null;
decision: string | null;
response: string | null;
action: Action;
outcome: 'accepted' | 'rejected';
error?: string;
};
type Result = {
name: string;
profile: Profile;
rules: Rules;
advanced: boolean;
seed: number;
resumed: boolean;
actions: number;
attempts: number;
restores: number;
turn: number;
phase: number;
winner: string[];
used: Record<string, number>;
rejected: Record<string, number>;
outcome: 'complete' | 'failure';
error?: string;
};
function usage() {
return (
'Usage: node --import tsx tools/faction-games.ts --out NEW_PRIVATE_DIR ' +
'[--seed UINT32] [--profile all|base|choam|ecaz|combined] ' +
'[--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 defaults to all 2–6-player samples. --players requires --profile base. Output must be a new private directory outside the checkout.'
);
}
function supplied(name: string) {
return process.argv
.slice(2)
.some((value) => value === `--${name}` || value.startsWith(`--${name}=`));
}
function unsigned32(value: string | undefined) {
if (value === undefined) return DEFAULT_SEED;
if (!/^\d+$/.test(value))
throw new Error('--seed must be an unsigned 32-bit integer.');
const parsed = Number(value);
if (!Number.isSafeInteger(parsed) || parsed < 0 || parsed > 0xffff_ffff)
throw new Error('--seed must be an unsigned 32-bit integer.');
return parsed;
}
function positive(value: string | undefined) {
if (value === undefined) return DEFAULT_MAX_ACTIONS;
if (!/^\d+$/.test(value))
throw new Error('--max-actions must be a positive integer.');
const parsed = Number(value);
if (!Number.isSafeInteger(parsed) || parsed <= 0)
throw new Error('--max-actions must be a positive integer.');
return parsed;
}
function scenarioName(scenario: Scenario) {
return scenario.profile === 'base'
? `base-${scenario.roster.length}-${scenario.rules}`
: `${scenario.profile}-${scenario.rules}`;
}
function parseProfile(value: string | undefined) {
const profile = value ?? 'all';
if (!['all', 'base', 'choam', 'ecaz', 'combined'].includes(profile))
throw new Error('--profile must be all, base, choam, ecaz or combined.');
return profile as Profile | 'all';
}
function parseRules(value: string | undefined) {
const rules = value ?? 'both';
if (!['both', 'basic', 'advanced'].includes(rules))
throw new Error('--rules must be both, basic or advanced.');
return rules as Rules | 'both';
}
function parsePlayers(value: string | undefined) {
if (value === undefined || value === 'all') return 'all';
if (!/^[2-6]$/.test(value))
throw new Error('--players must be all or 2 through 6.');
return Number(value);
}
function privateWrite(directory: string, name: string, value: unknown) {
writeFileSync(
resolve(directory, name),
JSON.stringify(value, null, 2) + '\n',
{
encoding: 'utf8',
flag: 'wx',
mode: 0o600,
},
);
}
function freshGame(scenario: Scenario) {
const [first, ...rest] = scenario.roster;
const game = createGame(
'FACTIONR',
newPlayer(first, first, first),
scenario.rules === 'advanced',
scenario.expansions,
);
for (const faction of rest)
joinGame(game, newPlayer(faction, faction, faction));
for (const [index, player] of game.players.entries()) {
player.bot = DIFFICULTIES[index % DIFFICULTIES.length];
player.ready = true;
}
return scenario.profile === 'base'
? initializeBaseGameForAudit(game)
: initializeFactionExpansionsGameForAudit(game);
}
function resumedGame(path: string) {
if (!statSync(path).isFile())
throw new Error('--resume must name one regular JSON Game snapshot.');
const input = readFileSync(path);
const hash = createHash('sha256').update(input).digest('hex');
let value: unknown;
try {
value = JSON.parse(input.toString('utf8'));
} catch {
throw new Error('--resume must contain one JSON Game snapshot.');
}
if (!value || typeof value !== 'object' || Array.isArray(value))
throw new Error('--resume must contain one JSON Game snapshot.');
const game = value as Game;
if (
!['setup', 'playing'].includes(game.status) ||
typeof game.advanced !== 'boolean' ||
!Array.isArray(game.expansions) ||
!Array.isArray(game.players) ||
!game.players.length ||
game.players.some(
(player) =>
!player ||
typeof player.id !== 'string' ||
typeof player.faction !== 'string' ||
!DIFFICULTIES.includes(player.bot as (typeof DIFFICULTIES)[number]),
)
)
throw new Error(
'--resume is not an incomplete faction-games snapshot with saved AI profiles.',
);
if (
game.homeworlds ||
game.nexusCards ||
game.leaderSkills ||
game.discoveryEnabled ||
game.discoveries ||
game.discoveryStash ||
game.greatMaker ||
game.techTokens ||
game.strongholdCards ||
game.ecazTreachery ||
game.mentatQuestionPreview ||
game.moritaniAssassinatePreview ||
game.moritaniAssassinate ||
game.moritaniAssassinateResume ||
game.moritaniAssassinateCallEvents
)
throw new Error('--resume sample scenarios exclude optional modules.');
const scenario = [...SCENARIOS, ...BASE_SCENARIOS].find(
(candidate) =>
candidate.rules === (game.advanced ? 'advanced' : 'basic') &&
JSON.stringify(candidate.expansions) ===
JSON.stringify(game.expansions) &&
JSON.stringify(candidate.roster) ===
JSON.stringify(game.players.map((player) => player.faction)),
);
if (!scenario)
throw new Error(
'--resume does not match a fixed base 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);
return { game, scenario, hash, path: resolve(path) };
}
function simulate(
initial: Game,
scenario: Scenario,
seed: number,
maxActions: number,
resumed: boolean,
) {
let game = initial;
const expected = sampleInventory(game);
const trace: TraceEntry[] = [];
const used: Record<string, number> = {};
const rejected: Record<string, number> = {};
let actions = 0;
let restores = 0;
try {
verifySampleCustody(game, expected);
while (actions < maxActions && game.status !== 'finished') {
let next: Game | undefined;
for (const player of game.players) {
for (const action of botActions(viewGame(game, player.id))) {
const context = {
attempt: trace.length,
accepted: actions,
player: player.id,
turn: game.turn,
phase: game.phase,
setup: game.setupStage ?? null,
decision: game.decision?.kind ?? null,
response: game.response?.kind ?? null,
action,
};
const unchanged = JSON.stringify(game);
try {
next = applyAction(game, player.id, action);
const label =
action.type +
(action.type === 'decision'
? `:${game.decision?.kind ?? 'none'}`
: '');
used[label] = (used[label] ?? 0) + 1;
trace.push({ ...context, outcome: 'accepted' });
actions++;
break;
} catch (error) {
assert.equal(
JSON.stringify(game),
unchanged,
'rejected-action immutability',
);
if (!(error instanceof RuleError)) throw error;
const message = error.message;
const label = `${action.type}: ${message}`;
rejected[label] = (rejected[label] ?? 0) + 1;
trace.push({ ...context, outcome: 'rejected', error: message });
}
}
if (next) break;
}
if (!next)
throw new Error(
`No legal candidate: ${JSON.stringify({
status: game.status,
setup: game.setupStage,
phase: game.phase,
decision: game.decision?.kind,
response: game.response?.kind,
preparation: game.battle?.preparation,
})}`,
);
game = next;
verifySampleCustody(game, expected);
if (actions % 37 === 0) {
const restored = JSON.parse(JSON.stringify(game)) as Game;
for (const player of game.players)
assert.deepEqual(
viewGame(restored, player.id),
viewGame(game, player.id),
'restored view',
);
for (const player of game.players) {
const view = viewGame(restored, player.id);
for (const other of view.players.filter(
(seat) => seat.id !== player.id,
))
for (const field of ['hand', 'spice', 'traitors', 'faceDancers'])
assert.equal(
field in other,
false,
`private rival field ${field}`,
);
}
game = restored;
restores++;
}
}
if (game.status !== 'finished') throw new Error('Action limit');
return {
game,
trace,
result: {
name: scenarioName(scenario),
profile: scenario.profile,
rules: scenario.rules,
advanced: scenario.rules === 'advanced',
seed,
resumed,
actions,
attempts: trace.length,
restores,
turn: game.turn,
phase: game.phase,
winner: game.winner,
used,
rejected,
outcome: 'complete',
} satisfies Result,
};
} catch (error) {
return {
game,
trace,
result: {
name: scenarioName(scenario),
profile: scenario.profile,
rules: scenario.rules,
advanced: scenario.rules === 'advanced',
seed,
resumed,
actions,
attempts: trace.length,
restores,
turn: game.turn,
phase: game.phase,
winner: game.winner,
used,
rejected,
outcome: 'failure',
error: String(error),
} satisfies Result,
};
}
}
async function main() {
const { values, positionals } = parseArgs({
options: {
out: { type: 'string' },
seed: { type: 'string' },
profile: { type: 'string' },
rules: { type: 'string' },
players: { type: 'string' },
'max-actions': { type: 'string' },
resume: { type: 'string' },
help: { type: 'boolean', short: 'h' },
},
allowPositionals: true,
strict: true,
});
if (values.help) {
console.log(usage());
return;
}
if (positionals.length)
throw new Error(`Unexpected argument: ${positionals[0]}`);
if (!values.out) throw new Error('--out is required.\n' + usage());
if (
values.resume &&
(supplied('profile') || supplied('rules') || supplied('players'))
)
throw new Error(
'--resume cannot be combined with --profile, --rules or --players.',
);
const seed = unsigned32(values.seed);
const maxActions = positive(values['max-actions']);
const profile = parseProfile(values.profile);
const rules = parseRules(values.rules);
const players = parsePlayers(values.players);
if (supplied('players') && profile !== 'base')
throw new Error('--players requires --profile base.');
const resume = values.resume ? resumedGame(values.resume) : null;
const selected = resume
? [resume.scenario]
: (profile === 'base' ? BASE_SCENARIOS : SCENARIOS).filter(
(scenario) =>
(profile === 'all' || scenario.profile === profile) &&
(rules === 'both' || scenario.rules === rules) &&
(players === 'all' || scenario.roster.length === players),
);
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const directory = privateOutputDirectory(root, values.out);
const before = sourceSnapshot(root);
const originalRandom = Object.getOwnPropertyDescriptor(
globalThis.crypto,
'getRandomValues',
);
let randomState = seed;
Object.defineProperty(globalThis.crypto, 'getRandomValues', {
configurable: true,
value: <T extends ArrayBufferView | null>(array: T): T => {
if (!(array instanceof Uint32Array))
throw new Error(
'Faction game simulation expects Uint32Array randomness.',
);
for (let index = 0; index < array.length; index++) {
randomState = (randomState + 0x6d2b79f5) >>> 0;
let word = Math.imul(
randomState ^ (randomState >>> 15),
randomState | 1,
);
word ^= word + Math.imul(word ^ (word >>> 7), word | 61);
array[index] = (word ^ (word >>> 14)) >>> 0;
}
return array;
},
});
const results: Result[] = [];
try {
for (const scenario of selected) {
const scenarioSeed = (seed + scenario.ordinal) >>> 0;
randomState = scenarioSeed;
const outcome = simulate(
resume ? structuredClone(resume.game) : freshGame(scenario),
scenario,
scenarioSeed,
maxActions,
!!resume,
);
privateWrite(
directory,
`trace-${outcome.result.name}.json`,
outcome.trace,
);
if (outcome.result.outcome === 'failure')
privateWrite(
directory,
`failed-${outcome.result.name}.json`,
outcome.game,
);
results.push(outcome.result);
console.log(JSON.stringify(outcome.result));
}
} finally {
if (originalRandom)
Object.defineProperty(
globalThis.crypto,
'getRandomValues',
originalRandom,
);
else Reflect.deleteProperty(globalThis.crypto, 'getRandomValues');
}
const after = sourceSnapshot(root);
const sourceUnchanged =
before.commit === after.commit && before.tree === after.tree;
const status = !sourceUnchanged
? 'source-changed'
: results.every((result) => result.outcome === 'complete')
? 'passed'
: 'failed';
privateWrite(directory, 'results.json', { format: 1, results });
privateWrite(directory, 'report.json', {
format: 1,
before,
after,
sourceUnchanged,
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.',
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.',
options: {
seed,
profile: resume ? resume.scenario.profile : profile,
rules: resume ? resume.scenario.rules : rules,
maxActions,
players: resume ? resume.scenario.roster.length : players,
...(resume ? { resume: { path: resume.path, sha256: resume.hash } } : {}),
},
results: results.map(
({ used: _used, rejected: _rejected, ...result }) => result,
),
});
console.log(
JSON.stringify({ status, report: resolve(directory, 'report.json') }),
);
if (status !== 'passed') process.exitCode = 1;
}
main().catch((error) => {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
});