From 185f3b37be96d7f09f1e35f774521bcedbbc2fac Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Wed, 7 Oct 2026 10:18:19 +1100 Subject: [PATCH] Compose standalone E3 Nexus without Leader Skills --- docs/CURRENT_STATUS.md | 24 ++ docs/DISCOVERY_PROTOTYPE.md | 18 ++ docs/HOMEWORLD_RUNTIME_IMPLEMENTATION.md | 16 + docs/IMPLEMENTATION_STATUS.md | 37 +++ docs/NEXUS_CARD_RULES.md | 107 +++++++ docs/RULE_DECISIONS.md | 17 ++ game/discovery-module-profile.ts | 31 ++ game/engine.ts | 22 +- game/nexus-ecaz-duke.ts | 6 +- game/nexus-module-profile.ts | 34 ++- game/reference.ts | 28 +- tests/discovery-native-e3-runtime.test.ts | 11 +- tests/e3-nexus-ecaz-runtime.test.ts | 335 +++++++++++++++++++++ tests/e3-nexus-moritani-runtime.test.ts | 342 ++++++++++++++++++++++ tests/fixture-e3-nexus-ecaz.ts | 322 ++++++++++++++++++++ tests/fixture-e3-nexus-moritani.ts | 308 +++++++++++++++++++ tools/faction-games.ts | 50 +++- tools/start-prototype.ts | 5 +- 18 files changed, 1675 insertions(+), 38 deletions(-) create mode 100644 tests/e3-nexus-ecaz-runtime.test.ts create mode 100644 tests/e3-nexus-moritani-runtime.test.ts create mode 100644 tests/fixture-e3-nexus-ecaz.ts create mode 100644 tests/fixture-e3-nexus-moritani.ts diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index bb407210..c3eed31f 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -17,6 +17,30 @@ open mode gates. ## Current checkpoint and work +**Standalone E3 Nexus without Skills — bounded changed-path proof.** +Original Ecaz OR Moritani/classic ecaz33/all12 composes optional Homeworld/ +Discovery/Tech3+/Advanced Strongholds2+ through the existing generic entry. +48 original admissions reject before and reach Traitors after. Original +native setup, distinct roster/Cunning exclusions and ruling gates remain. +New15 semantic cases and affected372/372 across32 files, types/lint/build pass. + +Across16 families,46/48 original six-seat games complete44,796accepted/ +45,084attempts/1,189JSON across two frozen trees. Eight initial new trace +name collisions are preserved/repaired without changing seeds; only those +interrupted families rerun. Basic odd-force/high-threshold revival captures +keep their actual source guards; no first-batch48-green claim. +Actual390px no-training `W6SCKGAA`v114 consumes supply Cunning then reveals +on paid Guild1 arrival: Staban dies once, bounty5 credits12→17, Guild +res14/Polar1/Tanks0. `WFEGQTRU`v94 retains random Cistern/free2 entry, actual +Maker/both-pile/alliance close, paid native Junction1 and living-Duke Cunning: +res11/Imperial6/Cistern2/visitor1/Tanks0, unchanged Cunning purse7. +[Canonical source/game/human receipts](NEXUS_CARD_RULES.md#standalone-e3-nexus-without-skills--6-october-2026). +Final types/lint/build and compiled no-training Nexus guide/evidence pass +at viewport/document390. Owned QA cutover retains originalv114/v94; +all tabs close and our three temporary scripts are removed. Checkpoint/ +exact-revision receipt follows. Other native families, pending rulings, +full-mode acceptance and protected deployment remain open. + **Native Homeworld Nexus — bounded Development Verified.** Existing paired E1/E2 Skills/no-Skills module/Discovery and standalone Ecaz OR Moritani Skills entries retain original Homeworld/Nexus with optional Discovery/ diff --git a/docs/DISCOVERY_PROTOTYPE.md b/docs/DISCOVERY_PROTOTYPE.md index e4bc24bf..013c3ae6 100644 --- a/docs/DISCOVERY_PROTOTYPE.md +++ b/docs/DISCOVERY_PROTOTYPE.md @@ -5,6 +5,24 @@ partial rules coverage. It connects physical components, real actions, private controls, AI choices and saved continuation. Normal public Discovery and Advanced start gates, and the publication gate, remain closed. +## Standalone E3 Nexus without Skills — 6 October 2026 + +Original Ecaz OR Moritani/classic `nexus --discoveries` now composes +ecaz33/all12/Discovery7+8 without Skills, optional Homeworlds, Tech3+ and +Advanced Strongholds2+. The existing generic initializer and original +native aftermath are reused. Ecaz's no-Skills roster/independent Cunning +exclusion and Advanced Moritani's non-Harkonnen boundary remain distinct. +No starting Nexus deal, public start or played-save conversion is added. + +Actual human Ecaz inspects its random Cistern, reveals it and freely moves +its two paid native parent counters at the next turn. Real Maker majority, +both Advanced piles and settled alliance precede its actual closing draw; +a paid native Junction visitor and living-Duke Cunning preserve20 counters. +Native/nested losses, private assassination, original card cleanup/Tech +and ordinary Collection remain separate source-local effects. +[Canonical15-case/372-check,16-family and human receipts](NEXUS_CARD_RULES.md#standalone-e3-nexus-without-skills--6-october-2026). +Two original ruling captures and all broader release/assurance gates remain. + ## Native Homeworld Nexus composition — 6 October 2026 Original paired Ixians+Tleilaxu OR CHOAM+Richese Nexus/Discovery entries, diff --git a/docs/HOMEWORLD_RUNTIME_IMPLEMENTATION.md b/docs/HOMEWORLD_RUNTIME_IMPLEMENTATION.md index 3895d0ab..863e7a3d 100644 --- a/docs/HOMEWORLD_RUNTIME_IMPLEMENTATION.md +++ b/docs/HOMEWORLD_RUNTIME_IMPLEMENTATION.md @@ -6,6 +6,22 @@ disabled.** It does not certify Homeworld battles, occupation, card effects or complete Advanced/expansion games. The earlier component checkpoint remains in [HOMEWORLD_IMPLEMENTATION.md](HOMEWORLD_IMPLEMENTATION.md). +## Standalone E3 Nexus without Skills — 6 October 2026 + +Fresh original Ecaz OR Moritani/classic Nexus now retains Homeworlds with +no Skills, optional Discovery7/8, Tech3+ and Advanced Strongholds2+. +Original ecaz33/all12, native setup, typed pools and roster/effect exclusions +remain. Generic Nexus setup reuses the original faction flag; no parallel +initializer or return adapter is added. + +Actual human native Ecaz2 free Cistern entry, paid Junction visitor1 and +living-Duke Cunning retain native11/Imperial6/Cistern2/visitor1. Original +Mori supply Cunning/paid Guild1 Polar arrival/human Assassination reveal +consume one Nexus/token and kill the actual Staban once for its printed5. +[Full canonical source/game/human receipts](NEXUS_CARD_RULES.md#standalone-e3-nexus-without-skills--6-october-2026) +records372 affected checks and46/48 completed original games across two +frozen source trees. No full-mode, new ruling or protected deployment claim. + ## Native Homeworld Nexus composition — 6 October 2026 Fresh original paired Ixians+Tleilaxu OR CHOAM+Richese entries now retain diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 2326ee38..2ca3da96 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,42 @@ # Dune implementation status +## 6 October 2026 — Standalone E3 Nexus without Skills + +Original Ecaz OR Moritani/classic ecaz33/all12 Nexus now composes optional +Homeworld/Discovery/Tech3+/Advanced Strongholds2+ without training, through +the generic existing initializer and original native aftermath.48 original +admissions reject before and reach real Traitors afterward. Ecaz's legal +no-Skills Harkonnen roster does not lift its separate Cunning exclusion; +Advanced Moritani/Harkonnen, mixed families and pending effects stay gated. + +New15 semantic cases and affected372/372 across32 files, types/lint/build +pass. Actual continuation, foreign-Homeworld no-Traitor rule, Grumman decline, +sole-token automatic transfer and separate ordinary Collection correct +fixture assumptions without suppressing game rules. Old no-Nexus native +Discovery assassination retains its physical regression with conserved +ordinary unplayed land ordering; private view-copy pins are removed. + +Sixteen runner families preserve seeds/bands12600–14100. Eight new trace +name collisions are repaired by the original faction/roster name branch; +partial artifacts are preserved. Original unaffected24 gives23 completions; +only interrupted24 is rerun at original seeds and gives23 completions. +Combined46/48 across two frozen trees completes44,796/45,084 actions/ +1,189JSON, not one uninterrupted certificate. Two source guards remain. + +Original human390px `W6SCKGAA`v114 plays actual supply-only Cunning then +reveals Assassination on Guild's paid native1 Polar arrival: original Staban +dies once, one token/card is consumed, bounty5 credits Mori12→17, Guild +14reserves/Polar1/Tanks0. `WFEGQTRU`v94 randomly inspects/reveals Cistern, +freely enters native2 after real parent payment, finishes Maker/both piles/ +alliance/closing draw, pays for native Junction visitor1 and spends Ecaz +for the living Duke with unchanged purse7 and original20 counters. +[Exact source/game/control evidence and limits](NEXUS_CARD_RULES.md#standalone-e3-nexus-without-skills--6-october-2026). +Final types/lint/build and compiled no-training Nexus guide/evidence pass +at viewport/document390. Owned QA cutover retains originalv114/v94; +all tabs close and temporary helpers are removed. Private source/human +receipts and partial/ruling artifacts remain. Exact checkpoint receipt +follows; no complete-mode or protected-deployment claim. + ## 6 October 2026 — Native Homeworld Nexus optional modules Original paired E1/E2 skill/Nexus and no-Skills Nexus module/Discovery diff --git a/docs/NEXUS_CARD_RULES.md b/docs/NEXUS_CARD_RULES.md index ac92478a..cbf97096 100644 --- a/docs/NEXUS_CARD_RULES.md +++ b/docs/NEXUS_CARD_RULES.md @@ -2,6 +2,113 @@ Original source audit, 10 September 2026, with dated bounded runtime follow-ups below. This document establishes the printed component inventory, source protocol and implementation boundaries; none lifts expansion release gates. The ordinary worm-triggered alliance Nexus already in the engine is distinct from this optional card module. +## Standalone E3 Nexus without Skills — 6 October 2026 + +The existing original `nexus` private entry now admits Ecaz **OR** Moritani +with classic opponents and the exact ecaz33/all12 components, without +Leader Skills. Basic/Advanced2–6 retains original Traitors/native placement, +optionally Homeworlds, Discovery7/8 through `--discoveries`, unused Tech3+ +and Advanced Strongholds2+. Exactly one native E3 faction is required. +Advanced Moritani excludes Harkonnen. No-Skills Ecaz may face Advanced +Harkonnen under its original roster contract; that does **not** lift the +independent Duke Cunning exclusion. + +Revision physical PDF21–23 permits these optional variants together or +separately. Original printed Nexus faces still own each effect. Two focused +no-Skills profile predicates extend the existing generic Nexus initializer; +the paired initializer remains a genuine both-native contract. No new +setup/deposit adapter, training component, starting Nexus hand, public start +or played-save conversion is added. Original Ecaz Arrakis Occupy and +Advanced Moritani assassination opt-ins retain their own source boundaries. + +Fifteen original semantic cases and the affected32-file union372/372 pass; +types/lint/build pass. Actual closing draws, native paid Homeworld entry, +living unclaimed Duke/expiry, chosen coalition payer and separately owned +casualties, source-local held benefits, native Cunning supply placement, +real Terror entry/death, Harkonnen Secret Ally's two-Traitor exchange, +post-loss assassination/normal-call forfeiture/private replacement and +Discovery reveal/free entry retain original physical pieces. All four +minimal saved policies consume actual owned windows with JSON continuation. + +The real continuation determines timing: native assassination pauses the +original winner's casualties/support/bounty/cleanup suffix; a sole loser +Tech token transfers automatically after cleanup, not through an invented +prompt. Ordinary last-battle Collection is separate from held-card battle +income and native free strength. Foreign Homeworld visitors cannot make +normal Traitor calls, so actual forfeiture is proved on Arrakis before a +later paid Homeworld loss. A legal Grumman decline preserves original +Assassination supply for Cunning without rewriting a token or its bank income. +New incidental view/default/response-shape/action-type/whole-disc-copy pins +are removed, not re-pinned. An existing no-Nexus Discovery assassination +fixture preserves its physical regression with conserved ordinary unplayed +land ordering before the first blow; it no longer randomly consumes the +later fixture token. Duplicate private view/history-copy assertions are removed. + +Sixteen runner profiles preserve stable bands12600–14100 and older names/ +seeds: `e3-nexus`, `e3-discovery-nexus`, `e3-homeworld-nexus` and +`e3-homeworld-nexus-discovery`, each optionally `-tech`, `-stronghold` or +`-stronghold-tech`. Original dispatch, explicit prerequisites and exact +saved-scenario matching remain. Two-seat Tech and Basic Stronghold requests +reject before creating output directories. + +The first genuine batch exposes eight new no-Homeworld trace-name collisions: +Ecaz and Moritani previously share profile/count/rules filenames. Partial +directories/stdout/traces are preserved; the original faction/roster name +branch is extended only to these new families. No file is overwritten or +existing scenario seed/name changed. The unaffected eight Homeworld families +produce24 original games,23 completions (23,497accepted/23,785attempts/625JSON) +on unchanged tree +`37f84f1a265139d041ad0926c96e33a1c1d50969f83de69a0bbdd5e59a60c73f`. +Only the eight interrupted families are rerun in new private directories at +their original seeds:24 games,23 completions (21,299/21,299actions/564JSON) +on unchanged tree +`df686433db6066146a154d35db70b4d6acd27e90d6138b4bf9be38d63f3dca9e`. +Every report's before/after receipt is read. Combined46/48 finishes +44,796accepted/45,084attempts/1,189JSON across **two** frozen trees, not an +uninterrupted first-batch or later-documentation certificate. Both original +remaining trace reasons are read: Basic odd-force Ecaz and a Homeworld +revival crossing its high threshold retain existing source guards. +Reports/captures remain `/tmp/dune-standalone-{profile}-six-seat-20261006` +and `...-name-repaired-20261006`, seed20269006; partial collision artifacts remain. + +Actual390px `W6SCKGAA` uses its original Mori/Guild/Atreides HTTP lobby, +ready Homeworld/Tech/Strongholds and `nexus --discoveries` CLI. The human +keeps actually offered Esmar; no training or hands are injected. Only +original unplayed Spice/Nexus order is selected. Real first-Mentat Extortion, +both Advanced piles, Guild/Atreides alliance and closing draw precede the +actual Moritani singleton. Real Grumman declines keep supply available. +Atv85 the human selects Cunning/Assassination/Polar Sink; v86 consumes the +card and commits the original supply token, purse12 unchanged. Guild's +actual paid native1 arrival then triggers the human reveal: atv114 the +original Staban Tuek dies once, the token is removed, Mori receives its +printed5 bounty (12→17), Guild reserves14/Polar1/Tanks0 remain. +This is Terror death, not a fabricated native post-loss Traitor assassination. + +Actual390px `WFEGQTRU` uses its original Ecaz/Guild/Emperor HTTP lobby and +the same original no-Skills CLI/modules. The human keeps offered Hasimir; +original Ecaz6 placement and first hands complete. A real supply lottery +yields Cistern: paid native2 parent shipment, human private inspection/ +public revelation and v54 free entry precede actual Maker majority, both +Advanced piles, Guild/Emperor alliance and closing draw. The human draws +Ecaz, then declares native1 to Junction for1 (reserves12→11, separate +visitor1). At the real Battle boundary v94 the human spends Ecaz for the +living unclaimed Duke, purse7 unchanged. Original20 counters remain: +native11, Imperial Basin6, Cistern2 and Junction visitor1; Tanks0. +Fresh choice/result accessibility, screenshots and document width390 prove +both bounded working-tree paths. Each singleton is discarded once. + +Other native singles/mixed decks/E3 pairs, native free-return/companion and +Terror relocation/Grumman/HMS rulings, shared/captured/Ghola Duke authority, +full modes, comprehensive assurance/calibration and protected deployment +remain open. These prototypes do not reduce the complete project goal. + +Final types/lint/build and compiled no-training Nexus guide pass. The +owned isolated worker cutover retains originalv114/v94 counters, removed +Terror, dead Staban/bounty and living Duke. Fresh guide/evidence text and +screenshots render at viewport/document390. All three managed tabs close; +our three temporary TypeScript helpers are removed. Credential-free human +and source receipts, partial collisions and ruling captures remain outside Git. + ## Native Homeworld Nexus composition — 6 October 2026 Existing paired Ixians+Tleilaxu OR CHOAM+Richese entries retain original diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index 5803cd40..526d078f 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -2,6 +2,23 @@ Updated 2 October 2026. This is the authoritative navigation index for existing decisions, not a replacement rulebook or a new ruling. Detailed linked contracts retain their source citations and exact supported boundaries. Runtime follow-ups supersede historical absence claims; passing tests establish software behavior, not publisher authority. +## Standalone E3 Nexus without Skills — 6 October 2026 + +Revision physical PDF21–23 permits original optional modules together/ +separately. The fresh one-native Ecaz OR Moritani/classic Nexus entry +therefore composes Homeworld/Discovery/Tech/Advanced Strongholds without +training, retaining each original prerequisite and printed effect source. +Legal no-Skills Advanced Ecaz/Harkonnen admission does not grant Duke +Cunning there; Advanced Moritani/Harkonnen remains inadmissible. +Foreign Homeworld visitors cannot make normal Traitor calls. +[Canonical source/consumer receipts](NEXUS_CARD_RULES.md#standalone-e3-nexus-without-skills--6-october-2026) +distinguish actual native Cunning supply, Harkonnen extra Traitor exchange, +Terror death and post-loss private assassination, support/cleanup/Tech and +ordinary Collection. No default response/prompt or bank income is invented. +Original Basic odd-force Ecaz and high-threshold revival timing captures +remain guarded; native free returns, exceptional Duke and Terror relocation/ +Grumman/HMS questions are not settled by software passes. + ## Native Homeworld Nexus composition — 6 October 2026 Authorized revision physical PDF21–23 permits optional variants together diff --git a/game/discovery-module-profile.ts b/game/discovery-module-profile.ts index 3a14ab8d..2930be7f 100644 --- a/game/discovery-module-profile.ts +++ b/game/discovery-module-profile.ts @@ -16,6 +16,7 @@ export function homeworldDiscoveryProfile(game: FactionModuleProfile): boolean { /** The same roster/module contract during play; live Discovery frames are expected. */ export function discoveryModeSupported(game: FactionModuleProfile): boolean { + if (game.nexusCards) return standaloneE3DiscoveryNexusProfile(game); if (typeof game.advanced !== 'boolean' || game.discoveryEnabled !== true || game.players.length < 2 || game.players.length > 6 || (game.techTokens && game.players.length < 3) || game.expansions.length > 2 || @@ -124,3 +125,33 @@ export function pairedDiscoveryNexusProfile(game: FactionModuleProfile): boolean } return hasPrimary && hasSecondary; } + +/** One original E3 native/classics with Nexus/Discovery and no Skills. + * Optional Homeworlds/Tech/Advanced Strongholds retain their original prerequisites. */ +export function standaloneE3DiscoveryNexusProfile(game: FactionModuleProfile): boolean { + if (typeof game.advanced !== 'boolean' || game.discoveryEnabled !== true || + !game.nexusCards || game.expansions.length !== 1 || game.expansions[0] !== 'ecaz' || + game.players.length < 2 || game.players.length > 6 || + (game.techTokens && game.players.length < 3) || + (game.strongholdCards && !game.advanced) || game.leaderSkills || + game.ecazTreachery || game.semutaPreview || game.advancedPreview || game.kullPreview || + game.nexusKullPreview || game.guildBetrayalPreview || game.richeseBetrayalPreview || + game.nexusIxianReplacementPreview || game.nexusIxianBetrayalPreview || + game.nexusHarkonnenBetrayalPreview) return false; + let native: 'ecaz' | 'moritani' | null = null, harkonnen = false; + for (let i = 0; i < game.players.length; i++) { + const player = game.players[i]; + for (let j = 0; j < i; j++) if (player.faction === game.players[j].faction) return false; + if (player.faction === 'ecaz' || player.faction === 'moritani') { + if (native !== null) return false; + native = player.faction; + } else { + let classic = false; + for (const faction of FACTIONS) + if (faction.id === player.faction) { classic = faction.expansion === 'base'; break; } + if (!classic) return false; + } + if (player.faction === 'harkonnen') harkonnen = true; + } + return native !== null && !(game.advanced && native === 'moritani' && harkonnen); +} diff --git a/game/engine.ts b/game/engine.ts index b13d50f3..df353b05 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -1,6 +1,6 @@ -import { classicNexusModulesProfile, pairedNexusModulesProfile } from './nexus-module-profile'; +import { classicNexusModulesProfile, pairedNexusModulesProfile, standaloneE3NexusProfile } from './nexus-module-profile'; import { nativeFactionTechProfile } from './faction-module-profile'; -import { classicDiscoveryLeaderSkillsProfile, classicDiscoveryNexusProfile, homeworldDiscoveryProfile, discoveryModeSupported, discoveryModuleProfile, pairedDiscoveryNexusProfile } from './discovery-module-profile'; +import { classicDiscoveryLeaderSkillsProfile, classicDiscoveryNexusProfile, homeworldDiscoveryProfile, discoveryModeSupported, discoveryModuleProfile, pairedDiscoveryNexusProfile, standaloneE3DiscoveryNexusProfile } from './discovery-module-profile'; import { advancedMoritaniLeaderSkillsProfile, advancedNativeLeaderSkillsProfile, homeworldLeaderSkillsProfile, classicNexusLeaderSkillsProfile, classicTechLeaderSkillsProfile, nativeDiscoveryLeaderSkillsProfile, nativeExpansionLeaderSkillsProfile, nativeTechLeaderSkillsProfile, ordinaryLeaderSkillModeSupported, pairedNexusLeaderSkillsProfile, standaloneE3NexusLeaderSkillsProfile, strongholdLeaderSkillsProfile } from './leader-skill-profile'; import { bribeTimingBlock, maximumBribe, type BribeOptions } from './bribe-options'; import { quoteSpicePlacement, stormExposesTerritory, stormSectorAfter, wormConsumesForces } from './disaster-rules'; @@ -7612,6 +7612,8 @@ function e3StrongholdFactionProfile(g: Game): boolean { return native !== null && (native !== 'moritani' || !byFaction(g, 'harkonnen')); } function moritaniAssassinateModeSupported(g: Game) { + if (standaloneE3NexusProfile(g)) + return g.advanced && g.players.some(player => player.faction === 'moritani'); if (g.discoveryEnabled || g.discoveries) return g.advanced && !!g.discoveries && (discoveryModeSupported(g) || nativeDiscoveryLeaderSkillsProfile(g)) && @@ -8566,16 +8568,22 @@ export function initializeHomeworldOccupationGameForAudit(state: Game): Game { if (byFaction(g, 'ecaz')) g.homeworldEcazVictoryPreview = true; return g; } -/** Offline-only seam for the independent Nexus module; public starts remain gated. */ +/** Original classic or standalone E3 Nexus setup; public starts remain gated. */ export function initializeNexusGameForAudit(state: Game): Game { requireRule(!!state.nexusCards && state.nexusCards.cards === null && state.nexusCards.phase === null, 'Enable Nexus cards in a fresh audit lobby first.'); + const standalone = standaloneE3NexusProfile(state); if (state.discoveryEnabled) { - requireRule(classicDiscoveryNexusProfile(state), 'Discovery Nexus requires a fresh classic lobby with optional original Homeworlds, Tech and Advanced Strongholds.'); + requireRule(classicDiscoveryNexusProfile(state) || standaloneE3DiscoveryNexusProfile(state), + 'Discovery Nexus requires a fresh classic or standalone E3 lobby with optional original Homeworlds, Tech and Advanced Strongholds.'); requireFreshBaseRuntime(state); requireFreshFactionInventory(state); } - return initializeSetupGameForAudit(state, !!state.homeworlds, true, false, classicDiscoveryNexusProfile(state)); + const g = initializeSetupGameForAudit(state, !!state.homeworlds, true, false, + classicDiscoveryNexusProfile(state) || standaloneE3DiscoveryNexusProfile(state), false, false, standalone); + if (standalone && byFaction(g, 'ecaz')) g.ecazOccupyPreview = true; + if (standalone && g.advanced && byFaction(g, 'moritani')) initializeMoritaniAssassinateState(g); + return g; } /** Fresh audit admission only; ordinary games and public starts never opt in. */ export function initializeHarkonnenNexusBetrayalGameForAudit(state: Game): Game { @@ -9391,6 +9399,8 @@ export function initializeFactionExpansionsGameForAudit(state: Game): Game { return initialized; } function ecazOccupyCompositionSupported(g: Game): boolean { + if (g.ecazOccupyPreview === true && standaloneE3NexusProfile(g) && + g.players.some(player => player.faction === 'ecaz')) return true; if (g.leaderSkills && standaloneE3NexusLeaderSkillsProfile(g) && g.players.some(p => p.faction === 'ecaz')) return true; if (g.ecazOccupyPreview === true && g.discoveries && discoveryModeSupported(g) && @@ -9597,7 +9607,7 @@ function initializeSetupGameForAudit(state: Game, homeworlds: boolean, nexus = f const discoveryComposition = discovery && (discoveryModuleProfile(g) || classicDiscoveryNexusProfile(g) || pairedDiscoveryNexusProfile(g) || (leaderSkills && (classicDiscoveryLeaderSkillsProfile(g) || nativeDiscoveryLeaderSkillsProfile(g) || classicNexusLeaderSkillsProfile(g)))); - const nexusModules = nexus && (classicNexusModulesProfile(g) || pairedNexusModulesProfile(g) || + const nexusModules = nexus && (classicNexusModulesProfile(g) || pairedNexusModulesProfile(g) || standaloneE3NexusProfile(g) || (leaderSkills && (classicNexusLeaderSkillsProfile(g) || pairedNexusLeaderSkillsProfile(g) || standaloneE3NexusLeaderSkillsProfile(g)))); requireRule(!g.techTokens || ((skillTech || nativeTech || nexusModules || discoveryComposition) && JSON.stringify(g.techTokens) === JSON.stringify(createTechTokens())), diff --git a/game/nexus-ecaz-duke.ts b/game/nexus-ecaz-duke.ts index b05f62d6..16f75eaa 100644 --- a/game/nexus-ecaz-duke.ts +++ b/game/nexus-ecaz-duke.ts @@ -4,6 +4,7 @@ import { acquireDuke, DUKE_VIDAL_ID, type DukeSource } from './duke-vidal'; import { nexusCardMode } from './nexus-cards'; import { nexusCleanPlayBlocked } from './nexus-play-boundary'; import { standaloneE3NexusLeaderSkillsProfile } from './leader-skill-profile'; +import { standaloneE3NexusProfile } from './nexus-module-profile'; const SUPPORTED_FACTIONS: Partial> = { ecaz: true, moritani: true, atreides: true, harkonnen: true, @@ -65,7 +66,7 @@ export function quoteNexusEcazDuke(g: Game, owner: string, automaticPending = fa if (g.advanced && g.players.some(player => player.faction === 'harkonnen')) blocked = 'Duke Vidal battle use is unavailable with Advanced Harkonnen.'; else if (!nexusEcazDukeModeSupported(g) || nexusCardMode('ecaz', holder.faction, roster) !== 'cunning') - blocked = 'Ecaz Nexus Cunning requires the paired Nexus-only roster or fresh standalone Ecaz Skills/Nexus profile.'; + blocked = 'Ecaz Nexus Cunning requires the paired Nexus-only roster or a fresh supported standalone Ecaz Nexus profile.'; else if (holder.ally) blocked = 'Use Ecaz Nexus Cunning while unallied.'; else if (g.status !== 'playing' || g.phase !== 6) blocked = 'Use Ecaz Nexus Cunning at the Battle phase boundary.'; @@ -87,6 +88,9 @@ export function quoteNexusEcazDuke(g: Game, owner: string, automaticPending = fa } export function nexusEcazDukeModeSupported(g: Game): boolean { + if (standaloneE3NexusProfile(g) && g.players.some(player => player.faction === 'ecaz') && + !g.sandtrout && !(g.advanced && g.players.some(player => player.faction === 'harkonnen'))) + return !!g.nexusCards?.cards && g.players.length >= 3; if (g.leaderSkills && standaloneE3NexusLeaderSkillsProfile(g) && g.players.some(player => player.faction === 'ecaz') && !g.sandtrout) return !!g.nexusCards?.cards && g.players.length >= 3; diff --git a/game/nexus-module-profile.ts b/game/nexus-module-profile.ts index 31b1e045..12649993 100644 --- a/game/nexus-module-profile.ts +++ b/game/nexus-module-profile.ts @@ -1,6 +1,6 @@ import { FACTIONS } from './catalog'; import type { FactionModuleProfile } from './faction-module-profile'; -import { classicDiscoveryNexusProfile, pairedDiscoveryNexusProfile } from './discovery-module-profile'; +import { classicDiscoveryNexusProfile, pairedDiscoveryNexusProfile, standaloneE3DiscoveryNexusProfile } from './discovery-module-profile'; /** Original classic Nexus modules, optionally Homeworlds; initializer checks undealt components. */ export function classicNexusModulesProfile(game: FactionModuleProfile): boolean { @@ -56,3 +56,35 @@ export function pairedNexusModulesProfile(game: FactionModuleProfile): boolean { } return hasPrimary && hasSecondary; } + +/** One original E3 native/classics without Skills, optionally Homeworlds, + * Discovery, Tech or Advanced Strongholds; setup owns undealt physical pieces. */ +export function standaloneE3NexusProfile(game: FactionModuleProfile): boolean { + if (game.discoveryEnabled || game.discoveries) return standaloneE3DiscoveryNexusProfile(game); + if (typeof game.advanced !== 'boolean' || !game.nexusCards || + game.expansions.length !== 1 || game.expansions[0] !== 'ecaz' || + game.players.length < 2 || game.players.length > 6 || + (game.techTokens && game.players.length < 3) || + (game.strongholdCards && !game.advanced) || game.leaderSkills || + game.discoveryEnabled || game.discoveries || game.discoveryStash || game.greatMaker || + game.ecazTreachery || game.semutaPreview || game.advancedPreview || + game.kullPreview || game.nexusKullPreview || game.guildBetrayalPreview || + game.richeseBetrayalPreview || game.nexusIxianReplacementPreview || + game.nexusIxianBetrayalPreview || game.nexusHarkonnenBetrayalPreview) return false; + let native: 'ecaz' | 'moritani' | null = null, harkonnen = false; + for (let i = 0; i < game.players.length; i++) { + const player = game.players[i]; + for (let j = 0; j < i; j++) if (player.faction === game.players[j].faction) return false; + if (player.faction === 'ecaz' || player.faction === 'moritani') { + if (native !== null) return false; + native = player.faction; + } else { + let classic = false; + for (const faction of FACTIONS) + if (faction.id === player.faction) { classic = faction.expansion === 'base'; break; } + if (!classic) return false; + } + if (player.faction === 'harkonnen') harkonnen = true; + } + return native !== null && !(game.advanced && native === 'moritani' && harkonnen); +} diff --git a/game/reference.ts b/game/reference.ts index 3d2fa4e6..97d9da5d 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -3820,7 +3820,7 @@ export const RULE_TOPICS: RuleTopic[] = [ ).join(' '), steps: [ 'Homeworlds are an optional module from Ecaz & Moritani, available under the official rules with Basic or Advanced play. They are separate locations outside Arrakis; they do not receive ordinary storm movement or count as ordinary strongholds.', - 'Fresh private Homeworld prototypes compose all fourteen Leader Skills, Discovery seven cards/eight tokens or both, Basic/Advanced two through six classic or supported native E1/E2 seats, or standalone Ecaz OR Moritani with classic opponents. Original family decks and Duke/assassination exclusions remain. Optional original Tech requires three or more seats; Stronghold Cards require Advanced. Classic or paired Ixians+Tleilaxu/CHOAM+Richese entries may additionally retain all12 Nexus through the existing skill or Nexus entry. Standalone Ecaz OR Moritani retains Homeworld/Nexus only with Skills in this bounded entry. Original setup/native placement, phase-end industry, source-local held cards and cleanup before mandatory winner Tech remain. Typed native Suk saves stay at the same home; external Emperor returns explicitly allocate normal/elite counters to actual native homes, an implementation interpretation rather than ordinary revival placement. Normal revival separately deposits Sardaukar at Salusa. Nested battles receive no Homeworld bonus; later Cistern income is separate from support/rescue. Other native singles/mixed-deck Nexus, unsupported E3 pairs/mixed families, unrelated overlays, public starts and pending interactions stay separate.', + 'Fresh private Homeworld prototypes compose all fourteen Leader Skills, Discovery seven cards/eight tokens or both, Basic/Advanced two through six classic or supported native E1/E2 seats, or standalone Ecaz OR Moritani with classic opponents. Optional original Tech requires three or more seats; Stronghold Cards require Advanced. Classic, paired Ixians+Tleilaxu/CHOAM+Richese or standalone Ecaz OR Moritani entries may retain all12 Nexus through the existing skill or Nexus entry, including no-Skills standalone E3. Original family decks and each roster/effect restriction remain: Advanced Moritani excludes Harkonnen; an otherwise legal no-Skills Ecaz/Harkonnen roster does not lift the separate Duke Cunning exclusion. Original setup/native placement, phase-end industry, source-local held cards and cleanup before mandatory winner Tech remain. Typed native Suk saves stay at the same home; external Emperor returns explicitly allocate normal/elite counters to actual native homes, an implementation interpretation rather than ordinary revival placement. Normal revival separately deposits Sardaukar at Salusa. Nested battles receive no Homeworld bonus; later Cistern income is separate from support/rescue. Other native singles/mixed-deck Nexus, unsupported E3 pairs/mixed families, unrelated overlays, public starts and pending interactions stay separate.', 'Each faction uses its own card and matching world token. In Basic play the Emperor uses Kaitain alone. Advanced play adds Salusa Secundus for its Sardaukar; movement can transfer both force types between the two Emperor worlds.', 'Native reserves determine high or low population. Salusa counts Sardaukar, while the other cards count the faction’s reserves there. Reaching the high face’s minimum restores high population. Occupation also applies the low penalty.', 'Low population normally grants one additional free force revival and one additional bank spice when collecting CHOAM Charity. Salusa grants neither bonus; CHOAM’s Tupile reverses the usual pattern by granting an advantage while low and a penalty while high.', @@ -3984,7 +3984,7 @@ export const RULE_TOPICS: RuleTopic[] = [ area: 'Verification', status: 'Partial', detail: - 'Native Homeworld Nexus adds15 meaningful cases; the affected25-file union passes307/307. Across23 native families,59 of68 original six-seat games finish71137 accepted/71484 attempted actions with1894 JSON on one unchanged source tree; nine preserved captures reach existing source rulings. Actual390px original Richese nested two-marker Cunning pays one invoice and materializes8 counters; Ecaz keeps actual Warmaster training through paid native invasion and living-Duke Cunning. Earlier classic and native evidence remains in canonical logs. Not complete rules, all combinations, comprehensive assurance/calibration or deployed acceptance.', + 'Standalone E3 without Skills adds15 meaningful cases; affected372/372 across32 files, types/lint/build pass. Across16 families,46 of48 original six-seat games finish44796 accepted/45084 attempted actions with1189 JSON across two frozen source trees. Eight initial new-name collisions are preserved and repaired without changing seeds; Basic odd-force and high-threshold revival captures retain their ruling guards. Actual390px no-training Cistern/native invasion/Duke Cunning and supply-only Moritani Cunning/real paid Terror arrival retain original card/token/leader/counter costs. Earlier skill/native/classic evidence remains canonical. Not complete rules, all combinations, comprehensive assurance/calibration or deployed acceptance.', evidence: [ 'tests/homeworld-classic-skills-runtime.test.ts', 'tests/homeworld-classic-discovery-runtime.test.ts', @@ -3996,6 +3996,8 @@ export const RULE_TOPICS: RuleTopic[] = [ 'tests/homeworld-classic-discovery-nexus-runtime.test.ts', 'tests/homeworld-paired-nexus-runtime.test.ts', 'tests/homeworld-standalone-nexus-runtime.test.ts', + 'tests/e3-nexus-ecaz-runtime.test.ts', + 'tests/e3-nexus-moritani-runtime.test.ts', 'tests/homeworld-custody.test.ts', 'tests/homeworld-cards.test.ts', 'tests/homeworld-payment-income.test.ts', @@ -4091,10 +4093,10 @@ export const RULE_TOPICS: RuleTopic[] = [ searchText: 'Hiereg Smuggler Jacurutu Sietch Cistern Ecological Testing Station Shrine Orgiz Processing Station Treachery Card Stash Spice Stash Ornithopter', steps: [ 'Fresh Discovery development setup supports classic, selected Ixian/Tleilaxu/CHOAM/Richese families, or standalone Ecaz or Moritani with classic opponents in Basic or Advanced, with seven extra Spice Cards and eight tokens. Original unused Tech Tokens may be selected with three through six seats; original Stronghold Cards require Advanced. Other modules, paired or mixed Ecaz/Moritani families and normal public starts stay gated.', - 'Fresh Discovery retains Homeworlds in Basic/Advanced two through six classic or supported native E1/E2 seats, or standalone Ecaz OR Moritani with classic opponents, original seven cards/eight tokens and native reserve placement. Original Tech requires three or more seats; Stronghold Cards require Advanced. The separate original leader-skills --discoveries entry also composes all fourteen Skills. Classic or paired Ixians+Tleilaxu/CHOAM+Richese entries additionally retain all12 Nexus through the original Nexus or skill entry; standalone Ecaz OR Moritani composes Homeworld/Nexus with Skills. Maker, later parent entry, nested shipment/rescue, phase-end industry and cleanup before mandatory winner Tech retain actual sources; held benefits never spill into a Homeworld or unrelated nested site. Other native singles/mixed-deck Nexus, unsupported E3 pairs/mixed families and unrelated overlays remain separate in this bounded profile, not outside the full goal.', + 'Fresh Discovery retains Homeworlds in Basic/Advanced two through six classic or supported native E1/E2 seats, or standalone Ecaz OR Moritani with classic opponents, original seven cards/eight tokens and native reserve placement. Original Tech requires three or more seats; Stronghold Cards require Advanced. The separate original leader-skills --discoveries entry composes all fourteen Skills. Classic, paired Ixians+Tleilaxu/CHOAM+Richese or standalone Ecaz OR Moritani entries retain all12 Nexus through the original Nexus entry without Skills, or their existing skill entry. Maker, later parent entry, nested shipment/rescue, phase-end industry and cleanup before mandatory winner Tech retain actual sources; held benefits never spill into a Homeworld or unrelated nested site. Other native singles/mixed-deck Nexus, unsupported E3 pairs/mixed families and unrelated overlays remain separate in this bounded profile, not outside the full goal.', 'The fresh Leader Skills entry may explicitly add Discoveries with original classic or supported native decks, all fourteen skills, original Tech at three or more seats and Advanced Stronghold Cards. Original Homeworlds may be retained; --nexus-cards requires classic factions, paired Ixians+Tleilaxu OR CHOAM+Richese with one family deck, or standalone Ecaz OR Moritani with classics. All12 Nexus retain the actual settled-alliance end-Spice closing deal after Great Maker and both Advanced piles. Existing native roster and Duke/assassination/Harkonnen restrictions remain. Actual hands/offers, physical entry, nested tariffs, stash capacity, typed rescue, native aftermath and printed invoices retain their sources. Other native singles/mixed decks, unsupported E3 pairs/mixed families, independent variants and separate Banker/Mentat previews remain outside these bounded compositions.', 'The same fresh entry also adds --nexus-cards --discoveries to paired Ixians+Tleilaxu, paired CHOAM+Richese or standalone Ecaz OR Moritani with classics, optionally retaining original Homeworlds: all14 offered training, all12 Nexus and Discovery7/8, Basic/Advanced two through six, optional Tech at three or more seats and Advanced Strongholds. Original setup, first hands, offers and native components stay authoritative. Real Maker losses/votes/typed rides, both Advanced piles and settled alliance precede the end-Spice closing draw. E1 Suboid Cunning settles typed Suk rescue before one equal Cyborg Tank substitution; original cleanup and mandatory winner Tech precede a matching Face Dance, whose foreign-Homeworld suppression remains. E2 keeps the signed two-marker frame and one invoice. Ecaz keeps quiet living/unclaimed Duke separate from training and a real owner-labelled Arrakis Occupy; Moritani keeps skill-first assassination and one private Mentat replacement. Mixed/E3 pairs, shared Duke, borrowed Smuggler/pair companions, native free returns, captured/foreign training, Basic odd-force Ecaz and Advanced Harkonnen stay separate.', - 'A separate fresh Nexus entry may explicitly add Discoveries to classic factions or both native Ixians/Tleilaxu or CHOAM/Richese with classics, one original family deck and no Skills, optionally retaining original Homeworlds. Original Tech requires three or more seats; Stronghold Cards require Advanced. End-Mentat ownership and next-turn physical entry remain. Great Maker finishes its losses, votes and typed reserve ride before ordinary Nexus settlement; cards are offered only at the end of the entire Spice Blow after both Advanced piles and settled alliance, not immediately or at Mentat. Source-clear return ledgers, Guild-priced nested shipment and signed native one-invoice marker pairs retain physical pieces and original capacities. No-Skills E3/mixed families, unrelated previews and unresolved mixed-module Fremen Betrayal stay separate from this no-Skills entry.', + 'A separate fresh Nexus entry may explicitly add Discoveries to classic factions, both native Ixians/Tleilaxu OR CHOAM/Richese with classics, or standalone Ecaz OR Moritani with classics and one original ecaz deck, without Skills and optionally retaining original Homeworlds. Original Tech requires three or more seats; Stronghold Cards require Advanced. Native Ecaz Arrakis Occupy and Advanced Moritani assassination retain their original source/effect exclusions; no training component or starting Nexus deal is added. End-Mentat ownership and next-turn physical entry remain. Great Maker finishes its losses, votes and typed reserve ride before ordinary Nexus settlement; cards are offered only at the end of the entire Spice Blow after both Advanced piles and settled alliance, not immediately or at Mentat. Source-clear ledgers, Guild-priced nested shipment and signed native one-invoice marker pairs retain physical pieces and original capacities. E3pairs/mixed families, unrelated previews, unresolved native free returns and mixed-module Fremen Betrayal remain separate.', 'Original Stronghold Cards are claimed only at end-Mentat. A held benefit applies in its named territory or the mobile stronghold through its actual declared copy; revealed Discovery locations do not inherit another territory’s defense or bank support. Jacurutu counts toward stronghold victory but creates no seventh Stronghold Card. Physical free entry, nested tariffs, trained or native force casualties, card cleanup and optional Tech reward retain their original sources.', 'Great Maker first resolves the ordinary worm consequences. All players then vote in storm order on creating a Nexus. A strict majority is required; a tie adds no Nexus and an existing Nexus in the phase stays recorded. Fremen may then ride with actual reserve forces to one legal destination for free, without using ordinary shipment or movement. Storm and occupancy restrictions still apply.', 'Each of the six Discovery territory cards destroys prior spice and forces in the named blow territory, including Fremen forces, before placing six new spice subject to the storm. It also places a random available face-down token of the printed type at the card’s separate token destination.', @@ -4134,8 +4136,9 @@ export const RULE_TOPICS: RuleTopic[] = [ }, { area: 'Verification', status: 'Partial', - detail: 'Native Homeworld Nexus adds15 meaningful cases; affected307/307 across25 files. Across23 native families,59 of68 original six-seat games finish71137/71484 actions with1894 JSON on one unchanged source tree; nine captures preserve existing rulings. Actual390px original no-Skills Richese Cistern pair materializes8 counters from native reserves with one spice invoice; actual Ecaz Warmaster training, paid foreign-home entry and living-Duke Cunning retain original custody. Earlier classic/native source-qualified evidence remains in canonical logs. Other native families, pending effects, full rules/combinations and deployed acceptance remain open.', - evidence: ['tests/homeworld-paired-nexus-runtime.test.ts', 'tests/homeworld-standalone-nexus-runtime.test.ts', + detail: 'Standalone E3 without Skills adds15 meaningful cases; affected372/372 across32 files, types/lint/build pass. Across16 families,46 of48 original six-seat games finish44796/45084 actions with1189 JSON across two frozen trees after eight new-name collisions are repaired with unchanged seeds. Two original source guards remain. Actual390px original no-training Ecaz random Cistern reveal/free entry, Maker/both-pile closing draw, native invasion and living-Duke Cunning retain20 counters; Moritani supply Cunning and paid arrival reveal consume one card/token and kill one random actual Guild disc for its printed bounty. Earlier source-qualified evidence remains canonical; full rules/combinations and deployed acceptance remain open.', + evidence: ['tests/e3-nexus-ecaz-runtime.test.ts', 'tests/e3-nexus-moritani-runtime.test.ts', + 'tests/homeworld-paired-nexus-runtime.test.ts', 'tests/homeworld-standalone-nexus-runtime.test.ts', 'tests/homeworld-classic-nexus-skills-runtime.test.ts', 'tests/homeworld-classic-discovery-nexus-runtime.test.ts', 'tests/homeworld-native-e1e2-modules-runtime.test.ts', 'tests/homeworld-native-e3-modules-runtime.test.ts', 'tests/homeworld-classic-discovery-runtime.test.ts', 'tests/homeworld-skills-discovery-runtime.test.ts', 'tests/homeworld-optional-modules-runtime.test.ts', 'tests/discovery-native-e1-skills-runtime.test.ts', 'tests/discovery-native-e2-skills-runtime.test.ts', 'tests/discovery-native-e3-skills-runtime.test.ts', 'tests/discovery-skills-strongholds-runtime.test.ts', 'tests/discovery-native-strongholds-runtime.test.ts', 'tests/ecaz-stronghold-runtime.test.ts', 'tests/e3-native-tech-runtime.test.ts', 'tests/discovery-classic-nexus-lifecycle.test.ts', 'tests/discovery-classic-nexus-effects.test.ts', 'tests/discovery-orgiz-advanced-runtime.test.ts', 'tests/discovery-classic-skills-runtime.test.ts', 'tests/discovery-module-profile.test.ts', 'tests/discovery-native-typed-runtime.test.ts', 'tests/discovery-native-marker-runtime.test.ts', 'tests/discovery-native-e3-runtime.test.ts', 'tests/discoveries.test.ts', 'tests/discovery-runtime.test.ts', 'tests/great-maker.test.ts', 'tests/discovery-board.test.ts', 'tests/discovery-admission.test.ts', 'tests/discovery-controls.test.ts', 'tests/discovery-entry.test.ts', 'tests/discovery-entry-runtime.test.ts', 'tests/discovery-entry-controls.test.ts', 'tests/discovery-entry-review.test.ts', 'tests/discovery-collection.test.ts', 'tests/discovery-flight.test.ts', 'tests/discovery-flight-engine.test.ts', 'tests/discovery-flight-recovery.test.ts', 'tests/discovery-battle.test.ts', 'tests/discovery-battle-recovery.test.ts', 'tests/discovery-storm.test.ts', 'tests/discovery-storm-recovery.test.ts', 'tests/discovery-shrine-engine.test.ts', 'tests/discovery-shrine-recovery.test.ts', 'docs/HOMEWORLD_RUNTIME_IMPLEMENTATION.md'], }, ], @@ -4416,12 +4419,13 @@ export const RULE_TOPICS: RuleTopic[] = [ 'Before drawing, choose whether to keep your own faction card, redraw it once, or redraw whenever it appears. Its effect is available in the reference before making this choice. The server applies that preference immediately; the table never pauses in a way that identifies a card you chose to keep.', 'Entering any alliance discards your held Nexus Card, including alliances made through an Ambassador or Terror token. An offer that has not been accepted does not discard it.', 'Use Cunning for your own faction, Betrayal when another player controls the printed faction, and Secret Ally when that faction is absent. Used cards go to the separate Nexus discard pile. Each printed effect has its own timing.', - 'Fresh local classic Nexus tables may retain original Homeworlds with optional Tech Tokens in Basic/Advanced three-through-six-seat lobbies and/or Stronghold Cards in Advanced two-through-six-seat lobbies; selecting both requires three or more. Original base33/all12 setup, closing Nexus deals, first-Storm Tech and actual end-Mentat Stronghold custody remain. Separate leader-skills --nexus-cards composes all14 original training with these classic Homeworld modules, optionally Discovery. Native Homeworld/Nexus, unrelated previews, public starts and saved-game conversion remain separate.', - 'Fresh nexus --discoveries preserves original Tech and Advanced Stronghold prerequisites with classic factions or both native Ixians/Tleilaxu or CHOAM/Richese and one family deck, without Skills. Only classic entries additionally retain original Homeworlds. The original seven Discovery Spice Cards/eight tokens, protected nested locations, next-turn physical entry and Great Maker losses/votes/typed native reserve ride remain. The closing draw follows both Advanced piles and settled alliances; a preceding Shai-Hulud may already have resolved the original losses, which do not repeat. Source-clear revival quotas, borrowed Guild tariffs and signed native two-marker one-invoice shipments into currently revealed sites use original controls. Native Homeworld/Nexus, E3/mixed families, unresolved mixed-module Fremen Betrayal and other unfinished effects remain guarded; no public start or save conversion.', - 'The same fresh local Nexus entry also composes Tech and/or Advanced Strongholds with paired Ixians + Tleilaxu OR CHOAM + Richese and classic opponents, one original family deck and no Skills. Discovery may additionally be selected through --discoveries, including without Tech or Strongholds. Tech needs three through six seats; Strongholds needs two through six Advanced seats. Real closing draws, native typed forces, physical Face Dancers and No-Fields, original payments/rewards and held effects remain. Mixed families, E3, Homeworlds, unrelated previews, public starts and unresolved rulings are not opened.', + 'Fresh local classic Nexus tables retain original Homeworlds with optional Tech Tokens in Basic/Advanced three-through-six-seat lobbies and/or Stronghold Cards in Advanced two-through-six-seat lobbies; selecting both requires three or more. Original base33/all12 setup, closing Nexus deals, first-Storm Tech and actual end-Mentat Stronghold custody remain. Separate leader-skills --nexus-cards composes all14 original training with these modules, optionally Discovery. Original paired E1/E2 and standalone E3 entries retain their own documented native roster/deck/effect boundaries. Unrelated previews, public starts and saved-game conversion remain separate.', + 'Fresh nexus --discoveries preserves original Tech and Advanced Stronghold prerequisites with classic factions, both native Ixians/Tleilaxu OR CHOAM/Richese with one family deck, or standalone Ecaz OR Moritani with classics and ecaz33, without Skills. These entries may retain original Homeworlds. The original seven Discovery Spice Cards/eight tokens, protected nested locations, next-turn physical entry and Great Maker losses/votes/typed native reserve ride remain. The closing draw follows both Advanced piles and settled alliances; a preceding Shai-Hulud’s completed losses do not repeat. Source-clear quotas, borrowed Guild tariffs and signed native two-marker one-invoice shipments into currently revealed sites use original controls. E3pairs/mixed families, unresolved native free-return and mixed-module Fremen Betrayal or other unfinished effects remain guarded; no public start or save conversion.', + 'The fresh local Nexus entry also composes Tech and/or Advanced Strongholds with paired Ixians + Tleilaxu OR CHOAM + Richese and classic opponents, one original family deck and no Skills, optionally original Homeworlds. Discovery may be selected through --discoveries, including without Tech or Strongholds. Tech needs three through six seats; Strongholds needs two through six Advanced seats. Real closing draws, native typed forces, physical Face Dancers/No-Fields, original payments/rewards and held effects remain. Other native single/mixed families, unrelated previews, public starts and unresolved rulings remain separate.', 'The separate fresh local leader-skills --nexus-cards entry combines classic base33/all14 Skills/all12 Nexus in Basic or Advanced, two through six seats, optionally adding original Discovery7/8 through --discoveries. Original Tech requires three or more seats; Stronghold Cards require Advanced. Starting hands/offers, actual end-Spice closing draws, first-Storm Tech and end-Mentat held custody remain. Extra/ordinary free-three returns earn original phase-end Axlotl without changing skill custody. Emperor Cunning composes skill posture, support and Suk rescue; Arrakeen reduces payer cost, not committed support or rescue eligibility. Winner cleanup precedes mandatory Tech reward. Unrelated overlays and public starts remain guarded.', 'The same fresh skill/Nexus entry supports both Ixians + Tleilaxu OR both CHOAM + Richese with classics and one original family deck/all14/all12, Basic/Advanced two through six seats. Selected original Tech requires three or more; Strongholds requires Advanced. Full Suboid Cunning composes typed skill rescue before equal actual Cyborg Tank substitution. Original held subsidy changes debit, not rescue support. Card cleanup and mandatory original-winner Tech precede Face Dance; a matching trainer dies and returns its skill once without another bounty, while held cards and tokens remain with the original winner. Partial Tleilaxu refresh preserves Mentat rights. Original signed Richese pair and CHOAM fuel/industry stay distinct. Mixed families, E3, companions/mixed plans, native free-return accounting and pending rulings stay guarded.', - 'A separate standalone Ecaz OR Moritani skill/Nexus entry uses exact ecaz33/all14/all12 with classics, Basic/Advanced two through six seats; original Tech requires3+, Strongholds requires Advanced. Actual closing draws, five ordinary training discs, quiet living/unclaimed Duke Cunning and original Advanced assassination/normal-Traitor forfeiture remain. Native support/rescue, held effects, original card cleanup and mandatory winner Tech use existing handlers. Advanced Harkonnen, E3 pairs/mixed families/HW/previews, Duke training/exceptional custody, enhanced Terror relocation/HMS/Grumman and other pending rulings stay guarded.', + 'A separate standalone Ecaz OR Moritani skill/Nexus entry uses exact ecaz33/all14/all12 with classics, Basic/Advanced two through six seats, optionally original Homeworlds and Discovery; original Tech requires3+, Strongholds requires Advanced. Actual closing draws, five ordinary training discs, quiet living/unclaimed Duke Cunning and original Advanced assassination/normal-Traitor forfeiture remain. Native support/rescue, held effects, original card cleanup and mandatory winner Tech use existing handlers. Advanced Harkonnen, E3 pairs/mixed families/previews, Duke training/exceptional custody, enhanced Terror relocation/HMS/Grumman and other pending rulings stay guarded.', + 'The original nexus entry also admits standalone Ecaz OR Moritani with classics, ecaz33/all12 and NO Skills, Basic/Advanced2–6, optionally Homeworlds, Discovery7/8, Tech3+ and Advanced Strongholds2+. Original Traitors/native setup precede a qualifying closing draw, with no starting Nexus hand. Quiet Duke Cunning stays separate from shared-disc training; no-Skills Ecaz may face Advanced Harkonnen without lifting the Cunning exclusion. Advanced Moritani excludes Harkonnen, retains post-loss private assassination/normal-call forfeiture and Mentat replacement, and uses supply-only Terror Cunning. Original Ecaz-selected coalition losses/payments and source-local held benefits/Tech remain. A sole loser token transfers automatically after cleanup; a real multi-token choice remains mandatory. Other native/mixed families, borrowed free-return/companion and relocation rulings, public starts and save conversion remain separate.', 'Atreides Cunning adds a second, different opposing plan element after the first native answer. Both remain binding. Use it in your own battle before sealing your plan; Karama may cancel the extra attempt while preserving the first disclosure.', 'With Atreides absent, Secret Ally inspects one opposing element in your battle. A sealed plan supplies only that selected answer automatically. Otherwise the opponent chooses an answer that permits a legal plan. Other players receive no private answer.', 'The No-Field restriction names Atreides, so Cunning cannot inspect that dial. A non-Atreides Secret Ally holder may request it without learning the token denomination or other plan elements.', @@ -4453,7 +4457,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 'When Moritani is absent, a defeated combatant with an eligible publicly played card receives the same postbattle choice whether or not they hold Moritani Nexus. An unallied holder may spend it to retain one played card that a winner could keep; other played cards follow ordinary discard. Without a publicly eligible card there is no prompt. The choice is after the battle result and before later income/capture; the public prompt protects hidden custody and is an interface inference. This Secret Ally effect has no invented ordinary Karama response. Native Moritani allied retention remains a different, cancelable ability.', 'With actual Advanced Stronghold Cards, Arrakeen bank support changes the real payer debit, not personal income. Emperor Cunning temporary Sardaukar remain physically ordinary counters and retain ordinary Tanks custody. Original losing Moritani retention precedes mandatory original-winner Tech, then CHOAM inspection excludes used opposing cards even if retained. Ordinary city Collection remains separate; none of these windows repeats the original support, card cost or casualties.', 'Ecaz Betrayal is available to an unallied holder with Ecaz seated and reciprocally allied in a shared Arrakis territory. At the opening of Shipment and Movement, choose one such territory; the ally returns its entire normal and starred force group from every sector there to reserves, leaving Ecaz forces in place. The Nexus card is spent before a Karama response. Cancellation preserves the board but not the card. Only the base/Ecaz Nexus roster without combined modules is supported.', - 'Ecaz Cunning lets an unallied native holder spend the physical Nexus card at a quiet Battle boundary to take the existing living, unclaimed Duke Vidal for the turn, even from Moritani. The Duke can be selected through ordinary Battle plans and is set aside after use or at turn end. The original paired Ecaz/Moritani Nexus-only profile and supported standalone Ecaz skill/Nexus profile with classic opponents admit this bounded effect; the latter may retain original Discovery, Tech and Advanced Strongholds. Duke remains separate from training. Advanced Harkonnen is excluded because the existing battle path cannot select Duke there. Taking Duke from the Tanks, capture or Ghola custody remains unimplemented. The Battle-only timing is a product boundary, not printed text.', + 'Ecaz Cunning lets an unallied native holder spend the physical Nexus card at a quiet Battle boundary to take the existing living, unclaimed Duke Vidal for the turn, even from Moritani. The Duke can be selected through ordinary Battle plans and is set aside after use or at turn end. The original paired Ecaz/Moritani Nexus-only profile and supported standalone Ecaz Nexus profile with classic opponents admit this bounded effect, with or without Skills and optional original Homeworlds, Discovery, Tech and Advanced Strongholds. Duke remains separate from training. Advanced Harkonnen is independently excluded even when the no-Skills roster is otherwise admitted. Taking Duke from the Tanks, capture or Ghola custody remains unimplemented. The Battle-only timing is a product boundary, not printed text.', 'Fremen Betrayal has two bounded advance-declaration alternatives for an unallied holder in a classic Nexus table. Before the first Spice Blow or worm appearance, spend the physical card to prevent Fremen ordinary and Cunning worm rides for the turn; worm survival, destruction and Nexus still resolve. This clean timing is a product boundary, not an official post-appearance priority ruling. Instead, before Fremen move during Shipment and Movement, spend it to suppress their native two-territory advantage for that turn while ordinary movement and independent ornithopters remain. Only one alternative can be used even if the card is recycled later that turn. Owner controls show the currently available choice; combined modules and reactive post-worm timing remain gated.', 'When Ecaz is absent, an unallied holder may spend its Nexus card at a quiet play boundary to ask whether any one player, including themself, holds one of the holder’s native faction leaders as an ordinary Traitor Card. The owner receives a private yes/no answer saved at the moment of use; no name or count is disclosed. Dead or captured native leaders count, but foreign Gholas, Duke Vidal, Cheap Hero and Face Dancers do not. Native-leader scope and private-only audience are explicit product interpretations, not publisher rulings; exceptional controlled-leader and public-disclosure combinations remain open.', 'When Richese is absent, its Secret Ally replaces your ordinary reserve shipment with up to five physical forces charged as one. Select the explicit shipment checkbox; forces, elite identity, destination restrictions, Homeworld reserve sources and normal movement remain unchanged. Fremen remain free within their normal reinforcement range; Guild and an active Karama rate retain their existing tariff and payment routing. This is not a concealed No-Field or an extra shipment.', @@ -4472,7 +4476,7 @@ export const RULE_TOPICS: RuleTopic[] = [ { area: 'Player controls', status: 'Partial', detail: 'Private lifecycle, inspections, exchanges, Tleilaxu refresh, Ixian activation, advisor selection, temporary Sardaukar and CHOAM effects, Moritani Betrayal, Ecaz Betrayal and owner-only Secret Ally inquiry, native Duke Cunning, Richese shipment and bounded auction Betrayal, Emperor purchase and Fremen Cunning controls are connected. Remaining effect controls are unfinished.', evidence: ['components/nexus-cards.tsx', 'components/nexus-traitors.tsx', 'components/nexus-tleilaxu.tsx', 'components/nexus-suboids.tsx', 'components/nexus-advisors.tsx', 'components/nexus-sardaukar.tsx', 'components/choam-power-cost.tsx', 'components/moritani-terror.tsx', 'components/nexus-moritani-betrayal.tsx', 'components/nexus-ecaz-betrayal.tsx', 'components/nexus-ecaz-inquiry.tsx', 'components/nexus-ecaz-duke.tsx', 'components/nexus-richese-betrayal.tsx', 'components/game-table.tsx'] }, { area: 'AI', status: 'Partial', detail: 'All four profiles choose legal actions through private offers, including Moritani/Ecaz Betrayal, Ecaz Secret Ally inquiry and native Duke Cunning, Fremen Cunning, Emperor purchase and bounded Richese auction Betrayal. Complete module strategy and calibration remain unfinished.', evidence: ['game/nexus-card-options.ts', 'game/nexus-traitor-options.ts', 'game/nexus-tleilaxu-options.ts', 'game/nexus-suboid-options.ts', 'game/nexus-advisor-options.ts', 'game/nexus-sardaukar-options.ts', 'game/choam-power-options.ts', 'game/nexus-moritani-options.ts', 'game/nexus-moritani-betrayal-options.ts', 'game/nexus-ecaz-betrayal-options.ts', 'game/nexus-ecaz-inquiry-options.ts', 'game/nexus-ecaz-duke-options.ts', 'game/nexus-richese-options.ts', 'game/bot-nexus-richese-betrayal.ts', 'game/nexus-guild-cunning-options.ts', 'game/bots.ts'] }, { area: 'Documentation', status: 'Partial', detail: 'Common rules, twelve printed faces and bounded runtime contracts retain explicit unresolved Ecaz Duke capture/Tanks/Ghola and inquiry audience/scope, Fremen nested Cunning, Great Maker, combined modules, fresh advisors, storm and borrowed Voice. Richese auction Betrayal has a separate cost/prepayment/privacy contract and excluded-producer boundaries. Guild Betrayal separately records full funded payment, own pre-funding, donor escrow once, the printed full-Junction override, four native adapters and neutral privacy; ordinary occupation/rounding and general response-priority questions remain unresolved.', evidence: ['docs/NEXUS_CARD_RULES.md', 'docs/NEXUS_ATREIDES_RULES.md', 'docs/NEXUS_HARKONNEN_RULES.md', 'docs/NEXUS_TLEILAXU_RULES.md', 'docs/NEXUS_IXIAN_CUNNING_RULES.md', 'docs/NEXUS_CUNNING_RUNTIME.md', 'docs/NEXUS_BENE_GESSERIT_RULES.md', 'docs/NEXUS_ADVISOR_RUNTIME.md', 'docs/NEXUS_EMPEROR_RULES.md', 'docs/NEXUS_SARDAUKAR_RUNTIME.md', 'docs/NEXUS_FREMEN_RULES.md', 'docs/NEXUS_ECAZ_RULES.md', 'docs/NEXUS_CHOAM_RULES.md', 'docs/NEXUS_CHOAM_RUNTIME.md', 'docs/NEXUS_MORITANI_RULES.md', 'docs/NEXUS_MORITANI_RUNTIME.md', 'docs/NEXUS_RICHESE_RUNTIME.md', 'docs/NEXUS_RICHESE_RULES.md', 'docs/NEXUS_GUILD_CUNNING_RUNTIME.md', 'docs/NEXUS_GUILD_RULES.md'] }, - { area: 'Verification', status: 'Partial', detail: 'Classic Homeworld Nexus optional modules add13 semantic cases and affected242/242, types/lint/build. Twenty-two original six-seat games across15 families complete14233/14233 actions with375 JSON on one unchanged tree. Human390px original qualifying draw, typed invasion/zero-Suk/fixed-three native return and random Shrine/Maker/both-pile closing draw retain actual cards and armies. Earlier base/native effect, module, rule-specific private/recovery and phone receipts remain in canonical feature logs; they are not a new assurance campaign or complete-mode certificate. Native optional-module Homeworld compositions, pending interpretations, full combinations and deployed acceptance remain open.', evidence: ['tests/homeworld-classic-nexus-skills-runtime.test.ts', 'tests/homeworld-classic-discovery-nexus-runtime.test.ts', 'tests/nexus-cards.test.ts', 'tests/nexus-card-engine.test.ts', 'tests/nexus-fremen-cunning.test.ts', 'tests/nexus-fremen-cunning-recovery.test.ts', 'tests/nexus-fremen-cunning-summon.test.ts', 'tests/nexus-fremen-cunning-summon-recovery.test.ts', 'tests/nexus-ecaz-betrayal-quotes.test.ts', 'tests/nexus-ecaz-betrayal.test.ts', 'tests/nexus-ecaz-betrayal-recovery.test.ts', 'tests/nexus-ecaz-inquiry.test.ts', 'tests/nexus-ecaz-duke-quote.test.ts', 'tests/nexus-ecaz-duke-engine.test.ts', 'tests/nexus-ecaz-duke-controls.test.ts', 'tests/nexus-ecaz-duke-recovery.test.ts', 'tests/nexus-traitor-recovery.test.ts', 'tests/nexus-face-dancer-engine.test.ts', 'tests/nexus-suboids-engine.test.ts', 'tests/normal-free-support.test.ts', 'tests/nexus-suboid-controls.test.ts', 'tests/nexus-advisor-controls.test.ts', 'tests/nexus-advisor-bots.test.ts', 'tests/nexus-sardaukar-controls.test.ts', 'tests/nexus-sardaukar-bots.test.ts', 'tests/nexus-choam-controls.test.ts', 'tests/nexus-choam-bots.test.ts', 'tests/nexus-moritani-controls.test.ts', 'tests/nexus-moritani-bots.test.ts', 'tests/nexus-moritani-betrayal.test.ts', 'tests/nexus-richese-controls.test.ts', 'tests/nexus-richese-bots.test.ts', 'tests/nexus-richese-betrayal-engine.test.ts', 'tests/nexus-richese-betrayal-recovery.test.ts', 'tests/nexus-guild-cunning-controls.test.ts', 'tests/nexus-guild-cunning-bots.test.ts', 'tests/nexus-module-payments-runtime.test.ts', 'tests/nexus-module-battles-runtime.test.ts', 'tests/paired-ix-nexus-modules-runtime.test.ts', 'tests/paired-choam-nexus-modules-runtime.test.ts', 'tests/classic-nexus-skills-payments-runtime.test.ts', 'tests/classic-nexus-skills-battles-runtime.test.ts', 'tests/nexus-skills-modules-payments-runtime.test.ts', 'tests/nexus-skills-modules-battles-runtime.test.ts', 'tests/paired-ix-nexus-skills-modules-runtime.test.ts', 'tests/paired-choam-nexus-skills-modules-runtime.test.ts', 'tests/ecaz-nexus-skills-modules-runtime.test.ts', 'tests/moritani-nexus-skills-modules-runtime.test.ts'] }, + { area: 'Verification', status: 'Partial', detail: 'Standalone E3 without Skills adds15 semantic cases; affected372/372 across32 files, types/lint/build pass. Across16 families,46 of48 original six-seat games finish44796/45084 actions with1189 JSON across two frozen trees after eight new trace-name collisions are repaired without changing seeds. Basic odd-force and high-threshold revival captures retain source guards. Actual390px original no-training Ecaz Cistern/free entry/Maker/closing draw/native invasion/Duke and Moritani supply Cunning/paid arrival/random assassination retain physical card/token/disc/counter costs. Earlier base/native/skill/private and phone evidence remains source-qualified, not a new assurance campaign or complete-mode/deployed certificate.', evidence: ['tests/e3-nexus-ecaz-runtime.test.ts', 'tests/e3-nexus-moritani-runtime.test.ts', 'tests/homeworld-classic-nexus-skills-runtime.test.ts', 'tests/homeworld-classic-discovery-nexus-runtime.test.ts', 'tests/nexus-cards.test.ts', 'tests/nexus-card-engine.test.ts', 'tests/nexus-fremen-cunning.test.ts', 'tests/nexus-fremen-cunning-recovery.test.ts', 'tests/nexus-fremen-cunning-summon.test.ts', 'tests/nexus-fremen-cunning-summon-recovery.test.ts', 'tests/nexus-ecaz-betrayal-quotes.test.ts', 'tests/nexus-ecaz-betrayal.test.ts', 'tests/nexus-ecaz-betrayal-recovery.test.ts', 'tests/nexus-ecaz-inquiry.test.ts', 'tests/nexus-ecaz-duke-quote.test.ts', 'tests/nexus-ecaz-duke-engine.test.ts', 'tests/nexus-ecaz-duke-controls.test.ts', 'tests/nexus-ecaz-duke-recovery.test.ts', 'tests/nexus-traitor-recovery.test.ts', 'tests/nexus-face-dancer-engine.test.ts', 'tests/nexus-suboids-engine.test.ts', 'tests/normal-free-support.test.ts', 'tests/nexus-suboid-controls.test.ts', 'tests/nexus-advisor-controls.test.ts', 'tests/nexus-advisor-bots.test.ts', 'tests/nexus-sardaukar-controls.test.ts', 'tests/nexus-sardaukar-bots.test.ts', 'tests/nexus-choam-controls.test.ts', 'tests/nexus-choam-bots.test.ts', 'tests/nexus-moritani-controls.test.ts', 'tests/nexus-moritani-bots.test.ts', 'tests/nexus-moritani-betrayal.test.ts', 'tests/nexus-richese-controls.test.ts', 'tests/nexus-richese-bots.test.ts', 'tests/nexus-richese-betrayal-engine.test.ts', 'tests/nexus-richese-betrayal-recovery.test.ts', 'tests/nexus-guild-cunning-controls.test.ts', 'tests/nexus-guild-cunning-bots.test.ts', 'tests/nexus-module-payments-runtime.test.ts', 'tests/nexus-module-battles-runtime.test.ts', 'tests/paired-ix-nexus-modules-runtime.test.ts', 'tests/paired-choam-nexus-modules-runtime.test.ts', 'tests/classic-nexus-skills-payments-runtime.test.ts', 'tests/classic-nexus-skills-battles-runtime.test.ts', 'tests/nexus-skills-modules-payments-runtime.test.ts', 'tests/nexus-skills-modules-battles-runtime.test.ts', 'tests/paired-ix-nexus-skills-modules-runtime.test.ts', 'tests/paired-choam-nexus-skills-modules-runtime.test.ts', 'tests/ecaz-nexus-skills-modules-runtime.test.ts', 'tests/moritani-nexus-skills-modules-runtime.test.ts', 'docs/NEXUS_CARD_RULES.md'] }, ], }, { diff --git a/tests/discovery-native-e3-runtime.test.ts b/tests/discovery-native-e3-runtime.test.ts index 63566b51..02528b90 100644 --- a/tests/discovery-native-e3-runtime.test.ts +++ b/tests/discovery-native-e3-runtime.test.ts @@ -1,6 +1,6 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { applyAction, createGame, initializeDiscoveryGameForAudit, joinGame, newPlayer, viewGame } from '../game/engine'; +import { applyAction, createGame, initializeDiscoveryGameForAudit, joinGame, newPlayer } from '../game/engine'; import { createTechTokens } from '../game/tech-tokens'; import { nativeTypedClearBoard, nativeTypedPhase, nativeTypedPlace, nativeTypedPlayer, nativeTypedReload, nativeTypedStep } from './fixture-discovery-native-typed'; import { putDiscovery } from './fixture-discovery'; @@ -14,6 +14,13 @@ void test('native Moritani assassination retains the actual revealed Discovery b game.discoveryEnabled = true; game.techTokens = createTechTokens(); game = initializeDiscoveryGameForAudit(game); for (let n = 0; game.status === 'setup' && n < 100; n++) game = nativeTypedStep(game); + // Keep the conserved token supply available: the first real Spice Blow + // draws ordinary land, rather than randomly placing this later fixture token. + for (const position of [0, 1]) { + const index = game.spiceDeck.findIndex((card, i) => i >= position && 'territory' in card && !card.discovery); + assert.ok(index >= position); + game.spiceDeck.splice(position, 0, game.spiceDeck.splice(index, 1)[0]); + } game = nativeTypedPhase(game, 5); nativeTypedClearBoard(game); // Conserved original supply token and reserve positions; inspect/reveal, @@ -40,7 +47,6 @@ void test('native Moritani assassination retains the actual revealed Discovery b const next = nextSkillsTechBattleStep(game); game = applyAction(game, next.actor, next.action); } assert.ok(game.decision?.kind === 'moritaniAssassinate'); - assert.equal(viewGame(game, 'm').moritaniAssassinate!.pending!.territory, 'cistern'); const spice = nativeTypedPlayer(game, 'm').spice; const event = game.decision.event; game = applyAction(nativeTypedReload(game), 'm', { type: 'decision', event, card: 'guild-1' }); @@ -49,5 +55,4 @@ void test('native Moritani assassination retains the actual revealed Discovery b assert.equal(game.moritaniAssassinate!.opportunities[0].territory, 'cistern'); assert.equal(game.moritaniAssassinate!.opportunities[0].card, 'guild-1'); assert.equal(game.moritaniAssassinate!.opportunities[0].stage, 'revealed'); - assert.equal(viewGame(game, 'm').moritaniAssassinate!.history[0].territory, 'cistern'); }); diff --git a/tests/e3-nexus-ecaz-runtime.test.ts b/tests/e3-nexus-ecaz-runtime.test.ts new file mode 100644 index 00000000..e8d17186 --- /dev/null +++ b/tests/e3-nexus-ecaz-runtime.test.ts @@ -0,0 +1,335 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { applyAction, viewGame, type Action, type Game } from '../game/engine'; +import { DIFFICULTIES } from '../game/bot-profiles'; +import { treacheryDeck } from '../game/cards'; +import { DUKE_VIDAL_ID } from '../game/duke-vidal'; +import { NEXUS_FACTIONS } from '../game/nexus-cards'; +import { ownedTech } from '../game/tech-tokens'; +import { strongholdControllers } from '../game/stronghold-cards'; +import { quoteBattleResolution, type BattleResolutionQuote, type ResolutionCombatant } from '../game/battle-resolution-quote'; +import { quoteSpiceCollection } from '../game/board-resolution-quote'; +import { homeworldContext } from '../game/homeworld-game'; +import { homeworldForceGroups } from '../game/homeworld-custody'; +import { homeworldCombatLocation } from '../game/homeworld-combat'; +import { homeworldShipmentChoice } from '../game/homeworld-shipment-options'; +import { nativeShipmentSources } from '../game/homeworld-options'; +import { ecazOccupancyRelation } from '../game/ecaz-occupy'; +import { nexusEcazDukeAction } from '../game/nexus-ecaz-duke-options'; +import { discoveryEntryMoveAction } from '../game/discovery-entry-options'; +import { + advanceE3NexusEcaz as advance, createE3NexusEcazClosing as closing, + createE3NexusEcazCoalition as coalition, createE3NexusEcazCunning as cunning, + createE3NexusEcazDiscovery as discovery, e3NexusEcazClean as clean, + e3NexusEcazPlayer as player, e3NexusEcazPolicy as policy, e3NexusEcazPool as pool, + e3NexusEcazReload as reload, e3NexusEcazStep as step, revealE3NexusEcaz as reveal, + shipE3NexusEcaz as ship, + type E3NexusEcazStep, +} from './fixture-e3-nexus-ecaz'; + +/** The last battle can enter ordinary Collection before this owned suffix returns. */ +function ordinaryCollectionSpice(g: Game, actor: string): number { + return g.phase === 7 ? quoteSpiceCollection(g).receipts.find(r => r.player === actor)?.strongholds ?? 0 : 0; +} + +function reject(g: Game, actor: string, action: Action, reason?: RegExp): void { + const before = structuredClone(g); + if (reason) assert.throws(() => applyAction(g, actor, action), reason); + else assert.throws(() => applyAction(g, actor, action)); + assert.deepEqual(g, before, 'Rejected ownership, stale event and unsupported FORCE choices cannot consume original pieces.'); +} +/** Count only real zones. The separate Duke never becomes a sixth native disc. */ +function physical(g: Game): void { + const nx = g.nexusCards!.cards!; + assert.deepEqual([...nx.deck, ...nx.discard, ...Object.values(nx.hands).filter(c => c !== null)].sort(), [...NEXUS_FACTIONS].sort()); + const auction = g.auction?.cards.slice(g.auction.index + (g.currentAuctionSale ? 1 : 0)) ?? []; + assert.deepEqual([...g.deck, ...g.discard, ...g.players.flatMap(p => p.hand), ...auction].map(c => c.id).sort(), + treacheryDeck(['ecaz']).map(c => c.id).sort()); + const homes = g.homeworlds?.custody ? homeworldForceGroups(homeworldContext(g), g.homeworlds.custody) : []; + for (const p of g.players) { + const visiting = homes.filter(h => h.native !== p.id).map(h => h.forces[p.id] ?? { normal: 0, elite: 0 }); + assert.equal(p.reserves + p.tanks + Object.values(p.forces).reduce((a, n) => a + n, 0) + + visiting.reduce((a, f) => a + f.normal + f.elite, 0), 20); + if (p.elites) assert.equal(p.elites.reserves + p.elites.tanks + Object.values(p.elites.forces).reduce((a, n) => a + n, 0) + + visiting.reduce((a, f) => a + f.elite, 0), p.faction === 'fremen' ? 3 : 5); + } + assert.equal(player(g, g.players.find(p => p.faction === 'ecaz')!.id).leaders.length, 5); + assert.ok(g.players.every(p => p.leaders.every(l => l.id !== DUKE_VIDAL_ID))); +} +/** Four minimal saved policies at genuine windows; assert their consumer effect. */ +function policies(g: Game, actor: string, effect: (after: Game, action: Action) => void): void { + for (const difficulty of DIFFICULTIES) { + const action = policy(g, actor, difficulty)[0]; assert.ok(action, `${difficulty} needs the actual owned action.`); + const after = step(g, { actor, action }); effect(after, action); physical(after); + } +} +function battleQuote(g: Game): BattleResolutionQuote { + const b = g.battle!, publicBattle = viewGame(g, g.host).battle!; + assert.ok(b?.revealed); + const side = (actor: string): ResolutionCombatant => { + const p = player(g, actor), view = viewGame(g, actor).battle!; + assert.ok(view.ownForces); + return { id: actor, faction: p.faction, ally: p.ally, spice: p.spice, hand: p.hand, + plan: b.plans[actor], leader: p.leaders.find(l => l.id === b.plans[actor].leader) ?? + (g.dukeVidal?.controller === actor && b.plans[actor].leader === DUKE_VIDAL_ID ? g.dukeVidal.leader : undefined), + forces: view.ownForces, stronghold: view.strongholdEffects[actor] }; + }; + const context = homeworldContext(g); + const home = b.territory.startsWith('homeworld:') ? homeworldCombatLocation({ ...context, + players: context.players.map(p => ({ ...p, ally: player(g, p.id).ally })), order: g.order }, g.homeworlds!.custody!, b.territory) : null; + return quoteBattleResolution({ advanced: g.advanced, typedCasualties: !!g.homeworlds, + turn: g.turn, territory: b.territory, aggressor: publicBattle.aggressor, + ...(publicBattle.ecazOccupy?.profile ? { ecazOccupy: publicBattle.ecazOccupy.profile } : {}), + ...(home ? { homeworld: { native: home.native, card: home.card, side: home.side, nativeForces: home.forces[home.native] } } : {}), + attacker: side(b.attacker), defender: side(b.defender), + voters: publicBattle.traitorVoters.map(actor => ({ id: actor, called: false, traitors: player(g, actor).traitors, + beneficiary: [b.attacker, b.defender].includes(actor) ? actor : player(g, actor).ally! })), + participants: g.players, physicalCards: [...g.deck, ...g.discard, ...g.players.flatMap(p => p.hand)], + pendingAuditorPresent: false, pendingRetentionPresent: false }); +} +function settle(g: Game, actions?: E3NexusEcazStep[], exercisePolicies = true): Game { + for (let n = 0; n < 180; n++) { + g = advance(g, s => !s.response && !s.phaseOpening && + (!!s.decision && ['battleLosses', 'battleCards', 'techToken'].includes(s.decision.kind) || !s.battle && clean(s)), actions); + if (!g.decision && !g.battle && clean(g)) return g; + const d = g.decision; assert.ok(d); + let action: Action; + if (d.kind === 'battleLosses') { + action = { type: 'decision', choice: 0 }; + if (exercisePolicies) policies(g, d.player, (after, selected) => { + const option = d.options[Number(selected.choice)], owner = d.forceOwner ?? d.player; + assert.ok(option); + assert.equal(player(after, owner).tanks, player(g, owner).tanks + option.normal + option.elite); + }); + } else if (d.kind === 'battleCards') { + action = { type: 'decision', discard: [] }; + if (exercisePolicies) policies(g, d.player, (after, selected) => { + for (const id of selected.discard as string[]) { + assert.ok(!player(after, d.player).hand.some(c => c.id === id)); + assert.equal(after.discard.filter(c => c.id === id).length, 1); + } + assert.notEqual(after.decision?.kind, 'battleCards'); + }); + } else if (d.kind === 'techToken') { + assert.equal(g.battle, null, 'Physical battle cleanup precedes mandatory winner Tech custody.'); + reject(g, d.loser, { type: 'decision', token: d.choices[0] }); + reject(g, d.player, { type: 'decision', decline: true }); + action = { type: 'decision', token: d.choices[0] }; + if (exercisePolicies) policies(g, d.player, (after, selected) => { + const token = selected.token as keyof NonNullable; + assert.ok(d.choices.includes(token)); assert.equal(g.techTokens![token].owner, d.loser); + assert.equal(after.techTokens![token].owner, d.player); + assert.equal(player(after, d.player).spice, player(g, d.player).spice, 'Taking a token is not another income payment.'); + }); + } else throw Error(`Unexpected original Ecaz settlement ${d.kind}`); + g = step(g, { actor: d.player, action }, actions); + } + throw Error('Original Ecaz physical settlement did not finish.'); +} +function assertClosing(f: { owner: string; others: string[]; alliance: Game; draw: { before: Game; step: E3NexusEcazStep; after: Game } }): void { + assert.equal(player(f.alliance, f.owner).ally, null); + assert.equal(player(f.alliance, f.others[0]).ally, f.others[1]); + assert.equal(f.draw.before.nexusCards!.cards!.hands[f.owner], null); + policies(f.draw.before, f.owner, after => { + assert.equal(after.nexusCards!.cards!.hands[f.owner], 'ecaz'); + assert.ok(!after.nexusCards!.cards!.deck.includes('ecaz')); + }); + reject(f.draw.before, f.others[0], f.draw.step.action); + reject(f.draw.after, f.owner, f.draw.step.action); +} + +void test('original no-Skills Ecaz closing draw → paid Wallach visitor → living unclaimed Duke → native support settlement → physical end-turn expiry', () => { + const f = cunning({ discovery: true }); assertClosing(f); + const sources = nativeShipmentSources(viewGame(f.arrival.before, f.owner), 1, 0); assert.ok(sources); + const quoted = homeworldShipmentChoice(viewGame(f.arrival.before, f.owner), f.location, + Object.fromEntries(Object.entries(sources).filter(([, group]) => group.normal + group.elite > 0))); + assert.equal(player(f.arrival.settled, f.owner).spice, player(f.arrival.before, f.owner).spice - quoted.cost); + assert.equal(player(f.arrival.settled, f.owner).reserves, player(f.arrival.before, f.owner).reserves - 1); + assert.equal(pool(f.arrival.settled, f.owner, f.location).normal, 1); + assert.deepEqual(pool(f.arrival.settled, f.opponent, f.location), pool(f.arrival.before, f.opponent, f.location)); + assert.ok(!f.arrival.settled.pendingAmbassador && !f.arrival.settled.pendingTerrorEntry); + assert.notEqual(f.arrival.settled.decision?.kind, 'intrusion'); + assert.ok(!Object.keys(player(f.arrival.settled, f.owner).forces).some(key => key.startsWith('homeworld:'))); + assert.ok(!Object.keys(player(f.arrival.settled, f.opponent).advisors ?? {}).some(key => key.startsWith('homeworld:'))); + assert.ok(!f.game.battle!.ecazOccupy, 'A real native-home battle never becomes Arrakis Occupy.'); + + const action = nexusEcazDukeAction(viewGame(f.cunning.before, f.owner)); assert.ok(action); + assert.equal(f.cunning.before.dukeVidal!.controller, null); + assert.equal(f.cunning.before.dukeVidal!.leader.dead, false); + reject(f.cunning.before, f.opponent, action); + reject(f.cunning.before, f.owner, { ...action, event: 'foreign-window' }); + policies(f.cunning.before, f.owner, after => { + assert.equal(after.dukeVidal!.controller, f.owner); assert.equal(after.dukeVidal!.source, 'ecazNexus'); + assert.equal(after.nexusCards!.cards!.hands[f.owner], null); + assert.equal(after.nexusCards!.cards!.discard.filter(c => c === 'ecaz').length, 1); + }); + reject(f.cunning.after, f.owner, action); + reject(f.game, f.owner, action); + const originalDisc = structuredClone(f.cunning.after.dukeVidal!.leader); + const revealed = reveal(f.game, f.plans, f.actions), quote = battleQuote(revealed); + const nativeSide = revealed.battle!.attacker === f.opponent ? 'attacker' : 'defender'; + assert.ok(quote.scores![nativeSide] > quote.leaderStrengths[nativeSide] + 3, + 'The actual native Homeworld supplies additional free strength, not additional paid support.'); + assert.equal(quote.winner, f.opponent); + assert.equal(quote.payments.find(p => p.player === f.opponent)!.ownPayment, 3, + 'Native free strength does not fund three dialed support.'); + const lostToken = ownedTech(revealed.techTokens, f.owner)[0]; assert.ok(lostToken); + const done = settle(revealed, f.actions); + assert.equal(player(done, f.opponent).spice, player(revealed, f.opponent).spice - 3); + assert.equal(player(done, f.opponent).tanks, player(revealed, f.opponent).tanks + 3); + assert.equal(pool(done, f.opponent, f.location).normal, pool(revealed, f.opponent, f.location).normal - 3); + assert.equal(pool(done, f.owner, f.location).normal, 0); + assert.equal(player(done, f.owner).tanks, player(revealed, f.owner).tanks + 1); + assert.equal(done.techTokens![lostToken].owner, f.opponent); + assert.equal(done.dukeVidal!.leader.dead, originalDisc.dead); + assert.equal(done.dukeVidal!.leader.deaths, originalDisc.deaths); + assert.deepEqual(done.strongholdCards!.owners, revealed.strongholdCards!.owners, 'A Homeworld win cannot acquire an Arrakis Stronghold Card.'); + physical(done); + const later = advance(done, s => s.turn === 3 && s.phase === 1 && clean(s), f.actions); + assert.equal(later.dukeVidal!.controller, null); assert.equal(later.dukeVidal!.source, null); + assert.equal(later.dukeVidal!.leader.dead, originalDisc.dead); + assert.equal(later.dukeVidal!.leader.deaths, originalDisc.deaths); + assert.equal(later.nexusCards!.cards!.discard.filter(c => c === 'ecaz').length, 1); + physical(later); +}); + +for (const advanced of [true, false]) void test(`${advanced ? 'Advanced held Tabr+Tech+Homeworlds' : 'Basic even-FORCE Tech'} original Ecaz-selected Arrakis coalition keeps source-local payments, owner casualties and winner rewards`, () => { + const f = coalition({ advanced, tech: true, strongholds: advanced, homeworlds: advanced }); assertClosing(f); + assert.ok(f.claim); + if (advanced) { + assert.deepEqual(f.firstMentat.after.strongholdCards!.owners, strongholdControllers(f.firstMentat.before.players, false)); + assert.equal(f.firstMentat.after.strongholdCards!.owners.sietch_tabr, f.owner); + } + for (const arrival of f.arrivals) { + const actor = arrival.step.actor, amount = Number(arrival.step.action.amount); + assert.equal(player(arrival.settled, actor).reserves, player(arrival.before, actor).reserves - amount); + assert.equal(player(arrival.settled, actor).forces[f.location], (player(arrival.before, actor).forces[f.location] ?? 0) + amount); + assert.ok(player(arrival.settled, actor).spice < player(arrival.before, actor).spice); + } + const lastArrival = f.arrivals.at(-1)!.settled, accrued = lastArrival.techTokens!.heighliners; + assert.ok(accrued.owner); assert.equal(accrued.spice, ownedTech(lastArrival.techTokens, accrued.owner).length); + assert.equal(f.beforeBattle.techTokens!.heighliners.spice, 0); + assert.equal(player(f.beforeBattle, accrued.owner).spice, player(lastArrival, accrued.owner).spice + accrued.spice, + 'Only the ORIGINAL Heighliners holder receives the phase-end shipment benefit.'); + assert.equal(ecazOccupancyRelation(f.beforeBattle.players, f.owner, f.ally, { kind: 'territory', id: 'sietch_tabr' }), 'ecazAlliance'); + assert.equal(ecazOccupancyRelation(f.beforeBattle.players, f.owner, f.ally, { kind: 'homeworld', id: 'homeworld:emperor' }), 'different'); + const d = f.game.decision; assert.ok(d?.kind === 'ecazBattleLead'); + reject(f.game, f.ally, { type: 'decision', event: d.event, lead: f.owner }); + reject(f.game, f.owner, { type: 'decision', event: 'stale', lead: f.owner }); + reject(f.game, f.owner, { type: 'decision', event: d.event, lead: f.opponent }); + const chooseAndReveal = (state: Game, lead: string) => { + let g = advance(state, s => !!s.battle && clean(s) && !s.battle.preparation && s.battle.preLeader?.closed !== false); + const profile = viewGame(g, lead).battle!.ecazOccupy!.profile!; + const own = player(g, lead).leaders.filter(l => !l.dead).sort((a, b) => b.strength - a.strength)[0]; + const enemy = player(g, f.opponent).leaders.filter(l => !l.dead).sort((a, b) => a.strength - b.strength)[0]; + const plans: E3NexusEcazStep[] = [ + { actor: lead, action: { type: 'battlePlan', leader: own.id, dial: profile.fixedEcazDial + 3, support: advanced ? 3 : 0 } }, + { actor: f.opponent, action: { type: 'battlePlan', leader: enemy.id, dial: 1, support: advanced ? 1 : 0 } }, + ]; + const before = reload(g); + policies(g, lead, (offered) => { + assert.equal(player(offered, lead).spice, player(g, lead).spice, 'Sealing is not a second debit.'); + const branch = reveal(offered, [plans[1]]), quoted = battleQuote(branch), finished = settle(branch, undefined, false); + assert.equal(finished.lastBattleContext!.winner, quoted.winner); + for (const payment of quoted.payments) { + const income = quoted.strongholdIncome.filter(i => i.player === payment.player).reduce((a, i) => a + i.amount, 0); + assert.equal(player(finished, payment.player).spice, player(branch, payment.player).spice - payment.ownPayment + income + + (quoted.bounty?.player === payment.player ? quoted.bounty.amount : 0) + ordinaryCollectionSpice(finished, payment.player)); + } + physical(finished); + }); + g = reveal(g, plans); + return { before, revealed: g, quote: battleQuote(g), done: settle(g) }; + }; + policies(f.game, f.owner, (after, chosen) => { + const lead = String(chosen.lead); + assert.ok([f.owner, f.ally].includes(lead)); + const result = chooseAndReveal(after, lead); + assert.equal(result.done.lastBattleContext!.winner, lead); + const enemyToken = ownedTech(result.before.techTokens, f.opponent)[0]; assert.ok(enemyToken); + assert.equal(result.done.techTokens![enemyToken].owner, lead); + assert.equal(ownedTech(result.done.techTokens, lead).length, 2); + assert.equal(ownedTech(result.done.techTokens, lead === f.owner ? f.ally : f.owner).length, 1); + }); + const selected = step(f.game, { actor: f.owner, action: { type: 'decision', event: d.event, lead: f.owner } }); + const result = chooseAndReveal(selected, f.owner), fixed = advanced ? 3 : 2; + assert.deepEqual(result.quote.fixedLosses, [{ owner: f.owner, normal: fixed, elite: 0 }]); + assert.equal(result.quote.casualties!.owner, f.ally); + assert.equal(player(result.done, f.owner).tanks - player(result.before, f.owner).tanks, fixed); + assert.equal(player(result.done, f.ally).tanks - player(result.before, f.ally).tanks, 3); + assert.equal(player(result.done, f.opponent).tanks - player(result.before, f.opponent).tanks, 8); + assert.equal(player(result.done, f.owner).forces[f.location], 2); + assert.equal(player(result.done, f.ally).forces[f.location], 1); + assert.equal(player(result.done, f.owner).spice, player(result.before, f.owner).spice - (advanced ? 3 : 0) + (advanced ? 1 : 0)); + assert.equal(player(result.done, f.ally).spice, player(result.before, f.ally).spice, 'The selected Ecaz payer never debits its variable army owner.'); + assert.deepEqual(result.quote.strongholdIncome, advanced ? [{ player: f.owner, amount: 1 }] : []); + const alliedLead = chooseAndReveal(step(f.game, { actor: f.owner, + action: { type: 'decision', event: d.event, lead: f.ally } }), f.ally); + assert.equal(alliedLead.done.lastBattleContext!.winner, f.ally); + assert.deepEqual(alliedLead.quote.strongholdIncome, [], 'The allied lead cannot borrow Ecaz’s held Tabr battle income.'); + assert.equal(player(alliedLead.done, f.owner).spice, player(alliedLead.before, f.owner).spice); + assert.equal(player(alliedLead.done, f.ally).spice, player(alliedLead.before, f.ally).spice - (advanced ? 3 : 0)); + assert.equal(alliedLead.done.techTokens![ownedTech(alliedLead.before.techTokens, f.opponent)[0]].owner, f.ally); + const completed = advance(result.done, s => s.turn === 4 && s.phase === 0); + assert.equal(completed.status, 'playing'); assert.equal(completed.lastBattleContext!.winner, f.owner); + assert.equal(completed.techTokens![ownedTech(result.before.techTokens, f.opponent)[0]].owner, f.owner); + physical(completed); +}); + +void test('the real Basic odd-FORCE coalition remains guarded before any selected lead, loss or reward can be consumed', () => { + const f = coalition({ advanced: false, tech: true }, 3); + reject(f.beforeBattle, f.choice.actor, f.choice.action, /Odd-force Basic Occupy/); + assert.equal(player(f.beforeBattle, f.owner).forces[f.location], 3); + assert.equal(f.beforeBattle.battle, null); physical(f.beforeBattle); +}); + +void test('original Discovery reveal and free parent entry precede, rather than predeal, the later qualifying Ecaz closing draw', () => { + const f = discovery({ advanced: false, roster: ['ecaz', 'guild', 'emperor', 'beneGesserit'], tech: true, homeworlds: true }); + assert.equal(player(f.arrival.settled, f.owner).reserves, player(f.arrival.before, f.owner).reserves - 2); + assert.ok(player(f.arrival.settled, f.owner).spice < player(f.arrival.before, f.owner).spice); + assert.equal(f.revealed.discoveries!.tokens.find(t => t.id === f.token)!.revealedTurn, 1); + assert.ok(Object.values(f.entry.before.nexusCards!.cards!.hands).every(c => c === null)); + const offer = viewGame(f.entry.before, f.owner), action = discoveryEntryMoveAction(offer, offer.discoveryEntry!.sources); assert.ok(action); + reject(f.entry.before, f.others[0], action); + reject(f.entry.before, f.owner, { ...action, event: 'stale' }); + reject(f.entry.after, f.owner, action); + policies(f.entry.before, f.owner, (after, offered) => { + assert.equal(player(after, f.owner).spice, player(f.entry.before, f.owner).spice); + assert.equal(player(after, f.owner).reserves, player(f.entry.before, f.owner).reserves); + assert.deepEqual(after.homeworlds!.custody, f.entry.before.homeworlds!.custody); + if (offered.groups) { + assert.equal(player(after, f.owner).forces['cistern:0'], 2); + assert.equal(player(after, f.owner).forces['gara_kulon:8'] ?? 0, 0); + } else assert.equal(player(after, f.owner).forces['gara_kulon:8'], 2); + assert.notEqual(after.decision?.kind, 'discoveryEntry'); + }); + assert.equal(player(f.entry.after, f.owner).forces['cistern:0'], 2); + assert.equal(f.draw.before.turn, 2); assertClosing(f); + const later = advance(f.game, s => s.turn === 3 && s.phase === 1 && clean(s)); + assert.equal(player(later, f.owner).forces['cistern:0'], 2); + assert.equal(later.nexusCards!.cards!.hands[f.owner], 'ecaz'); physical(later); +}); + +void test('the existing Basic Harkonnen roster boundary still permits source-clear Cunning without Skills or an invented seat parity guard', () => { + const f = closing({ advanced: false, roster: ['ecaz', 'guild', 'harkonnen'], tech: true, homeworlds: true }); assertClosing(f); + const arrival = ship(f.game, f.owner, 'homeworld:guild', 1); + const g = advance(arrival.settled, s => s.phase === 6 && clean(s) && !s.battle), action = nexusEcazDukeAction(viewGame(g, f.owner)); assert.ok(action); + policies(g, f.owner, after => { + assert.equal(after.dukeVidal!.controller, f.owner); + assert.equal(after.nexusCards!.cards!.hands[f.owner], null); + assert.equal(after.nexusCards!.cards!.discard.filter(c => c === 'ecaz').length, 1); + }); +}); + +void test('Advanced Harkonnen is still an eligible no-Skills roster opponent, not authority to bypass the canonical Duke Cunning exclusion', () => { + const f = closing({ advanced: true, roster: ['ecaz', 'guild', 'harkonnen'], tech: true, homeworlds: true }); assertClosing(f); + const arrival = ship(f.game, f.owner, 'homeworld:guild', 1); + const g = advance(arrival.settled, s => s.phase === 6 && clean(s) && !s.battle); + const offer = viewGame(g, f.owner).nexusEcazDuke; assert.ok(offer); + assert.match(offer.blocked!, /Advanced Harkonnen/); + assert.equal(nexusEcazDukeAction(viewGame(g, f.owner)), null); + reject(g, f.owner, { type: 'nexusEcazDuke', event: offer.event }, /Advanced Harkonnen/); + assert.equal(g.nexusCards!.cards!.hands[f.owner], 'ecaz'); + assert.equal(g.dukeVidal!.controller, null); assert.equal(g.dukeVidal!.leader.dead, false); + physical(g); +}); diff --git a/tests/e3-nexus-moritani-runtime.test.ts b/tests/e3-nexus-moritani-runtime.test.ts new file mode 100644 index 00000000..4b02d0a8 --- /dev/null +++ b/tests/e3-nexus-moritani-runtime.test.ts @@ -0,0 +1,342 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { applyAction, initializeNexusGameForAudit, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine'; +import { DIFFICULTIES } from '../game/bot-profiles'; +import { MOBILE_STRONGHOLD, territory } from '../game/board'; +import { ownedTech } from '../game/tech-tokens'; +import { quoteSpiceCollection } from '../game/board-resolution-quote'; +import { nativeShipmentSources } from '../game/homeworld-options'; +import { homeworldShipmentChoice } from '../game/homeworld-shipment-options'; +import { nativeE3HomeworldPool as homePool } from './fixture-homeworld-native-e3-modules'; +import { nexusMoritaniAction } from '../game/nexus-moritani-options'; +import { nexusTraitorDrawAction, nexusTraitorReturnAction } from '../game/nexus-traitor-options'; +import { discoveryEntryMoveAction } from '../game/discovery-entry-options'; +import { + advanceMoritaniNexus as advance, assertMoritaniNexusPhysical as physical, + closeMoritaniNexus as close, createMoritaniNexusClosing as closing, + createMoritaniNexusDiscovery as discovery, createMoritaniNexusLobby as lobby, + moritaniNexusAct as act, moritaniNexusClean as clean, moritaniNexusPlayer as player, + moritaniNexusPolicy as policy, moritaniNexusReload as reload, + openMoritaniNexusBattle as openBattle, openMoritaniNexusLoss as loss, + orderMoritaniNexusSpice as orderSpice, revealMoritaniNexus as reveal, + settleMoritaniNexus as settle, shipMoritaniNexus as ship, + type MoritaniNexusStep, +} from './fixture-e3-nexus-moritani'; + +function ordinaryCollectionSpice(g: Game, actor: string): number { + return g.phase === 7 ? quoteSpiceCollection(g).receipts.find(r => r.player === actor)?.strongholds ?? 0 : 0; +} + +function reject(g: Game, actor: string, action: Action): void { + const before = structuredClone(g); assert.throws(() => applyAction(g, actor, action)); assert.deepEqual(g, before); +} +/** Every policy consumes a real private owned window from a JSON continuation. */ +function policies(g: Game, actor: string, consume: (after: Game, action: Action) => void): void { + for (const difficulty of DIFFICULTIES) { + const saved = reload(g); player(saved, actor).bot = difficulty; + const selected = policy(saved, actor, difficulty)[0]; assert.ok(selected, `${difficulty} needs an actual owned choice.`); + const after = applyAction(saved, actor, selected); consume(after, selected); physical(after); + } +} +function finish(g: Game, actions?: MoritaniNexusStep[]): Game { + for (let n = 0; n < 12; n++) { + g = settle(g, actions); + const d = g.decision; + if (!d) return g; + if (d.kind === 'moritaniAssassinate') { + g = act(g, { actor: d.player, action: { type: 'decision', event: d.event, decline: true } }, actions); + continue; + } + assert.equal(d.kind, 'techToken'); + if (d.kind !== 'techToken') throw Error('Expected actual winner Tech choice.'); + assert.equal(g.battle, null, 'The battle and physical card cleanup precede its reward.'); + for (const entry of g.lastBattleContext!.mandatoryDiscard?.entries ?? []) + assert.ok(g.discard.some(c => c.id === entry.card)); + reject(g, d.loser, { type: 'decision', token: d.choices[0] }); + reject(g, d.player, { type: 'decision', decline: true }); + policies(g, d.player, (after, action) => { + const token = action.token as keyof NonNullable; + assert.ok(d.choices.includes(token)); assert.equal(after.techTokens![token].owner, d.player); + }); + g = act(g, { actor: d.player, action: { type: 'decision', token: d.choices[0] } }, actions); + } + throw Error('Original native aftermath reopened.'); +} + +void test('no-Skills native closing draw, paid Guild-home loss, printed bounty and private Mentat replacement obey the original physical order', () => { + const f = closing({ homeworlds: true, tech: true, strongholds: true, discovery: true }); + assert.equal(f.firstMentat.after.strongholdCards!.owners.sietch_tabr, f.owner); + assert.equal(f.firstMentat.after.strongholdCards!.owners.tueks_sietch, f.opponent); + assert.equal(f.alliance.players.find(p => p.id === f.owner)!.ally, null); + policies(f.draw.before, f.owner, after => { + assert.equal(after.nexusCards!.cards!.hands[f.owner], 'moritani'); + assert.ok(!after.nexusCards!.cards!.deck.includes('moritani')); + }); + reject(f.draw.before, f.opponent, f.draw.step.action); + reject(f.draw.after, f.owner, f.draw.step.action); + const b = loss(f, f.game); + const sources = nativeShipmentSources(viewGame(b.arrival.before, f.owner), 1, 0); assert.ok(sources); + const quote = homeworldShipmentChoice(viewGame(b.arrival.before, f.owner), b.location, + Object.fromEntries(Object.entries(sources).filter(([, group]) => group.normal + group.elite > 0))); + assert.equal(player(b.settledArrival, f.owner).spice, player(b.arrival.before, f.owner).spice - quote.cost); + assert.equal(player(b.settledArrival, f.owner).reserves, player(b.arrival.before, f.owner).reserves - 1); + assert.equal(homePool(b.settledArrival, f.owner, b.location).normal, 1); + assert.ok(!b.settledArrival.pendingTerrorEntry && !b.settledArrival.pendingAmbassador); + const revealed = reveal(b.game, b.plans, f.actions); + assert.equal(player(revealed, f.opponent).spice, player(b.game, f.opponent).spice, 'Sealed support is not prepaid.'); + let g = settle(revealed, f.actions); + const d = g.decision; assert.ok(d?.kind === 'moritaniAssassinate'); + assert.equal(player(g, f.owner).leaders.find(l => l.id === b.ownLeader)!.dead, true); + assert.equal(player(g, f.owner).tanks, player(revealed, f.owner).tanks + 1); + assert.equal(homePool(g, f.owner, b.location).normal, 0); + assert.equal(player(g, f.opponent).tanks, player(revealed, f.opponent).tanks); + assert.equal(homePool(g, f.opponent, b.location).normal, homePool(revealed, f.opponent, b.location).normal); + const killed = player(revealed, f.owner).leaders.find(l => l.id === b.ownLeader)!; + assert.equal(player(g, f.opponent).spice, player(revealed, f.opponent).spice, + 'Native assassination pauses the original winner casualties, support, bounty and cleanup continuation.'); + assert.equal(player(g, f.owner).spice, player(revealed, f.owner).spice); + const token = ownedTech(g.techTokens, f.owner)[0]; assert.ok(token); + const target = player(g, f.opponent).leaders.find(l => l.id === f.target)!; + assert.equal(target.dead, false); assert.notEqual(b.enemyLeader, f.target); + const before = reload(g), action: Action = { type: 'decision', event: d.event, card: f.target }; + reject(g, f.opponent, action); reject(g, f.owner, { ...action, event: 'stale' }); + reject(g, f.owner, { ...action, card: b.enemyLeader }); + policies(g, f.owner, after => { + assert.equal(player(after, f.opponent).leaders.find(l => l.id === f.target)!.dead, true); + assert.equal(player(after, f.owner).spice, player(before, f.owner).spice + target.strength); + assert.equal(after.techTokens![token].owner, f.owner, 'Winner Tech cannot bypass private assassination and cleanup.'); + }); + g = act(g, { actor: f.owner, action }, f.actions); + assert.equal(player(g, f.opponent).leaders.find(l => l.id === f.target)!.deaths, target.deaths + 1); + assert.ok(player(g, f.owner).traitors.includes(f.target), 'The revealed physical card remains held until Mentat.'); + reject(g, f.owner, action); + g = finish(g, f.actions); + assert.equal(g.techTokens![token].owner, f.opponent); + assert.equal(player(g, f.opponent).tanks, player(revealed, f.opponent).tanks + 1); + assert.equal(homePool(g, f.opponent, b.location).normal, homePool(revealed, f.opponent, b.location).normal - 1); + assert.equal(player(g, f.opponent).spice, player(revealed, f.opponent).spice - 1 + killed.strength + + ordinaryCollectionSpice(g, f.opponent), 'The original winner support/bounty settles once after assassination.'); + const reserve = [...g.traitorReserve!], held = [...player(g, f.owner).traitors]; + g = advance(g, s => s.phase === 8 && s.decision?.kind === 'moritaniPlacement', f.actions); + const receipt = g.moritaniAssassinate!.opportunities[0]; + assert.equal(receipt.stage, 'replaced'); assert.equal(receipt.replacement, reserve[0]); + assert.deepEqual(g.traitorReserve, reserve.slice(1)); + assert.deepEqual(player(g, f.owner).traitors, [...held.filter(id => id !== f.target), reserve[0]]); + const balance = player(g, f.owner).spice; + g = act(g, { actor: f.owner, action: { type: 'decision', decline: true } }, f.actions); + assert.equal(player(g, f.owner).spice, balance, 'Replacement never pays the bounty a second time.'); + physical(g); +}); + +void test('native Moritani Cunning spends one physical Nexus for an original supply placement and real assassination Terror entry, never relocation or home placement', () => { + const f = closing({ roster: ['moritani', 'guild', 'atreides'], homeworlds: true, tech: true, strongholds: true }); + let g = advance(f.game, s => s.phase === 8 && s.decision?.kind === 'moritaniPlacement', f.actions); + const view = viewGame(g, f.owner), token = g.moritaniTerror!.tokens.find(t => t.kind === 'assassination' && t.status === 'available')!; + const action = nexusMoritaniAction(view, view.nexusMoritani!.event, token.id, 'polar_sink'); assert.ok(action); + reject(g, f.opponent, action); reject(g, f.owner, { ...action, nexus: 'stale' }); + reject(g, f.owner, { ...action, territory: 'homeworld:guild' }); + reject(g, f.owner, { ...action, territory: 'homeworld:moritani' }); + reject(g, f.owner, { ...action, territory: MOBILE_STRONGHOLD }); + const placed = g.moritaniTerror!.tokens.find(t => t.status === 'placed')!; + reject(g, f.owner, { ...action, token: placed.id }); + policies(g, f.owner, (after, chosen) => { + assert.equal(after.nexusCards!.cards!.hands[f.owner], null); + assert.equal(after.nexusCards!.cards!.discard.filter(c => c === 'moritani').length, 1); + const committed = advance(after, s => clean(s) && !s.pendingMoritaniPlacement); + assert.equal(committed.moritaniTerror!.tokens.find(t => t.id === chosen.token)!.location, chosen.territory); + assert.equal(committed.moritaniTerror!.tokens.filter(t => t.status === 'available').length, + g.moritaniTerror!.tokens.filter(t => t.status === 'available').length - 1); + }); + g = act(g, { actor: f.owner, action }, f.actions); + g = advance(g, s => clean(s) && !s.pendingMoritaniPlacement, f.actions); + assert.equal(g.moritaniTerror!.tokens.find(t => t.id === token.id)!.location, 'polar_sink'); + assert.equal(g.nexusMoritaniHistory!.at(-1)!.stage, 'complete'); + reject(g, f.owner, action); + g = advance(g, s => s.turn === 3 && s.phase === 5 && s.active === f.opponent && clean(s), f.actions); + const beforeShipment = reload(g), sources = nativeShipmentSources(viewGame(g, f.opponent), 1, 0); assert.ok(sources); + g = act(g, { actor: f.opponent, action: { type: 'ship', territory: 'polar_sink', sector: 0, + amount: 1, elite: 0, allyPayment: 0, homeworldSources: sources } }, f.actions); + g = advance(g, s => !s.response && !s.phaseOpening && s.decision?.kind === 'moritaniTerror', f.actions); + if (g.pendingTerrorEntry!.stage === 'select') { + g = act(g, { actor: f.owner, action: { type: 'decision', token: token.id } }, f.actions); + } + const before = reload(g), revealAction: Action = { type: 'decision', reveal: true }; + reject(g, f.opponent, revealAction); + g = act(g, { actor: f.owner, action: revealAction }, f.actions); + g = advance(g, s => !s.pendingShipment && !s.pendingTerrorEntry && clean(s), f.actions); + const dead = player(g, f.opponent).leaders.filter(l => l.dead && !player(before, f.opponent).leaders.find(old => old.id === l.id)!.dead); + assert.equal(dead.length, 1); + assert.equal(dead[0].deaths, player(before, f.opponent).leaders.find(l => l.id === dead[0].id)!.deaths + 1); + assert.equal(player(g, f.owner).spice, player(before, f.owner).spice + dead[0].strength); + assert.equal(player(g, f.opponent).reserves, player(beforeShipment, f.opponent).reserves - 1); + assert.equal(player(g, f.opponent).forces['polar_sink:0'], 1); + assert.ok(player(g, f.opponent).spice < player(beforeShipment, f.opponent).spice); + assert.equal(g.moritaniTerror!.tokens.find(t => t.id === token.id)!.status, 'removed'); + assert.equal(g.moritaniAssassinate!.opportunities.length, 0, 'Terror death is not a fabricated post-loss Traitor assassination.'); + reject(g, f.owner, revealAction); physical(g); +}); + +void test('actual Harkonnen Nexus Secret Ally draws and returns physical extra Traitors before a no-Skills native loss can assassinate the retained identity', () => { + const f = closing({ nexus: 'harkonnen', homeworlds: true, tech: true }); + let g = advance(f.game, s => s.phase === 8 && clean(s), f.actions); + assert.equal(g.players.some(p => p.faction === 'harkonnen'), false); + assert.equal(player(g, f.owner).traitors.includes(f.target), false); + // Select only the still-undealt original reserve. Both other draw identities + // stay physical, and the old held identity is returned by the owner below. + const i = g.traitorReserve!.indexOf(f.target); assert.ok(i >= 0); + g.traitorReserve!.unshift(g.traitorReserve!.splice(i, 1)[0]); + const before = reload(g), view = viewGame(g, f.owner), offer = view.nexusTraitors!.offer!; + const action = nexusTraitorDrawAction(view, offer.event, 'secretAlly'); assert.ok(action); + reject(g, f.opponent, action); reject(g, f.owner, { ...action, event: 'stale' }); + policies(g, f.owner, after => { + assert.equal(after.nexusCards!.cards!.hands[f.owner], null); + assert.deepEqual(after.traitorReserve, before.traitorReserve!.slice(2)); + }); + g = act(g, { actor: f.owner, action }, f.actions); + const pending = viewGame(g, f.owner).nexusTraitors!.pending!; + const choices = pending.choices.map(c => c.id), returned = choices.filter(id => id !== f.target); assert.equal(returned.length, 2); + const returnAction = nexusTraitorReturnAction(viewGame(g, f.owner), pending.event, returned); assert.ok(returnAction); + reject(g, f.opponent, returnAction); reject(g, f.owner, { ...returnAction, cards: [returned[0], returned[0]] }); + policies(g, f.owner, after => { + assert.equal(player(after, f.owner).traitors.length, player(before, f.owner).traitors.length); + assert.equal(after.traitorReserve!.length, before.traitorReserve!.length); + }); + g = act(g, { actor: f.owner, action: returnAction }, f.actions); + assert.deepEqual(player(g, f.owner).traitors, [f.target]); + reject(g, f.owner, returnAction); + const b = loss(f, g), revealed = reveal(b.game, b.plans, f.actions); + g = settle(revealed, f.actions); + const d = g.decision; assert.ok(d?.kind === 'moritaniAssassinate'); + const bounty = player(g, f.opponent).leaders.find(l => l.id === f.target)!.strength, balance = player(g, f.owner).spice; + g = act(g, { actor: f.owner, action: { type: 'decision', event: d.event, card: f.target } }, f.actions); + assert.equal(player(g, f.owner).spice, balance + bounty); + assert.equal(player(g, f.opponent).leaders.find(l => l.id === f.target)!.dead, true); + g = finish(g, f.actions); physical(g); +}); + +void test('Advanced normal Traitor call forfeits assassination, while Basic keeps the printed ordinary call and battle outcome', () => { + for (const advanced of [true, false]) { + const f = closing({ advanced, normalCall: true, homeworlds: true, tech: true }); + const b = loss(f, f.game, true); assert.equal(b.enemyLeader, f.target); + let g = settle(reveal(b.game, b.plans, f.actions), f.actions, true); + assert.notEqual(g.decision?.kind, 'moritaniAssassinate'); + g = finish(g, f.actions); + assert.equal(g.lastBattleContext!.winner, f.owner); + assert.equal(player(g, f.opponent).leaders.find(l => l.id === f.target)!.dead, true); + assert.equal(player(g, f.opponent).forces[b.location] ?? 0, 0); + assert.equal(player(g, f.owner).forces[b.location], 1); + assert.equal(player(g, f.owner).leaders.find(l => l.id === b.ownLeader)!.dead, false); + if (advanced) { + assert.equal(g.moritaniAssassinate!.normalTraitorCall, true); + assert.equal(g.moritaniAssassinate!.opportunities.length, 0); + const blocked = viewGame(g, f.owner).moritaniAssassinate; assert.ok(blocked?.blocked); + const emperor = g.players.find(p => p.faction === 'emperor')!.id; + const arrival = ship(g, f.owner, 'homeworld:emperor', 1, f.actions); + const battle = openBattle(arrival.settled, 'homeworld:emperor', f.owner, emperor, f.actions); + const own = player(battle, f.owner).leaders.filter(l => !l.dead && !l.usedAt).sort((a, b) => a.strength - b.strength)[0]; + const enemy = player(battle, emperor).leaders.filter(l => !l.dead && !l.usedAt).sort((a, b) => b.strength - a.strength)[0]; + g = finish(reveal(battle, [ + { actor: f.owner, action: { type: 'battlePlan', leader: own.id, dial: 0, support: 0 } }, + { actor: emperor, action: { type: 'battlePlan', leader: enemy.id, dial: 1, support: 1 } }, + ], f.actions), f.actions); + assert.equal(g.lastBattleContext!.winner, emperor); + assert.equal(g.moritaniAssassinate!.opportunities.length, 0, 'A later real native-home loss cannot recover the forfeited power.'); + assert.equal(player(g, emperor).leaders.find(l => l.id === enemy.id)!.dead, false); + } else assert.ok(!g.moritaniAssassinate, 'Basic must not receive the Advanced private assassination power.'); + reject(g, f.owner, { type: 'decision', event: b.game.battle!.event, card: f.target }); + physical(g); + } +}); + +void test('Basic no-Skills ordinary Homeworld loss uses printed battle casualties without an Advanced assassination suffix', () => { + const f = closing({ advanced: false, homeworlds: true, tech: true }); + const b = loss(f, f.game), revealed = reveal(b.game, b.plans, f.actions); + const done = finish(revealed, f.actions); + assert.equal(done.lastBattleContext!.winner, f.opponent); + assert.ok(!done.moritaniAssassinate); + assert.equal(player(done, f.owner).leaders.find(l => l.id === b.ownLeader)!.dead, true); + assert.equal(player(done, f.opponent).leaders.find(l => l.id === f.target)!.dead, false); + assert.equal(player(done, f.owner).tanks, player(revealed, f.owner).tanks + 1); + assert.equal(homePool(done, f.owner, b.location).normal, 0); + assert.equal(player(done, f.opponent).spice, player(revealed, f.opponent).spice + + player(revealed, f.owner).leaders.find(l => l.id === b.ownLeader)!.strength); + physical(done); +}); + +void test('original held Tabr rewards only its local normal win after real paid support, before loser Tech is transferred', () => { + const f = closing({ tech: true, strongholds: true }); + const location = `sietch_tabr:${territory('sietch_tabr').sectors[0]}`; + const arrival = ship(f.game, f.opponent, location, 1, f.actions); + assert.equal(player(arrival.settled, f.opponent).reserves, player(arrival.before, f.opponent).reserves - 1); + assert.ok(player(arrival.settled, f.opponent).spice < player(arrival.before, f.opponent).spice); + const g = openBattle(arrival.settled, location, f.owner, f.opponent, f.actions); + const own = player(g, f.owner).leaders.filter(l => !l.dead && !l.usedAt).sort((a, b) => b.strength - a.strength)[0]; + const enemy = player(g, f.opponent).leaders.filter(l => !l.dead && !l.usedAt).sort((a, b) => a.strength - b.strength)[0]; + const revealed = reveal(g, [ + { actor: f.owner, action: { type: 'battlePlan', leader: own.id, dial: 3, support: 3 } }, + { actor: f.opponent, action: { type: 'battlePlan', leader: enemy.id, dial: 1, support: 1 } }, + ], f.actions); + const pending = settle(revealed, f.actions); + assert.equal(pending.lastBattleContext!.winner, f.owner); + assert.equal(player(pending, f.owner).spice, player(revealed, f.owner).spice - 3 + 1 + ordinaryCollectionSpice(pending, f.owner)); + assert.equal(player(pending, f.owner).forces[location], player(revealed, f.owner).forces[location] - 3); + assert.equal(player(pending, f.owner).tanks, player(revealed, f.owner).tanks + 3); + assert.equal(player(pending, f.opponent).forces[location] ?? 0, 0); + const token = ownedTech(revealed.techTokens, f.opponent)[0]; assert.ok(token); + assert.equal(pending.techTokens![token].owner, f.owner, 'A sole loser token transfers automatically after physical cleanup.'); + const done = finish(pending, f.actions); + assert.equal(done.techTokens![token].owner, f.owner); + assert.equal(player(done, f.owner).spice, player(pending, f.owner).spice, 'Tech choice cannot replay held-card income.'); + physical(done); +}); + +void test('original Discovery supply, reveal and free nested entry survive the late closing draw and source-local physical battle loss without Skills', () => { + const f = discovery({ homeworlds: true, tech: true, strongholds: true }); + assert.equal(player(f.shipment.settled, f.owner).reserves, player(f.shipment.before, f.owner).reserves - 2); + assert.ok(player(f.shipment.settled, f.owner).spice < player(f.shipment.before, f.owner).spice); + const entryView = viewGame(f.entry.before, f.owner), entry = discoveryEntryMoveAction(entryView, entryView.discoveryEntry!.sources); assert.ok(entry); + reject(f.entry.before, f.opponent, entry); reject(f.entry.before, f.owner, { ...entry, event: 'stale' }); + policies(f.entry.before, f.owner, (after, action) => { + const moved = Array.isArray(action.groups) + ? action.groups.reduce((sum, group) => sum + Number(group.normal) + Number(group.elite), 0) : 0; + assert.equal(player(after, f.owner).forces['cistern:0'] ?? 0, moved); + assert.equal(player(after, f.owner).forces['gara_kulon:8'] ?? 0, 2 - moved); + assert.equal(player(after, f.owner).spice, player(f.entry.before, f.owner).spice); + }); + assert.equal(player(f.entry.after, f.owner).forces['cistern:0'], 2); + assert.equal(player(f.entry.after, f.owner).reserves, player(f.entry.before, f.owner).reserves); + assert.equal(player(f.entry.after, f.owner).spice, player(f.entry.before, f.owner).spice); + assert.equal(player(f.entry.after, f.owner).forces[f.shipment.step.action.territory + ':' + f.shipment.step.action.sector] ?? 0, 0); + let g = advance(f.game, s => s.phase === 1 && clean(s), f.actions); orderSpice(g, true); + const closed = close(f, g); g = closed.game; + assert.equal(closed.draw.after.nexusCards!.cards!.hands[f.owner], 'moritani'); + const arrival = ship(g, f.opponent, 'cistern:0', 1, f.actions); + assert.equal(player(arrival.settled, f.opponent).forces['cistern:0'], 1); + assert.ok(player(arrival.settled, f.opponent).spice < player(arrival.before, f.opponent).spice); + g = openBattle(arrival.settled, 'cistern:0', f.owner, f.opponent, f.actions); + const own = player(g, f.owner).leaders.filter(l => !l.dead && !l.usedAt).sort((a, b) => a.strength - b.strength)[0]; + const enemy = player(g, f.opponent).leaders.filter(l => !l.dead && !l.usedAt && l.id !== f.target).sort((a, b) => b.strength - a.strength)[0]; + const revealed = reveal(g, [ + { actor: f.owner, action: { type: 'battlePlan', leader: own.id, dial: 0, support: 0 } }, + { actor: f.opponent, action: { type: 'battlePlan', leader: enemy.id, dial: 1, support: 1 } }, + ], f.actions); + const done = finish(revealed, f.actions); + assert.equal(done.lastBattleContext!.winner, f.opponent); + assert.equal(player(done, f.owner).forces['cistern:0'] ?? 0, 0); + assert.equal(player(done, f.owner).tanks, player(revealed, f.owner).tanks + 2); + assert.equal(player(done, f.opponent).tanks, player(revealed, f.opponent).tanks + 1); + assert.equal(player(done, f.opponent).spice, player(revealed, f.opponent).spice - 1 + ordinaryCollectionSpice(done, f.opponent), + 'Ordinary Collection is separate; held Tuek income and native-home strength do not migrate into a nested battle.'); + assert.equal(homePool(done, f.owner, 'homeworld:moritani').normal, homePool(revealed, f.owner, 'homeworld:moritani').normal); + physical(done); +}); + +void test('Advanced Harkonnen and mixed-E3 original lobbies remain inadmissible without mutating the declared source pieces', () => { + const native = lobby(); joinGame(native, newPlayer('harkonnen', 'Harkonnen', 'harkonnen')); + const mixed = lobby(); joinGame(mixed, newPlayer('ecaz', 'Ecaz', 'ecaz')); + for (const g of [native, mixed]) { + let ready = reload(g); + for (const p of ready.players) ready = act(ready, { actor: p.id, action: { type: 'ready' } }); + const before = structuredClone(ready); assert.throws(() => initializeNexusGameForAudit(ready)); assert.deepEqual(ready, before); + } +}); diff --git a/tests/fixture-e3-nexus-ecaz.ts b/tests/fixture-e3-nexus-ecaz.ts new file mode 100644 index 00000000..e3dca5db --- /dev/null +++ b/tests/fixture-e3-nexus-ecaz.ts @@ -0,0 +1,322 @@ +import assert from 'node:assert/strict'; +import { + applyAction, createGame, initializeNexusGameForAudit, joinGame, newPlayer, viewGame, + type Action, type Game, +} from '../game/engine'; +import type { FactionId } from '../game/catalog'; +import { botActions } from '../game/bots'; +import { treacheryDeck } from '../game/cards'; +import type { Difficulty } from '../game/bot-profiles'; +import { territory } from '../game/board'; +import { NEXUS_FACTIONS } from '../game/nexus-cards'; +import { nativeShipmentSources } from '../game/homeworld-options'; +import { homeworldShipmentChoice } from '../game/homeworld-shipment-options'; +import { nexusEcazDukeAction } from '../game/nexus-ecaz-duke-options'; +import { nexusCardAction } from '../game/nexus-card-options'; +import { DISCOVERY_CARD_PLACEMENTS } from '../game/discoveries'; +import { discoveryEntryMoveAction } from '../game/discovery-entry-options'; +import { nextNativeE3HomeworldStep, nativeE3HomeworldPlayer, nativeE3HomeworldPool } from './fixture-homeworld-native-e3-modules'; +import { withClassicDiscoveryNexusLottery, withClassicDiscoveryNexusToken } from './fixture-discovery-classic-nexus'; + +export { nativeE3HomeworldPlayer as e3NexusEcazPlayer, nativeE3HomeworldPool as e3NexusEcazPool }; +export type E3NexusEcazStep = { actor: string; action: Action }; +export type E3NexusEcazTransition = { before: Game; step: E3NexusEcazStep; after: Game }; +export type E3NexusEcazOptions = { + /** An original undealt lobby or its native setup; never convert a played save. */ + initial?: Game; + advanced?: boolean; + roster?: readonly FactionId[]; + tech?: boolean; + strongholds?: boolean; + homeworlds?: boolean; + discovery?: boolean; +}; +export type E3NexusEcazSetup = { + initial: Game; offered: Game; afterSetup: Game; owner: string; others: string[]; + actions: E3NexusEcazStep[]; +}; +export type E3NexusEcazClosing = E3NexusEcazSetup & { + firstMentat: E3NexusEcazTransition; alliance: Game; draw: E3NexusEcazTransition; game: Game; + claim?: E3NexusEcazTransition; +}; +export type E3NexusEcazCoalition = E3NexusEcazClosing & { + ally: string; opponent: string; location: string; ecazForces: number; + arrivals: (E3NexusEcazTransition & { settled: Game })[]; beforeBattle: Game; choice: E3NexusEcazStep; game: Game; +}; +export const e3NexusEcazReload = (g: Game): Game => JSON.parse(JSON.stringify(g)) as Game; +export const e3NexusEcazClean = (g: Game): boolean => + !g.response && !g.phaseOpening && !g.decision && !g.pendingTreacheryDiscard && !g.truthtrance; + +/** Minimal persisted bot profiles, projected only after genuine owner custody exists. */ +export function e3NexusEcazPolicy(g: Game, actor: string, difficulty: Difficulty): Action[] { + const saved = e3NexusEcazReload(g); + nativeE3HomeworldPlayer(saved, actor).bot = difficulty; + return botActions(viewGame(e3NexusEcazReload(saved), actor)); +} +export function e3NexusEcazStep(g: Game, step: E3NexusEcazStep, actions?: E3NexusEcazStep[]): Game { + actions?.push(structuredClone(step)); + return withClassicDiscoveryNexusLottery(() => applyAction(e3NexusEcazReload(g), step.actor, step.action)); +} +export function nextE3NexusEcazStep(g: Game): E3NexusEcazStep | null { + if (!g.response && !g.phaseOpening && !g.pendingTreacheryDiscard) { + if (g.nexusCards?.phase?.stage === 'drawing' && !g.decision) { + const actor = g.nexusCards.phase.eligible.find(id => !g.nexusCards!.phase!.done.includes(id)); + if (actor) { + const view = viewGame(g, actor), choices = view.nexusCards!.choices; + const action = nexusCardAction(view, choices.includes('keep') ? 'keep' : 'draw'); assert.ok(action); + return { actor, action }; + } + } + if (g.decision?.kind === 'ecazBattleLead' || g.decision?.kind === 'techToken') return null; + if (g.decision?.kind === 'homeworldShipmentGuild') return { actor: g.decision.player, + action: { type: 'decision', event: g.decision.event, allow: true } }; + if (g.decision && ['advisor', 'intrusion', 'advisorBattle'].includes(g.decision.kind)) + return { actor: g.decision.player, action: { type: 'decision', accept: false } }; + } + return nextNativeE3HomeworldStep(g); +} +export function advanceE3NexusEcaz(g: Game, until: (g: Game) => boolean, actions?: E3NexusEcazStep[]): Game { + for (let n = 0; n < 3000; n++) { + if (until(g)) return g; + assert.notEqual(g.status, 'finished'); + const next = nextE3NexusEcazStep(g); + assert.ok(next, 'The actual owner must choose coalition lead, seal a plan or select the winner Tech token.'); + g = e3NexusEcazStep(g, next, actions); + } + throw Error('Original no-Skills Ecaz chronology did not reach its owned boundary.'); +} +function permutation(source: readonly string[], desired: readonly string[]): number[] { + const current = [...source], rolls: number[] = []; + for (let i = current.length - 1; i > 0; i--) { + const j = current.indexOf(desired[i]); assert.ok(j >= 0 && j <= i); + rolls.push(Math.floor((j + 0.5) * 0x100000000 / (i + 1))); + [current[i], current[j]] = [current[j], current[i]]; + } + assert.deepEqual(current, desired); return rolls; +} +/** Select only ORIGINAL undealt Nexus/Treachery permutations. No hand is assigned. */ +function initialize(g: Game): Game { + const cards = treacheryDeck(['ecaz']), remaining = [...cards], front: string[] = []; + for (const p of g.players) for (let n = 0; n < (p.faction === 'harkonnen' ? 2 : 1); n++) { + const index = remaining.findIndex(c => c.kind === 'worthless' || c.kind === 'shield'); + assert.ok(index >= 0); front.push(remaining.splice(index, 1)[0].id); + } + const rolls = [...(g.discoveryEnabled ? Array(7).fill(0xffffffff) : []), + ...permutation(NEXUS_FACTIONS, ['ecaz', ...NEXUS_FACTIONS.filter(c => c !== 'ecaz')]), + ...permutation(cards.map(c => c.id), [...front, ...remaining.map(c => c.id)])]; + const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues'); + const native = crypto.getRandomValues.bind(crypto); let index = 0; + crypto.getRandomValues = (array: T): T => { + if (array instanceof Uint32Array && array.length === 1) array[0] = rolls[index++] ?? 0xffffffff; + else Reflect.apply(native, crypto, [array]); + return array; + }; + try { return initializeNexusGameForAudit(g); } + finally { + if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor); + else Reflect.deleteProperty(crypto, 'getRandomValues'); + } +} +export function createE3NexusEcazSetup(options: E3NexusEcazOptions = {}): E3NexusEcazSetup { + const roster = options.roster ?? ['ecaz', 'guild', 'emperor']; + let g = options.initial ? e3NexusEcazReload(options.initial) + : createGame('E3NEXUSECAZ', newPlayer(roster[0], roster[0], roster[0]), options.advanced ?? true, ['ecaz']); + if (!options.initial) for (const faction of roster.slice(1)) joinGame(g, newPlayer(faction, faction, faction)); + assert.ok(g.status === 'lobby' || g.status === 'setup' && g.turn === 1 && g.phase === 0); + assert.equal(g.advanced, options.advanced ?? g.advanced); assert.deepEqual(g.expansions, ['ecaz']); + assert.equal(g.players.filter(p => p.faction === 'ecaz').length, 1); + assert.ok(g.players.every(p => ['ecaz', 'guild', 'emperor', 'atreides', 'fremen', 'beneGesserit', 'harkonnen'].includes(p.faction))); + assert.ok(!g.leaderSkills && !g.ecazTreachery); + const modules = { techTokens: options.tech ?? !!g.techTokens, + strongholdCards: options.strongholds ?? !!g.strongholdCards, homeworlds: options.homeworlds ?? !!g.homeworlds }; + const actions: E3NexusEcazStep[] = []; + if (g.status === 'lobby') { + for (const [type, enabled] of Object.entries(modules)) if (!!g[type as keyof typeof modules] !== enabled) + g = e3NexusEcazStep(g, { actor: g.host, action: { type, enabled } }, actions); + g.discoveryEnabled = options.discovery ?? !!g.discoveryEnabled; + g.nexusCards ??= { cards: null, phase: null }; + } else { + for (const [type, enabled] of Object.entries(modules)) assert.equal(!!g[type as keyof typeof modules], enabled); + assert.equal(!!g.discoveryEnabled, options.discovery ?? !!g.discoveryEnabled); + assert.ok(g.nexusCards?.cards, 'Never graft Nexus onto a dealt setup.'); + } + const initial = e3NexusEcazReload(g); + if (g.status === 'lobby') { + for (const p of g.players) if (!p.ready) g = e3NexusEcazStep(g, { actor: p.id, action: { type: 'ready' } }, actions); + g = initialize(g); + } + const offered = e3NexusEcazReload(g), owner = g.players.find(p => p.faction === 'ecaz')!.id; + for (let n = 0; g.status === 'setup' && n < 200; n++) { + const next = nextE3NexusEcazStep(g); assert.ok(next); + g = e3NexusEcazStep(g, next, actions); + } + assert.equal(g.status, 'playing'); + assert.equal(nativeE3HomeworldPlayer(g, owner).reserves, 14); + assert.equal(Object.values(nativeE3HomeworldPlayer(g, owner).forces).reduce((n, f) => n + f, 0), 6); + return { initial, offered, afterSetup: g, owner, others: g.players.filter(p => p.id !== owner).map(p => p.id), actions }; +} +/** Only unplayed Spice Cards move within their original deck. */ +export function orderE3NexusEcazSpice(g: Game, worm = false): void { + const kinds = [...(worm ? ['worm'] : []), ...Array(g.advanced ? 2 : 1).fill('land')]; + for (const [position, kind] of kinds.entries()) { + const index = g.spiceDeck.findIndex((c, i) => i >= position && (kind === 'worm' + ? 'worm' in c && !c.greatMaker && !c.suppressed : 'territory' in c && !c.discovery)); + assert.ok(index >= position); g.spiceDeck.splice(position, 0, g.spiceDeck.splice(index, 1)[0]); + } +} +export function shipE3NexusEcaz(g: Game, actor: string, destination: string, amount: number, + actions?: E3NexusEcazStep[]): E3NexusEcazTransition & { settled: Game } { + g = advanceE3NexusEcaz(g, s => s.phase === 5 && s.active === actor && e3NexusEcazClean(s), actions); + const before = e3NexusEcazReload(g), sources = g.homeworlds ? nativeShipmentSources(viewGame(g, actor), amount, 0) : undefined; + if (g.homeworlds) assert.ok(sources); + let action: Action; + if (destination.startsWith('homeworld:')) { + assert.ok(sources); + const choice = homeworldShipmentChoice(viewGame(g, actor), destination, + Object.fromEntries(Object.entries(sources).filter(([, f]) => f.normal + f.elite > 0))); + assert.ok(choice.action, choice.blocked ?? 'Original native invasion is unavailable.'); action = choice.action; + } else { + const [kind, sector] = destination.split(':'); + action = { type: 'ship', territory: kind, sector: Number(sector), amount, elite: 0, allyPayment: 0, + ...(sources ? { homeworldSources: sources } : {}) }; + } + const step = { actor, action }; g = e3NexusEcazStep(g, step, actions); + const after = e3NexusEcazReload(g); + g = advanceE3NexusEcaz(g, s => !s.pendingShipment && !s.pendingHomeworldShipment && e3NexusEcazClean(s), actions); + return { before, step, after, settled: g }; +} +function finishMentat(g: Game, actions: E3NexusEcazStep[]): E3NexusEcazTransition { + const turn = g.turn; + for (let n = 0; n < 200; n++) { + const before = e3NexusEcazReload(g), step = nextE3NexusEcazStep(g); assert.ok(step); + g = e3NexusEcazStep(g, step, actions); + if (g.turn !== turn) return { before, step, after: g }; + } + throw Error('Original Mentat did not finish.'); +} +/** Two other real seats ally; only then can unallied Ecaz draw at Nexus closing. */ +export function closeE3NexusEcaz(f: E3NexusEcazSetup, state: Game, firstMentat: E3NexusEcazTransition): E3NexusEcazClosing { + let g = advanceE3NexusEcaz(state, s => s.turn === 2 && s.phase === 1 && e3NexusEcazClean(s), f.actions); + orderE3NexusEcazSpice(g, true); + g = advanceE3NexusEcaz(g, s => s.nexus && !s.spiceWindow && !s.spiceResolution && e3NexusEcazClean(s), f.actions); + for (const [actor, target] of [[f.others[0], f.others[1]], [f.others[1], f.others[0]]]) + g = e3NexusEcazStep(g, { actor, action: { type: 'alliance', target } }, f.actions); + const alliance = e3NexusEcazReload(g); + g = advanceE3NexusEcaz(g, s => s.nexusCards?.phase?.stage === 'drawing' && e3NexusEcazClean(s), f.actions); + assert.ok(g.nexusCards!.phase!.eligible.includes(f.owner)); + const before = e3NexusEcazReload(g), step: E3NexusEcazStep = { actor: f.owner, + action: { type: 'nexusCardChoice', turn: g.turn, card: g.nexusCards!.cards!.hands[f.owner], choice: 'draw', ownRedraws: 0 } }; + g = e3NexusEcazStep(g, step, f.actions); + return { ...f, firstMentat, alliance, draw: { before, step, after: g }, game: g }; +} +export function createE3NexusEcazClosing(options: E3NexusEcazOptions = {}, claimTabr = false): E3NexusEcazClosing { + const f = createE3NexusEcazSetup(options); assert.ok(f.others.length >= 2); + let g = e3NexusEcazReload(f.afterSetup); orderE3NexusEcazSpice(g); + let claim: E3NexusEcazTransition | undefined; + if (claimTabr) { + const arrival = shipE3NexusEcaz(g, f.owner, 'sietch_tabr:14', 1, f.actions); + g = arrival.settled; claim = arrival; + } + g = advanceE3NexusEcaz(g, s => s.phase === 8 && e3NexusEcazClean(s), f.actions); + const mentat = finishMentat(g, f.actions); + return { ...closeE3NexusEcaz(f, mentat.after, mentat), claim }; +} +export function openE3NexusEcazBattle(g: Game, destination: string, first: string, second: string, + actions?: E3NexusEcazStep[]): Game { + g = advanceE3NexusEcaz(g, s => s.phase === 6 && e3NexusEcazClean(s) && !!s.active, actions); + const choice = viewGame(g, g.active!).battleChoices.find(b => b.territory === destination && + [first, second].includes(b.attacker) && [first, second].includes(b.defender)); assert.ok(choice); + g = e3NexusEcazStep(g, { actor: choice.chooser, action: { type: 'chooseBattle', territory: destination, + target: choice.attacker === choice.chooser ? choice.defender : choice.attacker } }, actions); + return advanceE3NexusEcaz(g, s => !!s.battle && e3NexusEcazClean(s) && !s.battle.preparation && s.battle.preLeader?.closed !== false, actions); +} +export function createE3NexusEcazCunning(options: E3NexusEcazOptions = {}) { + const f = createE3NexusEcazClosing({ ...options, advanced: true, roster: ['ecaz', 'guild', 'beneGesserit'], + homeworlds: true, tech: true, strongholds: true }); + const opponent = f.afterSetup.players.find(p => p.faction === 'beneGesserit')!.id, location = 'homeworld:beneGesserit'; + const arrival = shipE3NexusEcaz(f.game, f.owner, location, 1, f.actions); + let g = advanceE3NexusEcaz(arrival.settled, s => s.phase === 6 && e3NexusEcazClean(s) && !s.battle, f.actions); + const before = e3NexusEcazReload(g), action = nexusEcazDukeAction(viewGame(g, f.owner)); assert.ok(action); + const step = { actor: f.owner, action }; g = e3NexusEcazStep(g, step, f.actions); + const cunning: E3NexusEcazTransition = { before, step, after: g }; + g = openE3NexusEcazBattle(g, location, f.owner, opponent, f.actions); + const own = nativeE3HomeworldPlayer(g, f.owner).leaders.filter(l => !l.dead).sort((a, b) => a.strength - b.strength)[0]; + const enemy = nativeE3HomeworldPlayer(g, opponent).leaders.filter(l => !l.dead).sort((a, b) => b.strength - a.strength)[0]; + const plans: E3NexusEcazStep[] = [ + { actor: f.owner, action: { type: 'battlePlan', leader: own.id, dial: 0, support: 0 } }, + { actor: opponent, action: { type: 'battlePlan', leader: enemy.id, dial: 3, support: 3 } }, + ]; + return { ...f, opponent, location, arrival, cunning, game: g, plans }; +} +/** Every army reaches Tabr by paid native shipment; no board/wallet/card staging. */ +export function createE3NexusEcazCoalition(options: E3NexusEcazOptions = {}, ecazForces?: number): E3NexusEcazCoalition { + const f = createE3NexusEcazClosing({ ...options, roster: ['ecaz', 'guild', 'emperor'] }, true); + let g = advanceE3NexusEcaz(f.game, s => s.turn === 3 && s.phase === 1 && e3NexusEcazClean(s), f.actions); + orderE3NexusEcazSpice(g, true); + g = advanceE3NexusEcaz(g, s => s.nexus && !s.spiceWindow && !s.spiceResolution && e3NexusEcazClean(s), f.actions); + const ally = g.players.find(p => p.faction === 'emperor')!.id, opponent = g.players.find(p => p.faction === 'guild')!.id; + g = e3NexusEcazStep(g, { actor: ally, action: { type: 'alliance', target: null } }, f.actions); + for (const [actor, target] of [[f.owner, ally], [ally, f.owner]]) + g = e3NexusEcazStep(g, { actor, action: { type: 'alliance', target } }, f.actions); + const location = `sietch_tabr:${territory('sietch_tabr').sectors[0]}`; + const amount = ecazForces ?? (g.advanced ? 5 : 4); + const requested: Record = { [f.owner]: amount - 1, [ally]: 4, [opponent]: 8 }; + const arrivals: (E3NexusEcazTransition & { settled: Game })[] = []; + const shipped = new Set(); + for (let n = 0; n < 300; n++) { + if (g.phase === 6 && e3NexusEcazClean(g)) break; + if (g.phase === 5 && e3NexusEcazClean(g) && g.active && !shipped.has(g.active)) { + const actor = g.active, arrival = shipE3NexusEcaz(g, actor, location, requested[actor], f.actions); + arrivals.push(arrival); shipped.add(actor); g = arrival.settled; + } else { + const next = nextE3NexusEcazStep(g); assert.ok(next); g = e3NexusEcazStep(g, next, f.actions); + } + } + assert.equal(shipped.size, 3); + const beforeBattle = e3NexusEcazReload(g), choice = viewGame(g, g.active!).battleChoices.find(b => b.territory === 'sietch_tabr'); assert.ok(choice); + const owned: E3NexusEcazStep = { actor: choice.chooser, action: { type: 'chooseBattle', territory: 'sietch_tabr', + target: choice.attacker === choice.chooser ? choice.defender : choice.attacker } }; + if (g.advanced || amount % 2 === 0) g = e3NexusEcazStep(g, owned, f.actions); + return { ...f, ally, opponent, location, ecazForces: amount, arrivals, beforeBattle, choice: owned, game: g }; +} +export function revealE3NexusEcaz(g: Game, plans: readonly E3NexusEcazStep[], actions?: E3NexusEcazStep[]): Game { + for (const plan of plans) { + g = e3NexusEcazStep(g, plan, actions); + if (!g.battle?.revealed) g = advanceE3NexusEcaz(g, s => !!s.battle && e3NexusEcazClean(s) && !s.battle.preparation && s.battle.preLeader?.closed !== false, actions); + } + assert.ok(g.battle?.revealed); return g; +} +/** Token placement uses its original supply lottery, not a preplaced token. */ +export function createE3NexusEcazDiscovery(options: E3NexusEcazOptions = {}) { + const f = createE3NexusEcazSetup({ ...options, discovery: true }); + let g = e3NexusEcazReload(f.afterSetup); + const index = g.spiceDeck.findIndex(c => 'territory' in c && c.discovery === 'discovery-hagga-basin'); assert.ok(index >= 0); + g.spiceDeck.unshift(g.spiceDeck.splice(index, 1)[0]); + if (g.advanced) { + const second = g.spiceDeck.findIndex((c, i) => i > 0 && 'territory' in c && !c.discovery && c.territory !== 'hagga_basin'); assert.ok(second > 0); + g.spiceDeck.splice(1, 0, g.spiceDeck.splice(second, 1)[0]); + } + g = advanceE3NexusEcaz(g, s => s.phase === 1 && e3NexusEcazClean(s), f.actions); + for (let n = 0; !g.discoveries!.tokens.some(t => t.face === 'cistern' && t.status === 'placed') && n < 160; n++) { + const step = nextE3NexusEcazStep(g); assert.ok(step); f.actions.push(structuredClone(step)); + g = withClassicDiscoveryNexusToken(g, 'cistern', () => applyAction(e3NexusEcazReload(g), step.actor, step.action)); + } + const destination = DISCOVERY_CARD_PLACEMENTS['discovery-hagga-basin']; + const arrival = shipE3NexusEcaz(g, f.owner, `${destination.territory}:${destination.sector}`, 2, f.actions); + g = advanceE3NexusEcaz(arrival.settled, s => s.phase === 7 && e3NexusEcazClean(s), f.actions); + const token = g.discoveries!.tokens.find(t => t.face === 'cistern' && t.status === 'placed')!.id; + if (viewGame(g, f.owner).discoveries!.canInspect.includes(token)) + g = e3NexusEcazStep(g, { actor: f.owner, action: { type: 'discovery', token, reveal: false } }, f.actions); + const beforeReveal = e3NexusEcazReload(g); + g = e3NexusEcazStep(g, { actor: f.owner, action: { type: 'discovery', token, reveal: true } }, f.actions); + const revealed = e3NexusEcazReload(g); + g = advanceE3NexusEcaz(g, s => s.phase === 8 && e3NexusEcazClean(s), f.actions); + const firstMentat = finishMentat(g, f.actions); + g = advanceE3NexusEcaz(firstMentat.after, s => !s.response && !s.phaseOpening && + s.decision?.kind === 'discoveryEntry' && s.decision.player === f.owner, f.actions); + const before = e3NexusEcazReload(g), view = viewGame(g, f.owner), action = discoveryEntryMoveAction(view, view.discoveryEntry!.sources); assert.ok(action); + const step = { actor: f.owner, action }; g = e3NexusEcazStep(g, step, f.actions); + const entry: E3NexusEcazTransition = { before, step, after: e3NexusEcazReload(g) }; + const closing = closeE3NexusEcaz(f, g, firstMentat); + return { ...closing, arrival, token, beforeReveal, revealed, entry }; +} diff --git a/tests/fixture-e3-nexus-moritani.ts b/tests/fixture-e3-nexus-moritani.ts new file mode 100644 index 00000000..1fbff29f --- /dev/null +++ b/tests/fixture-e3-nexus-moritani.ts @@ -0,0 +1,308 @@ +import assert from 'node:assert/strict'; +import { + applyAction, createGame, initializeNexusGameForAudit, joinGame, newPlayer, viewGame, + type Action, type Game, +} from '../game/engine'; +import type { FactionId } from '../game/catalog'; +import { territory } from '../game/board'; +import { leaders, spiceDeck, treacheryDeck } from '../game/cards'; +import { traitorDeck } from '../game/traitors'; +import { NEXUS_FACTIONS, validateNexusCards } from '../game/nexus-cards'; +import { DISCOVERY_CARD_PLACEMENTS, DISCOVERY_SPICE_CARDS, validateDiscoveryState } from '../game/discoveries'; +import { discoveryEntryMoveAction } from '../game/discovery-entry-options'; +import { nativeShipmentSources } from '../game/homeworld-options'; +import { homeworldShipmentChoice } from '../game/homeworld-shipment-options'; +import { homeworldContext, homeworldGameIntegrity } from '../game/homeworld-game'; +import { homeworldForceGroups } from '../game/homeworld-custody'; +import { nextDiscoveryStandaloneNexusE3SkillsStep } from './fixture-discovery-standalone-nexus-e3-skills'; +import { moritaniStrongholdsPolicy } from './fixture-moritani-strongholds'; +import { withClassicDiscoveryNexusLottery, withClassicDiscoveryNexusToken } from './fixture-discovery-classic-nexus'; + +export type MoritaniNexusStep = { actor: string; action: Action }; +export type MoritaniNexusTransition = { before: Game; step: MoritaniNexusStep; after: Game }; +export type MoritaniNexusOptions = { + initial?: Game; advanced?: boolean; roster?: readonly FactionId[]; + homeworlds?: boolean; discovery?: boolean; tech?: boolean; strongholds?: boolean; + nexus?: 'moritani' | 'harkonnen'; normalCall?: boolean; +}; +export type MoritaniNexusSetup = { + initial: Game; offered: Game; afterSetup: Game; owner: string; opponent: string; + target: string; actions: MoritaniNexusStep[]; +}; +export type MoritaniNexusClosing = MoritaniNexusSetup & { + firstStorm: Game; firstMentat: MoritaniNexusTransition; alliance: Game; + draw: MoritaniNexusTransition; game: Game; +}; +export type MoritaniNexusBattle = { + arrival: MoritaniNexusTransition; settledArrival: Game; game: Game; + location: string; plans: MoritaniNexusStep[]; ownLeader: string; enemyLeader: string; +}; +export const moritaniNexusReload = (g: Game): Game => JSON.parse(JSON.stringify(g)) as Game; +export const moritaniNexusClean = (g: Game) => + !g.response && !g.phaseOpening && !g.decision && !g.pendingTreacheryDiscard && !g.truthtrance; +export const moritaniNexusPlayer = (g: Game, actor: string) => { + const p = g.players.find(p => p.id === actor); assert.ok(p); return p; +}; +export const moritaniNexusPolicy = moritaniStrongholdsPolicy; +export function moritaniNexusAct(g: Game, step: MoritaniNexusStep, actions?: MoritaniNexusStep[]): Game { + actions?.push(structuredClone(step)); + return withClassicDiscoveryNexusLottery(() => applyAction(moritaniNexusReload(g), step.actor, step.action)); +} +export function nextMoritaniNexusStep(g: Game): MoritaniNexusStep | null { + if (!g.response && !g.phaseOpening && !g.pendingTreacheryDiscard) { + const d = g.decision; + if (d && ['moritaniAssassinate', 'techToken'].includes(d.kind)) return null; + if (d?.kind === 'homeworldShipmentGuild') return { actor: d.player, action: { type: 'decision', event: d.event, allow: true } }; + if (d?.kind === 'homeworldDefense') return { actor: d.player, action: { type: 'decision', event: d.event, use: false } }; + if (d?.kind === 'homeworldRevivalDeployment') return { actor: d.player, action: { type: 'decision', event: d.event, decline: true } }; + if (d?.kind === 'grummanCollection') return { actor: d.player, + action: { type: 'decision', event: d.event, decline: true } }; + } + const next = nextDiscoveryStandaloneNexusE3SkillsStep(g); + if (next?.action.type === 'stormDial') next.action.amount = g.turn === 1 ? 0 : 1; + return next; +} +export function advanceMoritaniNexus(g: Game, until: (g: Game) => boolean, actions?: MoritaniNexusStep[]): Game { + for (let n = 0; n < 3000; n++) { + if (until(g)) return g; + assert.notEqual(g.status, 'finished'); + const next = nextMoritaniNexusStep(g); assert.ok(next, 'Original sealed plans or native aftermath require their actual owner.'); + g = moritaniNexusAct(g, next, actions); + } + throw Error('Original no-Skills Moritani programme did not reach its owned boundary.'); +} +/** Scope entropy to the original undealt physical supply. UUID generation stays native. */ +function permutation(source: readonly string[], desired: readonly string[]): number[] { + assert.deepEqual([...source].sort(), [...desired].sort()); + const working = [...source], rolls: number[] = []; + for (let i = working.length - 1; i > 0; i--) { + const j = working.indexOf(desired[i]); assert.ok(j >= 0 && j <= i); + rolls.push(Math.floor((j + 0.5) * 0x100000000 / (i + 1))); + [working[i], working[j]] = [working[j], working[i]]; + } + return rolls; +} +function lottery(rolls: readonly number[], operation: () => T): T { + const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues'); + const native = crypto.getRandomValues.bind(crypto); let cursor = 0; + crypto.getRandomValues = (array: V): V => { + if (array instanceof Uint32Array && array.length === 1 && cursor < rolls.length) array[0] = rolls[cursor++]; + else Reflect.apply(native, crypto, [array]); + return array; + }; + try { return operation(); } + finally { + if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor); + else Reflect.deleteProperty(crypto, 'getRandomValues'); + } +} +export function createMoritaniNexusLobby(options: MoritaniNexusOptions = {}): Game { + const roster: readonly FactionId[] = options.roster ?? ['moritani', 'guild', 'emperor']; + const advanced = options.advanced ?? options.initial?.advanced ?? true; + let g = options.initial ? moritaniNexusReload(options.initial) + : createGame('E3NEXUSMORITANI', newPlayer(roster[0], roster[0], roster[0]), advanced, ['ecaz']); + if (!options.initial) for (const faction of roster.slice(1)) joinGame(g, newPlayer(faction, faction, faction)); + assert.equal(g.status, 'lobby'); assert.equal(g.advanced, advanced); assert.deepEqual(g.expansions, ['ecaz']); + assert.equal(g.players.filter(p => ['ecaz', 'moritani'].includes(p.faction)).length, 1); + assert.ok(g.players.some(p => p.faction === 'moritani')); + assert.ok(g.players.every(p => ['moritani', 'guild', 'emperor', 'atreides', 'fremen', 'beneGesserit', + ...(!advanced ? ['harkonnen'] : [])].includes(p.faction))); + assert.equal(new Set(g.players.map(p => p.faction)).size, g.players.length); + assert.ok(g.players.length >= (options.tech ? 3 : 2) && g.players.length <= 6); + assert.ok(!options.strongholds || advanced); assert.ok(!g.leaderSkills && !g.ecazTreachery); + for (const [type, enabled, present] of [ + ['homeworlds', options.homeworlds ?? false, !!g.homeworlds], + ['techTokens', options.tech ?? false, !!g.techTokens], + ['strongholdCards', options.strongholds ?? false, !!g.strongholdCards], + ] as const) if (enabled !== present) g = applyAction(g, g.host, { type, enabled }); + g.discoveryEnabled = options.discovery ?? false; + g.nexusCards ??= { cards: null, phase: null }; + assert.ok(g.nexusCards.cards === null && g.nexusCards.phase === null); + return g; +} +export function createMoritaniNexusSetup(options: MoritaniNexusOptions = {}): MoritaniNexusSetup { + let g = createMoritaniNexusLobby(options); + const initial = moritaniNexusReload(g), actions: MoritaniNexusStep[] = []; + const owner = g.players.find(p => p.faction === 'moritani')!.id; + const opponent = g.players.find(p => p.faction === 'guild')!.id; assert.ok(opponent); + const target = leaders('guild').find(l => l.name === (options.normalCall ? 'Staban Tuek' : 'Master Bewt'))!.id; + for (const p of g.players) if (!p.ready) g = moritaniNexusAct(g, { actor: p.id, action: { type: 'ready' } }, actions); + if (options.initial) g = initializeNexusGameForAudit(g); + else { + const nx = options.nexus ?? 'moritani', cards = treacheryDeck(['ecaz']), remaining = [...cards]; + const front = g.players.flatMap(p => { + const kinds = [p.id === opponent ? 'projectile' : 'worthless', ...(p.faction === 'harkonnen' ? ['shield'] : [])]; + return kinds.map(kind => { const i = remaining.findIndex(c => c.kind === kind); assert.ok(i >= 0); return remaining.splice(i, 1)[0].id; }); + }); + const traitors = traitorDeck(g.players), desired = [target, ...traitors.filter(c => c !== target)]; + const index = nx === 'harkonnen' ? g.players.length * 4 : g.players.findIndex(p => p.id === owner) * 4; + [desired[0], desired[index]] = [desired[index], desired[0]]; + const spiceCount = spiceDeck().length + (g.discoveryEnabled ? DISCOVERY_SPICE_CARDS.length + 1 : 0); + g = lottery([...(g.discoveryEnabled ? Array(7).fill(0xffffffff) : []), + ...permutation(NEXUS_FACTIONS, [nx, ...NEXUS_FACTIONS.filter(c => c !== nx)]), + ...permutation(cards.map(c => c.id), [...front, ...remaining.map(c => c.id)]), + ...Array(spiceCount - 1).fill(0xffffffff), ...permutation(traitors, desired)], () => initializeNexusGameForAudit(g)); + } + const offered = moritaniNexusReload(g); + // A real Bene Gesserit prediction can postpone the same original Traitor deal. + // Supplied human lobbies keep their native lottery. + while (g.status === 'setup') { + let next = nextMoritaniNexusStep(g); assert.ok(next); + if (!options.initial && g.setupStage === 'prediction') { + const source = traitorDeck(g.players), desired = [target, ...source.filter(c => c !== target)]; + const index = options.nexus === 'harkonnen' ? g.players.length * 4 : g.players.findIndex(p => p.id === owner) * 4; + [desired[0], desired[index]] = [desired[index], desired[0]]; + const step = next; actions.push(structuredClone(step)); + g = lottery(permutation(source, desired), () => applyAction(moritaniNexusReload(g), step.actor, step.action)); + continue; + } + if (!options.initial && options.nexus !== 'harkonnen' && g.setupStage === 'traitors' && moritaniNexusPlayer(g, owner).traitorChoices.length) + next = { actor: owner, action: { type: 'traitor', leader: target } }; + if (g.decision?.kind === 'moritaniSetup' && + g.players.every(p => Object.entries(p.forces).every(([key, amount]) => !key.startsWith('sietch_tabr:') || amount === 0))) + next = { actor: owner, action: { type: 'decision', territory: 'sietch_tabr', sector: territory('sietch_tabr').sectors[0] } }; + g = moritaniNexusAct(g, next, actions); + } + assert.equal(g.status, 'playing'); + return { initial, offered, afterSetup: g, owner, opponent, target, actions }; +} +/** Only unplayed original Spice Cards are positioned. */ +export function orderMoritaniNexusSpice(g: Game, worm = false): void { + const kinds = [...(worm ? ['worm'] : []), ...Array(g.advanced ? 2 : 1).fill('land')]; + for (const [position, kind] of kinds.entries()) { + const index = g.spiceDeck.findIndex((c, i) => i >= position && (kind === 'worm' + ? 'worm' in c && !c.greatMaker : 'territory' in c && !c.discovery)); + assert.ok(index >= position); g.spiceDeck.splice(position, 0, g.spiceDeck.splice(index, 1)[0]); + } +} +export function closeMoritaniNexus(f: MoritaniNexusSetup, state: Game): { alliance: Game; draw: MoritaniNexusTransition; game: Game } { + let g = advanceMoritaniNexus(state, s => !!s.nexus && !s.spiceWindow && !s.spiceResolution && moritaniNexusClean(s), f.actions); + const others = g.players.filter(p => p.id !== f.owner); assert.ok(others.length >= 2); + for (const [actor, target] of [[others[0].id, others[1].id], [others[1].id, others[0].id]]) + g = moritaniNexusAct(g, { actor, action: { type: 'alliance', target } }, f.actions); + const alliance = moritaniNexusReload(g); + g = advanceMoritaniNexus(g, s => s.nexusCards?.phase?.stage === 'drawing' && moritaniNexusClean(s), f.actions); + assert.ok(g.nexusCards!.phase!.eligible.includes(f.owner)); + const before = moritaniNexusReload(g), step: MoritaniNexusStep = { actor: f.owner, + action: { type: 'nexusCardChoice', turn: g.turn, card: g.nexusCards!.cards!.hands[f.owner], choice: 'draw', ownRedraws: 0 } }; + g = moritaniNexusAct(g, step, f.actions); + return { alliance, draw: { before, step, after: moritaniNexusReload(g) }, game: g }; +} +export function createMoritaniNexusClosing(options: MoritaniNexusOptions = {}): MoritaniNexusClosing { + const f = createMoritaniNexusSetup(options); let g = moritaniNexusReload(f.afterSetup); + orderMoritaniNexusSpice(g); + g = advanceMoritaniNexus(g, s => s.phase === 1 && moritaniNexusClean(s), f.actions); + const firstStorm = moritaniNexusReload(g); + g = advanceMoritaniNexus(g, s => s.phase === 8 && s.decision?.kind === 'moritaniPlacement', f.actions); + const token = g.moritaniTerror!.tokens.find(t => t.kind === 'extortion' && t.status === 'available'); assert.ok(token); + g = moritaniNexusAct(g, { actor: f.owner, action: { type: 'decision', token: token.id, territory: 'arrakeen' } }, f.actions); + let firstMentat: MoritaniNexusTransition | undefined; + while (g.turn === 1) { + const before = moritaniNexusReload(g), step = nextMoritaniNexusStep(g); assert.ok(step); + g = moritaniNexusAct(g, step, f.actions); + if (g.turn === 2) firstMentat = { before, step, after: moritaniNexusReload(g) }; + } + assert.ok(firstMentat); + g = advanceMoritaniNexus(g, s => s.phase === 1 && moritaniNexusClean(s), f.actions); + orderMoritaniNexusSpice(g, true); + return { ...f, firstStorm, firstMentat, ...closeMoritaniNexus(f, g) }; +} +export function shipMoritaniNexus(g: Game, actor: string, destination: string, amount: number, + actions?: MoritaniNexusStep[]): MoritaniNexusTransition & { settled: Game } { + g = advanceMoritaniNexus(g, s => s.phase === 5 && s.active === actor && moritaniNexusClean(s), actions); + const before = moritaniNexusReload(g), view = viewGame(g, actor); + let action: Action; + const sources = g.homeworlds ? nativeShipmentSources(view, amount, 0) : undefined; + if (destination.startsWith('homeworld:')) { + assert.ok(sources); const quote = homeworldShipmentChoice(view, destination, + Object.fromEntries(Object.entries(sources).filter(([, f]) => f.normal + f.elite > 0))); + assert.ok(quote.action, quote.blocked ?? 'Original paid native entry unavailable.'); action = quote.action; + } else { + const [territory, sector] = destination.split(':'); + action = { type: 'ship', territory, sector: Number(sector), amount, elite: 0, allyPayment: 0, + ...(sources ? { homeworldSources: sources } : {}) }; + } + const step = { actor, action }; g = moritaniNexusAct(g, step, actions); + const after = moritaniNexusReload(g); + g = advanceMoritaniNexus(g, s => !s.pendingHomeworldShipment && !s.pendingShipment && moritaniNexusClean(s), actions); + return { before, step, after, settled: g }; +} +export function openMoritaniNexusBattle(g: Game, location: string, owner: string, opponent: string, + actions?: MoritaniNexusStep[]): Game { + g = advanceMoritaniNexus(g, s => s.phase === 6 && moritaniNexusClean(s) && !!s.active, actions); + assert.ok(g.active === owner || g.active === opponent); + const territory = location.startsWith('homeworld:') ? location : location.split(':')[0]; + g = moritaniNexusAct(g, { actor: g.active!, action: { type: 'chooseBattle', territory, + target: g.active === owner ? opponent : owner } }, actions); + return advanceMoritaniNexus(g, s => !!s.battle && moritaniNexusClean(s) && !s.battle.preparation && s.battle.preLeader?.closed !== false, actions); +} +export function openMoritaniNexusLoss(f: MoritaniNexusSetup, state: Game, normalCall = false): MoritaniNexusBattle { + const location = state.homeworlds && !normalCall ? 'homeworld:guild' : 'tueks_sietch:5'; + const arrival = shipMoritaniNexus(state, f.owner, location, 1, f.actions); + const g = openMoritaniNexusBattle(arrival.settled, location, f.owner, f.opponent, f.actions); + const own = moritaniNexusPlayer(g, f.owner).leaders.filter(l => !l.dead && !l.usedAt).sort((a, b) => a.strength - b.strength)[0]; + const enemy = moritaniNexusPlayer(g, f.opponent).leaders.filter(l => !l.dead && !l.usedAt && (normalCall || l.id !== f.target)) + .sort((a, b) => b.strength - a.strength)[0]; + const weapon = moritaniNexusPlayer(g, f.opponent).hand.find(c => c.kind === 'projectile'); + const worthless = moritaniNexusPlayer(g, f.owner).hand.find(c => c.kind === 'worthless'); + assert.ok(own && enemy && weapon && worthless); + return { arrival, settledArrival: arrival.settled, game: g, location, ownLeader: own.id, enemyLeader: enemy.id, plans: [ + { actor: f.owner, action: { type: 'battlePlan', leader: own.id, dial: 0, support: 0, weapon: worthless.id } }, + { actor: f.opponent, action: { type: 'battlePlan', leader: enemy.id, dial: 1, support: g.advanced ? 1 : 0, weapon: weapon.id } }, + ] }; +} +export function revealMoritaniNexus(g: Game, plans: MoritaniNexusStep[], actions?: MoritaniNexusStep[]): Game { + for (const step of plans) { + g = moritaniNexusAct(g, step, actions); + if (!g.battle?.revealed) g = advanceMoritaniNexus(g, s => !!s.battle && moritaniNexusClean(s) && !s.battle.preparation && s.battle.preLeader?.closed !== false, actions); + } + assert.ok(g.battle?.revealed); return g; +} +export function settleMoritaniNexus(g: Game, actions?: MoritaniNexusStep[], normalCall = false): Game { + for (let n = 0; n < 160; n++) { + if (!g.response && !g.phaseOpening && (g.decision?.kind === 'moritaniAssassinate' || g.decision?.kind === 'techToken' || !g.battle && moritaniNexusClean(g))) return g; + const next = nextMoritaniNexusStep(g); assert.ok(next); + if (next.action.type === 'traitorCall') next.action.call = normalCall && moritaniNexusPlayer(g, next.actor).faction === 'moritani'; + g = moritaniNexusAct(g, next, actions); + } + throw Error('Original native battle suffix did not settle.'); +} +export function createMoritaniNexusDiscovery(options: MoritaniNexusOptions = {}) { + const f = createMoritaniNexusSetup({ ...options, discovery: true }); let g = moritaniNexusReload(f.afterSetup); + const index = g.spiceDeck.findIndex(c => 'territory' in c && c.discovery === 'discovery-hagga-basin'); assert.ok(index >= 0); + g.spiceDeck.unshift(g.spiceDeck.splice(index, 1)[0]); + const second = g.spiceDeck.findIndex((c, i) => i > 0 && 'territory' in c && !c.discovery && c.territory !== 'hagga_basin'); + assert.ok(second > 0); g.spiceDeck.splice(1, 0, g.spiceDeck.splice(second, 1)[0]); + g = advanceMoritaniNexus(g, s => s.phase === 1 && moritaniNexusClean(s), f.actions); + for (let n = 0; !g.discoveries!.tokens.some(t => t.face === 'cistern' && t.status === 'placed') && n < 160; n++) { + const step = nextMoritaniNexusStep(g); assert.ok(step); f.actions.push(structuredClone(step)); + g = withClassicDiscoveryNexusToken(g, 'cistern', () => applyAction(moritaniNexusReload(g), step.actor, step.action)); + } + const placement = DISCOVERY_CARD_PLACEMENTS['discovery-hagga-basin']; + const shipment = shipMoritaniNexus(g, f.owner, `${placement.territory}:${placement.sector}`, 2, f.actions); + g = advanceMoritaniNexus(shipment.settled, s => s.phase === 7 && moritaniNexusClean(s), f.actions); + const token = g.discoveries!.tokens.find(t => t.face === 'cistern' && t.status === 'placed')!.id; + if (viewGame(g, f.owner).discoveries!.canInspect.includes(token)) + g = moritaniNexusAct(g, { actor: f.owner, action: { type: 'discovery', token, reveal: false } }, f.actions); + g = moritaniNexusAct(g, { actor: f.owner, action: { type: 'discovery', token, reveal: true } }, f.actions); + const revealed = moritaniNexusReload(g); + g = advanceMoritaniNexus(g, s => s.turn === 2 && !s.response && !s.phaseOpening && s.decision?.kind === 'discoveryEntry' && s.decision.player === f.owner, f.actions); + const before = moritaniNexusReload(g), view = viewGame(g, f.owner); + const action = discoveryEntryMoveAction(view, view.discoveryEntry!.sources); assert.ok(action); + const step = { actor: f.owner, action }; g = moritaniNexusAct(g, step, f.actions); + return { ...f, shipment, revealed, token, entry: { before, step, after: moritaniNexusReload(g) }, game: g }; +} +export function assertMoritaniNexusPhysical(g: Game): void { + validateNexusCards(g.nexusCards!.cards!, g.players); + if (g.discoveries) validateDiscoveryState(g.discoveries); + if (g.homeworlds) homeworldGameIntegrity(g); + const homes = g.homeworlds?.custody ? homeworldForceGroups(homeworldContext(g), g.homeworlds.custody) : []; + for (const p of g.players) { + const visitors = homes.filter(h => h.native !== p.id).reduce((n, h) => n + (h.forces[p.id]?.normal ?? 0), 0); + assert.equal(p.reserves + p.tanks + Object.values(p.forces).reduce((n, f) => n + f, 0) + visitors, 20); + } + const retired = g.moritaniAssassinate?.opportunities.filter(o => o.stage === 'replaced').map(o => o.card!) ?? []; + assert.deepEqual([...g.traitorReserve!, ...g.players.flatMap(p => [...p.traitors, ...p.traitorChoices]), ...retired].sort(), traitorDeck(g.players).sort()); + const auction = g.auction?.cards.slice(g.auction.index + (g.currentAuctionSale ? 1 : 0)) ?? []; + assert.deepEqual([...g.deck, ...g.discard, ...g.players.flatMap(p => p.hand), ...auction].map(c => c.id).sort(), treacheryDeck(['ecaz']).map(c => c.id).sort()); +} diff --git a/tools/faction-games.ts b/tools/faction-games.ts index fa0c9b47..2eacf770 100644 --- a/tools/faction-games.ts +++ b/tools/faction-games.ts @@ -4,7 +4,7 @@ import { readFileSync, statSync, writeFileSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { parseArgs } from 'node:util'; -import { classicNexusModulesProfile, pairedNexusModulesProfile } from '../game/nexus-module-profile'; +import { classicNexusModulesProfile, pairedNexusModulesProfile, standaloneE3NexusProfile } from '../game/nexus-module-profile'; import { nativeFactionTechProfile } from '../game/faction-module-profile'; import { classicDiscoveryLeaderSkillsProfile } from '../game/discovery-module-profile'; import { botActions } from '../game/bots'; @@ -78,6 +78,15 @@ const HOMEWORLD_NATIVE_NEXUS_MODULE_PROFILES = [ 'e3-homeworld-nexus-discovery-skills', 'e3-homeworld-nexus-discovery-skills-tech', 'e3-homeworld-nexus-discovery-skills-stronghold', 'e3-homeworld-nexus-discovery-skills-stronghold-tech', ] as const; +const E3_NEXUS_MODULE_PROFILES = [ + 'e3-nexus', 'e3-nexus-tech', 'e3-nexus-stronghold', 'e3-nexus-stronghold-tech', + 'e3-discovery-nexus', 'e3-discovery-nexus-tech', + 'e3-discovery-nexus-stronghold', 'e3-discovery-nexus-stronghold-tech', + 'e3-homeworld-nexus', 'e3-homeworld-nexus-tech', + 'e3-homeworld-nexus-stronghold', 'e3-homeworld-nexus-stronghold-tech', + 'e3-homeworld-nexus-discovery', 'e3-homeworld-nexus-discovery-tech', + 'e3-homeworld-nexus-discovery-stronghold', 'e3-homeworld-nexus-discovery-stronghold-tech', +] as const; type Profile = 'base' | 'choam' | 'ecaz' | 'ecaz-treachery' | 'ecaz-occupy' | 'moritani-assassinate' | 'stronghold-factions' | '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' | 'richese-skills' | 'ecaz-skills' | 'skills-tech' | 'skills-stronghold' | 'skills-stronghold-tech' | 'factions-tech' | 'stronghold-factions-tech' | 'nexus-tech' | 'nexus-stronghold' | 'nexus-stronghold-tech' | 'paired-nexus-tech' | 'paired-nexus-stronghold' | 'paired-nexus-stronghold-tech' | 'nexus-skills' | 'paired-nexus-skills' @@ -94,7 +103,8 @@ type Profile = 'base' | 'choam' | 'ecaz' | 'ecaz-treachery' | 'ecaz-occupy' | 'm | 'e3-discovery-nexus-skills' | 'e3-discovery-nexus-skills-tech' | 'e3-discovery-nexus-skills-stronghold' | 'e3-discovery-nexus-skills-stronghold-tech' | (typeof HOMEWORLD_MODULE_PROFILES)[number] | (typeof HOMEWORLD_NEXUS_MODULE_PROFILES)[number] - | (typeof HOMEWORLD_NATIVE_NEXUS_MODULE_PROFILES)[number]; + | (typeof HOMEWORLD_NATIVE_NEXUS_MODULE_PROFILES)[number] + | (typeof E3_NEXUS_MODULE_PROFILES)[number]; const discoveryStrongholdProfile = (profile: string) => profile === 'discovery-stronghold' || profile === 'discovery-stronghold-tech' || profile === 'discovery-skills-stronghold' || profile === 'discovery-skills-stronghold-tech' || profile === 'native-discovery-skills-stronghold' || @@ -117,9 +127,12 @@ const nativeDiscoveryNexusSkillsProfile = (profile: string) => profile === 'paired-discovery-nexus-skills-stronghold' || profile === 'paired-discovery-nexus-skills-stronghold-tech' || profile === 'e3-discovery-nexus-skills' || profile === 'e3-discovery-nexus-skills-tech' || profile === 'e3-discovery-nexus-skills-stronghold' || profile === 'e3-discovery-nexus-skills-stronghold-tech'; +const e3NexusSampleProfile = (profile: string) => + (E3_NEXUS_MODULE_PROFILES as readonly string[]).includes(profile); const nativeHomeworldNexusSampleProfile = (profile: string) => (HOMEWORLD_NATIVE_NEXUS_MODULE_PROFILES as readonly string[]).includes(profile); const homeworldNexusSampleProfile = (profile: string) => + (e3NexusSampleProfile(profile) && profile.includes('-homeworld-')) || (HOMEWORLD_NEXUS_MODULE_PROFILES as readonly string[]).includes(profile) || nativeHomeworldNexusSampleProfile(profile); const homeworldModuleSampleProfile = (profile: string) => (HOMEWORLD_MODULE_PROFILES as readonly string[]).includes(profile) || homeworldNexusSampleProfile(profile); @@ -266,15 +279,17 @@ const CLASSIC_NEXUS_MODULE_SCENARIOS: readonly Scenario[] = (profile === 'nexus-tech' || scenario.rules === 'advanced') && (profile === 'nexus-stronghold' || scenario.roster.length >= 3)) .map((scenario, index): Scenario => ({ ...scenario, profile, ordinal: 800 + band * 100 + index }))); -const discoveryNexusProfile = (profile: string) => profile === 'discovery-nexus' || +const discoveryNexusProfile = (profile: string) => + (e3NexusSampleProfile(profile) && profile.includes('-discovery')) || profile === 'discovery-nexus' || profile === 'discovery-nexus-tech' || profile === 'discovery-nexus-stronghold' || profile === 'discovery-nexus-stronghold-tech' || discoveryNexusSkillsProfile(profile) || pairedDiscoveryNexusSampleProfile(profile) || nativeDiscoveryNexusSkillsProfile(profile); const nexusModuleProfile = (profile: string) => - homeworldNexusSampleProfile(profile) || + e3NexusSampleProfile(profile) || homeworldNexusSampleProfile(profile) || profile === 'nexus-tech' || profile === 'nexus-stronghold' || profile === 'nexus-stronghold-tech' || discoveryNexusProfile(profile) || pairedNexusModuleProfile(profile) || nexusSkillsModuleProfile(profile) || pairedNexusSkillsModuleProfile(profile) || e3NexusSkillsProfile(profile); const nexusTechProfile = (profile: string) => + (e3NexusSampleProfile(profile) && profile.endsWith('-tech')) || ((discoveryNexusSkillsProfile(profile) || pairedDiscoveryNexusSampleProfile(profile) || nativeDiscoveryNexusSkillsProfile(profile)) && profile.endsWith('tech')) || profile === 'discovery-nexus-tech' || profile === 'discovery-nexus-stronghold-tech' || profile === 'nexus-tech' || profile === 'nexus-stronghold-tech' || profile === 'paired-nexus-tech' || profile === 'paired-nexus-stronghold-tech' || @@ -282,6 +297,7 @@ const nexusTechProfile = (profile: string) => profile === 'paired-nexus-skills-tech' || profile === 'paired-nexus-skills-stronghold-tech' || profile === 'e3-nexus-skills-tech' || profile === 'e3-nexus-skills-stronghold-tech'; const nexusStrongholdProfile = (profile: string) => + (e3NexusSampleProfile(profile) && profile.includes('stronghold')) || ((discoveryNexusSkillsProfile(profile) || pairedDiscoveryNexusSampleProfile(profile) || nativeDiscoveryNexusSkillsProfile(profile)) && profile.includes('stronghold')) || profile === 'discovery-nexus-stronghold' || profile === 'discovery-nexus-stronghold-tech' || profile === 'nexus-stronghold' || profile === 'nexus-stronghold-tech' || profile === 'paired-nexus-stronghold' || profile === 'paired-nexus-stronghold-tech' || @@ -558,6 +574,12 @@ const HOMEWORLD_NATIVE_NEXUS_MODULE_SCENARIOS: readonly Scenario[] = .filter(scenario => (!homeworldStrongholdSampleProfile(profile) || scenario.rules === 'advanced') && (!homeworldTechSampleProfile(profile) || scenario.roster.length >= 3)) .map((scenario, index): Scenario => ({ ...scenario, profile, ordinal: 10300 + band * 100 + index }))); +const E3_NEXUS_MODULE_SCENARIOS: readonly Scenario[] = + E3_NEXUS_MODULE_PROFILES.flatMap((profile, band) => + E3_DISCOVERY_NEXUS_SKILLS_BASE_SCENARIOS + .filter(scenario => (!nexusStrongholdProfile(profile) || scenario.rules === 'advanced') && + (!nexusTechProfile(profile) || scenario.roster.length >= 3)) + .map((scenario, index): Scenario => ({ ...scenario, profile, ordinal: 12600 + band * 100 + index }))); const STRONGHOLD_FACTIONS_ROSTER: readonly FactionId[] = [ 'ixians', 'choam', 'emperor', 'fremen', 'harkonnen', 'beneGesserit', ]; @@ -642,7 +664,7 @@ const discoveryProfile = (profile: string) => profile === 'discovery' || profile (homeworldDiscoverySampleProfile(profile) && !homeworldSkillsSampleProfile(profile)); const assassinationScenario = (scenario: Scenario) => scenario.rules === 'advanced' && scenario.roster.includes('moritani') && - (scenario.profile === 'moritani-assassinate' || discoveryProfile(scenario.profile) || nativeStrongholdProfile(scenario.profile) || nativeTechProfile(scenario.profile) || skillProfile(scenario.profile)); + (e3NexusSampleProfile(scenario.profile) || scenario.profile === 'moritani-assassinate' || discoveryProfile(scenario.profile) || nativeStrongholdProfile(scenario.profile) || nativeTechProfile(scenario.profile) || skillProfile(scenario.profile)); type TraceEntry = { attempt: number; @@ -680,9 +702,11 @@ type Result = { function usage() { return ( 'Usage: node --import tsx tools/faction-games.ts --out NEW_PRIVATE_DIR ' + - '[--seed UINT32] [--profile all|base|discovery|discovery-tech|discovery-skills|discovery-skills-tech|discovery-nexus|discovery-nexus-tech|discovery-nexus-stronghold|discovery-nexus-stronghold-tech|choam|ecaz|ecaz-treachery|ecaz-occupy|moritani-assassinate|stronghold-factions|factions-tech|stronghold-factions-tech|combined|combined-nexus|combined-homeworld-nexus|homeworld|homeworld-occupation|nexus|nexus-skills|paired-nexus-skills|nexus-skills-tech|nexus-skills-stronghold|nexus-skills-stronghold-tech|paired-nexus-skills-tech|paired-nexus-skills-stronghold|paired-nexus-skills-stronghold-tech|e3-nexus-skills|e3-nexus-skills-tech|e3-nexus-skills-stronghold|e3-nexus-skills-stronghold-tech|nexus-tech|nexus-stronghold|nexus-stronghold-tech|paired-nexus-tech|paired-nexus-stronghold|paired-nexus-stronghold-tech|homeworld-nexus|choam-roster|ecaz-roster|ix-roster|choam-nexus|ecaz-nexus|ix-nexus|moritani-skills|tleilaxu-skills|ix-skills|choam-skills|richese-skills|ecaz-skills|skills-tech|skills-stronghold|skills-stronghold-tech] ' + + '[--seed UINT32] [--profile all|base|discovery|discovery-tech|discovery-skills|discovery-skills-tech|discovery-nexus|discovery-nexus-tech|discovery-nexus-stronghold|discovery-nexus-stronghold-tech|choam|ecaz|ecaz-treachery|ecaz-occupy|moritani-assassinate|stronghold-factions|factions-tech|stronghold-factions-tech|combined|combined-nexus|combined-homeworld-nexus|homeworld|homeworld-occupation|nexus|nexus-skills|paired-nexus-skills|nexus-skills-tech|nexus-skills-stronghold|nexus-skills-stronghold-tech|paired-nexus-skills-tech|paired-nexus-skills-stronghold|paired-nexus-skills-stronghold-tech|e3-nexus-skills|e3-nexus-skills-tech|e3-nexus-skills-stronghold|e3-nexus-skills-stronghold-tech|nexus-tech|nexus-stronghold|nexus-stronghold-tech|paired-nexus-tech|paired-nexus-stronghold|paired-nexus-stronghold-tech|homeworld-nexus|choam-roster|ecaz-roster|ix-roster|choam-nexus|ecaz-nexus|ix-nexus|moritani-skills|tleilaxu-skills|ix-skills|choam-skills|richese-skills|ecaz-skills|skills-tech|skills-stronghold|skills-stronghold-tech|' + + E3_NEXUS_MODULE_PROFILES.join('|') + '] ' + '[--rules both|basic|advanced] [--players all|2|3|4|5|6] [--max-actions POSITIVE] ' + '[--resume FAILED_GAME.json]\n' + + 'Standalone E3 without Skills: e3-nexus, e3-discovery-nexus, e3-homeworld-nexus and e3-homeworld-nexus-discovery, each optionally -tech, -stronghold or -stronghold-tech. Original Ecaz OR Moritani/classic ecaz33/all12 setup, Basic/Advanced2–6, Tech3+, Strongholds Advanced, no training/public starts/save conversion. Native Duke/Occupy and Moritani traitor/Terror/assassination retain their original source and ruling boundaries. Stable new12600–14100 bands leave older names/seeds unchanged.\n' + 'Native trained Discovery/Nexus profiles: paired-discovery-nexus-skills[-tech|-stronghold|-stronghold-tech] retains original paired E1/E2 setup/all14; e3-discovery-nexus-skills[-tech|-stronghold|-stronghold-tech] retains standalone Ecaz OR Moritani original setup/all14. Discovery7/8/all12, Basic/Advanced2–6, Tech3+, Strongholds Advanced; original roster/custody/pending-rule limits and old defaults/seeds remain.\n' + 'Discovery Nexus combinations: discovery-nexus-skills[-tech|-stronghold|-stronghold-tech] uses original classic all14 training; paired-discovery-nexus[-tech|-stronghold|-stronghold-tech] uses both native E1 or E2 factions without Skills. Basic/Advanced2–6, Tech3+, Strongholds Advanced, no Homeworlds/variants/previews; old profiles/defaults/seeds unchanged.\n' + 'Native skill Discovery profiles: native-discovery-skills, native-discovery-skills-tech, native-discovery-skills-stronghold, native-discovery-skills-stronghold-tech; original native skill families/rosters and setup, optional Tech3+ and Advanced Strongholds, no Nexus/Homeworlds/variants/previews. Earlier profiles/defaults/seeds remain unchanged.\n' + @@ -710,7 +734,7 @@ function usage() { + '\nHomeworld-skills, homeworld-discovery and homeworld-discovery-skills use original classic/base33 rosters in Basic/Advanced2..6, all14 offered training when Skills is selected and Discovery7/8 when Discovery is selected. Each family may add -tech (original Tech3+), -stronghold (Advanced2+) or -stronghold-tech (Advanced3+). Original setup/cards/native force sources, physical rescue, Maker/entry, phase-end industry and source-local held effects remain. No Nexus, native families, public starts, save conversion or pending-rule changes.' + '\nNative-homeworld-skills, native-homeworld-discovery and native-homeworld-discovery-skills retain each existing E1/E2 or standalone Ecaz/Moritani roster/deck predicate and original Homeworld pools, Basic/Advanced2..6. Optional -tech requires3+; -stronghold requiresAdvanced2+; -stronghold-tech requiresAdvanced3+. Original private starts, all14 when selected, DS7/8 when selected, physical Suk/residual Cyborg exchange, native aftermath and held/industry/reward timing remain. Nexus, E3pairs/mixed families, unrelated previews, public starts, save conversion and pending rulings remain separate.' + '\nHomeworld-nexus-skills, homeworld-nexus-discovery and homeworld-nexus-discovery-skills compose original classic/base33/all12 with optional all14 training and Discovery7/8, Basic/Advanced2..6. Each family adds -tech at3+, -stronghold inAdvanced2+ or -stronghold-tech inAdvanced3+. Without Skills/Discovery, homeworld-nexus-tech, homeworld-nexus-stronghold and homeworld-nexus-stronghold-tech retain the same prerequisites. Original native Homeworld pools, real closing draws, printed revival deposits, physical Suk and phase-end industry/held rewards remain. Native families, unrelated previews, unresolved effects, public starts and save conversion stay guarded.' - + '\nPaired-homeworld-nexus-skills, paired-homeworld-nexus-discovery and paired-homeworld-nexus-discovery-skills retain original both-native E1 or E2 roster/deck contracts, Homeworlds/all12 and optional all14/DS7+8, Basic/Advanced2..6. Families add -tech at3+, -stronghold inAdvanced2+, -stronghold-tech inAdvanced3+. Paired-homeworld-nexus-tech/-stronghold/-stronghold-tech omit Skills/Discovery. E3-homeworld-nexus-skills and e3-homeworld-nexus-discovery-skills retain standalone Ecaz OR Moritani/classics, Ecaz33/all14/all12, with the same optional prerequisites and original Advanced Harkonnen/Duke/assassination guards. Original typed custody, real closing deals, physical rescue/substitution/marker invoices, phase-end industry and winner/held timing remain. Other native singles/mixed decks, no-Skills E3 Nexus modules, E3pairs/mixed families, pending effects, public starts and save conversion stay separate.' + + '\nPaired-homeworld-nexus-skills, paired-homeworld-nexus-discovery and paired-homeworld-nexus-discovery-skills retain original both-native E1 or E2 roster/deck contracts, Homeworlds/all12 and optional all14/DS7+8, Basic/Advanced2..6. Families add -tech at3+, -stronghold inAdvanced2+, -stronghold-tech inAdvanced3+. Paired-homeworld-nexus-tech/-stronghold/-stronghold-tech omit Skills/Discovery. E3-homeworld-nexus-skills and e3-homeworld-nexus-discovery-skills retain standalone Ecaz OR Moritani/classics, Ecaz33/all14/all12, with the same optional prerequisites and original Advanced Harkonnen/Duke/assassination guards. Original typed custody, real closing deals, physical rescue/substitution/marker invoices, phase-end industry and winner/held timing remain. Separate no-Skills E3 families above retain their own roster/effect predicates. Other native singles/mixed decks, E3pairs/mixed families, pending effects, public starts and save conversion stay separate.' ); } @@ -741,7 +765,7 @@ function positive(value: string | undefined) { } function scenarioName(scenario: Scenario) { - if (homeworldModuleSampleProfile(scenario.profile)) + if (e3NexusSampleProfile(scenario.profile) || homeworldModuleSampleProfile(scenario.profile)) return `${scenario.profile}-${scenario.expansions.join('-') || 'classic'}-${scenario.roster.join('-')}-${scenario.roster.length}-${scenario.rules}`; if (nativeDiscoverySkillsProfile(scenario.profile) || discoveryNexusSkillsProfile(scenario.profile) || pairedDiscoveryNexusSampleProfile(scenario.profile) || nativeDiscoveryNexusSkillsProfile(scenario.profile)) @@ -911,11 +935,11 @@ function resumedGame(path: string) { if ( (game.leaderSkills && !nativeExpansionLeaderSkillsProfile(game) && !composedSkills) || ((game.discoveryEnabled || game.discoveries || game.discoveryStash || game.greatMaker) && !discovery) || - (game.techTokens && !discovery && !composedSkills && !nativeFactionTechProfile(game) && !classicNexusModulesProfile(game) && !pairedNexusModulesProfile(game)) || + (game.techTokens && !discovery && !composedSkills && !nativeFactionTechProfile(game) && !classicNexusModulesProfile(game) && !pairedNexusModulesProfile(game) && !standaloneE3NexusProfile(game)) || game.mentatQuestionPreview ) throw new Error('--resume sample scenarios exclude unsupported optional modules.'); - const scenario = [...SCENARIOS, ...HOMEWORLD_NATIVE_NEXUS_MODULE_SCENARIOS, ...HOMEWORLD_CLASSIC_NEXUS_MODULE_SCENARIOS, ...COMBINED_NEXUS_SCENARIOS, ...COMBINED_HOMEWORLD_NEXUS_SCENARIOS, ...BASE_SCENARIOS, ...MODULE_SCENARIOS, ...EXPANSION_ROSTER_SCENARIOS, ...ECAZ_TREACHERY_SCENARIOS, ...ECAZ_OCCUPY_SCENARIOS, ...PAIRED_NEXUS_SCENARIOS, ...MORITANI_ASSASSINATE_SCENARIOS, ...STRONGHOLD_FACTIONS_SCENARIOS, ...MORITANI_SKILLS_SCENARIOS, ...TLEILAXU_SKILLS_SCENARIOS, ...IX_SKILLS_SCENARIOS, ...CHOAM_SKILLS_SCENARIOS, ...RICHESE_SKILLS_SCENARIOS, ...ECAZ_SKILLS_SCENARIOS, ...SKILLS_TECH_SCENARIOS, ...SKILLS_STRONGHOLD_SCENARIOS, ...NATIVE_TECH_SCENARIOS, ...CLASSIC_NEXUS_MODULE_SCENARIOS, ...PAIRED_NEXUS_MODULE_SCENARIOS, ...NEXUS_SKILLS_SCENARIOS, ...PAIRED_NEXUS_SKILLS_SCENARIOS, ...NEXUS_SKILLS_MODULE_SCENARIOS, ...PAIRED_NEXUS_SKILLS_MODULE_SCENARIOS, ...E3_NEXUS_SKILLS_SCENARIOS, ...HOMEWORLD_OCCUPATION_SCENARIOS, ...DISCOVERY_SCENARIOS, ...DISCOVERY_SKILLS_SCENARIOS, ...DISCOVERY_NEXUS_SCENARIOS, ...DISCOVERY_STRONGHOLD_SCENARIOS, ...NATIVE_DISCOVERY_SKILLS_SCENARIOS, ...DISCOVERY_NEXUS_SKILLS_SCENARIOS, ...PAIRED_DISCOVERY_NEXUS_SCENARIOS, ...NATIVE_DISCOVERY_NEXUS_SKILLS_SCENARIOS, ...HOMEWORLD_CLASSIC_MODULE_SCENARIOS, ...HOMEWORLD_NATIVE_MODULE_SCENARIOS].find((candidate) => + const scenario = [...SCENARIOS, ...E3_NEXUS_MODULE_SCENARIOS, ...HOMEWORLD_NATIVE_NEXUS_MODULE_SCENARIOS, ...HOMEWORLD_CLASSIC_NEXUS_MODULE_SCENARIOS, ...COMBINED_NEXUS_SCENARIOS, ...COMBINED_HOMEWORLD_NEXUS_SCENARIOS, ...BASE_SCENARIOS, ...MODULE_SCENARIOS, ...EXPANSION_ROSTER_SCENARIOS, ...ECAZ_TREACHERY_SCENARIOS, ...ECAZ_OCCUPY_SCENARIOS, ...PAIRED_NEXUS_SCENARIOS, ...MORITANI_ASSASSINATE_SCENARIOS, ...STRONGHOLD_FACTIONS_SCENARIOS, ...MORITANI_SKILLS_SCENARIOS, ...TLEILAXU_SKILLS_SCENARIOS, ...IX_SKILLS_SCENARIOS, ...CHOAM_SKILLS_SCENARIOS, ...RICHESE_SKILLS_SCENARIOS, ...ECAZ_SKILLS_SCENARIOS, ...SKILLS_TECH_SCENARIOS, ...SKILLS_STRONGHOLD_SCENARIOS, ...NATIVE_TECH_SCENARIOS, ...CLASSIC_NEXUS_MODULE_SCENARIOS, ...PAIRED_NEXUS_MODULE_SCENARIOS, ...NEXUS_SKILLS_SCENARIOS, ...PAIRED_NEXUS_SKILLS_SCENARIOS, ...NEXUS_SKILLS_MODULE_SCENARIOS, ...PAIRED_NEXUS_SKILLS_MODULE_SCENARIOS, ...E3_NEXUS_SKILLS_SCENARIOS, ...HOMEWORLD_OCCUPATION_SCENARIOS, ...DISCOVERY_SCENARIOS, ...DISCOVERY_SKILLS_SCENARIOS, ...DISCOVERY_NEXUS_SCENARIOS, ...DISCOVERY_STRONGHOLD_SCENARIOS, ...NATIVE_DISCOVERY_SKILLS_SCENARIOS, ...DISCOVERY_NEXUS_SKILLS_SCENARIOS, ...PAIRED_DISCOVERY_NEXUS_SCENARIOS, ...NATIVE_DISCOVERY_NEXUS_SKILLS_SCENARIOS, ...HOMEWORLD_CLASSIC_MODULE_SCENARIOS, ...HOMEWORLD_NATIVE_MODULE_SCENARIOS].find((candidate) => (homeworldDiscoverySampleProfile(candidate.profile) || nativeDiscoverySkillsProfile(candidate.profile) || discoveryProfile(candidate.profile) || discoverySkillsProfile(candidate.profile) || discoveryNexusProfile(candidate.profile)) === discovery && (homeworldModuleSampleProfile(candidate.profile) || candidate.profile === 'homeworld' || candidate.profile === 'homeworld-occupation' || candidate.profile === 'homeworld-nexus' || candidate.profile === 'combined-homeworld-nexus') === !!game.homeworlds && (candidate.profile === 'paired-nexus-skills' || candidate.profile === 'nexus-skills' || candidate.profile === 'nexus' || nexusModuleProfile(candidate.profile) || 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 && @@ -925,7 +949,7 @@ function resumedGame(path: string) { (homeworldStrongholdSampleProfile(candidate.profile) || discoveryStrongholdProfile(candidate.profile) || nativeStrongholdProfile(candidate.profile) || strongholdSkillProfile(candidate.profile) || nexusStrongholdProfile(candidate.profile)) === !!game.strongholdCards && (candidate.profile === 'ecaz-treachery' || candidate.ecazTreachery === true) === !!game.ecazTreachery && assassinationScenario(candidate) === !!game.moritaniAssassinatePreview && - (candidate.profile === 'ecaz-occupy' || ((homeworldModuleSampleProfile(candidate.profile) || nativeDiscoveryNexusSkillsProfile(candidate.profile) || nativeDiscoverySkillsProfile(candidate.profile) || nativeTechProfile(candidate.profile) || discoveryProfile(candidate.profile)) && candidate.roster.includes('ecaz'))) === !!game.ecazOccupyPreview && + (candidate.profile === 'ecaz-occupy' || ((e3NexusSampleProfile(candidate.profile) || homeworldModuleSampleProfile(candidate.profile) || nativeDiscoveryNexusSkillsProfile(candidate.profile) || nativeDiscoverySkillsProfile(candidate.profile) || nativeTechProfile(candidate.profile) || discoveryProfile(candidate.profile)) && candidate.roster.includes('ecaz'))) === !!game.ecazOccupyPreview && assassinationScenario(candidate) === !!game.moritaniAssassinate && candidate.rules === (game.advanced ? 'advanced' : 'basic') && JSON.stringify(candidate.expansions) === @@ -1133,12 +1157,12 @@ async function main() { throw new Error('Moritani assassination samples require Advanced rules.'); if ((homeworldStrongholdSampleProfile(profile) || profile === 'stronghold-factions' || strongholdSkillProfile(profile) || nexusStrongholdProfile(profile)) && rules === 'basic') throw new Error('Stronghold samples require Advanced rules.'); - if ((homeworldTechSampleProfile(profile) || profile === 'skills-tech' || profile === 'skills-stronghold-tech') && players === 2) + if ((homeworldTechSampleProfile(profile) || nexusTechProfile(profile) || profile === 'skills-tech' || profile === 'skills-stronghold-tech') && players === 2) throw new Error('Tech Tokens samples require three through six players.'); if ((discoveryTechProfile(profile) || (discoveryNexusProfile(profile) && nexusTechProfile(profile))) && players === 2) throw new Error('Discovery with Tech Tokens requires three through six players.'); const resume = values.resume ? resumedGame(values.resume) : null; - const requestedSamples = homeworldModuleSampleProfile(profile) + const requestedSamples = e3NexusSampleProfile(profile) ? E3_NEXUS_MODULE_SCENARIOS : homeworldModuleSampleProfile(profile) ? (nativeHomeworldNexusSampleProfile(profile) ? HOMEWORLD_NATIVE_NEXUS_MODULE_SCENARIOS : homeworldNexusSampleProfile(profile) ? HOMEWORLD_CLASSIC_NEXUS_MODULE_SCENARIOS : profile.startsWith('native-') ? HOMEWORLD_NATIVE_MODULE_SCENARIOS : HOMEWORLD_CLASSIC_MODULE_SCENARIOS) diff --git a/tools/start-prototype.ts b/tools/start-prototype.ts index f7207e87..900dd8a8 100644 --- a/tools/start-prototype.ts +++ b/tools/start-prototype.ts @@ -34,6 +34,7 @@ async function main() { '\nWithout Skills, factions preserves unused original Tech Tokens in fresh 3–6-seat Basic/Advanced selected E1/E2 native or standalone Ecaz OR Moritani lobbies with classic opponents; stronghold-factions composes them with Advanced Stronghold Cards. Original physical inventories, first-Storm owners, selected Ecaz lead rewards, even-force Basic Occupy and non-Harkonnen Advanced Moritani assassination remain. Skills, unrelated overlays, paired/mixed E3 rosters and public starts remain separate.', '\nNexus preserves selected unused original Tech Tokens in classic/base33/all12 three-through-six-seat Basic/Advanced lobbies, and/or unused Stronghold Cards in Advanced two-through-six-seat lobbies; both requires3+. Existing Homeworlds may be retained. Genuine first-Storm/end-Mentat custody and closing Nexus deals remain. Borrowed tariff/force identity, original free-return ledgers, phase-end industry, held support and retention/winner rewards reuse existing handlers. Skills, expansion families outside their documented entries, unrelated previews, unresolved effects and public starts remain separate.', '\nNexus additionally routes a supported paired native E1 (Ixians+Tleilaxu) OR E2 (CHOAM+Richese) lobby with classics and one required deck through original paired setup when Tech and/or Advanced Strongholds is selected. Tech requires3–6 Basic/Advanced, Strongholds2–6 Advanced, both3–6 Advanced. All12 Nexus, original47/35 decks/cache, native setup and actual module custody remain. Mixed families, E3, Skills, other overlays, public starts and pending rulings stay separate.', + '\nNexus additionally admits standalone Ecaz OR Moritani with classics and the original ecaz33/all12 deck without Skills, Basic/Advanced2–6, optionally original Homeworlds, Discovery through --discoveries, Tech3+ and Advanced Strongholds2+. Original Traitors/native setup and qualifying end-Spice closing draw remain; no starting Nexus deal or training is added. Ecaz Arrakis Occupy and quiet living-Duke Cunning retain their own boundaries; an otherwise legal Advanced Ecaz/Harkonnen roster does not lift the separate Cunning exclusion. Advanced Moritani excludes Harkonnen and uses original post-loss assassination/normal-call forfeiture, private replacement and supply-only Terror Cunning. E3 pairs/mixed families, native free-return/relocation rulings, unrelated previews, public starts and save conversion stay separate.', '\nLeader-skills --nexus-cards explicitly adds all12 Nexus to fresh classic/base33 OR paired Ixians+Tleilaxu/Ix47 OR paired CHOAM+Richese/CHOAM35+cache setups with classics and all14 skills, two-through-six seats Basic/Advanced. Existing Homeworlds may be retained. Original starting hands/offers, skill/Traitor/native order and genuine closing deals remain. Mixed families/E3 pairs, Banker/Mentat previews, borrowed Smuggler/pair-companion arithmetic, public starts and unresolved interactions stay separate.', '\nThese classic or paired one-family skill-Nexus entries preserve selected unused original Tech Tokens at3–6 Basic/Advanced and/or Stronghold Cards at2–6 Advanced; both require3–6 Advanced. Real first-Storm/end-Mentat custody, phase-end industry, held subsidy, typed skill rescue/equal substitution, signed one-invoice marker pairs and mandatory original-winner Tech before Face Dance use original handlers. No mixed-family/native free-return accounting or pending rulings are opened.', '\nThe same fresh skill/Nexus entry admits standalone Ecaz OR Moritani with classics, exact ecaz33/all14/all12, Basic/Advanced2–6. Optional unused Tech requires3+; Strongholds requires Advanced; both3+ Advanced. Native Ecaz five-disc/Duke assignment and quiet living-Duke Cunning, plus original Advanced Moritani skill-first assassination/normal-traitor forfeiture, remain. Advanced Harkonnen, shared-Duke assignment, enhanced Terror relocation/HMS/Grumman and other pending effects stay guarded.', @@ -43,8 +44,8 @@ async function main() { '\nWhen native Ecaz is seated, this original fresh entry separately marks its printed high Homeworld alliance victory: current native seven-plus, one actual joint stronghold, two distinct other native factions with proved current occupied holdings. It adds no ordinary stronghold/Tech points, pays no reward and changes no Duke authority. Public progress remains prospective until original Mentat; qualifying members join original winners before BG prediction/final fallback. Unmarked played/public profiles remain unchanged.', '\nDiscovery opts fresh classic, selected E1/E2 native families or standalone Ecaz OR Moritani with classics into original Discovery setup in Basic/Advanced2–6, optionally retaining original Homeworlds, unused Tech3–6 and Advanced Strongholds2–6. Original decks/cache, HMS/Face Dancers, typed entry, revealed-only nested No-Fields, stash limits, end-Mentat claims and native outcomes remain. Advanced Moritani excludes Harkonnen. Skills/Nexus, paired/mixed E3, contested/shared-payer rulings, public starts and played-game conversion stay separate.', '\nLeader-skills --discoveries composes fresh supported classic or native original family decks/all14 Skills with Discovery7/8 at2–6 seats Basic/Advanced under each existing roster predicate. Original Homeworlds may be retained; adding --nexus-cards requires classic factions, both native Ixians+Tleilaxu OR CHOAM+Richese with one family deck, or standalone Ecaz OR Moritani with classics. Tech requires3–6 and Strongholds Advanced2–6. Original private card choices/training/hands, Maker/votes/rides, physical residual Cyborg exchange, native aftermath and cleanup/Tech/Face Dance retain their own timing. Other native singles/mixed decks, E3pairs/mixed families, independent variants, Banker/Mentat previews, shared Duke/borrowed Smuggler/pair companion/native returns and pending rulings remain separate.', - '\nNexus --discoveries explicitly composes fresh classic/base33 OR both native Ixians+Tleilaxu/Ix47 OR CHOAM+Richese/CHOAM35+cache with all12 Nexus and original Discovery7/8 at2–6 seats Basic/Advanced, optionally retaining original Homeworlds. Unused Tech requires3+; optional Strongholds require Advanced; both3+. Actual Great Maker losses/votes/typed reserve rides, both Advanced piles, settled-alliance end-Spice closing deals and original held claims remain. Signed native two-marker nested shipments retain one printed invoice, current revealed destination and original reserve materialization. No-Skills E3/mixed families, mixed-module Fremen Betrayal, pending rulings, public activation and save conversion stay guarded.', - '\nFresh supported classic or native E1/E2/standalone E3 lobbies with original Homeworlds support leader-skills, discovery or leader-skills --discoveries, Basic/Advanced2–6. Preserve unused Tech3+ and Advanced Strongholds2+; both requireAdvanced3+. Classic or paired E1/E2 entries additionally compose Nexus through original leader-skills --nexus-cards with optional --discoveries, or original nexus with optional --discoveries/Tech/Strongholds. Standalone Ecaz OR Moritani with classics additionally composes Homeworld/Nexus only with Skills in this bounded entry. Native roster/deck/assassination/Duke boundaries remain. Original all14/DS7+8 when selected, private native starts, split Imperial and typed native/visitor/nested pools, real closing draws, printed native revival deposits, phase-end industry, held effects and winner rewards use original consumers. Other native singles/mixed-deck Nexus, no-Skills E3 Nexus modules, unsupported E3pair/mixed rosters, unrelated previews, public starts and save conversion remain separate.', + '\nNexus --discoveries explicitly composes fresh classic/base33, both native Ixians+Tleilaxu/Ix47 OR CHOAM+Richese/CHOAM35+cache, or standalone Ecaz OR Moritani/ecaz33 with classics, all12 Nexus and original Discovery7/8 at2–6 seats Basic/Advanced without Skills, optionally retaining original Homeworlds. Unused Tech requires3+; optional Strongholds require Advanced; both3+. Actual Great Maker losses/votes/typed reserve rides, both Advanced piles, settled-alliance end-Spice closing deals and original held claims remain. Signed native two-marker nested shipments retain one printed invoice, current revealed destination and original reserve materialization. E3pairs/mixed families, mixed-module Fremen Betrayal, pending rulings, public activation and save conversion stay guarded.', + '\nFresh supported classic or native E1/E2/standalone E3 lobbies with original Homeworlds support leader-skills, discovery or leader-skills --discoveries, Basic/Advanced2–6. Preserve unused Tech3+ and Advanced Strongholds2+; both requireAdvanced3+. Classic, paired E1/E2 or standalone Ecaz OR Moritani/classic entries additionally compose Nexus through original leader-skills --nexus-cards with optional --discoveries, or original nexus with optional --discoveries/Tech/Strongholds without Skills. Native roster/deck/assassination/Duke boundaries remain. Original all14 when Skills selected/DS7+8 when selected, private native starts, split Imperial and typed native/visitor/nested pools, real closing draws, printed native revival deposits, phase-end industry, held effects and winner rewards use original consumers. Other native singles/mixed-deck Nexus, unsupported E3pair/mixed rosters, unrelated previews, public starts and save conversion remain separate.', ); return; }