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
This commit is contained in:
1 parent
9f4de647ce
commit
368a7a5144
10 files changed
+755
-84
No files matched your search
+14
-12
@@ -6,14 +6,14 @@ gated.** Prototypes do not open completion or publication gates.
|
||||
|
||||
## Current checkpoint and work
|
||||
|
||||
Latest verified checkpoint: `cb691e1`, [Sapho Battle-phase order](SAPHO_BATTLE_ORDER.md).
|
||||
Types, lint, **4,792 offline tests**, build and **43 HTTP tests** passed.
|
||||
Six fixed genuine Basic/Advanced faction samples finished in 3,672 accepted actions
|
||||
with 97 periodic save/restore checks. They retained 82 rejected candidates at
|
||||
existing guarded arrival combinations and completed through legal fallbacks.
|
||||
Browser battle ordering, tie preservation and private refresh/replay passed.
|
||||
All 970 opening games and their original seat/recovery records were preserved,
|
||||
with 1,004 rooms after checks.
|
||||
Latest verified checkpoint: `9f4de64`, [compound shipment promises](COMPOUND_SHIPMENT_PROMISES.md).
|
||||
Types, lint, **4,815 offline tests**, build and **43 HTTP tests** passed.
|
||||
Genuine Basic/Advanced samples finished in 1,534 accepted actions, with 41
|
||||
periodic save/restore checks and no rejected candidates. Each used one actual
|
||||
held Truthtrance at a legal opportunity for a scenario-directed compound question.
|
||||
Browser second-OR-branch fulfillment and private refresh/replay passed.
|
||||
All 1,004 opening games and original seat/recovery records were preserved,
|
||||
with 1,038 rooms after checks.
|
||||
Earlier evidence remains in the [implementation history](IMPLEMENTATION_STATUS.md).
|
||||
|
||||
The connected [Harass & Withdraw prototype](HARASS_WITHDRAW_RUNTIME.md) supports either
|
||||
@@ -31,10 +31,12 @@ and foreign-key checks passed, and refresh restored the test seat. No restart,
|
||||
database reset or recurring automation was needed. Exact verification, source
|
||||
fingerprints and delivery are recorded in the private report and Git message.
|
||||
|
||||
Current work: [compound shipment promises](COMPOUND_SHIPMENT_PROMISES.md) connects
|
||||
shipment-only AND/OR questions, whole-expression enforcement, usable controls,
|
||||
legal AI answers and saved continuation. Reinforcements and the other recorded
|
||||
material rule questions remain pending.
|
||||
Current work: [reusable base samples](VERIFICATION_WORKFLOW.md#reusable-base-and-faction-sample-games)
|
||||
connect genuine two-through-six-player Basic/Advanced verification to the existing
|
||||
source-bound runner. Fixed seeds, exact card/force/elite custody, private JSON
|
||||
restoration and retained failure snapshots replace repeated private harness copies.
|
||||
The Moritani Secret Ally audit identified the same pending hidden-reaction UX
|
||||
boundary as CHOAM inspection; the physical retention helper does not resolve it.
|
||||
|
||||
## Authoritative navigation
|
||||
|
||||
|
||||
@@ -178,7 +178,7 @@ complete games with every printed effect or public mode acceptance.
|
||||
|
||||
The following historical `cab2f1c` samples predate the
|
||||
[deferred movement repair](DEFERRED_MOVEMENT_ARRIVAL.md). New source-bound
|
||||
samples use [the reusable harness](VERIFICATION_WORKFLOW.md#reusable-faction-sample-games).
|
||||
samples use [the reusable harness](VERIFICATION_WORKFLOW.md#reusable-base-and-faction-sample-games).
|
||||
|
||||
Six deterministic samples used genuine faction setup, all four AI levels,
|
||||
unchanged physical inventories and JSON continuations every 37 accepted actions.
|
||||
|
||||
@@ -1,5 +1,23 @@
|
||||
# Dune implementation status
|
||||
|
||||
|
||||
## 14 September — reusable base player-count samples
|
||||
|
||||
The faction sample runner now also selects genuine base Basic/Advanced games
|
||||
at every player count from two through six. Original expansion defaults and
|
||||
seed offsets remain stable. A shared inventory check includes unsold auctions,
|
||||
nonnegative force/spice values, exact elite totals and subsets, and post-setup
|
||||
base Traitor custody. Rejected-action immutability and restored private rival
|
||||
boundaries are checked. Failed traces and snapshots remain replayable through
|
||||
explicit resume. This consolidates the repeated private harness corrections;
|
||||
it does not certify complete player-count rules or open any mode gate.
|
||||
|
||||
The Moritani Secret Ally source audit also records why a holder-only retention
|
||||
pause would leak its hidden Nexus. The existing shared UX question remains
|
||||
pending; no duplicate question or new timing policy was introduced. Focused
|
||||
and full-run results, source identity and delivery are recorded in the private
|
||||
checkpoint report and Git message.
|
||||
|
||||
## 14 September 2026 — Compound shipment promises
|
||||
|
||||
[Shipment-only AND/OR](COMPOUND_SHIPMENT_PROMISES.md) extends the existing Truthtrance
|
||||
|
||||
@@ -77,3 +77,19 @@ Later focused source contracts cover [Emperor strength, purchases and revival](N
|
||||
- A complete twelve-card reference/inventory and common lifecycle are substantial first work, but do not alone make all thirty-six panels playable. Keep the public module release gated until every face's supported timing, secrecy, custody, bots, UI, rejection, multiplayer CAS and saved continuation paths are verified. If development supports only Atreides' family first, other faces must remain accurately represented and explicitly unavailable in the audit mode; they must not silently act as blanks in a released module.
|
||||
|
||||
No user question was sent during this audit. No tournament rule, fan effect, public predeal reveal, extra Nexus identity or Discoveries prerequisite has been adopted.
|
||||
|
||||
## Moritani Secret Ally retention timing
|
||||
|
||||
14 September follow-up. The recorded physical card permits retaining one
|
||||
otherwise retainable played Treachery Card after losing a battle with a winner;
|
||||
mandatory discards stay excluded. The existing Moritani alliance retention
|
||||
quote, card selection and saved discard continuation can supply the physical
|
||||
operation, but do not supply a private opportunity to use a hidden Nexus card.
|
||||
|
||||
Automatically continuing a loser without the card while pausing a holder
|
||||
reveals custody through the table's progress. Calling both paths the same
|
||||
cleanup boundary does not hide that difference. Committing before the result
|
||||
moves the printed after-loss choice earlier. A universal loser confirmation
|
||||
requires the same pending product exception as other hidden optional reactions.
|
||||
Keep this effect unavailable until that shared response policy is resolved;
|
||||
no new outcome ruling or duplicate user question is introduced by this audit.
|
||||
@@ -67,6 +67,7 @@ These are unresolved in the cited records. Do not infer an answer from elapsed t
|
||||
| Jacurutu physical dial | The user question asks whether the losing player should choose the physical normal/elite forces represented by its dial. When legal Advanced allocations produce different rewards, income remains explicitly unpaid; numeric wheel subtraction is not a ruling. | [Discovery interpretations](DISCOVERY_PROTOTYPE.md#interpretations-retained-for-refinement) |
|
||||
| Testing Station contest/timing | Only sole non-advisor occupation is prototyped. Two-occupant priority/stacking remains unresolved; opening after the ordinary storm-card window is an explicit source-timing inference. Weather Control is excluded by the printed text. | [Discovery interpretations](DISCOVERY_PROTOTYPE.md#interpretations-retained-for-refinement) |
|
||||
| CHOAM Secret Ally battle timing | The printed after-victory inspection is optional and the Nexus identity stays secret. No existing postbattle choice covers every winner: a pause only for a holder leaks ownership, precommitment removes the after-win choice, and a universal window makes nonholders pass. The product-exception question has been sent; no timing policy is implemented pending the answer. Ordinary Karama cancellation is also not established by the retrieved sources. | [After-victory inspection audit](NEXUS_CHOAM_SECRET_ALLY.md#after-victory-inspection-audit) |
|
||||
| Moritani Secret Ally battle timing | The optional retention choice follows a battle loss while the Nexus remains secret until use. Automatically continuing nonholders but pausing holders exposes custody; earlier precommitment removes the after-loss choice. A universal loser confirmation needs the same pending UX exception as other hidden reactive cards. The existing alliance retention/cleanup code does not resolve this timing problem; no duplicate user question has been sent. | [Nexus retention boundary](NEXUS_CARD_RULES.md#moritani-secret-ally-retention-timing) |
|
||||
| Emperor Secret Ally purchase timing | The purchase choice follows winning the bid. A holder-only payment pause exposes concealed card eligibility, while per-bid preauthorization moves the choice earlier. The user has been asked whether to permit a uniform winner payment confirmation, including sole spice payments, or retain the purchase gate. No answer is assumed; the independent three-force revival grant can proceed. | [Purchase source contract](NEXUS_EMPEROR_RULES.md#secret-ally-retain-the-actual-purchase-price) |
|
||||
| Discovery prototype inferences | Free entry uses storm order and ordinary source-storm restrictions; these are general-rule inferences. Great Maker currently follows ordinary first-turn worm skipping and Sandtrout suppression; an exhausted token type creates no replacement token. These preserve current source interpretation and physical inventory but are not separately confirmed rulings or complete combined-module certification. | [Interpretations for refinement](DISCOVERY_PROTOTYPE.md#interpretations-retained-for-refinement) |
|
||||
| Other Homeworld combinations | Population/force splitting, removal custody, replacement shortages, income accounting and placement ordering remain local to their detailed contracts. | [Homeworld rules](HOMEWORLD_RULES.md), [occupation](HOMEWORLD_OCCUPATION_RULES.md) and the effect documents above |
|
||||
|
||||
@@ -28,28 +28,46 @@ An interrupted or incomplete report is never evidence of success.
|
||||
|
||||
## Saved games and online backups
|
||||
|
||||
### Reusable faction sample games
|
||||
### Reusable base and faction sample games
|
||||
|
||||
```sh
|
||||
node --import tsx tools/faction-games.ts --out /tmp/dune-new-faction-games
|
||||
# Ten genuine base samples: Basic and Advanced at each player count.
|
||||
node --import tsx tools/faction-games.ts --out /tmp/dune-new-base-games --profile base
|
||||
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
|
||||
node --import tsx tools/faction-games.ts --out /tmp/dune-new-resume --resume /private/failed-combined-advanced.json
|
||||
```
|
||||
|
||||
This offline tool runs six fixed CHOAM/Richese, Ecaz/Moritani and combined
|
||||
Basic/Advanced samples from genuine setup, using all four AI profiles. Filters
|
||||
retain each scenario's seed offset. `--seed` defaults to 20260926;
|
||||
Default and `--profile all` retain the original six fixed CHOAM/Richese,
|
||||
Ecaz/Moritani and combined Basic/Advanced samples. `--profile base` selects ten
|
||||
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.
|
||||
|
||||
All samples use genuine setup and saved AI profiles, cycling Easy, Medium,
|
||||
Hard and Brutal by seat. Smaller games necessarily contain fewer profiles;
|
||||
the matrix collectively exercises all four. Filters retain each scenario's
|
||||
seed offset: the original six use 0–5; base uses
|
||||
`6 + 2 × (players − 2) + Advanced`, where Advanced is 0 or 1. `--seed` defaults to 20260926;
|
||||
`--max-actions` defaults to 3500 accepted actions per game. Every action checks
|
||||
physical card and force custody; every 37 actions checks all private views after
|
||||
JSON restoration. Rejected candidates remain visible in private traces even when
|
||||
physical card custody (including unsold auction cards), nonnegative integer
|
||||
forces/spice, twenty-force totals and the distinct Fremen/Emperor/Ixian elite totals
|
||||
and subsets. Base samples also check the exact Traitor inventory after dealing
|
||||
has finished; expansion Traitor/Face Dancer zones are not certified by this
|
||||
check. Every rejected candidate must leave its input unchanged. Every 37 actions
|
||||
checks all private views after JSON restoration and absence of rival hand,
|
||||
spice, Traitor and Face Dancer fields. Rejected candidates remain visible in private traces even when
|
||||
a later candidate succeeds. Samples are development evidence, not calibration or
|
||||
complete rules certification.
|
||||
|
||||
The output is a new private directory outside the checkout. It contains private
|
||||
traces, incomplete-game snapshots, `results.json` and a source-bound `report.json`.
|
||||
An incomplete game or changed source exits nonzero. Resume requires a matching
|
||||
fixed-scenario snapshot with saved AI profiles and records its SHA-256; it cannot
|
||||
be combined with profile/rules filters. Its action budget starts at the snapshot,
|
||||
fixed base or expansion scenario snapshot with saved AI profiles and records its
|
||||
SHA-256; it cannot be combined with profile/rules/player-count filters. Its action budget starts at the snapshot,
|
||||
and its random stream restarts from the supplied seed plus scenario offset.
|
||||
Check setup provenance against the original report: accepting a supplied snapshot
|
||||
does not prove it came from genuine setup. No live room or database is accessed.
|
||||
|
||||
@@ -0,0 +1,334 @@
|
||||
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('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);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { botActions } from '../game/bots';
|
||||
import {
|
||||
applyAction,
|
||||
createGame,
|
||||
initializeBaseGameForAudit,
|
||||
initializeFactionExpansionsGameForAudit,
|
||||
joinGame,
|
||||
newPlayer,
|
||||
viewGame,
|
||||
} from '../game/engine';
|
||||
import { sampleInventory, verifySampleCustody } from '../tools/sample-custody';
|
||||
|
||||
function fixture(advanced = false) {
|
||||
let game = createGame(
|
||||
'SAMPLEQA',
|
||||
newPlayer('emperor', 'Emperor', 'emperor'),
|
||||
advanced,
|
||||
);
|
||||
for (const faction of ['fremen', 'atreides', 'harkonnen'] as const)
|
||||
joinGame(game, newPlayer(faction, faction, faction));
|
||||
for (const player of game.players) {
|
||||
player.ready = true;
|
||||
player.bot = 'Medium';
|
||||
}
|
||||
game = initializeBaseGameForAudit(game);
|
||||
const inventory = sampleInventory(game);
|
||||
verifySampleCustody(game, inventory);
|
||||
for (let steps = 0; game.status === 'setup' && steps < 30; steps++) {
|
||||
const choice = game.players.flatMap((player) =>
|
||||
botActions(viewGame(game, player.id)).map((action) => ({
|
||||
player,
|
||||
action,
|
||||
})),
|
||||
)[0];
|
||||
assert.ok(choice, 'genuine setup has a legal AI choice');
|
||||
game = applyAction(game, choice.player.id, choice.action);
|
||||
verifySampleCustody(game, inventory);
|
||||
}
|
||||
assert.equal(game.status, 'playing');
|
||||
return { game, inventory };
|
||||
}
|
||||
|
||||
void test('sample custody follows genuine setup and distinct Advanced elite inventories', () => {
|
||||
for (const advanced of [false, true]) {
|
||||
const { game, inventory } = fixture(advanced);
|
||||
verifySampleCustody(JSON.parse(JSON.stringify(game)), inventory);
|
||||
const corrupted = structuredClone(game);
|
||||
const card = corrupted.deck.pop();
|
||||
assert.ok(card);
|
||||
assert.throws(
|
||||
() => verifySampleCustody(corrupted, inventory),
|
||||
/physical card custody/,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
void test('sample custody detects balanced negative forces and elite subsets outside their group', () => {
|
||||
const { game, inventory } = fixture(true);
|
||||
const negative = structuredClone(game);
|
||||
negative.players[0].tanks = -1;
|
||||
negative.players[0].reserves++;
|
||||
assert.throws(
|
||||
() => verifySampleCustody(negative, inventory),
|
||||
/nonnegative forces/,
|
||||
);
|
||||
const missing = structuredClone(game);
|
||||
delete missing.players.find((seat) => seat.faction === 'fremen')!.elites;
|
||||
assert.throws(
|
||||
() => verifySampleCustody(missing, inventory),
|
||||
/missing elite inventory fremen/,
|
||||
);
|
||||
const misplaced = structuredClone(game);
|
||||
const player = misplaced.players.find((seat) => seat.faction === 'emperor')!;
|
||||
assert.ok(player.elites);
|
||||
player.elites.reserves--;
|
||||
player.elites.forces['arrakeen:10'] = 1;
|
||||
assert.throws(
|
||||
() => verifySampleCustody(misplaced, inventory),
|
||||
/elite board subset/,
|
||||
);
|
||||
});
|
||||
|
||||
void test('sample custody catches a duplicated base traitor after setup despite preserved hand size', () => {
|
||||
const { game, inventory } = fixture();
|
||||
const original = game.players[0].traitors[0];
|
||||
assert.ok(original);
|
||||
const other = game.players.find((player) => player.id !== game.players[0].id)!
|
||||
.traitors[0];
|
||||
assert.ok(other && other !== original);
|
||||
game.players[0].traitors[0] = other;
|
||||
assert.throws(
|
||||
() => verifySampleCustody(game, inventory),
|
||||
/physical base traitor custody/,
|
||||
);
|
||||
});
|
||||
|
||||
void test('sample custody retains all seven Ixian cyborgs in genuine expansion setup', () => {
|
||||
const lobby = createGame(
|
||||
'SAMPLEIX',
|
||||
newPlayer('ixians', 'Ixians', 'ixians'),
|
||||
false,
|
||||
['ix'],
|
||||
);
|
||||
for (const faction of ['tleilaxu', 'atreides', 'harkonnen'] as const)
|
||||
joinGame(lobby, newPlayer(faction, faction, faction));
|
||||
for (const player of lobby.players) player.ready = true;
|
||||
const game = initializeFactionExpansionsGameForAudit(lobby);
|
||||
const inventory = sampleInventory(game);
|
||||
verifySampleCustody(game, inventory);
|
||||
const player = game.players.find((seat) => seat.faction === 'ixians')!;
|
||||
assert.ok(player.elites);
|
||||
player.elites.reserves--;
|
||||
assert.throws(
|
||||
() => verifySampleCustody(game, inventory),
|
||||
/elite custody ixians/,
|
||||
);
|
||||
});
|
||||
|
||||
void test('resumed sample inventory cannot shrink with a deleted leader and matching Traitor card', () => {
|
||||
const { game } = fixture();
|
||||
const leader = game.players[0].leaders.pop();
|
||||
assert.ok(leader);
|
||||
game.traitorReserve = game.traitorReserve?.filter(
|
||||
(card) => card !== leader.id,
|
||||
);
|
||||
for (const player of game.players)
|
||||
player.traitors = player.traitors.filter((card) => card !== leader.id);
|
||||
assert.throws(
|
||||
() => verifySampleCustody(game, sampleInventory(game)),
|
||||
/physical base traitor custody/,
|
||||
);
|
||||
});
|
||||
+97
-63
@@ -5,11 +5,11 @@ import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { parseArgs } from 'node:util';
|
||||
import { botActions } from '../game/bots';
|
||||
import { treacheryDeck } from '../game/cards';
|
||||
import type { FactionId } from '../game/catalog';
|
||||
import {
|
||||
applyAction,
|
||||
createGame,
|
||||
initializeBaseGameForAudit,
|
||||
initializeFactionExpansionsGameForAudit,
|
||||
joinGame,
|
||||
newPlayer,
|
||||
@@ -18,13 +18,13 @@ import {
|
||||
type Action,
|
||||
type Game,
|
||||
} from '../game/engine';
|
||||
import { richeseCards } from '../game/richese-cards';
|
||||
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 = 'choam' | 'ecaz' | 'combined';
|
||||
type Profile = 'base' | 'choam' | 'ecaz' | 'combined';
|
||||
type Rules = 'basic' | 'advanced';
|
||||
|
||||
type Scenario = {
|
||||
@@ -80,6 +80,25 @@ const SCENARIOS: readonly Scenario[] = [
|
||||
},
|
||||
];
|
||||
|
||||
// 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;
|
||||
@@ -116,10 +135,10 @@ type Result = {
|
||||
function usage() {
|
||||
return (
|
||||
'Usage: node --import tsx tools/faction-games.ts --out NEW_PRIVATE_DIR ' +
|
||||
'[--seed UINT32] [--profile all|choam|ecaz|combined] ' +
|
||||
'[--rules both|basic|advanced] [--max-actions POSITIVE] ' +
|
||||
'[--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 faction-expansion setup and gameplay offline. Output must be a new private directory outside the checkout.'
|
||||
'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.'
|
||||
);
|
||||
}
|
||||
|
||||
@@ -150,13 +169,15 @@ function positive(value: string | undefined) {
|
||||
}
|
||||
|
||||
function scenarioName(scenario: Scenario) {
|
||||
return `${scenario.profile}-${scenario.rules}`;
|
||||
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', 'choam', 'ecaz', 'combined'].includes(profile))
|
||||
throw new Error('--profile must be all, choam, ecaz or combined.');
|
||||
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';
|
||||
}
|
||||
|
||||
@@ -167,6 +188,13 @@ function parseRules(value: string | undefined) {
|
||||
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),
|
||||
@@ -179,49 +207,6 @@ function privateWrite(directory: string, name: string, value: unknown) {
|
||||
);
|
||||
}
|
||||
|
||||
function gameCards(g: Game) {
|
||||
return [
|
||||
...g.players.flatMap((player) => player.hand),
|
||||
...g.deck,
|
||||
...g.discard,
|
||||
...(g.richeseCache ?? []),
|
||||
...(g.richeseRemoved ?? []),
|
||||
...(g.ixSetupCards ?? []),
|
||||
...(g.ixAuction?.cards ?? []),
|
||||
...(g.ornithopter ? [g.ornithopter.card] : []),
|
||||
...(g.auction?.cards.slice(
|
||||
g.auction.index + Number(g.currentAuctionSale?.origin === 'normal'),
|
||||
) ?? []),
|
||||
];
|
||||
}
|
||||
|
||||
function expectedCards(g: Game, roster: readonly FactionId[]) {
|
||||
return [
|
||||
...treacheryDeck(g.expansions),
|
||||
...(roster.includes('richese') ? richeseCards() : []),
|
||||
]
|
||||
.map((card) => card.id)
|
||||
.sort();
|
||||
}
|
||||
|
||||
function verifyCustody(g: Game, expected: readonly string[]) {
|
||||
assert.deepEqual(
|
||||
gameCards(g)
|
||||
.map((card) => card.id)
|
||||
.sort(),
|
||||
expected,
|
||||
'physical card custody',
|
||||
);
|
||||
for (const player of g.players)
|
||||
assert.equal(
|
||||
player.reserves +
|
||||
player.tanks +
|
||||
Object.values(player.forces).reduce((sum, amount) => sum + amount, 0),
|
||||
20,
|
||||
`force custody ${player.faction}`,
|
||||
);
|
||||
}
|
||||
|
||||
function freshGame(scenario: Scenario) {
|
||||
const [first, ...rest] = scenario.roster;
|
||||
const game = createGame(
|
||||
@@ -236,7 +221,9 @@ function freshGame(scenario: Scenario) {
|
||||
player.bot = DIFFICULTIES[index % DIFFICULTIES.length];
|
||||
player.ready = true;
|
||||
}
|
||||
return initializeFactionExpansionsGameForAudit(game);
|
||||
return scenario.profile === 'base'
|
||||
? initializeBaseGameForAudit(game)
|
||||
: initializeFactionExpansionsGameForAudit(game);
|
||||
}
|
||||
|
||||
function resumedGame(path: string) {
|
||||
@@ -270,7 +257,25 @@ function resumedGame(path: string) {
|
||||
throw new Error(
|
||||
'--resume is not an incomplete faction-games snapshot with saved AI profiles.',
|
||||
);
|
||||
const scenario = SCENARIOS.find(
|
||||
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) ===
|
||||
@@ -280,7 +285,7 @@ function resumedGame(path: string) {
|
||||
);
|
||||
if (!scenario)
|
||||
throw new Error(
|
||||
'--resume does not match one of the six fixed faction-games scenarios.',
|
||||
'--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);
|
||||
@@ -295,14 +300,14 @@ function simulate(
|
||||
resumed: boolean,
|
||||
) {
|
||||
let game = initial;
|
||||
const expected = expectedCards(game, scenario.roster);
|
||||
const expected = sampleInventory(game);
|
||||
const trace: TraceEntry[] = [];
|
||||
const used: Record<string, number> = {};
|
||||
const rejected: Record<string, number> = {};
|
||||
let actions = 0;
|
||||
let restores = 0;
|
||||
try {
|
||||
verifyCustody(game, expected);
|
||||
verifySampleCustody(game, expected);
|
||||
while (actions < maxActions && game.status !== 'finished') {
|
||||
let next: Game | undefined;
|
||||
for (const player of game.players) {
|
||||
@@ -318,6 +323,7 @@ function simulate(
|
||||
response: game.response?.kind ?? null,
|
||||
action,
|
||||
};
|
||||
const unchanged = JSON.stringify(game);
|
||||
try {
|
||||
next = applyAction(game, player.id, action);
|
||||
const label =
|
||||
@@ -330,6 +336,11 @@ function simulate(
|
||||
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}`;
|
||||
@@ -351,7 +362,7 @@ function simulate(
|
||||
})}`,
|
||||
);
|
||||
game = next;
|
||||
verifyCustody(game, expected);
|
||||
verifySampleCustody(game, expected);
|
||||
if (actions % 37 === 0) {
|
||||
const restored = JSON.parse(JSON.stringify(game)) as Game;
|
||||
for (const player of game.players)
|
||||
@@ -360,6 +371,18 @@ function simulate(
|
||||
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++;
|
||||
}
|
||||
@@ -419,6 +442,7 @@ async function main() {
|
||||
seed: { type: 'string' },
|
||||
profile: { type: 'string' },
|
||||
rules: { type: 'string' },
|
||||
players: { type: 'string' },
|
||||
'max-actions': { type: 'string' },
|
||||
resume: { type: 'string' },
|
||||
help: { type: 'boolean', short: 'h' },
|
||||
@@ -433,19 +457,28 @@ async function main() {
|
||||
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')))
|
||||
throw new Error('--resume cannot be combined with --profile or --rules.');
|
||||
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]
|
||||
: SCENARIOS.filter(
|
||||
: (profile === 'base' ? BASE_SCENARIOS : SCENARIOS).filter(
|
||||
(scenario) =>
|
||||
(profile === 'all' || scenario.profile === profile) &&
|
||||
(rules === 'both' || scenario.rules === rules),
|
||||
(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);
|
||||
@@ -528,7 +561,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 faction-only setup; no cards, forces, factions, phases or statistics staged. Real saved AI profiles choose every setup and gameplay 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.',
|
||||
@@ -537,6 +570,7 @@ async function main() {
|
||||
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(
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { leaders, treacheryDeck } from '../game/cards';
|
||||
import type { Game } from '../game/engine';
|
||||
import { richeseCards } from '../game/richese-cards';
|
||||
import { traitorDeck } from '../game/traitors';
|
||||
|
||||
/** Fixed inventory captured after genuine setup initialization, before any choices. */
|
||||
export function sampleInventory(game: Game) {
|
||||
return {
|
||||
cards: [
|
||||
...treacheryDeck(game.expansions),
|
||||
...(game.players.some((player) => player.faction === 'richese')
|
||||
? richeseCards()
|
||||
: []),
|
||||
]
|
||||
.map((card) => card.id)
|
||||
.sort(),
|
||||
// Expansion Face Dancer/assassination zones require a separate adapter.
|
||||
traitors: game.expansions.length
|
||||
? null
|
||||
: traitorDeck(
|
||||
game.players.map((player) => ({ leaders: leaders(player.faction) })),
|
||||
).sort(),
|
||||
};
|
||||
}
|
||||
|
||||
export function verifySampleCustody(
|
||||
game: Game,
|
||||
inventory: ReturnType<typeof sampleInventory>,
|
||||
) {
|
||||
const cards = [
|
||||
...game.players.flatMap((player) => player.hand),
|
||||
...game.deck,
|
||||
...game.discard,
|
||||
...(game.richeseCache ?? []),
|
||||
...(game.richeseRemoved ?? []),
|
||||
...(game.ixSetupCards ?? []),
|
||||
...(game.ixAuction?.cards ?? []),
|
||||
...(game.ornithopter ? [game.ornithopter.card] : []),
|
||||
...(game.auction?.cards.slice(
|
||||
game.auction.index + Number(game.currentAuctionSale?.origin === 'normal'),
|
||||
) ?? []),
|
||||
];
|
||||
assert.deepEqual(
|
||||
cards.map((card) => card.id).sort(),
|
||||
inventory.cards,
|
||||
'physical card custody',
|
||||
);
|
||||
for (const player of game.players) {
|
||||
const amounts = [
|
||||
player.reserves,
|
||||
player.tanks,
|
||||
...Object.values(player.forces),
|
||||
];
|
||||
assert.ok(
|
||||
amounts.every((amount) => Number.isSafeInteger(amount) && amount >= 0),
|
||||
`nonnegative forces ${player.faction}`,
|
||||
);
|
||||
assert.equal(
|
||||
amounts.reduce((sum, amount) => sum + amount, 0),
|
||||
20,
|
||||
`force custody ${player.faction}`,
|
||||
);
|
||||
assert.ok(
|
||||
Number.isSafeInteger(player.spice) && player.spice >= 0,
|
||||
`nonnegative spice ${player.faction}`,
|
||||
);
|
||||
if (
|
||||
game.status !== 'setup' &&
|
||||
(player.faction === 'ixians' ||
|
||||
(game.advanced && ['emperor', 'fremen'].includes(player.faction)))
|
||||
)
|
||||
assert.ok(player.elites, `missing elite inventory ${player.faction}`);
|
||||
if (player.elites) {
|
||||
const elites = player.elites;
|
||||
const typed = [
|
||||
elites.reserves,
|
||||
elites.tanks,
|
||||
...Object.values(elites.forces),
|
||||
];
|
||||
assert.ok(
|
||||
typed.every((amount) => Number.isSafeInteger(amount) && amount >= 0),
|
||||
`nonnegative elites ${player.faction}`,
|
||||
);
|
||||
assert.ok(
|
||||
['emperor', 'fremen', 'ixians'].includes(player.faction),
|
||||
'known elite inventory',
|
||||
);
|
||||
assert.equal(
|
||||
typed.reduce((sum, amount) => sum + amount, 0),
|
||||
player.faction === 'fremen' ? 3 : player.faction === 'ixians' ? 7 : 5,
|
||||
`elite custody ${player.faction}`,
|
||||
);
|
||||
assert.ok(
|
||||
elites.reserves <= player.reserves && elites.tanks <= player.tanks,
|
||||
`elite reserve/Tanks subset ${player.faction}`,
|
||||
);
|
||||
for (const [location, amount] of Object.entries(elites.forces))
|
||||
assert.ok(
|
||||
amount <= (player.forces[location] ?? 0),
|
||||
`elite board subset ${player.faction}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (inventory.traitors && game.status !== 'setup')
|
||||
assert.deepEqual(
|
||||
[
|
||||
...(game.traitorReserve ?? []),
|
||||
...game.players.flatMap((player) => player.traitors),
|
||||
].sort(),
|
||||
inventory.traitors,
|
||||
'physical base traitor custody',
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user