Exercise Ecaz card variant and canonicalize advisor stances

This commit is contained in:
Quintine committed 2026-09-29 21:22:34 +10:00
1 parent 6ac5dabdee
commit c961a93f62
10 files changed
+185 -40

No files matched your search

+10
View File
@@ -6,6 +6,16 @@ Expansion starts and full rules/publication acceptance remain gated.**
## Current checkpoint and work ## Current checkpoint and work
The independent Ecaz three-card variant now has a repeatable
[genuine-game sample profile](VERIFICATION_WORKFLOW.md#reusable-base-and-faction-sample-games)
for paired Ecaz/Moritani rosters from two to six seats. Ten Basic/Advanced
games at base seed `20260929` completed with 3,768 accepted actions, zero
rejected candidates and 95 JSON continuations; the six-seat Advanced game
actually played Recruits, Reinforcements and Harass & Withdraw. That run
exposed and confirmed a saved-view drift from unlocked Bene Gesserit advisor
stances, now corrected at four arrival/movement writers. These samples do
not certify all card interactions, full expansion modes or deployment.
The separate [Reinforcements card prototype](REINFORCEMENTS_RUNTIME.md) The separate [Reinforcements card prototype](REINFORCEMENTS_RUNTIME.md)
now works in clean Ecaz/Moritani expansion battles as well as classic-faction now works in clean Ecaz/Moritani expansion battles as well as classic-faction
tables in Basic and Advanced. Genuine setup, owner-only two-slot controls, tables in Basic and Advanced. Genuine setup, owner-only two-slot controls,
+36
View File
@@ -1,5 +1,41 @@
# Dune implementation status # Dune implementation status
## 29 September 2026 — Ecaz card variant full-game integration samples
The reusable offline runner now has `--profile ecaz-treachery`: two through
six genuine paired Ecaz/Moritani seats, each Basic and Advanced, with the
independent three-card deck, physical custody checks, all four saved AI
profiles and explicit JSON continuation. Its default six historical games
and seed offsets remain unchanged; incomplete snapshots can resume only
under the exact saved variant/roster. Public starts remain gated.
The first same-seed matrix completed eight of ten games and caught an actual
saved-view mismatch: an unlocked Bene Gesserit advisor stance was stored as
`{lockedTurn:undefined}` by movement, Guild transport, Junction transport or
Fremen Ambassador relocation, but JSON restored it as `{}`. The four writers
now store a canonical empty stance or a numeric lock. Focused Guild,
Junction, ordinary move and Fremen relocation cases preserve view parity
after JSON; locked arrivals still retain their original turn. No saved room
or credential was altered.
The final source-bound run at base seed `20260929` completed all ten games:
3,768 accepted actions, no rejected candidates, 95 JSON continuations and
unchanged source fingerprint. The six-seat Advanced game used all three
physical Ecaz card effects. Report:
`/tmp/dune-ecaz-treachery-matrix-final-20260929/report.json`, based on
`6ac5dab` plus the uncommitted source changes of this checkpoint (source
fingerprint `466f801d36e8dc9aa1b14d2c9215813c8b4561926cc2ba43664d38e03ebbb081`).
This samples playable continuation, not complete card interactions,
combined-module acceptance, calibrated AI or deployed behavior.
The focused runner/advisor suites pass 101/101 cases. An independent
read-only review of the four writer changes, source custody and resume gates
found no concrete defect; it did not run tests or certify release readiness.
`npm run check` passes typecheck, lint and 5,765/5,765 offline cases;
`npm run build` passes. No database schema or HTTP route changed.
The last verified live image predates these changes.
## 29 September 2026 — Reinforcements in paired Ecaz/Moritani battles ## 29 September 2026 — Reinforcements in paired Ecaz/Moritani battles
The independently selected three-card Ecaz variant now allows its physical The independently selected three-card Ecaz variant now allows its physical
+25 -7
View File
@@ -111,6 +111,7 @@ node --import tsx tools/faction-games.ts --out /tmp/dune-new-homeworld-nexus --p
node --import tsx tools/faction-games.ts --out /tmp/dune-new-choam-roster --profile choam-roster node --import tsx tools/faction-games.ts --out /tmp/dune-new-choam-roster --profile choam-roster
node --import tsx tools/faction-games.ts --out /tmp/dune-new-ecaz-roster --profile ecaz-roster node --import tsx tools/faction-games.ts --out /tmp/dune-new-ecaz-roster --profile ecaz-roster
node --import tsx tools/faction-games.ts --out /tmp/dune-new-ix-roster --profile ix-roster node --import tsx tools/faction-games.ts --out /tmp/dune-new-ix-roster --profile ix-roster
node --import tsx tools/faction-games.ts --out /tmp/dune-new-ecaz-cards --profile ecaz-treachery
node --import tsx tools/faction-games.ts --out /tmp/dune-new-choam-nexus --profile choam-nexus node --import tsx tools/faction-games.ts --out /tmp/dune-new-choam-nexus --profile choam-nexus
node --import tsx tools/faction-games.ts --out /tmp/dune-new-ecaz-nexus --profile ecaz-nexus node --import tsx tools/faction-games.ts --out /tmp/dune-new-ecaz-nexus --profile ecaz-nexus
node --import tsx tools/faction-games.ts --out /tmp/dune-new-ix-nexus --profile ix-nexus node --import tsx tools/faction-games.ts --out /tmp/dune-new-ix-nexus --profile ix-nexus
@@ -124,8 +125,9 @@ Default and `--profile all` retain the original six fixed CHOAM/Richese,
Ecaz/Moritani and combined Basic/Advanced samples. `--profile base` selects ten Ecaz/Moritani and combined Basic/Advanced samples. `--profile base` selects ten
additional samples: Basic and Advanced with two through six players. additional samples: Basic and Advanced with two through six players.
`--players 2..6` narrows the base, Homeworld, Nexus, combined `--players 2..6` narrows the base, Homeworld, Nexus, combined
`homeworld-nexus`, expansion-roster or paired-Nexus profiles. The `homeworld-nexus`, expansion-roster, paired-Nexus, independent Ecaz-card or
`combined-nexus` and `combined-homeworld-nexus` profiles support `--players 5` Leader Skills profiles. The `combined-nexus` and
`combined-homeworld-nexus` profiles support `--players 5`
or `6` only. Omission or `all` retains every roster in a selected profile. The fixed or `6` only. Omission or `all` retains every roster in a selected profile. The fixed
base roster adds Atreides, Harkonnen, Fremen, Emperor, Guild and Bene Gesserit base roster adds Atreides, Harkonnen, Fremen, Emperor, Guild and Bene Gesserit
in that order. These are sample rosters, not the only rules-permitted in that order. These are sample rosters, not the only rules-permitted
@@ -136,6 +138,11 @@ respective physical expansion enabled: CHOAM/Richese start with CHOAM then
Richese, Ecaz/Moritani with Moritani then Ecaz, Ixian/Tleilaxu with Ixians Richese, Ecaz/Moritani with Moritani then Ecaz, Ixian/Tleilaxu with Ixians
then Tleilaxu. Subsequent seats extend each to six without changing existing then Tleilaxu. Subsequent seats extend each to six without changing existing
fixed four-seat samples. Their seed offsets are 68–77, 78–87 and 88–97. fixed four-seat samples. Their seed offsets are 68–77, 78–87 and 88–97.
`ecaz-treachery` reuses the two-through-six-seat Ecaz/Moritani roster with the
independent three-card Treachery variant instead of Nexus Cards. Basic and
Advanced seed offsets are 136–145; default/all keeps its original six games.
The runner counts all three physical cards and resume requires the variant
marker and exact roster. This is still a gated development profile.
`choam-nexus`, `ecaz-nexus` and `ix-nexus` reuse the respective expansion `choam-nexus`, `ecaz-nexus` and `ix-nexus` reuse the respective expansion
rosters with Nexus Cards enabled at seed offsets 98–107, 108–117 and 118–127. rosters with Nexus Cards enabled at seed offsets 98–107, 108–117 and 118–127.
`combined-nexus` selects five or six actual expansion factions and all three `combined-nexus` selects five or six actual expansion factions and all three
@@ -153,6 +160,17 @@ rosters use 40–47. The combined module uses offsets 58–67. These samples
do not open public module or expansion starts or assert complete interaction do not open public module or expansion starts or assert complete interaction
coverage. coverage.
At base seed `20260929`, the Ecaz-card profile completed all ten
two-to-six-seat Basic/Advanced samples with 3,768 accepted actions, no
rejected candidates and 95 JSON continuations. The six-seat Advanced trace
includes Recruits, Reinforcements and Harass & Withdraw actual uses. The
private source-bound report is
`/tmp/dune-ecaz-treachery-matrix-final-20260929/report.json`. The first
matrix exposed a non-serializable unlocked advisor stance; its corrected
writer paths are documented in
[the implementation log](IMPLEMENTATION_STATUS.md#29-september-2026--ecaz-card-variant-full-game-integration-samples).
This is not expansion release evidence.
All-AI Nexus samples now make public reciprocal offers as well as accepting All-AI Nexus samples now make public reciprocal offers as well as accepting
them, so an actual alliance can form without a human proposer. At the them, so an actual alliance can form without a human proposer. At the
20260927 seed, each six-seat sample made six alliance actions and completed; 20260927 seed, each six-seat sample made six alliance actions and completed;
@@ -181,11 +199,11 @@ Advanced requests fail before running. Its seed offsets are 16–20, leaving
existing samples unchanged. The full fourteen-card skill census and ordinary existing samples unchanged. The full fourteen-card skill census and ordinary
Moritani/base Traitor census are checked throughout. `--resume` preserves this Moritani/base Traitor census are checked throughout. `--resume` preserves this
explicit profile; other optional modules still fail its admission checks. explicit profile; other optional modules still fail its admission checks.
Homeworld, Nexus and combined snapshots resume only when their saved modules Homeworld, Nexus, independent Ecaz-card and combined snapshots resume only
and fixed rosters match a supported profile; other optional module when their saved modules and fixed rosters match a supported profile; other
combinations remain excluded. An edited snapshot with a removed module can optional-module combinations remain excluded. An edited snapshot with a
match a different fixed profile, so verify its original private report removed module can match a different fixed profile, so verify its original
before relying on resumed provenance. private report before relying on resumed provenance.
All samples use genuine setup and saved AI profiles, cycling Easy, Medium, All samples use genuine setup and saved AI profiles, cycling Easy, Medium,
Hard and Brutal by seat. Smaller games necessarily contain fewer profiles; Hard and Brutal by seat. Smaller games necessarily contain fewer profiles;
+16 -24
View File
@@ -11237,13 +11237,10 @@ function completeMove(g: Game, move: MovementOrder) {
removeGroup(p, group, eliteGroup); removeGroup(p, group, eliteGroup);
if (n > (move.noField ? 1 : 0)) if (n > (move.noField ? 1 : 0))
place(p, to, s, n - (move.noField ? 1 : 0), elite); place(p, to, s, n - (move.noField ? 1 : 0), elite);
if (advisors) if (advisors) {
(p.advisors ??= {})[to] = { const arrivalLock = Math.max(lockedTurn ?? 0, p.advisors?.[to]?.lockedTurn ?? 0);
lockedTurn: (p.advisors ??= {})[to] = arrivalLock ? { lockedTurn: arrivalLock } : {};
Math.max(lockedTurn ?? 0, p.advisors?.[to]?.lockedTurn ?? 0) || } else if (p.advisors) delete p.advisors[to];
undefined,
};
else if (p.advisors) delete p.advisors[to];
if (wantsFighters) if (wantsFighters)
g.response = { g.response = {
kind: 'advisorFlip', kind: 'advisorFlip',
@@ -12451,15 +12448,10 @@ function commitAmbassadorRelocation(g: Game, move: FremenAmbassadorMove) {
removeGroup(p, move.group, move.eliteGroup); removeGroup(p, move.group, move.eliteGroup);
const physical = move.total - (move.noField ? 1 : 0); const physical = move.total - (move.noField ? 1 : 0);
if (physical) place(p, move.to, move.sector, physical, move.elite); if (physical) place(p, move.to, move.sector, physical, move.elite);
if (move.advisors) if (move.advisors) {
(p.advisors ??= {})[move.to] = { const lockedTurn = Math.max(move.lockedTurn ?? 0, p.advisors?.[move.to]?.lockedTurn ?? 0);
lockedTurn: (p.advisors ??= {})[move.to] = lockedTurn ? { lockedTurn } : {};
Math.max( } else if (p.advisors) delete p.advisors[move.to];
move.lockedTurn ?? 0,
p.advisors?.[move.to]?.lockedTurn ?? 0,
) || undefined,
};
else if (p.advisors) delete p.advisors[move.to];
entry.stage = 'arrival'; entry.stage = 'arrival';
entry.relocation = { order: move, next: 'flip' }; entry.relocation = { order: move, next: 'flip' };
log( log(
@@ -20454,9 +20446,10 @@ function performJunctionTransport(g: Game, p: Player, action: Action) {
observeOccupation(g); observeOccupation(g);
if (arrival) { if (arrival) {
place(p, arrival.territory, arrival.sector, quote.amount, quote.elite); place(p, arrival.territory, arrival.sector, quote.amount, quote.elite);
if (advisors) (p.advisors ??= {})[arrival.territory] = { if (advisors) {
lockedTurn: Math.max(sourceLock ?? 0, p.advisors?.[arrival.territory]?.lockedTurn ?? 0) || undefined}; const lockedTurn = Math.max(sourceLock ?? 0, p.advisors?.[arrival.territory]?.lockedTurn ?? 0);
else if (p.advisors) delete p.advisors[arrival.territory]; (p.advisors ??= {})[arrival.territory] = lockedTurn ? { lockedTurn } : {};
} else if (p.advisors) delete p.advisors[arrival.territory];
} }
p.shipped = true; p.shipped = true;
g.junctionOffer = null; g.junctionOffer = null;
@@ -21043,11 +21036,10 @@ function commitGuildTransport(
if (p.elites) p.elites.reserves += elite; if (p.elites) p.elites.reserves += elite;
} else { } else {
place(p, to, sector, amount, elite); place(p, to, sector, amount, elite);
if (advisors) if (advisors) {
(p.advisors ??= {})[to] = { const lockedTurn = Math.max(sourceLock ?? 0, p.advisors?.[to]?.lockedTurn ?? 0);
lockedTurn: Math.max(sourceLock ?? 0, p.advisors?.[to]?.lockedTurn ?? 0) || undefined, (p.advisors ??= {})[to] = lockedTurn ? { lockedTurn } : {};
}; } else if (p.advisors) delete p.advisors[to];
else if (p.advisors) delete p.advisors[to];
} }
observeOccupation(g); observeOccupation(g);
finishShipmentPromises(g, p, fromReserves ? { territory: to, amount } : null); finishShipmentPromises(g, p, fromReserves ? { territory: to, amount } : null);
+29
View File
@@ -387,6 +387,19 @@ void test('new advisors cannot flip into occupied fighters during their arrival
/cannot become fighters/, /cannot become fighters/,
); );
}); });
void test('an unlocked ordinary advisor move keeps the same private and public views after JSON', () => {
let g = fixture();
army(g, 'a', { 'arrakeen:10': 1, 'imperial_basin:10': 1 });
army(g, 'b', { 'arrakeen:10': 2, 'imperial_basin:10': 1 }, [
'arrakeen', 'imperial_basin',
]);
g.active = 'b';
g = move(g, 'b', 'arrakeen:10', 'imperial_basin', 10, 1);
assert.deepEqual(g.players[1].advisors?.imperial_basin, {});
for (const player of g.players)
assert.deepEqual(viewGame(g, player.id), viewGame(JSON.parse(JSON.stringify(g)) as Game, player.id));
});
void test('advisor setup waits for Fremen, follows their placement, and becomes fighters only when alone', () => { void test('advisor setup waits for Fremen, follows their placement, and becomes fighters only when alone', () => {
for (const occupied of [false, true]) { for (const occupied of [false, true]) {
let g = createGame('ADSETUP2', newPlayer('a', 'Atreides', 'atreides')); let g = createGame('ADSETUP2', newPlayer('a', 'Atreides', 'atreides'));
@@ -506,6 +519,22 @@ void test('Guild cross-shipping matches existing advisors and preserves their ar
); );
}); });
void test('an unlocked Guild advisor arrival projects identically after JSON continuation', () => {
let g = fixture();
g.players[2].faction = 'guild';
g.players[2].ally = 'b';
g.players[1].ally = 'h';
army(g, 'a', { 'red_chasm:7': 1 });
army(g, 'b', { 'arrakeen:10': 2, 'red_chasm:7': 1 }, ['arrakeen', 'red_chasm']);
g.active = 'b';
g = allow(applyAction(g, 'b', {
type: 'guildShip', from: 'arrakeen:10', territory: 'red_chasm',
sector: 7, amount: 2,
}));
assert.deepEqual(g.players[1].advisors?.red_chasm, {});
assert.deepEqual(viewGame(g, 'a'), viewGame(JSON.parse(JSON.stringify(g)) as Game, 'a'));
});
void test('accompanying advisors may select a valid sector of the shipment territory, with no reserve loss on a rejected choice', () => { void test('accompanying advisors may select a valid sector of the shipment territory, with no reserve loss on a rejected choice', () => {
let g = fixture(); let g = fixture();
g = applyAction(g, 'a', { g = applyAction(g, 'a', {
+25
View File
@@ -94,6 +94,31 @@ function assertFailedEvidence(
return report; return report;
} }
void test('Ecaz card samples keep all three physical identities through saved resume', (t) => {
const area = temporary(t);
const out = join(area, 'ecaz-cards');
const result = run(out, '--profile', 'ecaz-treachery', '--players', '2',
'--rules', 'advanced', '--seed', '1000', '--max-actions', '1');
assert.equal(result.status, 1);
assertFailedEvidence(out, 'ecaz-treachery-2-advanced', 1137, false);
const snapshot = join(out, 'failed-ecaz-treachery-2-advanced.json');
const game = json<{
ecazTreachery: boolean;
deck: { id: string }[];
discard: { id: string }[];
players: { hand: { id: string }[] }[];
}>(snapshot);
assert.equal(game.ecazTreachery, true);
assert.deepEqual(
[...game.deck, ...game.discard, ...game.players.flatMap(player => player.hand)]
.map(card => card.id).filter(id => id.startsWith('ecaz-')).sort(),
['ecaz-harass-withdraw', 'ecaz-recruits', 'ecaz-reinforcements'],
);
const resumed = join(area, 'resumed');
assert.equal(run(resumed, '--resume', snapshot, '--seed', '1000', '--max-actions', '1').status, 1);
assertFailedEvidence(resumed, 'ecaz-treachery-2-advanced', 1137, true);
});
void test('Moritani Skills samples preserve their full module on saved continuation and reject Advanced before running', (t) => { void test('Moritani Skills samples preserve their full module on saved continuation and reject Advanced before running', (t) => {
const area = temporary(t); const area = temporary(t);
const out = join(area, 'skills'); const out = join(area, 'skills');
+3
View File
@@ -727,6 +727,9 @@ void test('Ecaz Occupy permits its unlocked BG ally to choose fighters beside Ec
settled(advisors); settled(advisors);
continue; continue;
} }
const unlockedAdvisors = move(g, { ...action, fighters: false });
assert.deepEqual(p(unlockedAdvisors, 'a').advisors?.sietch_tabr, {});
assert.deepEqual(viewGame(unlockedAdvisors, 'a'), viewGame(reload(unlockedAdvisors), 'a'));
let done = applyAction(reload(g), 'a', action); let done = applyAction(reload(g), 'a', action);
assert.equal(done.response?.kind, 'advisorFlip'); assert.equal(done.response?.kind, 'advisorFlip');
done = allow(reload(done)); done = allow(reload(done));
+20
View File
@@ -206,6 +206,26 @@ void test('Junction cross-transport preserves BG advisor locks and forbids mergi
homeworldGameIntegrity(g); homeworldGameIntegrity(g);
}); });
void test('an unlocked Junction advisor group has identical views across JSON recovery', () => {
let g = fixture();
g.active = 'beneGesserit';
g.movementRemaining = ['beneGesserit', 'atreides', 'guild'];
const bg = player(g, 'beneGesserit');
bg.forces = { 'arrakeen:10': 2, 'tueks_sietch:5': 1 };
bg.reserves = 17;
bg.advisors = { arrakeen: {}, tueks_sietch: {} };
g = offered(g);
const option = viewGame(g, bg.id).junctionTransport!;
g = applyAction(g, bg.id, {
type: 'junctionShip', event: option.event, offer: option.offer!.event,
destination: 'tueks_sietch:5',
sources: { 'arrakeen:10': { normal: 1, elite: 0 } },
});
assert.deepEqual(player(g, bg.id).advisors!.tueks_sietch, {});
assert.deepEqual(viewGame(g, bg.id), viewGame(JSON.parse(JSON.stringify(g)) as Game, bg.id));
homeworldGameIntegrity(g);
});
void test('Junction distinguishes native Fremen Arrakis arrival from a foreign Homeworld departure for BG and technology', () => { void test('Junction distinguishes native Fremen Arrakis arrival from a foreign Homeworld departure for BG and technology', () => {
let native = fixture(true); let native = fixture(true);
native.active = 'fremen'; native.active = 'fremen';
+19 -9
View File
@@ -14,6 +14,7 @@ import {
initializeHomeworldGameForAudit, initializeHomeworldGameForAudit,
initializeNexusGameForAudit, initializeNexusGameForAudit,
initializePairedNexusGameForAudit, initializePairedNexusGameForAudit,
initializeEcazTreacheryGameForAudit,
initializeCombinedNexusGameForAudit, initializeCombinedNexusGameForAudit,
initializeLeaderSkillsGameForAudit, initializeLeaderSkillsGameForAudit,
joinGame, joinGame,
@@ -30,7 +31,7 @@ import { privateOutputDirectory, sourceSnapshot } from './verification';
const DEFAULT_SEED = 20_260_926; const DEFAULT_SEED = 20_260_926;
const DEFAULT_MAX_ACTIONS = 3_500; const DEFAULT_MAX_ACTIONS = 3_500;
const DIFFICULTIES = ['Easy', 'Medium', 'Hard', 'Brutal'] as const; const DIFFICULTIES = ['Easy', 'Medium', 'Hard', 'Brutal'] as const;
type Profile = 'base' | 'choam' | 'ecaz' | 'combined' | 'combined-nexus' | 'combined-homeworld-nexus' | 'homeworld' | 'nexus' | 'homeworld-nexus' | 'choam-roster' | 'ecaz-roster' | 'ix-roster' | 'choam-nexus' | 'ecaz-nexus' | 'ix-nexus' | 'moritani-skills' | 'tleilaxu-skills' | 'ix-skills' | 'choam-skills'; type Profile = 'base' | 'choam' | 'ecaz' | 'ecaz-treachery' | 'combined' | 'combined-nexus' | 'combined-homeworld-nexus' | 'homeworld' | 'nexus' | 'homeworld-nexus' | 'choam-roster' | 'ecaz-roster' | 'ix-roster' | 'choam-nexus' | 'ecaz-nexus' | 'ix-nexus' | 'moritani-skills' | 'tleilaxu-skills' | 'ix-skills' | 'choam-skills';
type Rules = 'basic' | 'advanced'; type Rules = 'basic' | 'advanced';
type Scenario = { type Scenario = {
@@ -145,6 +146,12 @@ const EXPANSION_ROSTER_SCENARIOS: readonly Scenario[] = [
})), })),
), ),
); );
/** Same genuine paired roster, with the independent three-card variant enabled. */
const ECAZ_TREACHERY_SCENARIOS: readonly Scenario[] = EXPANSION_ROSTER_SCENARIOS
.filter(scenario => scenario.profile === 'ecaz-roster')
.map(scenario => ({
...scenario, profile: 'ecaz-treachery', ordinal: scenario.ordinal + 58,
}));
const PAIRED_NEXUS_SCENARIOS: readonly Scenario[] = EXPANSION_ROSTER_SCENARIOS const PAIRED_NEXUS_SCENARIOS: readonly Scenario[] = EXPANSION_ROSTER_SCENARIOS
.map(scenario => ({ .map(scenario => ({
...scenario, ...scenario,
@@ -236,10 +243,10 @@ type Result = {
function usage() { function usage() {
return ( return (
'Usage: node --import tsx tools/faction-games.ts --out NEW_PRIVATE_DIR ' + 'Usage: node --import tsx tools/faction-games.ts --out NEW_PRIVATE_DIR ' +
'[--seed UINT32] [--profile all|base|choam|ecaz|combined|combined-nexus|combined-homeworld-nexus|homeworld|nexus|homeworld-nexus|choam-roster|ecaz-roster|ix-roster|choam-nexus|ecaz-nexus|ix-nexus|moritani-skills|tleilaxu-skills|ix-skills|choam-skills] ' + '[--seed UINT32] [--profile all|base|choam|ecaz|ecaz-treachery|combined|combined-nexus|combined-homeworld-nexus|homeworld|nexus|homeworld-nexus|choam-roster|ecaz-roster|ix-roster|choam-nexus|ecaz-nexus|ix-nexus|moritani-skills|tleilaxu-skills|ix-skills|choam-skills] ' +
'[--rules both|basic|advanced] [--players all|2|3|4|5|6] [--max-actions POSITIVE] ' + '[--rules both|basic|advanced] [--players all|2|3|4|5|6] [--max-actions POSITIVE] ' +
'[--resume FAILED_GAME.json]\n' + '[--resume FAILED_GAME.json]\n' +
'Runs genuine setup and gameplay offline. Default/all keeps the six expansion samples; combined-nexus and combined-homeworld-nexus add five/six-seat Basic/Advanced all-expansion samples. Base, Homeworld, Nexus, Homeworld-Nexus, expansion roster, paired expansion Nexus and skill profiles default to their 2–6-player samples. --players requires a supported profile. Output must be a new private directory outside the checkout.' 'Runs genuine setup and gameplay offline. Default/all keeps the six expansion samples; combined-nexus and combined-homeworld-nexus add five/six-seat Basic/Advanced all-expansion samples. Base, Homeworld, Nexus, Homeworld-Nexus, expansion roster, paired expansion Nexus, Ecaz card variant and skill profiles default to their 2–6-player samples. --players requires a supported profile. Output must be a new private directory outside the checkout.'
); );
} }
@@ -271,15 +278,15 @@ function positive(value: string | undefined) {
function scenarioName(scenario: Scenario) { function scenarioName(scenario: Scenario) {
return scenario.profile === 'base' || scenario.profile === 'homeworld' || scenario.profile === 'nexus' || scenario.profile === 'homeworld-nexus' || scenario.profile === 'combined-nexus' || scenario.profile === 'combined-homeworld-nexus' || return scenario.profile === 'base' || scenario.profile === 'homeworld' || scenario.profile === 'nexus' || scenario.profile === 'homeworld-nexus' || scenario.profile === 'combined-nexus' || scenario.profile === 'combined-homeworld-nexus' ||
scenario.profile === 'choam-roster' || scenario.profile === 'ecaz-roster' || scenario.profile === 'ix-roster' || scenario.profile === 'choam-nexus' || scenario.profile === 'ecaz-nexus' || scenario.profile === 'ix-nexus' || skillProfile(scenario.profile) scenario.profile === 'choam-roster' || scenario.profile === 'ecaz-roster' || scenario.profile === 'ecaz-treachery' || scenario.profile === 'ix-roster' || scenario.profile === 'choam-nexus' || scenario.profile === 'ecaz-nexus' || scenario.profile === 'ix-nexus' || skillProfile(scenario.profile)
? `${scenario.profile}-${scenario.roster.length}-${scenario.rules}` ? `${scenario.profile}-${scenario.roster.length}-${scenario.rules}`
: `${scenario.profile}-${scenario.rules}`; : `${scenario.profile}-${scenario.rules}`;
} }
function parseProfile(value: string | undefined) { function parseProfile(value: string | undefined) {
const profile = value ?? 'all'; const profile = value ?? 'all';
if (!['all', 'base', 'choam', 'ecaz', 'combined', 'combined-nexus', 'combined-homeworld-nexus', 'homeworld', 'nexus', 'homeworld-nexus', 'choam-roster', 'ecaz-roster', 'ix-roster', 'choam-nexus', 'ecaz-nexus', 'ix-nexus', 'moritani-skills', 'tleilaxu-skills', 'ix-skills', 'choam-skills'].includes(profile)) if (!['all', 'base', 'choam', 'ecaz', 'ecaz-treachery', 'combined', 'combined-nexus', 'combined-homeworld-nexus', 'homeworld', 'nexus', 'homeworld-nexus', 'choam-roster', 'ecaz-roster', 'ix-roster', 'choam-nexus', 'ecaz-nexus', 'ix-nexus', 'moritani-skills', 'tleilaxu-skills', 'ix-skills', 'choam-skills'].includes(profile))
throw new Error('--profile must be all, base, choam, ecaz, combined, combined-nexus, combined-homeworld-nexus, homeworld, nexus, homeworld-nexus, choam-roster, ecaz-roster, ix-roster, choam-nexus, ecaz-nexus, ix-nexus, moritani-skills, tleilaxu-skills, ix-skills or choam-skills.'); throw new Error('--profile must be all, base, choam, ecaz, ecaz-treachery, combined, combined-nexus, combined-homeworld-nexus, homeworld, nexus, homeworld-nexus, choam-roster, ecaz-roster, ix-roster, choam-nexus, ecaz-nexus, ix-nexus, moritani-skills, tleilaxu-skills, ix-skills or choam-skills.');
return profile as Profile | 'all'; return profile as Profile | 'all';
} }
@@ -331,6 +338,8 @@ function freshGame(scenario: Scenario) {
return initializeCombinedNexusGameForAudit(game, scenario.profile === 'combined-homeworld-nexus'); return initializeCombinedNexusGameForAudit(game, scenario.profile === 'combined-homeworld-nexus');
if (scenario.profile === 'choam-nexus' || scenario.profile === 'ecaz-nexus' || scenario.profile === 'ix-nexus') if (scenario.profile === 'choam-nexus' || scenario.profile === 'ecaz-nexus' || scenario.profile === 'ix-nexus')
return initializePairedNexusGameForAudit(game); return initializePairedNexusGameForAudit(game);
if (scenario.profile === 'ecaz-treachery')
return initializeEcazTreacheryGameForAudit(game);
return skillProfile(scenario.profile) return skillProfile(scenario.profile)
? initializeLeaderSkillsGameForAudit(game) ? initializeLeaderSkillsGameForAudit(game)
: scenario.profile === 'homeworld' : scenario.profile === 'homeworld'
@@ -381,7 +390,6 @@ function resumedGame(path: string) {
game.greatMaker || game.greatMaker ||
game.techTokens || game.techTokens ||
game.strongholdCards || game.strongholdCards ||
game.ecazTreachery ||
game.mentatQuestionPreview || game.mentatQuestionPreview ||
game.moritaniAssassinatePreview || game.moritaniAssassinatePreview ||
game.moritaniAssassinate || game.moritaniAssassinate ||
@@ -389,11 +397,12 @@ function resumedGame(path: string) {
game.moritaniAssassinateCallEvents game.moritaniAssassinateCallEvents
) )
throw new Error('--resume sample scenarios exclude unsupported optional modules.'); throw new Error('--resume sample scenarios exclude unsupported optional modules.');
const scenario = [...SCENARIOS, ...COMBINED_NEXUS_SCENARIOS, ...COMBINED_HOMEWORLD_NEXUS_SCENARIOS, ...BASE_SCENARIOS, ...MODULE_SCENARIOS, ...EXPANSION_ROSTER_SCENARIOS, ...PAIRED_NEXUS_SCENARIOS, ...MORITANI_SKILLS_SCENARIOS, ...TLEILAXU_SKILLS_SCENARIOS, ...IX_SKILLS_SCENARIOS, ...CHOAM_SKILLS_SCENARIOS].find( const scenario = [...SCENARIOS, ...COMBINED_NEXUS_SCENARIOS, ...COMBINED_HOMEWORLD_NEXUS_SCENARIOS, ...BASE_SCENARIOS, ...MODULE_SCENARIOS, ...EXPANSION_ROSTER_SCENARIOS, ...ECAZ_TREACHERY_SCENARIOS, ...PAIRED_NEXUS_SCENARIOS, ...MORITANI_SKILLS_SCENARIOS, ...TLEILAXU_SKILLS_SCENARIOS, ...IX_SKILLS_SCENARIOS, ...CHOAM_SKILLS_SCENARIOS].find(
(candidate) => (candidate) =>
(candidate.profile === 'homeworld' || candidate.profile === 'homeworld-nexus' || candidate.profile === 'combined-homeworld-nexus') === !!game.homeworlds && (candidate.profile === 'homeworld' || candidate.profile === 'homeworld-nexus' || candidate.profile === 'combined-homeworld-nexus') === !!game.homeworlds &&
(candidate.profile === 'nexus' || candidate.profile === 'homeworld-nexus' || candidate.profile === 'choam-nexus' || candidate.profile === 'ecaz-nexus' || candidate.profile === 'ix-nexus' || candidate.profile === 'combined-nexus' || candidate.profile === 'combined-homeworld-nexus') === !!game.nexusCards && (candidate.profile === 'nexus' || candidate.profile === 'homeworld-nexus' || candidate.profile === 'choam-nexus' || candidate.profile === 'ecaz-nexus' || candidate.profile === 'ix-nexus' || candidate.profile === 'combined-nexus' || candidate.profile === 'combined-homeworld-nexus') === !!game.nexusCards &&
skillProfile(candidate.profile) === !!game.leaderSkills && skillProfile(candidate.profile) === !!game.leaderSkills &&
(candidate.profile === 'ecaz-treachery') === !!game.ecazTreachery &&
candidate.rules === (game.advanced ? 'advanced' : 'basic') && candidate.rules === (game.advanced ? 'advanced' : 'basic') &&
JSON.stringify(candidate.expansions) === JSON.stringify(candidate.expansions) ===
JSON.stringify(game.expansions) && JSON.stringify(game.expansions) &&
@@ -586,7 +595,7 @@ async function main() {
const profile = parseProfile(values.profile); const profile = parseProfile(values.profile);
const rules = parseRules(values.rules); const rules = parseRules(values.rules);
const players = parsePlayers(values.players); const players = parsePlayers(values.players);
if (supplied('players') && !['base', 'homeworld', 'nexus', 'homeworld-nexus', 'combined-nexus', 'combined-homeworld-nexus', 'choam-roster', 'ecaz-roster', 'ix-roster', 'choam-nexus', 'ecaz-nexus', 'ix-nexus'].includes(profile) && !skillProfile(profile)) if (supplied('players') && !['base', 'homeworld', 'nexus', 'homeworld-nexus', 'combined-nexus', 'combined-homeworld-nexus', 'choam-roster', 'ecaz-roster', 'ecaz-treachery', 'ix-roster', 'choam-nexus', 'ecaz-nexus', 'ix-nexus'].includes(profile) && !skillProfile(profile))
throw new Error('--players requires a roster or optional-module profile.'); throw new Error('--players requires a roster or optional-module profile.');
if ((profile === 'combined-nexus' || profile === 'combined-homeworld-nexus') && players !== 'all' && ![5, 6].includes(players)) if ((profile === 'combined-nexus' || profile === 'combined-homeworld-nexus') && players !== 'all' && ![5, 6].includes(players))
throw new Error(`--profile ${profile} supports only five or six players.`); throw new Error(`--profile ${profile} supports only five or six players.`);
@@ -596,6 +605,7 @@ async function main() {
const requestedSamples = profile === 'homeworld' || profile === 'nexus' || profile === 'homeworld-nexus' const requestedSamples = profile === 'homeworld' || profile === 'nexus' || profile === 'homeworld-nexus'
? MODULE_SCENARIOS ? MODULE_SCENARIOS
: profile === 'choam-roster' || profile === 'ecaz-roster' || profile === 'ix-roster' ? EXPANSION_ROSTER_SCENARIOS : profile === 'choam-roster' || profile === 'ecaz-roster' || profile === 'ix-roster' ? EXPANSION_ROSTER_SCENARIOS
: profile === 'ecaz-treachery' ? ECAZ_TREACHERY_SCENARIOS
: profile === 'choam-nexus' || profile === 'ecaz-nexus' || profile === 'ix-nexus' ? PAIRED_NEXUS_SCENARIOS : profile === 'choam-nexus' || profile === 'ecaz-nexus' || profile === 'ix-nexus' ? PAIRED_NEXUS_SCENARIOS
: profile === 'combined-nexus' ? COMBINED_NEXUS_SCENARIOS : profile === 'combined-nexus' ? COMBINED_NEXUS_SCENARIOS
: profile === 'combined-homeworld-nexus' ? COMBINED_HOMEWORLD_NEXUS_SCENARIOS : profile === 'combined-homeworld-nexus' ? COMBINED_HOMEWORLD_NEXUS_SCENARIOS
+2
View File
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
import { leaders, treacheryDeck } from '../game/cards'; import { leaders, treacheryDeck } from '../game/cards';
import type { Game } from '../game/engine'; import type { Game } from '../game/engine';
import { richeseCards } from '../game/richese-cards'; import { richeseCards } from '../game/richese-cards';
import { ecazTreacheryCards } from '../game/ecaz-cards';
import { traitorDeck } from '../game/traitors'; import { traitorDeck } from '../game/traitors';
import { basicExpansionLeaderSkillsProfile } from '../game/leader-skill-profile'; import { basicExpansionLeaderSkillsProfile } from '../game/leader-skill-profile';
import { validateLeaderSkills } from '../game/leader-skills'; import { validateLeaderSkills } from '../game/leader-skills';
@@ -12,6 +13,7 @@ export function sampleInventory(game: Game) {
leaderSkills: !!game.leaderSkills, leaderSkills: !!game.leaderSkills,
cards: [ cards: [
...treacheryDeck(game.expansions), ...treacheryDeck(game.expansions),
...(game.ecazTreachery ? ecazTreacheryCards() : []),
...(game.players.some((player) => player.faction === 'richese') ...(game.players.some((player) => player.faction === 'richese')
? richeseCards() ? richeseCards()
: []), : []),