From c961a93f62b7e12be72a9fc5cc9d3744ad5ac534 Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Tue, 29 Sep 2026 21:22:34 +1000 Subject: [PATCH] Exercise Ecaz card variant and canonicalize advisor stances --- docs/CURRENT_STATUS.md | 10 +++++++ docs/IMPLEMENTATION_STATUS.md | 36 ++++++++++++++++++++++ docs/VERIFICATION_WORKFLOW.md | 32 +++++++++++++++----- game/engine.ts | 40 ++++++++++--------------- tests/advisors.test.ts | 29 ++++++++++++++++++ tests/faction-games-cli.test.ts | 25 ++++++++++++++++ tests/fremen-ambassador-engine.test.ts | 3 ++ tests/junction-transport-review.test.ts | 20 +++++++++++++ tools/faction-games.ts | 28 +++++++++++------ tools/sample-custody.ts | 2 ++ 10 files changed, 185 insertions(+), 40 deletions(-) diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 182737d7..381c6e49 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -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, diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 0ee1bc2e..0f1c36a6 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -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 diff --git a/docs/VERIFICATION_WORKFLOW.md b/docs/VERIFICATION_WORKFLOW.md index db500769..44118440 100644 --- a/docs/VERIFICATION_WORKFLOW.md +++ b/docs/VERIFICATION_WORKFLOW.md @@ -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; diff --git a/game/engine.ts b/game/engine.ts index cf49bb9b..db2b42db 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -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); diff --git a/tests/advisors.test.ts b/tests/advisors.test.ts index fe43eb98..882e989a 100644 --- a/tests/advisors.test.ts +++ b/tests/advisors.test.ts @@ -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', { diff --git a/tests/faction-games-cli.test.ts b/tests/faction-games-cli.test.ts index 2b8858a1..34c85eac 100644 --- a/tests/faction-games-cli.test.ts +++ b/tests/faction-games-cli.test.ts @@ -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'); diff --git a/tests/fremen-ambassador-engine.test.ts b/tests/fremen-ambassador-engine.test.ts index 52695aaa..61fa9717 100644 --- a/tests/fremen-ambassador-engine.test.ts +++ b/tests/fremen-ambassador-engine.test.ts @@ -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)); diff --git a/tests/junction-transport-review.test.ts b/tests/junction-transport-review.test.ts index 54004692..db5f71e0 100644 --- a/tests/junction-transport-review.test.ts +++ b/tests/junction-transport-review.test.ts @@ -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'; diff --git a/tools/faction-games.ts b/tools/faction-games.ts index ebe6a001..c3bdf845 100644 --- a/tools/faction-games.ts +++ b/tools/faction-games.ts @@ -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 diff --git a/tools/sample-custody.ts b/tools/sample-custody.ts index 1ae3b465..0f05ce96 100644 --- a/tools/sample-custody.ts +++ b/tools/sample-custody.ts @@ -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() : []),