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
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)
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,
+36
View File
@@ -1,5 +1,41 @@
# 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
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-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-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-ecaz-nexus --profile ecaz-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
additional samples: Basic and Advanced with two through six players.
`--players 2..6` narrows the base, Homeworld, Nexus, combined
`homeworld-nexus`, expansion-roster or paired-Nexus profiles. The
`combined-nexus` and `combined-homeworld-nexus` profiles support `--players 5`
`homeworld-nexus`, expansion-roster, paired-Nexus, independent Ecaz-card or
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
base roster adds Atreides, Harkonnen, Fremen, Emperor, Guild and Bene Gesserit
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
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.
`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
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
@@ -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
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
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;
@@ -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
Moritani/base Traitor census are checked throughout. `--resume` preserves this
explicit profile; other optional modules still fail its admission checks.
Homeworld, Nexus and combined snapshots resume only when their saved modules
and fixed rosters match a supported profile; other optional module
combinations remain excluded. An edited snapshot with a removed module can
match a different fixed profile, so verify its original private report
before relying on resumed provenance.
Homeworld, Nexus, independent Ecaz-card and combined snapshots resume only
when their saved modules and fixed rosters match a supported profile; other
optional-module combinations remain excluded. An edited snapshot with a
removed module can match a different fixed profile, so verify its original
private report before relying on resumed provenance.
All samples use genuine setup and saved AI profiles, cycling Easy, Medium,
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);
if (n > (move.noField ? 1 : 0))
place(p, to, s, n - (move.noField ? 1 : 0), elite);
if (advisors)
(p.advisors ??= {})[to] = {
lockedTurn:
Math.max(lockedTurn ?? 0, p.advisors?.[to]?.lockedTurn ?? 0) ||
undefined,
};
else if (p.advisors) delete p.advisors[to];
if (advisors) {
const arrivalLock = Math.max(lockedTurn ?? 0, p.advisors?.[to]?.lockedTurn ?? 0);
(p.advisors ??= {})[to] = arrivalLock ? { lockedTurn: arrivalLock } : {};
} else if (p.advisors) delete p.advisors[to];
if (wantsFighters)
g.response = {
kind: 'advisorFlip',
@@ -12451,15 +12448,10 @@ function commitAmbassadorRelocation(g: Game, move: FremenAmbassadorMove) {
removeGroup(p, move.group, move.eliteGroup);
const physical = move.total - (move.noField ? 1 : 0);
if (physical) place(p, move.to, move.sector, physical, move.elite);
if (move.advisors)
(p.advisors ??= {})[move.to] = {
lockedTurn:
Math.max(
move.lockedTurn ?? 0,
p.advisors?.[move.to]?.lockedTurn ?? 0,
) || undefined,
};
else if (p.advisors) delete p.advisors[move.to];
if (move.advisors) {
const lockedTurn = Math.max(move.lockedTurn ?? 0, p.advisors?.[move.to]?.lockedTurn ?? 0);
(p.advisors ??= {})[move.to] = lockedTurn ? { lockedTurn } : {};
} else if (p.advisors) delete p.advisors[move.to];
entry.stage = 'arrival';
entry.relocation = { order: move, next: 'flip' };
log(
@@ -20454,9 +20446,10 @@ function performJunctionTransport(g: Game, p: Player, action: Action) {
observeOccupation(g);
if (arrival) {
place(p, arrival.territory, arrival.sector, quote.amount, quote.elite);
if (advisors) (p.advisors ??= {})[arrival.territory] = {
lockedTurn: Math.max(sourceLock ?? 0, p.advisors?.[arrival.territory]?.lockedTurn ?? 0) || undefined};
else if (p.advisors) delete p.advisors[arrival.territory];
if (advisors) {
const lockedTurn = Math.max(sourceLock ?? 0, p.advisors?.[arrival.territory]?.lockedTurn ?? 0);
(p.advisors ??= {})[arrival.territory] = lockedTurn ? { lockedTurn } : {};
} else if (p.advisors) delete p.advisors[arrival.territory];
}
p.shipped = true;
g.junctionOffer = null;
@@ -21043,11 +21036,10 @@ function commitGuildTransport(
if (p.elites) p.elites.reserves += elite;
} else {
place(p, to, sector, amount, elite);
if (advisors)
(p.advisors ??= {})[to] = {
lockedTurn: Math.max(sourceLock ?? 0, p.advisors?.[to]?.lockedTurn ?? 0) || undefined,
};
else if (p.advisors) delete p.advisors[to];
if (advisors) {
const lockedTurn = Math.max(sourceLock ?? 0, p.advisors?.[to]?.lockedTurn ?? 0);
(p.advisors ??= {})[to] = lockedTurn ? { lockedTurn } : {};
} else if (p.advisors) delete p.advisors[to];
}
observeOccupation(g);
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/,
);
});
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', () => {
for (const occupied of [false, true]) {
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', () => {
let g = fixture();
g = applyAction(g, 'a', {
+25
View File
@@ -94,6 +94,31 @@ function assertFailedEvidence(
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) => {
const area = temporary(t);
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);
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);
assert.equal(done.response?.kind, 'advisorFlip');
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);
});
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', () => {
let native = fixture(true);
native.active = 'fremen';
+19 -9
View File
@@ -14,6 +14,7 @@ import {
initializeHomeworldGameForAudit,
initializeNexusGameForAudit,
initializePairedNexusGameForAudit,
initializeEcazTreacheryGameForAudit,
initializeCombinedNexusGameForAudit,
initializeLeaderSkillsGameForAudit,
joinGame,
@@ -30,7 +31,7 @@ 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' | '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 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
.map(scenario => ({
...scenario,
@@ -236,10 +243,10 @@ type Result = {
function usage() {
return (
'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] ' +
'[--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) {
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.rules}`;
}
function parseProfile(value: string | undefined) {
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))
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.');
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, 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';
}
@@ -331,6 +338,8 @@ function freshGame(scenario: Scenario) {
return initializeCombinedNexusGameForAudit(game, scenario.profile === 'combined-homeworld-nexus');
if (scenario.profile === 'choam-nexus' || scenario.profile === 'ecaz-nexus' || scenario.profile === 'ix-nexus')
return initializePairedNexusGameForAudit(game);
if (scenario.profile === 'ecaz-treachery')
return initializeEcazTreacheryGameForAudit(game);
return skillProfile(scenario.profile)
? initializeLeaderSkillsGameForAudit(game)
: scenario.profile === 'homeworld'
@@ -381,7 +390,6 @@ function resumedGame(path: string) {
game.greatMaker ||
game.techTokens ||
game.strongholdCards ||
game.ecazTreachery ||
game.mentatQuestionPreview ||
game.moritaniAssassinatePreview ||
game.moritaniAssassinate ||
@@ -389,11 +397,12 @@ function resumedGame(path: string) {
game.moritaniAssassinateCallEvents
)
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.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 &&
skillProfile(candidate.profile) === !!game.leaderSkills &&
(candidate.profile === 'ecaz-treachery') === !!game.ecazTreachery &&
candidate.rules === (game.advanced ? 'advanced' : 'basic') &&
JSON.stringify(candidate.expansions) ===
JSON.stringify(game.expansions) &&
@@ -586,7 +595,7 @@ async function main() {
const profile = parseProfile(values.profile);
const rules = parseRules(values.rules);
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.');
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.`);
@@ -596,6 +605,7 @@ async function main() {
const requestedSamples = profile === 'homeworld' || profile === 'nexus' || profile === 'homeworld-nexus'
? MODULE_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 === 'combined-nexus' ? COMBINED_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 type { Game } from '../game/engine';
import { richeseCards } from '../game/richese-cards';
import { ecazTreacheryCards } from '../game/ecaz-cards';
import { traitorDeck } from '../game/traitors';
import { basicExpansionLeaderSkillsProfile } from '../game/leader-skill-profile';
import { validateLeaderSkills } from '../game/leader-skills';
@@ -12,6 +13,7 @@ export function sampleInventory(game: Game) {
leaderSkills: !!game.leaderSkills,
cards: [
...treacheryDeck(game.expansions),
...(game.ecazTreachery ? ecazTreacheryCards() : []),
...(game.players.some((player) => player.faction === 'richese')
? richeseCards()
: []),