From 60d1bd3b592a07eb3061b531a131f4a608e40a9a Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Sun, 18 Oct 2026 01:17:13 +1100 Subject: [PATCH] Unify registered native E3 Leader Skills capability --- docs/CHOAM_LEADER_SKILLS.md | 4 +- docs/CURRENT_STATUS.md | 24 +++++ docs/ECAZ_OCCUPY_RULES.md | 10 ++ docs/ECAZ_TREACHERY_RULES.md | 10 ++ docs/HOMEWORLD_RUNTIME_IMPLEMENTATION.md | 20 ++-- docs/IMPLEMENTATION_STATUS.md | 28 ++++++ docs/IX_LEADER_SKILLS.md | 10 +- docs/LEADER_SKILLS_RUNTIME.md | 52 ++++++++++ docs/MORITANI_ASSASSINATE_LEADERS.md | 9 +- docs/MORITANI_LEADER_SKILLS.md | 15 ++- docs/RULE_DECISIONS.md | 8 +- docs/SUK_GRADUATE_RULES.md | 8 ++ docs/TLEILAXU_LEADER_SKILLS.md | 4 +- game/engine.ts | 4 +- game/leader-skill-profile.ts | 60 ++++++------ game/reference.ts | 26 ++--- .../fixture-homeworld-native-e1e2-modules.ts | 4 +- tests/fixture-mixed-e3-ecaz-nexus.ts | 84 +++++++++++----- tests/fixture-mixed-e3-moritani-nexus.ts | 76 +++++++++++---- tests/mixed-e3-ecaz-nexus-runtime.test.ts | 80 +++++++++++++++- tests/mixed-e3-moritani-nexus-runtime.test.ts | 95 ++++++++++++++++++- 21 files changed, 506 insertions(+), 125 deletions(-) diff --git a/docs/CHOAM_LEADER_SKILLS.md b/docs/CHOAM_LEADER_SKILLS.md index 03474166..4308d83b 100644 --- a/docs/CHOAM_LEADER_SKILLS.md +++ b/docs/CHOAM_LEADER_SKILLS.md @@ -15,10 +15,10 @@ Other Richese/module compositions below retain their separate guards. ## Configuration and components Fresh Basic/Advanced2–6 uses the shared selected-deck roster contract: select -CHOAM for a CHOAM/Richese seat, with other selected E1/E2/classic seats allowed. +CHOAM for a CHOAM/Richese seat, with other registered selected factions allowed. Neither a seated CHOAM nor independent cards is required just to select that ordinary deck. Existing World/all14/card/Tech/Advanced Stronghold compositions -follow the [17 October current scope](LEADER_SKILLS_RUNTIME.md#world-skills-and-selected-decks--17-october-2026), +follow the [18 October registered scope](LEADER_SKILLS_RUNTIME.md#registered-native-e3-selected-decks--18-october-2026), not the original single-family/no-module software restriction. Setup keeps all fourteen physical skill cards and the real 35-card Treachery diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index d6874579..f61cd018 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -29,6 +29,30 @@ optional skill bands and combinations remain open. Rules implementation continues; comprehensive assurance and administration remain deferred until all rules are implemented. +**Registered native E3 selected decks — 18 October. Prototyped / Partial.** +Sixteen ordinary plain/card all14 starts rejected before; all16 now reach +native setup. Registered seats simply require their selected ordinary family; +mixed E3 and unused extra decks need no incidental Nexus/single-family/card. +Original World/phase modules and native power bindings remain; Advanced +E3/Harkonnen, shared Duke, original Nexus/Discovery/Semuta and bare no-Skills +mixed World/phase guards stay separate. No played save is converted. + +Genuine mixed Eca World play retains earned Tabr1, support2, fee3/H2 and +committed-only Suk3, separate allied losses and physical cards. Genuine native +Mori/CHOAM loss pays CHOAM’s source3, kills different unused trained Frankos4, +returns one skill and draws one Mentat replacement. Unused selected Ix/CHOAM +decks still permit actual Eca Duke6 without creating those faction seats. + +Frozen53-file **609/609**, types/lint/build and actual controllers pass. +Old fixture shape/flag/helper-echo pins are deleted. The four-seat case no +longer assumes every player owns one of only three Tech tokens; its real Tabr +receipt remains, while the Basic three-seat case proves actual Tech transfer. +Three bounded normal tables reach turn3/earlier wins (nine battles). Real390px +four-seat five-module entry completes Ix card-before-training, native setup +and the Amal opening into Storm with no errors/overflow. Local prototype +evidence is not full-mode, calibration, deployed or deferred assurance acceptance. +[Current registered scope and source-bound evidence](LEADER_SKILLS_RUNTIME.md#registered-native-e3-selected-decks--18-october-2026). + **Planetologist mixed advisor gather — 18 October. Prototyped / Partial.** A genuine turn-two Carthag fighter plus older Imperial Basin advisor reproduced the enemy-Arrakeen guard despite ordinary city range. The explicit recommended diff --git a/docs/ECAZ_OCCUPY_RULES.md b/docs/ECAZ_OCCUPY_RULES.md index ac0e44f0..77558d5f 100644 --- a/docs/ECAZ_OCCUPY_RULES.md +++ b/docs/ECAZ_OCCUPY_RULES.md @@ -2,6 +2,16 @@ Historical primary-source audit, 2026-09-07, corrected after independent verification. The publisher/designer odd-force conflict remains unresolved as a publisher correction. Under the 8 October Basic-first prototype instruction, Basic now provisionally uses the main p8 paragraph: ceiling contribution/loss and floor survivors, with a visible control warning. This is not a numerical ruling by the user or designer. Advanced retains its separately authorized source. The earlier false attribution to Jack Reda remains withdrawn. +## Registered mixed Skills — 18 October 2026 + +Actual Eca and registered selected E1/E2/classic seats now use all14 without +Nexus or an exact single family, optionally original Worlds/phase/cards. +Basic typed coalition losses and actual three-seat Tech transfer remain; +Advanced earned Tabr1, support2, own source3/H2 and committed-only Suk3 stay +separate from allied2 losses. Unused selected decks also retain actual native +Duke6 acquisition without creating a faction seat. No new printed arithmetic. +[Current scope, role guards and609/609 proof](LEADER_SKILLS_RUNTIME.md#registered-native-e3-selected-decks--18-october-2026). + ## Native World cards and Skills — 17 October 2026 Standalone/paired Ecaz/Moritani Worlds now composes independent cards with or diff --git a/docs/ECAZ_TREACHERY_RULES.md b/docs/ECAZ_TREACHERY_RULES.md index f512b3cf..4762dcc1 100644 --- a/docs/ECAZ_TREACHERY_RULES.md +++ b/docs/ECAZ_TREACHERY_RULES.md @@ -479,3 +479,13 @@ actual390px normal native World setup pass their changed paths. [Current scope, controls and qualified evidence](LEADER_SKILLS_RUNTIME.md#native-e3-worlds-with-independent-cards--17-october-2026). No save conversion, full combinations, calibration or deployed acceptance claim. +## Registered mixed E3 Skills — 18 October 2026 + +Printed variant independence now also composes all14 across registered actual +selected-family seats, including mixed E3/E1/E2 and unused ordinary decks, +without an artificial Nexus or exact-family dependency. Original World/phase, +native setup/decks/cache and source-bearing R/H mechanics remain. Bare mixed +E3 World/phase cards, original Nexus/Discovery/Semuta, Advanced role and +effect-specific limits stay separate. No numeric or timing ruling changes. +[Actual native coalition/assassination, controls and609/609 proof](LEADER_SKILLS_RUNTIME.md#registered-native-e3-selected-decks--18-october-2026). + diff --git a/docs/HOMEWORLD_RUNTIME_IMPLEMENTATION.md b/docs/HOMEWORLD_RUNTIME_IMPLEMENTATION.md index c7d581ed..27d58f18 100644 --- a/docs/HOMEWORLD_RUNTIME_IMPLEMENTATION.md +++ b/docs/HOMEWORLD_RUNTIME_IMPLEMENTATION.md @@ -5,16 +5,16 @@ the authoritative game, player table and AI. Normal Homeworld starts are now labelled previews, not complete Advanced/expansion acceptance. The earlier component checkpoint remains in [HOMEWORLD_IMPLEMENTATION.md](HOMEWORLD_IMPLEMENTATION.md). -**17 October World Skills/cards:** original classic/selected E1/E2 and -standalone/paired E3 Worlds now compose all14 and independent cards, optionally -original Tech/Advanced Strongholds. Native E3 World cards also work without -Skills; paired plain World Skills needs no Nexus/card. Original typed sources, -own-World fee/dial disjointness, committed-only Suk, current-rate revival and -every own-World Harass ban remain. Nexus/Discovery card contexts, mixed E3 and -existing roles/effect guards stay separate. No World/counter/wallet or played -history is manufactured. -[Latest native E3 scope and459/459 proof](LEADER_SKILLS_RUNTIME.md#native-e3-worlds-with-independent-cards--17-october-2026); -[prior selected-deck/World683/683 proof](LEADER_SKILLS_RUNTIME.md#world-skills-and-selected-decks--17-october-2026). +**18 October registered World all14:** every registered actual seat uses its +selected ordinary family, including mixed E3/E1/E2 and unused extra decks, +without a Nexus, single-family or independent-card requirement. Original +Tech/Advanced Strongholds and optional independent cards retain native sources, +pre-fee traits, fee/dial disjointness, committed-only Suk and every own-World +Harass ban. Bare native E3 cards keep their original roster envelope without +Skills. Original Nexus/Discovery/Semuta, Advanced role and effect guards remain; +no World, counter, wallet, owner or played history is manufactured. +[Latest registered scope and609/609 proof](LEADER_SKILLS_RUNTIME.md#registered-native-e3-selected-decks--18-october-2026); +[prior standalone/paired World459/459 proof](LEADER_SKILLS_RUNTIME.md#native-e3-worlds-with-independent-cards--17-october-2026). ## Mixed E1/E2 and paired E3 Nexus — 7 October 2026 diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 5ec8b5b6..23bae27f 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,33 @@ # Dune implementation status +## 18 October 2026 — Registered native E3 selected-deck Skills + +Sixteen normal mixed-native/unused-deck/plain/card starts reject before. +Registered actual seats now use selected ordinary decks without an incidental +Nexus, exact E3 family or independent-card dependency. Actual native Eca/Mori +predicates reuse the shared capability; original World/phase modules remain. +Advanced E3/Harkonnen, shared Duke, original Nexus/Discovery/Semuta and bare +no-Skills mixed World/phase guards stay. No state/schema or played-save retrofit. + +Actual earned Tabr receipt1, support2, own source3/H2 and committed-only Suk3 +settle separately in the mixed World coalition; the ally loses2. Native +Mori/CHOAM/Richese/Guild loss spends CHOAM’s own World3, kills different unused +trained Frankos Aru4 once, returns one skill and receives one Mentat replacement. +Unused selected Ix/CHOAM decks retain native Eca Duke6/one paid Guild arrival +with no such faction seats and ordinary47 stock. + +Frozen53-file **609/609**, types/lint/build and independent controller smokes +pass. World stock counts the selected independent three cards; plan allocation +uses the native reserve type. Incidental setup/helper-copy pins are removed. +A four-seat loser legally has no Tech token; that assumption is deleted, +not re-pinned, while real Tabr income and Basic three-seat token transfer stay. +Three bounded original tables reach turn3/earlier wins through nine battles. +Actual390px four-seat five-module entry retains Ix draft-before-training, +native stock and Amal opening into Storm without errors/overflow. Existing +build warnings and provisional numeric/timing interpretations remain. No +full-mode, calibration, deployed or deferred assurance claim; no game reset. +[Canonical current scope](LEADER_SKILLS_RUNTIME.md#registered-native-e3-selected-decks--18-october-2026). + ## 18 October 2026 — Mixed Bene Gesserit Planetologist gather An original turn-two Carthag fighter plus older Imperial Basin advisor cannot diff --git a/docs/IX_LEADER_SKILLS.md b/docs/IX_LEADER_SKILLS.md index 5f704eeb..b8fc17d5 100644 --- a/docs/IX_LEADER_SKILLS.md +++ b/docs/IX_LEADER_SKILLS.md @@ -3,20 +3,20 @@ Connected development prototype, 21 September 2026. Genuine Basic Ixian setup supports all fourteen Skills; normal starts are labelled previews, while complete-mode/publication acceptance remains gated. The -[17 October selected-deck/World cutover](LEADER_SKILLS_RUNTIME.md#world-skills-and-selected-decks--17-october-2026) -supersedes the old single-family/no-module admission below, not printed powers. +[18 October registered selected-deck/World scope](LEADER_SKILLS_RUNTIME.md#registered-native-e3-selected-decks--18-october-2026) +supersedes the old single-family/no-module admission, not printed powers. **4 October Advanced continuation:** native Ixians and/or CHOAM with classic opponents, distinct required family decks and all fourteen skills now compose under the [bounded native contract](LEADER_SKILLS_RUNTIME.md#advanced-native-ixian-and-choam-composition--4-october-2026). Advanced Ixian support, skilled Suk rescue and subsequent native substitution use original physical counters. Foreign-ghola revival remains Advanced-only -under native Tleilaxu conditions; current optional scope follows17 October above. +under native Tleilaxu conditions; current optional scope follows18 October above. ## Setup and configuration -Use Basic or Advanced2–6 with the selected Ix deck; other selected E1/E2 and -classic seats follow the shared selected-deck contract. A native Ixian seat is +Use Basic or Advanced2–6 with the selected Ix deck; other registered selected +factions, including native Ecaz/Moritani, follow the shared contract. An Ixian seat is not required merely to select its ordinary deck. Existing supported World, Tech/Advanced Strongholds and independent-card combinations use the canonical scope above. The real private Ixian starting-card choice still precedes skill diff --git a/docs/LEADER_SKILLS_RUNTIME.md b/docs/LEADER_SKILLS_RUNTIME.md index 01adf2b0..f4d018a2 100644 --- a/docs/LEADER_SKILLS_RUNTIME.md +++ b/docs/LEADER_SKILLS_RUNTIME.md @@ -7,6 +7,58 @@ and fixture opt-ins are removed; existing games are not retrofitted. Earlier dated references to a separate entry describe historical checkpoints. See [current activation and limits](SPICE_BANKER_RUNTIME.md#mandatory-ordinary-skills-default--17-october-2026). +## Registered native E3 selected decks — 18 October 2026 + +Fresh all14 now validates every registered actual seat against its selected +ordinary family, Basic/Advanced2–6. Ecaz/Moritani may coexist with selected +E1/E2 natives; unused selected decks need no native seat. No Nexus, exact +single E3 family or independent-card dependency is invented. Original Worlds, +Tech3+ and Advanced Strongholds2+ remain optional; independent36/38/50 reuses +the same capability and separate Richese cache. + +Actual Eca/Mori role predicates bind the seated factions to this capability, +retaining printed setup, mandatory normal Banker and native powers. Original +Nexus/Discovery/Semuta envelopes retain their existing predicates; independent +Nexus/Discovery, bare no-Skills mixed E3 World/phase, Advanced E3/Harkonnen, +shared Duke and effect-specific exclusions remain. Foreign-ghola custody still +explicitly requires Advanced. No numeric ruling, new state or save retrofit. + +Sixteen normal starts rejected before and now reach actual native setup. +Actual unused Ecaz+Emperor+Guild with all three families keeps ordinary47, +no Nx/cards, a paid1 Guild arrival and untrained native Duke6 acquisition. +It creates neither an Ixian nor a CHOAM seat. Normal original Nx/Nx+Discovery +starts remain; unselected mixed Discovery and independent Nx reject unchanged. + +Existing mixed-Nexus Ecaz/Mori fixture/runtime pairs add default-preserving +`nexus:false` and optional `independent:true` at original setup. Null drawing/ +alliance fields are real absences, never synthetic phases or played-card +removal. World sources, original source entropy and labelled conserved cards +remain. The shared World stock guard now counts the selected original three +cards; the plan helper uses the actual native source type, not an impossible +literal-Action extraction. + +Actual turn3 mixed Eca/Mori/Ix/Guild World coalition earns Tabr and its receipt1. +Support2, own R fee3, H return2 and committed-only Suk3 (keep1/return2) settle +separately: Eca reserves9→10/Tanks0→3/board5→1/purse7→6; ally loses only2. +The four-seat loser owns no Tech token in this programme; the separate Basic +three-seat no-card case proves its actual transfer. No token is fabricated. +Actual Moritani/CHOAM/Richese/Guild World loss uses CHOAM’s original R: +native reserves19→16/Tanks0→4 (fee3+commit1), while Mori reserves14 stay. +Different unused trained Frankos Aru4 dies once, returns one technique and +yields one actual Mentat replacement. This route has no rescue window; +the Eca programme supplies the committed-only rescue proof. + +Frozen53-file **609/609**, types/lint/build and independent actual controllers +pass. Three bounded ordinary tables reach turn3/earlier wins through nine +battles with all four legal policies represented, not calibrated strategy. +Real390px four-seat Ix/Eca/Mori/Guild five-module normal entry chooses the +original Ix Shield draft before offered Smuggler/Dominic training, completes +Traitors/native setup and the Amal opening into Storm, with Ix10N4C/native14, +no browser errors/root overflow. Detailed targeted programmes, not those +natural tables, prove fees, income and assassination. No full-mode, deployed +or deferred assurance acceptance follows. + + ## Mixed BG Planetologist gather — 18 October 2026 The actual checklist gap now has a connected prototype: mixed advisor/fighter diff --git a/docs/MORITANI_ASSASSINATE_LEADERS.md b/docs/MORITANI_ASSASSINATE_LEADERS.md index 4eb3359d..e956c66b 100644 --- a/docs/MORITANI_ASSASSINATE_LEADERS.md +++ b/docs/MORITANI_ASSASSINATE_LEADERS.md @@ -1,12 +1,19 @@ # Moritani: Advanced Assassinate Leaders preview -Updated 17 October 2026. **Prototyped / Partial**. Supported fresh native +Updated18 October2026. **Prototyped / Partial**. Supported fresh native Advanced Moritani starts initialize this printed advantage automatically, including plain faction starts and the independent-card profile. Normal lobby access is a labelled preview, not complete-mode or publication acceptance. This battle-loss ability is separate from the random Assassination Terror token. Played saves are not assigned fabricated prior-call or opportunity history. +**18 October registered Skills:** mixed native E1/E2/E3 seats and unused +selected decks no longer need Nexus or one exact E3 family for all14. Actual +Mori binds its original Advanced producer; a CHOAM native-source fee and normal +loss reach unused trained Frankos4, one skill return and one real replacement. +Basic, normal-call forfeiture, shared Duke and Advanced role limits remain. +[Current609/609 evidence and preserved original module guards](LEADER_SKILLS_RUNTIME.md#registered-native-e3-selected-decks--18-october-2026). + **17 October World continuation:** native World/card/phase starts now retain the same Advanced power with or without Skills. Real paid World shipments and own reserve fees/typed H returns precede a normal loss; a different unused diff --git a/docs/MORITANI_LEADER_SKILLS.md b/docs/MORITANI_LEADER_SKILLS.md index dd046ec2..10ff4223 100644 --- a/docs/MORITANI_LEADER_SKILLS.md +++ b/docs/MORITANI_LEADER_SKILLS.md @@ -1,9 +1,16 @@ # Moritani with Leader Skills -Connected development prototype, updated 17 October 2026. Supported standalone -or paired native Ecaz/Moritani/classic Basic/Advanced profiles use all fourteen -Leader Skills. Normal-lobby access is a labelled preview; original roster and -module guards remain, and complete rules/publication acceptance is unfinished. +Connected development prototype, updated18 October2026. Registered selected +native Ecaz/Moritani with E1/E2/classic Basic/Advanced seats use all14 without an +incidental Nexus or single-family dependency. Original Worlds/phase/independent +cards compose; normal access is a preview, not full rules/publication acceptance. + +**Current mixed-native continuation:** actual CHOAM World source3/commit1 +remains in Tanks after the normal win; Mori pays no partner fee. Different unused +trained Frankos Aru4 dies once and gets one physical Mentat replacement. +Basic gains no assassination. Original Nexus/Discovery/Semuta, shared Duke and +Advanced role guards remain. +[Registered scope and609/609 proof](LEADER_SKILLS_RUNTIME.md#registered-native-e3-selected-decks--18-october-2026). ## Native World continuation — 17 October 2026 diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index ac2f1b2d..a60a43bb 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -681,9 +681,9 @@ dedicated retrieved worked FAQs. [Contract and remaining limits](HOMEWORLD_OCCUP | Recruits / classic Homeworlds | Publisher E3 pp11/16 gives low Fremen4 doubled but capped7. Under choose-recommended, compose original separate-request native population observations: current rate at acceptance, retained accepted group price/free classification, then later requests use changed population and recorded free usage. Bulk7 versus split1+5 is provisional timing, not a new FAQ/user answer. Fremen still cannot buy a seventh without Tleilaxu; typed native deposits and original special-force caps remain. Prior paid/ready returns retain payment and opportunity while phase4 stays open. Clean classic2–6 Basic/Advanced only; Reinforcements uses its separate native fee contract and other roster/overlay/public gates stay. | [Current-rate scope and proof](RECRUITS_RUNTIME.md#classic-homeworld-composition--16-october-2026) | | Reinforcements / classic Homeworlds | Under standing choose-recommended, seal exact native normal-first sources for the existing three-reserve/all-outcome fee. Ambiguous omission rejects; a unique source may derive. Native reserves on an own World are also its army, so that fee cannot fund the same physical dial/casualties; an off-World fee leaves the pool. Keep original pre-fee traits/printed face, including explosion loss choices across a threshold, then typed Tanks, Harass return and original casualties. This is explicit provisional physical-pool composition, not a new FAQ or own-World ban. Source-aware full/complying plan reviews and named independent-card conditions use canonical names and actual combat locations. Other rosters/overlays/public starts remain gated. | [Native sources and exact proof](REINFORCEMENTS_RUNTIME.md#classic-homeworld-reserve-fees--16-october-2026) | | Reinforcements / clean selected Ix and CHOAM | Admit original independent cards with clean selected native/base Basic/Advanced rosters and no optional modules/overlays. Reuse existing provisional normal-first/all-outcome payment, original No-Field materialization before the fee, then successful Harass return; physical Stone ignores +2. Native cyborg substitution uses its own casualty origins, not a duplicate reserve fee. This closes admission consistency, not a new numerical ruling or public expansion gate. | [Profile and bounded proof](REINFORCEMENTS_RUNTIME.md#clean-selected-ixchoam-families--16-october-2026) | -| Independent Treachery / all14 Skills | Printed E3 variant independence composes fresh classic, selected E1/E2 native/base or standalone/paired Ecaz/Moritani/classic all14, Basic/Advanced2–6, including original Worlds and Tech3+/Advanced Strongholds2+. Original36/38/50, cache, native6/6 and source custody stay. Native World cards also work without Skills; paired plain World Skills needs no Nexus/card. Separate Banker/Tech, own-World fee/dial disjointness, committed-only Suk and every own-World H ban remain. Nexus/Discovery card contexts, Advanced role, shared Duke, mixed E3 and effect-specific guards stay separate. No new numeric rule/save retrofit. | [Current native E3 World composition](LEADER_SKILLS_RUNTIME.md#native-e3-worlds-with-independent-cards--17-october-2026), [selected-deck scope](LEADER_SKILLS_RUNTIME.md#world-skills-and-selected-decks--17-october-2026), [physical sources](ECAZ_TREACHERY_RULES.md) | -| Fresh native Ecaz/Moritani defaults | Fresh plain/card Ecaz constructors initialize printed coalition participation in Basic/Advanced. Supported native Advanced Moritani constructors initialize Assassinate Leaders without requiring Nexus; no-Skills selected native rosters include existing Harkonnen/Ecaz/native-family and Homeworld occupation compositions, while Skills retains its Advanced Harkonnen and standalone/pair profile boundaries. Basic never gains assassination. Existing game-long forfeiture and opportunity history are not invented in played saves. This repairs default initialization, not a new printed rule or complete-mode acceptance. | [Ecaz scope](ECAZ_OCCUPY_RULES.md#standalone-ecaz-leader-skills--17-october-2026), [Moritani authority and scope](MORITANI_ASSASSINATE_LEADERS.md#connected-scope-and-private-information) | -| Independent Treachery / original phase modules | Printed variant independence permits original Tech3–6 Basic/Advanced and/or Strongholds2–6 Advanced with classic, selected E1/E2 or supported standalone/paired E3 card rosters including Worlds, optionally all14 Skills. Original first-Storm/end-Mentat owners, separate delayed income and physical funding stay; fees/H returns are not revival/shipment and +2 is not supported force. Native Ecaz coalition, Advanced Moritani assassination and original arrival queue remain source-labelled. No new priority ruling. Nexus/Discovery card contexts, mixed E3 and original role guards remain. | [Original phase contract](ECAZ_TREACHERY_RULES.md#original-phase-module-composition--16-october-2026), [current native World composition](LEADER_SKILLS_RUNTIME.md#native-e3-worlds-with-independent-cards--17-october-2026) | +| Independent Treachery / all14 Skills | Printed variant independence composes registered actual selected-family all14 Basic/Advanced2–6, including mixed E3/E1/E2, unused decks, original Worlds and Tech3+/Advanced Strongholds2+. No Nexus/exact E3 family/card dependency is invented. Original36/38/50, separate cache and native setup/custody remain. Bare mixed E3 World/phase cards, original Nexus/Discovery/Semuta, Advanced role, shared Duke and effect-specific limits stay separate. Fees/H/committed-only Suk, own-World bans and separate Banker/Tech remain. No new numeric rule/save retrofit. | [Current registered native scope](LEADER_SKILLS_RUNTIME.md#registered-native-e3-selected-decks--18-october-2026), [physical sources](ECAZ_TREACHERY_RULES.md) | +| Fresh native Ecaz/Moritani defaults | Fresh actual Eca/Mori roles reuse registered selected-deck all14 capability, including mixed families and original World/phase/cards without Nexus. Printed Eca participation and Advanced Mori assassination initialize only for actual seats; Basic gains no Advanced power and unused decks create no faction seat. Bare rosters and original Nexus/Discovery/Semuta retain their envelopes; Advanced role/shared Duke guards stay. No played-save forfeiture/opportunity history is fabricated. | [Current registered source envelope](LEADER_SKILLS_RUNTIME.md#registered-native-e3-selected-decks--18-october-2026), [Mori authority](MORITANI_ASSASSINATE_LEADERS.md#connected-scope-and-private-information) | +| Independent Treachery / original phase modules | Printed independence retains original Tech3–6 Basic/Advanced and/or Strongholds2–6 Advanced with classic/E1/E2 and original standalone/paired E3 bare cards; all14 also composes registered mixed selected families and Worlds. Real first-Storm/end-Mentat owners, separate delayed income and physical funding remain. Fees/H are not revival/shipment and +2 is not supported force. Native coalition/assassination and original queue stay source-labelled. Bare mixed E3 World/phase, original Nexus/Discovery/Semuta and role/effect guards remain. | [Original phase contract](ECAZ_TREACHERY_RULES.md#original-phase-module-composition--16-october-2026), [registered scope](LEADER_SKILLS_RUNTIME.md#registered-native-e3-selected-decks--18-october-2026) | | Classic World independent cards / phase modules | Compose original unused Tech3+ and/or Advanced Strongholds2+ with the existing classic36-card Homeworld entry. Preserve native typed custody, current separate-request rates/paid ledgers, exact source-bearing R fees, original face/support, fee/dial disjointness and fee-before-Harass. Keep every own-World Harass ban; native returns are not shipment/free Revival. Original delayed income and earned cards add no personal subsidy or supported +2. This inherits existing provisional timing, not a new publisher ruling or native expansion/public gate. | [Source-bound contract](ECAZ_TREACHERY_RULES.md#classic-world-phase-module-composition--16-october-2026) | | Selected E1/E2 Worlds / independent cards | Use original selected38/50 native/base Basic/Advanced setup, optionally all14 Skills and original Tech/Advanced Strongholds. Retain current-rate/unlimited revival, accepted invoices versus separate native income, native-only World declarations and every own-World H ban. R normal-first sources retain pre-fee face/traits and fee/dial disjointness. Richese validates3 AFTER projected materialization before a first seal, never from later H returns or skill strength. Only committed battle losses feed Suk and actual board Cyborg losses feed substitution; fee sources are never duplicated. Existing timing remains provisional, not a new FAQ. | [Original native funding](ECAZ_TREACHERY_RULES.md#native-selected-family-world-composition--17-october-2026), [current Skills composition](LEADER_SKILLS_RUNTIME.md#world-skills-and-selected-decks--17-october-2026) | | Clean Ix Stone / Tooth / Mirror | Reuse existing provisional original Stone-first/uncopied-Tooth order; the earlier priority question stays unanswered. Printed Mirror copy-first and user-selected physical retention remain independent. Selected Ix/CHOAM now retains original Tech3+ and Advanced Strongholds2+, original47/independent50 plus separate cache, with genuine earned custody. No numerical ruling changes. Typed winner ambiguity, mixed E3 with these phase modules and other overlays stay guarded; existing Ecaz/Moritani combinations remain Advanced-only. | [Source and profile distinction](MIRROR_WEAPON_ENGINE_AUDIT.md#clean-selected-ix-composition--16-october-2026), [phase-module proof](MIRROR_WEAPON_ENGINE_AUDIT.md#original-phase-module-composition--16-october-2026) | @@ -736,7 +736,7 @@ dedicated retrieved worked FAQs. [Contract and remaining limits](HOMEWORLD_OCCUP | Classic Leader Skills / Tech Tokens | Fresh classic3..6 Basic/Advanced, base33/all14, canonical unused three tokens and original setup/first-Storm owners. The ordinary leader-skills entry preserves selected Tech and now includes mandatory normal Banker income; native/other overlays retain their existing admission. Tech collects at phase end, independent Banker waits for actual Mentat; neither currency store is a recursive payment producer. Smuggler changes actual priced count; original Suk/Sandmaster precede card cleanup and mandatory token transfer. One token auto-transfers, several require choice, none skips. This composes existing rules, not a new ruling or public activation. | [Scope and evidence](LEADER_SKILLS_RUNTIME.md#classic-tech-tokens-composition--4-october-2026) | | Native E1/E2 Leader Skills / Tech Tokens | Fresh supported Ixian/Tleilaxu/CHOAM/Richese3..6 Basic/Advanced keeps original native deck/roster limits, all14 skills, cache and printed Tech setup. Original typed payments/rescue/card cleanup precede mandatory winner token reward; Tleilaxu A.1–3 expressly preserves that reward before Face Dance. Actual Charity affects Production, not Axlotl; native CHOAM initial income is not poverty Charity. Separate Banker/Mentat bounds and unrelated guards remain. E3/other overlays, public starts and save conversion are excluded. This composes sourced rules, not a new interpretation. | [Scope, supplied physical21/38 and evidence](LEADER_SKILLS_RUNTIME.md#native-e1e2-tech-tokens-composition--4-october-2026) | | Advanced Leader Skills / Stronghold Cards | Fresh classic or supported E1/E2 native2..6 Advanced preserves all14 skills, six unused cards, original decks/cache/wallets and actual end-Mentat claims; optional Tech requires3..6. Arrakeen bank subsidy is separate from actual payer debit/Banker threshold and original CHOAM income. Physical defense/normal discipline, holder tie and Tabr/Tuek income compose original skill outcomes. Suk/native cleanup and mandatory Tech precede Face Dance, retaining original rewards and one killed-skill return. Copied-Diplomat/active skill-Stone and unrelated guards remain. Basic Strongholds, E3/other overlays, public starts and save conversion are excluded. | [Authorized physical21/38, existing effects and evidence](LEADER_SKILLS_RUNTIME.md#advanced-stronghold-composition--4-october-2026) | -| Standalone/paired E3 Skills / Tech / Strongholds | Original Ecaz OR Moritani or both with classics retains ecaz33/all14, optional independent36, original Worlds, Tech3–6 Basic/Advanced and Advanced Strongholds2–6. No artificial Nexus/card dependency remains. Actual selected-holder support, separate allied armies/native World fee sources, committed-only Suk, skill-first assassination/normal-call forfeiture, bounty, winner Tech and one Mentat replacement reuse original handlers. Bare World/cards also needs no Skills. Native five discs, untrained Duke and original Advanced role/mixed-family/other-envelope guards stay; no fake owner or new numeric ruling. | [Current native World source envelope](LEADER_SKILLS_RUNTIME.md#native-e3-worlds-with-independent-cards--17-october-2026) | +| Registered E3 Skills / World / Tech / Strongholds | Actual Ecaz/Moritani with registered selected E1/E2/classic seats and unused decks uses all14 without Nexus or exact-family dependency, optionally cards, Worlds, Tech3+ and Advanced Strongholds2+. Actual selected-holder income/support, separate armies/native fees, committed-only Suk, skill-first assassination/normal-call forfeiture, bounty and one Mentat replacement reuse original handlers. Original extra-module/role bounds and bare World/card rosters remain; shared Duke never trains. No fake owner or new numeric ruling. | [Current registered native envelope](LEADER_SKILLS_RUNTIME.md#registered-native-e3-selected-decks--18-october-2026) | | Normal Mentat | The 11 October user-approved uniform private show step runs before posture/faction powers in new supported Leader Skills battles, without an extra opt-in. Matching and fallback preserve the same target-owned stage and held cards. Already-opened unversioned battles do not reopen; versioned receipts remain authoritative. Empty-hand interpretation and full/public acceptance remain separate. | [Approved normal flow](MENTAT_QUESTION.md#normal-activation-and-approved-ux--11-october-2026) | | Normal Spice Banker default | Fresh supported ordinary all14 Skills setups automatically initialize normal income, including their existing supported original modules and native overlays. The separate initializer/profile and fixture opt-ins are removed; no public start or old-game retrofit. Another payer's one actual final Bank payment ≥4 grants one additional deferred spice per physical card/turn/phase, unavailable until actual Mentat. Distinct payers and separate free-revival awards never pool into an invoice. Tleilaxu self-revival pays Bank; other-player revival pays Tleilaxu unless canceled. Existing same-payer diversion/rounding rulings remain. Earned-faction retention after death/capture/reassignment remains provisional, not a new answer to the pending custody question. | [Current setup and limits](SPICE_BANKER_RUNTIME.md#mandatory-ordinary-skills-default--17-october-2026), [payment contract](SPICE_BANKER_RUNTIME.md#normal-income-local-contract--2-october-2026), [skill authority](LEADER_SKILLS_RULES.md) | | Advanced Stronghold faction integration | Fresh `stronghold-factions` admits selected E1/E2 native families and standalone Ecaz OR Moritani with classic2..6-seat Advanced, canonical selected decks and six Stronghold Cards; optional unused Tech Tokens requires3..6, without Leader Skills. Standalone E3 uses ecaz33; Moritani excludes Harkonnen. Ecaz co-control holds the card at Ecaz, but only the selected actual holder-plan receives its effect; fixed Ecaz strength is free, and Arrakeen funds only actual variable support. Losing Moritani retains printed Tuek income before original assassination, printed bounty and one actual Mentat replacement; winner cleanup finishes once and normal Traitor reveal forfeits assassination for the game. Existing E1/E2 No-Field/Stone/FaceDance and retained winner rewards remain. E3 pairs/mixed families, mixed No-Field/full-plan, Stone/Poison Tooth and other overlays stay guarded. This composes existing contracts, not a new ruling. Save/privacy/custody assurance follows all rules. | [E1/E2 scope](STRONGHOLD_CARDS.md#expanded-e1e2-faction-rules), [standalone E3 scope and evidence](STRONGHOLD_CARDS.md#standalone-e3-stronghold-composition--4-october-2026), [native Tech composition](EXPANSION_FACTIONS_PROTOTYPE.md#native-tech-composition--4-october-2026) | diff --git a/docs/SUK_GRADUATE_RULES.md b/docs/SUK_GRADUATE_RULES.md index 200cc6bd..8d508e00 100644 --- a/docs/SUK_GRADUATE_RULES.md +++ b/docs/SUK_GRADUATE_RULES.md @@ -4,6 +4,14 @@ bands to real battle resolution, player choices, AI and saved continuation. Complete module, faction and publication gates remain closed. +**Current18 October scope:** registered mixed selected-family all14 now keeps +original Worlds/phase and independent cards without Nexus or one E3 family. +Actual Eca source3 fee/H2 leaves only its committed3 for trained Suk, keeping1 +and returning2 while the ally loses its own2. The following dated admission +limits are historical; Atreides’ loss-count guard was already resolved on +7 October below. Other role/effect limits and complete-mode gates remain. +[Current609/609 source-bound proof](LEADER_SKILLS_RUNTIME.md#registered-native-e3-selected-decks--18-october-2026). + ## Classic Homeworld Nexus composition — 6 October 2026 Only fresh classic Homeworld skill entries additionally compose Nexus with diff --git a/docs/TLEILAXU_LEADER_SKILLS.md b/docs/TLEILAXU_LEADER_SKILLS.md index e5516655..d6019687 100644 --- a/docs/TLEILAXU_LEADER_SKILLS.md +++ b/docs/TLEILAXU_LEADER_SKILLS.md @@ -3,7 +3,7 @@ Connected development prototype, 21 September 2026. Genuine Tleilaxu setup uses all fourteen physical skills. Normal starts are labelled previews, not complete mode/publication acceptance. The -[17 October selected-deck/World scope](LEADER_SKILLS_RUNTIME.md#world-skills-and-selected-decks--17-october-2026) +[18 October registered selected-deck/World scope](LEADER_SKILLS_RUNTIME.md#registered-native-e3-selected-decks--18-october-2026) supersedes the original single-family/no-module admission, not native powers. ## Advanced native composition — 4 October 2026 @@ -53,7 +53,7 @@ board counters after refresh; the detailed guide is visually readable at390px. ## Configuration and physical setup The ordinary `leader-skills` setup accepts Basic/Advanced2–6 with the selected -Ix deck and Tleilaxu, optionally other selected E1/E2/classic seats. Independent +Ix deck and Tleilaxu, optionally other registered selected factions. Independent cards are not required. The current shared contract above records supported World/Tech/Advanced Strongholds/card combinations. It retains the original Ix Treachery/Spice decks, Cheap Hero Traitor identity and native three Face Dancers. diff --git a/game/engine.ts b/game/engine.ts index 79992950..4899a24d 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -10715,7 +10715,7 @@ export function initializeLeaderSkillsGameForAudit(state: Game): Game { const homeworlds = homeworldLeaderSkillsProfile(state); const independent = independentTreacheryLeaderSkillsProfile(state); if (state.ecazTreachery) { - requireRule(independent, 'Independent cards with Skills require a fresh supported selected-deck roster with original Homeworlds/Tech/Advanced Strongholds; Nexus/Discovery and mixed E3 remain gated.'); + requireRule(independent, 'Independent cards with Skills require a fresh supported selected-deck roster with original Homeworlds/Tech/Advanced Strongholds; Nexus/Discovery and unrelated preview contexts remain gated.'); requireFreshBaseRuntime(state); requireFreshFactionInventory(state); } @@ -10755,7 +10755,7 @@ function independentCardRoster(g: Game): boolean { return family === 'base' || g.expansions.includes(family); }); } -/** World/phase compositions retain the original one-family native E3 envelope. */ +/** Bare card World/phase rosters keep their E3 envelope; Skills uses the shared registered capability. */ function independentCardModuleRoster(g: Game): boolean { return independentCardRoster(g) && (!g.players.some(player => player.faction === 'ecaz' || player.faction === 'moritani') || diff --git a/game/leader-skill-profile.ts b/game/leader-skill-profile.ts index 6d18ec95..d1764b1a 100644 --- a/game/leader-skill-profile.ts +++ b/game/leader-skill-profile.ts @@ -188,19 +188,11 @@ function pairedE3LeaderSkillRoster(game: LeaderSkillProfile): boolean { return ecaz && moritani; } -/** Both original E3 natives with original World/phase modules; cards are optional. */ +/** Both seated E3 natives share selected-deck Skills; original Nexus stays separate. */ export function pairedE3LeaderSkillsProfile(game: LeaderSkillProfile): boolean { - if (game.expansions.length !== 1 || game.expansions[0] !== 'ecaz' || - typeof game.advanced !== 'boolean' || !game.players || - game.players.length < 2 || game.players.length > 6 || - game.nexusCards || game.discoveries || game.discoveryEnabled || - !e3LeaderSkillModulesSupported(game) || - game.discoveryStash || game.greatMaker || game.semutaPreview || game.advancedPreview || - (game.moritaniAssassinatePreview && !game.advanced) || - game.kullPreview || game.nexusKullPreview || game.guildBetrayalPreview || - game.richeseBetrayalPreview || game.nexusIxianReplacementPreview || - game.nexusIxianBetrayalPreview || game.nexusHarkonnenBetrayalPreview) return false; - return pairedE3LeaderSkillRoster(game); + return selectedDeckLeaderSkillsProfile(game) && + game.players!.some(player => player.faction === 'ecaz') && + game.players!.some(player => player.faction === 'moritani'); } /** Original two/three selected families with native E3, Nexus and optional original modules. */ @@ -233,7 +225,7 @@ function semutaLeaderSkillContext(game: LeaderSkillProfile): boolean { game.expansions.includes('choam') && !!game.players?.some(p => p.faction === 'richese'); } -/** Selected ordinary decks retain original module envelopes; actual E3 seats are separate. */ +/** Selected decks retain original World/phase modules and explicitly supported overlays. */ function selectedDeckLeaderSkillModulesSupported(game: LeaderSkillProfile): boolean { if (game.homeworlds) return homeworldLeaderSkillModulesSupported(game); if (noOtherLeaderSkillModules(game) || independentTreacheryLeaderSkillsProfile(game) || pairedNexusLeaderSkillsProfile(game) || @@ -246,7 +238,7 @@ function selectedDeckLeaderSkillModulesSupported(game: LeaderSkillProfile): bool game.expansions.length < 1 || game.expansions.length > 3 || game.homeworlds || game.nexusCards || game.discoveries || game.discoveryEnabled || game.ecazTreachery || (game.semutaPreview && !semutaLeaderSkillContext(game)) || - game.moritaniAssassinatePreview || game.advancedPreview || game.kullPreview || + game.advancedPreview || game.kullPreview || game.nexusKullPreview || game.guildBetrayalPreview || game.richeseBetrayalPreview || game.nexusIxianReplacementPreview || game.nexusIxianBetrayalPreview || game.nexusHarkonnenBetrayalPreview) return false; @@ -325,7 +317,8 @@ export function strongholdLeaderSkillsProfile(game: LeaderSkillProfile): boolean /** Bounded integration of the ordinary Moritani roster, not Advanced assassination. */ export function basicMoritaniLeaderSkillsProfile(game: LeaderSkillProfile): boolean { - if (game.advanced === false && pairedE3LeaderSkillsProfile(game)) return true; + if (game.advanced === false && game.players?.some(player => player.faction === 'moritani') && + selectedDeckLeaderSkillsProfile(game)) return true; return game.advanced === false && game.expansions.length === 1 && game.expansions[0] === 'ecaz' && e3LeaderSkillModulesSupported(game) && !!game.players?.some(player => player.faction === 'moritani') && @@ -335,7 +328,8 @@ export function basicMoritaniLeaderSkillsProfile(game: LeaderSkillProfile): bool /** Existing bounded Advanced assassination roster, composed with native skills. */ export function advancedMoritaniLeaderSkillsProfile(game: LeaderSkillProfile): boolean { - if (game.advanced === true && pairedE3LeaderSkillsProfile(game)) return true; + if (game.advanced === true && game.players?.some(player => player.faction === 'moritani') && + selectedDeckLeaderSkillsProfile(game)) return true; const players = game.players; if (game.advanced !== true || game.expansions.length !== 1 || game.expansions[0] !== 'ecaz' || !players || players.length < 2 || players.length > 6 || @@ -361,7 +355,7 @@ export function advancedMoritaniLeaderSkillsProfile(game: LeaderSkillProfile): b /** Ecaz's five native discs; the separate temporarily controlled Duke is not an assignment disc. */ export function nativeEcazLeaderSkillsProfile(game: LeaderSkillProfile): boolean { - if (pairedE3LeaderSkillsProfile(game)) return true; + if (game.players?.some(player => player.faction === 'ecaz') && selectedDeckLeaderSkillsProfile(game)) return true; const players = game.players; if (typeof game.advanced !== 'boolean' || game.expansions.length !== 1 || game.expansions[0] !== 'ecaz' || !players || players.length < 2 || players.length > 6 || @@ -387,15 +381,14 @@ export function nativeEcazLeaderSkillsProfile(game: LeaderSkillProfile): boolean } -/** Base/E1/E2 seats with selected ordinary decks, Basic or Advanced; no faction prerequisite. */ +/** Every registered seat uses its selected ordinary family; no faction or Nexus prerequisite. */ export function selectedDeckLeaderSkillsProfile(game: LeaderSkillProfile): boolean { const players = game.players; if (typeof game.advanced !== 'boolean' || !players || players.length < 2 || players.length > 6 || game.expansions.length < 1 || game.expansions.length > 3 || !selectedDeckLeaderSkillModulesSupported(game) || (game.semutaPreview && !semutaLeaderSkillContext(game)) || - game.moritaniAssassinatePreview || game.advancedPreview || - game.kullPreview || game.nexusKullPreview || + game.advancedPreview || game.kullPreview || game.nexusKullPreview || game.guildBetrayalPreview || game.richeseBetrayalPreview || game.nexusIxianReplacementPreview || game.nexusIxianBetrayalPreview || game.nexusHarkonnenBetrayalPreview) return false; @@ -404,20 +397,26 @@ export function selectedDeckLeaderSkillsProfile(game: LeaderSkillProfile): boole if (expansion !== 'ix' && expansion !== 'choam' && expansion !== 'ecaz') return false; for (let j = 0; j < i; j++) if (game.expansions[j] === expansion) return false; } + let nativeE3 = false, moritani = false, harkonnen = false; for (let i = 0; i < players.length; i++) { const player = players[i]; for (let j = 0; j < i; j++) if (players[j].faction === player.faction) return false; let selected = false; for (const faction of FACTIONS) if (faction.id === player.faction) { - selected = faction.expansion === 'base' || - ((faction.expansion === 'ix' || faction.expansion === 'choam') && - game.expansions.includes(faction.expansion)); + selected = faction.expansion === 'base' || game.expansions.includes(faction.expansion); + if (faction.expansion === 'ecaz') nativeE3 = true; break; } if (!selected) return false; + if (player.faction === 'moritani') moritani = true; + if (player.faction === 'harkonnen') harkonnen = true; } - return true; + // E3's existing Nexus/Discovery/preview envelopes retain their original predicates. + if (nativeE3 && ((game.advanced && harkonnen) || game.nexusCards || + game.discoveries || game.discoveryEnabled || game.discoveryStash || + game.greatMaker || game.semutaPreview)) return false; + return !game.moritaniAssassinatePreview || (game.advanced && moritani); } export function nativeExpansionLeaderSkillsProfile(game: LeaderSkillProfile): boolean { @@ -450,8 +449,8 @@ function homeworldLeaderSkillModulesSupported(game: LeaderSkillProfile): boolean (game.techTokens && players.length < 3) || (game.strongholdCards && !game.advanced) || (game.ecazTreachery && !independentTreacheryLeaderSkillsProfile(game)) || game.semutaPreview || game.advancedPreview || - (game.moritaniAssassinatePreview && (!game.advanced || game.expansions.length !== 1 || - game.expansions[0] !== 'ecaz' || !players.some(player => player.faction === 'moritani'))) || + (game.moritaniAssassinatePreview && (!game.advanced || !game.expansions.includes('ecaz') || + !players.some(player => player.faction === 'moritani'))) || game.kullPreview || game.nexusKullPreview || game.guildBetrayalPreview || game.richeseBetrayalPreview || game.nexusIxianReplacementPreview || game.nexusIxianBetrayalPreview || game.nexusHarkonnenBetrayalPreview) return false; @@ -474,9 +473,8 @@ export function homeworldLeaderSkillsProfile(game: LeaderSkillProfile): boolean return true; } -/** Independent cards with classic, selected E1/E2 or standalone/paired E3 all14 setup. */ +/** Independent cards with registered selected-family all14 setup and original module/role guards. */ export function independentTreacheryLeaderSkillsProfile(game: LeaderSkillProfile): boolean { - const e3 = game.expansions.length === 1 && game.expansions[0] === 'ecaz'; if (game.ecazTreachery !== true || game.expansions.length > 3 || typeof game.advanced !== 'boolean' || !game.players || game.players.length < (game.techTokens ? 3 : 2) || game.players.length > 6 || @@ -504,10 +502,8 @@ export function independentTreacheryLeaderSkillsProfile(game: LeaderSkillProfile } if (!selected) return false; } - // Actual E3 seats retain their one-family and Advanced Harkonnen boundaries. - if (nativeE3 && (!e3 || - (game.advanced && players.some(player => player.faction === 'harkonnen')))) return false; - if (game.moritaniAssassinatePreview && (!e3 || !game.advanced || + if (nativeE3 && game.advanced && players.some(player => player.faction === 'harkonnen')) return false; + if (game.moritaniAssassinatePreview && (!game.advanced || !players.some(player => player.faction === 'moritani'))) return false; return true; } diff --git a/game/reference.ts b/game/reference.ts index 9e2c758d..e45cf9b8 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -3415,9 +3415,9 @@ export const RULE_TOPICS: RuleTopic[] = [ 'The mobile card copies a currently controlled stronghold, even if another faction still holds that location’s card. Holding a card after leaving its location does not itself make that location an eligible copy. The choice is public and fixed before battle plans; a sole eligible advantage is announced automatically.', 'Arrakeen pays only for actual support and creates no personal cash. CHOAM force-payment income includes the bank contribution, subject to its normal traitor and donor exclusions. Carthag does not protect an empty or Worthless defense, stop Poison Tooth, or remove a Shield’s Lasgun explosion risk.', 'Sietch Tabr uses the opposing dial, excluding leaders and other bonuses. A single traitor victory qualifies; mutual destruction does not. Tuek pays after an ordinary loss or a single traitor outcome as well as a win; a double traitor gives neither faction spice. Habbanya also decides a tied Stone Burner comparison.', - 'Fresh Advanced classic or supported E1/E2 all14 Skills retains original Strongholds2–6 and optional Tech3–6, including its independent-card/World envelope. Native E3 follows its one-family roster below; Nexus/Discovery card compositions remain separate. Original deck/cache and actual end-Mentat claims stay. Arrakeen funds only local holder-plan support, never native World support, personal cash or reserve fees; normal Banker uses the final actual payer debit. Rescue preserves support, then cleanup and Tech precede Face Dance. Specific copied-defense/skill-Stone and unrelated guards remain.', - 'Standalone or paired Ecaz/Moritani Skills retains original Worlds and Advanced phase modules, optionally independent cards, with exact ecaz33/36, native five-disc training and original role exclusions. Normal Warmaster one on a temporary Duke never grants trained three. Winner Suk precedes unused-disc assassination, printed bounty, winner Tech and one Mentat replacement. Ecaz-held support belongs only to the selected Ecaz plan, not an allied Moritani plan; fixed and variable armies stay separate. Mixed E3 families, Advanced Harkonnen and shared-Duke assignment remain guarded.', - 'Fresh independent Treachery previews compose classic, selected Ix/CHOAM native/base or supported standalone/paired Ecaz/Moritani rosters, including original Homeworlds, with Advanced Strongholds2–6 and optional Tech3–6, with or without Skills. Actual end-Mentat earns custody. Reinforcements adds no supported counters; Harass vacates control without transferring a retained card early. Exact native fee/return, original card effects and winner Tech order remain; native role and unrelated overlay guards stay.', + 'Fresh Advanced all14 Skills uses registered actual seats and selected ordinary decks with original Strongholds2–6 and optional Tech3–6, including its independent-card/World envelope. Native E3 needs no Nexus or exact single family merely to use Skills. Original Nexus/Discovery/Semuta card contexts and Advanced role guards remain separate. Actual end-Mentat claims stay. Arrakeen funds only local holder-plan support, never native World support, personal cash or reserve fees; normal Banker uses the final payer debit. Rescue, cleanup, Tech and Face Dance retain their order.', + 'Ecaz/Moritani Skills may include other registered selected-family seats and unused selected ordinary decks, with original Worlds/phase modules and optional independent cards. Native five-disc training and original role exclusions remain. Normal Warmaster one on a temporary Duke never grants trained three. Winner Suk precedes unused-disc assassination, bounty, winner Tech and one Mentat replacement. Ecaz-held support belongs only to the selected Ecaz plan, not an allied plan; fixed and variable armies remain separate. Advanced E3/Harkonnen, shared Duke and effect-specific guards stay.', + 'Fresh independent Treachery previews retain classic/E1/E2 and original standalone/paired E3 bare rosters; all14 additionally admits registered mixed selected families. Original Worlds, Advanced Strongholds2–6 and optional Tech3–6 retain actual end-Mentat custody. Reinforcements adds no supported counters; Harass vacates control without transferring a held card early. Exact native fees/returns, card effects and winner Tech remain; bare mixed-E3 World/phase, original Nexus/Discovery card and role/effect guards stay separate.', 'The six effects, public ownership and pre-plan choice are integrated in development games. Enable Stronghold Cards in an Advanced preview lobby. Complete Advanced compliance and every expansion interaction remain under verification.', ], example: @@ -3523,9 +3523,9 @@ export const RULE_TOPICS: RuleTopic[] = [ 'Fremen reserve arrivals and Guild cross-planet or return shipments do not trigger Heighliners. A stopped shipment never arrives and cannot trigger it. Bene Gesserit accompanying forces do arrive from off planet and can trigger the token even after a Guild shipment.', 'A battle winner takes one tech token from the defeated faction after casualties and played-card decisions. With several available tokens, the winner chooses. A winner still takes the token if no winning forces survive. Mutual destruction has no winner and transfers no token.', 'Owning all three tokens counts as one additional stronghold at victory checks. An alliance cannot combine separate token holdings to qualify: one member must own the complete set. Multiple sides reaching the victory target can share the win.', - 'Token ownership and pending income are public and persist. Fresh classic, selected E1/E2 or standalone/paired E3 all14 keeps original Tech3–6 Basic/Advanced and optional Advanced Strongholds2–6, including supported Worlds and independent cards. Nexus/Discovery card contexts retain separate guards. Rescue/board-spice precedes winner Tech and Face Dance. Mandatory normal Banker waits for Mentat, separate from phase-end Tech; fees/H returns are not revival/shipment. Native CHOAM opening income is not poverty Charity. Two-player Tech and full combined acceptance remain unfinished.', + 'Token ownership and pending income are public and persist. Fresh registered selected-family all14 keeps original Tech3–6 Basic/Advanced and optional Advanced Strongholds2–6, including supported Worlds and independent cards, without an incidental Nexus or single-family E3 requirement. Original Nexus/Discovery/Semuta and role/effect guards remain. Rescue/board-spice precedes winner Tech and Face Dance. Mandatory normal Banker waits for Mentat, separate from phase-end Tech; fees/H returns are not revival/shipment. Native CHOAM opening income is not Charity. Two-player Tech and full combined acceptance remain unfinished.', 'Without Skills, fresh native E1/E2 families or standalone Ecaz OR Moritani with classic opponents may preserve unused original Tech Tokens through the local factions entry, Basic/Advanced three through six players; Advanced stronghold-factions adds the original six Stronghold Cards. Native payments, typed casualties, selected Ecaz lead rewards and Moritani original assassination remain separate. Split allied holdings never qualify as a complete set. Basic odd Occupy, Richese cache/No-Field rulings, unrelated overlays, public starts and saved-game conversion remain guarded.', - 'Classic, selected Ix/CHOAM native/base or supported standalone/paired Ecaz/Moritani independent-card previews, including Homeworlds, retain unused Tech3–6 Basic/Advanced and optional Advanced Strongholds2–6, with or without Skills. Recruits changes current native rates without repeating Axlotl or advancing payout. Harass deposits and reserve fees are not free Revival or off-planet shipment. Original phase income/native custody/winner rewards remain; native role and unrelated overlay guards stay.', + 'Classic/E1/E2 and original standalone/paired E3 bare independent-card previews retain Worlds, Tech3–6 and optional Advanced Strongholds2–6. All14 extends this to registered mixed selected families. Recruits changes current native rates without repeating Axlotl or advancing payout. Harass deposits and reserve fees are not free Revival or off-planet shipment. Original phase income/native custody/winner rewards remain; bare mixed-E3 World/phase and unrelated module/role guards stay separate.', ], example: 'You own Heighliners and Axlotl Tanks. When Emperor ships from reserves, place two spice on Heighliners. A later shipment adds nothing. Collect the two spice when Shipping and Movement ends.', @@ -4064,9 +4064,9 @@ 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 classic/E1/E2 World all14 uses actual registered seats and selected ordinary decks in Basic/Advanced; no expansion faction or independent card is required to unlock training. Standalone/paired Ecaz/Moritani also retains original Worlds, optional independent cards and original Tech3+/Advanced Strongholds2+ within its one-family role envelope. Original no-card Nexus/Discovery retains separate limits. Actual starting hands, Ix card-before-training and native five-disc assignment remain; Auditor/shared Duke never train. Typed native Suk saves stay at that home; external Emperor returns allocate original Kaitain/Salusa destinations. No nested location gains an ordinary Stronghold benefit or saved history by default.', + 'Fresh World all14 uses actual registered seats and selected ordinary decks in Basic/Advanced, including mixed Ecaz/Moritani with E1/E2 natives. Neither Nexus, independent cards, one exact family nor a seated native from every selected deck is required. Original Tech3+/Advanced Strongholds2+ may remain. Existing no-card Nexus/Discovery and Semuta keep their separate limits. Actual hands, Ix card-before-training and native five-disc assignment remain; Auditor/shared Duke never train. Native Suk saves stay at that home; external Emperor returns retain original typed destinations. Nested locations gain no ordinary Stronghold effect or invented saved history.', '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.', - 'Fresh supported classic/E1/E2 and standalone/paired E3 Worlds compose all14 and the independent36/38/50-card setup, optionally Tech3+/Advanced Strongholds2+; native E3 card starts also work without Skills. Recruits observes current native rates/typed limits and recorded paid/free use; Banker and phase-end Axlotl remain separate. Reinforcements seals exact normal-first native sources and excludes an own-World fee from physical dial/casualties while preserving original face/support. Suk rescues committed losses, not fees. Harass returns actual visitors through native custody and is forbidden on every own World. Mixed E3, Nexus/Discovery and role/effect-specific guards remain.', + 'Registered selected-family World all14 composes independent36/38/50-card setup and original Tech3+/Advanced Strongholds2+, including mixed E3. Bare native E3 World cards retain their original roster limits. Recruits uses current native rates/typed limits and recorded paid/free use; Banker and phase-end Axlotl remain separate. Reinforcements seals exact normal-first sources and excludes an own-World fee from dial/casualties while preserving original face/support. Suk rescues committed losses, not fees. Harass returns actual visitors through native custody and is forbidden on every own World. Original Nexus/Discovery/Semuta and role/effect guards remain.', '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.', 'A qualifying charity claim separates its ordinary payer from the extra bank spice. Inflation doubles both portions or prevents the whole collection. Canceling Bene Gesserit charity prevents both portions of that claim.', @@ -4927,13 +4927,13 @@ export const RULE_TOPICS: RuleTopic[] = [ developmentStage: 'Prototyped', summary: 'Fourteen physical skills, private assignment choices, five battle disciplines, Planetologist movement/battle effects and Suk Graduate rescues, Mentat/Bureaucrat scoring and Bureaucrat payment redirection, Sandmaster victory spice, Rihani exchanges, optional Smuggler shipping and provisional modifier-inclusive battle collection, Sandmaster route and worm-ride collection, sealed Spice Banker battle spending and optional Diplomat base-defense copying in development tables. Other effects and combined-module acceptance remain unfinished.', steps: [ - 'Fresh all14 uses one selected-deck capability for registered base/E1/E2 seats, Basic/Advanced, without requiring an expansion faction, independent cards or Nexus merely to use a selected ordinary deck. Native E3 retains its dedicated roster boundaries. Existing World/phase and no-card Nexus/Discovery envelopes keep actual setup and original component custody. Ix chooses its starting card before training; Auditor/shared Duke never train. Native World Suk saves and external Emperor typed destinations stay source-labelled. No new Advanced power, income, module owner or played-save history is created by this configuration cutover.', + 'Fresh all14 uses one selected-deck capability for every registered actual seat, Basic/Advanced, including mixed native Ecaz/Moritani with selected E1/E2 families. Neither an expansion faction, independent cards, Nexus nor one exact E3 family is required just to use selected ordinary decks. An unused deck creates no seated faction. Original World/phase envelopes retain components and native power bindings; original Nexus/Discovery/Semuta and Advanced role limits stay separate. Ix drafts before training; Auditor/shared Duke never train. No Advanced power, income, owner or played-save history is invented.', 'Explicit guild-betrayal --leader-skills retains the original classic native-Guild Nexus profile without Homeworlds, Tech, Strongholds, Discovery, Semuta or other overlays. All14 training remains native. Ordinary skill quotes now admit the composition, restoring actual Mentat private questions before posture and Sandmaster ground collection rather than only enabling Bureaucrat. Native card custody, paid forces, original Betrayal Use/Pass and movement/plan continuation stay unchanged. Other combinations and complete-mode acceptance remain separate.', - 'Basic Moritani retains classic opponents including Harkonnen. Standalone or paired Ecaz/Moritani Skills retains original Tech3+ and Advanced Strongholds2+, optionally independent cards, with the original Advanced non-Harkonnen assassination roster. Winner Suk and Rihani finish before an eligible different trained disc dies, returns one skill and pays printed bounty. No casualties repeat; original set-aside and one Mentat replacement remain. Shared-Duke training, mixed families and other module envelopes stay guarded.', - 'Basic and Advanced selected-deck profiles admit actual Ixians, Tleilaxu, CHOAM/Richese and base seats only from their selected families; unused faction availability never creates a seat or requires one. All14 and native setup remain. Auditor is Advanced and unskilled; foreign-ghola authorization still explicitly requires Advanced, and foreign revival creates no new skill. Cyborg casualties, native funding and capped markers remain physical. Supplied Advanced p34 excludes Richese auctions from Ixian Technology; unrelated captured/KH/copy/role guards retain their own rules.', - 'Independent Recruits, Reinforcements and Harass compose all14 in supported classic/E1/E2 and standalone/paired E3 Basic/Advanced, including original Worlds and Tech3+/Advanced Strongholds2+. Native E3 World cards also retain native coalition/Advanced assassination without Skills. Original36/38/50, cache, setup and native prices stay. Shared admission feeds card/skill controls; own World fee/dial pools stay disjoint, visitor returns keep typed native homes and Suk cannot rescue fees. Earned support is not personal money or reserve funding. Nexus/Discovery, actual mixed E3, shared Duke and Advanced role guards remain.', - 'Fresh all14 keeps original Tech3–6 Basic/Advanced with actual selected decks, optional independent cards and supported World envelopes. Starting hands/cache, original offers/Traitors and printed/first-Storm owners remain genuine. Native rates use actual stock/population on each accepted request; normal Banker records the accepted Bank invoice, not a net wallet change after separate native income. No token owner, premature spendable grant or victory credit is assigned to simulate earned play. Other Nexus/Discovery, native E3 and effect-specific envelopes stay separate; played games are not converted.', - 'Fresh Advanced supported classic/native all14 tables retain six original Stronghold Cards, optional Tech3+, and independent cards within their own envelope. Actual end-Mentat earns the holder; benefit stays local to the selected holder-plan. Arrakeen changes actual payer debit, not physical commitment, Suk entitlement or Banker threshold. Native E1/E2 aftercare and standalone/paired E3 coalition/assassination retain original order. Shared Duke never trains; mixed E3, unsupported copied defenses and active skill/Stone retain their guards.', + 'Basic Moritani keeps legal Terror but gains no Advanced assassination, including registered mixed native Skills seats. Ecaz/Moritani with selected E1/E2 or unused decks keeps original Worlds/Tech3+/Advanced Strongholds2+ and optional independent cards without requiring Nexus. Advanced E3/Harkonnen remains guarded. Winner Suk and Rihani precede a different unused trained-disc death, one skill return, printed bounty and one original Mentat replacement. Native card/force sources, normal-call forfeiture and shared-Duke exclusion remain; unrelated overlays retain their original envelopes.', + 'Basic and Advanced selected-deck Skills admits registered base, Ixian/Tleilaxu, CHOAM/Richese and Ecaz/Moritani seats only when their family is selected. Unused decks neither create a seat nor require one. All14/native setup remain. Auditor is Advanced and unskilled; foreign-ghola authorization still explicitly requires Advanced, grants no new skill and retains original Duke/custody limits. Cyborg losses, native funding and capped markers stay physical. Supplied Advanced p34 excludes Richese auctions from Ixian Technology; existing capture/KH/copy/role guards keep their own rules.', + 'Independent Recruits, Reinforcements and Harass compose registered selected-family all14 Basic/Advanced, including mixed E3, original Worlds and Tech3+/Advanced Strongholds2+. Bare E3 World cards keep their original roster envelope without requiring Skills. Original36/38/50, cache, native setup/prices and selected-hand capability stay. Own-World fee/dial pools are disjoint; visitor returns retain typed homes and Suk cannot rescue fees. Earned support is not personal money or reserve funding. Original Nexus/Discovery/Semuta, shared Duke and Advanced role/effect guards remain.', + 'Fresh registered all14 keeps original Tech3–6 Basic/Advanced, optional independent cards and supported Worlds, including mixed E3 without a Nexus dependency. Starting hands/cache, offers/Traitors and printed/first-Storm owners stay genuine. Native rates use actual stock/population at acceptance; normal Banker records the accepted Bank invoice, not net wallet change after separate income. No token owner, premature spendable grant or victory credit is assigned to simulate earned play. Original Nexus/Discovery/Semuta and effect-specific envelopes remain; played games are not converted.', + 'Fresh Advanced registered all14 retains six original Stronghold Cards, optional Tech3+ and independent cards within its envelope, including selected mixed E3. Actual end-Mentat earns custody; the benefit stays local to the selected holder-plan. Arrakeen changes payer debit, not physical commitment, Suk entitlement or Banker threshold. Native E1/E2 aftercare and Ecaz/Moritani coalition/assassination retain original order. Shared Duke, Advanced E3/Harkonnen, unsupported copied defenses and active skill/Stone retain their own guards.', 'Fresh classic Leader Skills may explicitly add Nexus through local leader-skills --nexus-cards, Basic/Advanced two through six players, retaining selected original Tech at three or more seats and/or Advanced Strongholds. --discoveries additionally composes original Discovery7/8 in this classic profile. Saved hands/offers remain authoritative; the actual end-Spice closing draw follows Maker votes/typed rides, both Advanced piles and settled alliance. Free-return ledgers, bank purchase and Traitor Deck declarations stay distinct from training. Original phase-end industry, held payer support/physical defenses, physical Suk rescue and subsequent mandatory winner Tech rewards retain their own timing. Homeworlds, Banker/Mentat previews, borrowed Smuggler arithmetic and unresolved interactions remain separate.', 'The same fresh entry also adds --nexus-cards --discoveries to paired Ixians+Tleilaxu, paired CHOAM+Richese or standalone Ecaz OR Moritani with classics, original all14/all12/Discovery7/8, Basic/Advanced two through six with optional Tech3+ and Advanced Strongholds. Original offers/hands/native components remain authoritative. E1 Suboid Cunning settles typed Suk rescue before one equal Cyborg Tank substitution, then original cleanup and mandatory winner Tech precede a matching Face Dance; E2 keeps its signed one-invoice pair; Ecaz keeps quiet living/unclaimed Duke then a real owner-labelled mandatory 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 Ecaz, Advanced Harkonnen and other pending rulings stay guarded.', 'Both original paired Ixian/Tleilaxu or CHOAM/Richese factions plus classics may add Nexus through that fresh local skill entry, with one family deck/all14/all12, Basic/Advanced two through six players. Selected original Tech requires3+; Strongholds requires Advanced. Full unsupported Suboids retain Cyborg support and skill posture; physical Suk rescue precedes equal actual Cyborg Tank substitution. Original winner cleanup and mandatory Tech precede Face Dance, which returns a matching killed trainer skill once without another bounty. Held custody stays with the original winner. Richese Cunning retains its signed two-marker/one-invoice operation and native phase-end industry; ordinary skill proofs and companion exclusions stay separate. CHOAM fuel precedes ordinary Planetologist movement without a second card effect or artificial combat Special.', diff --git a/tests/fixture-homeworld-native-e1e2-modules.ts b/tests/fixture-homeworld-native-e1e2-modules.ts index 5e124169..a186f685 100644 --- a/tests/fixture-homeworld-native-e1e2-modules.ts +++ b/tests/fixture-homeworld-native-e1e2-modules.ts @@ -5,6 +5,7 @@ import { } from '../game/engine'; import type { FactionId } from '../game/catalog'; import { treacheryDeck, type Card, type SpiceCard } from '../game/cards'; +import { ecazTreacheryCards } from '../game/ecaz-cards'; import { LEADER_SKILL_CARDS, type LeaderSkillId } from '../game/leader-skill-cards'; import { validateLeaderSkills } from '../game/leader-skills'; import { homeworldContext, homeworldGameIntegrity } from '../game/homeworld-game'; @@ -72,7 +73,8 @@ export function nativeHomeworldInventory(game: Game): void { const cards = [...game.deck, ...game.discard, ...game.players.flatMap(p => p.hand), ...(game.ixSetupCards ?? []), ...(game.auction?.cards.slice(game.auction.index) ?? [])]; const ordinary = cards.filter(c => !c.id.startsWith('richese-')); - assert.deepEqual(ordinary.map(c => c.id).sort(), treacheryDeck(game.expansions).map(c => c.id).sort()); + const expected = [...treacheryDeck(game.expansions), ...(game.ecazTreachery ? ecazTreacheryCards() : [])]; + assert.deepEqual(ordinary.map(c => c.id).sort(), expected.map(c => c.id).sort()); const homes = homeworldForceGroups(homeworldContext(game), game.homeworlds!.custody!); for (const p of game.players) { const visitors = homes.filter(h => h.native !== p.id).map(h => h.forces[p.id] ?? { normal: 0, elite: 0 }); diff --git a/tests/fixture-mixed-e3-ecaz-nexus.ts b/tests/fixture-mixed-e3-ecaz-nexus.ts index cf7bbf8f..296fb271 100644 --- a/tests/fixture-mixed-e3-ecaz-nexus.ts +++ b/tests/fixture-mixed-e3-ecaz-nexus.ts @@ -6,7 +6,9 @@ import type { FactionId } from '../game/catalog'; import type { Difficulty } from '../game/bot-profiles'; import { botActions } from '../game/bots'; import { spiceDeck, treacheryDeck } from '../game/cards'; +import { ecazTreacheryCards } from '../game/ecaz-cards'; import type { SpiceCard } from '../game/cards'; +import type { HarassWithdrawSelection } from '../game/harass-withdraw'; import { DUKE_VIDAL_ID } from '../game/duke-vidal'; import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards'; import type { LeaderSkillId } from '../game/leader-skill-cards'; @@ -41,6 +43,10 @@ export interface MixedE3EcazNexusOptions { families?: readonly ('ix' | 'choam')[]; roster?: readonly FactionId[]; advanced?: boolean; + /** False selects the original all14 path without a Nexus Cards component. */ + nexus?: boolean; + /** Original independent three-card variant; only the no-Nexus all14 path. */ + independent?: boolean; skills?: boolean; homeworlds?: boolean; discovery?: boolean; @@ -84,7 +90,7 @@ export function nextMixedE3EcazNexusStep(g: Game): MixedE3EcazNexusStep | null { if (g.decision && ['ecazBattleLead', 'leaderSkillVisibility', 'moritaniPlacement', 'moritaniAssassinate', 'moritaniTerror', 'ecazPlacement', 'ecazAmbassador', 'battleLosses', 'battleCards', 'techToken', 'sukRescue', 'ixSubstitution', 'faceDance', 'strongholdCopy', - 'discoveryEntry', 'greatMakerVote', 'greatMakerRide', 'homeworldDefense', + 'discoveryEntry', 'greatMakerVote', 'greatMakerRide', 'homeworldDefense', 'harassWithdraw', 'homeworldRevivalDeployment', 'moritaniRetention', 'choamBattleFunding'].includes(g.decision.kind)) return null; if (g.decision?.kind === 'richeseUnbid') return { actor: g.decision.player, action: { type: 'decision', event: g.richeseBidding!.event, keep: false } }; @@ -186,6 +192,7 @@ export function createMixedE3EcazNexusSetup(options: MixedE3EcazNexusOptions = { const roster: readonly FactionId[] = options.roster ?? options.initial?.players.map(p => p.faction) ?? ['ecaz', ...(families.includes('ix') ? ['ixians' as const] : []), ...(families.includes('choam') ? ['choam' as const] : []), 'guild', options.discovery ? 'fremen' : 'emperor']; + const nexus = options.nexus ?? true, independent = options.independent ?? false; const advanced = options.advanced ?? options.initial?.advanced ?? true; let g = options.initial ? mixedE3EcazNexusReload(options.initial) : createGame('MIXEDE3ECAZNEXUS', newPlayer(roster[0], roster[0], roster[0]), advanced, ['ecaz', ...families]); @@ -194,14 +201,18 @@ export function createMixedE3EcazNexusSetup(options: MixedE3EcazNexusOptions = { assert.ok(roster.length >= 2 && roster.length <= 6 && new Set(roster).size === roster.length); assert.ok(roster.includes('ecaz') && families.length >= 1 && families.length <= 2 && new Set(families).size === families.length); assert.deepEqual([...g.expansions].sort(), ['ecaz', ...families].sort()); - for (const family of families) assert.ok(roster.some(f => (family === 'ix' ? ['ixians', 'tleilaxu'] : ['choam', 'richese']).includes(f))); - assert.ok(!g.ecazTreachery); + if (nexus) for (const family of families) + assert.ok(roster.some(f => (family === 'ix' ? ['ixians', 'tleilaxu'] : ['choam', 'richese']).includes(f))); + assert.equal(!!g.ecazTreachery, options.initial?.status === 'setup' ? independent : false); assert.ok(g.status === 'lobby' || g.status === 'setup' && g.turn === 1 && g.phase === 0, 'Continue only original lobby/setup, never convert a played save'); const skills = options.skills ?? (options.initial ? !!g.leaderSkills : true); const flags = { homeworlds: options.homeworlds ?? !!g.homeworlds, techTokens: options.tech ?? !!g.techTokens, strongholdCards: options.strongholds ?? !!g.strongholdCards }; const discovery = options.discovery ?? !!g.discoveryEnabled; + assert.ok(nexus || skills, 'The no-Nexus route is the original all14 module, not bare mixed Worlds'); + assert.ok(!independent || !nexus, 'Independent cards retain their no-Nexus all14 scope'); + assert.ok(nexus || !discovery, 'Existing Discovery/Nexus composition stays separate'); assert.ok(!flags.techTokens || roster.length >= 3); assert.ok(!flags.strongholdCards || advanced); assert.ok(!advanced || !roster.includes('harkonnen') || !skills && !roster.includes('moritani'), 'Original Advanced E3 capture exclusion is not weakened'); @@ -211,18 +222,27 @@ export function createMixedE3EcazNexusSetup(options: MixedE3EcazNexusOptions = { if (g.status === 'lobby') { for (const [type, enabled] of Object.entries(flags)) if (!!g[type as keyof typeof flags] !== enabled) g = stepMixedE3EcazNexus(g, { actor: g.host, action: { type, enabled } }, actions); - g.discoveryEnabled = discovery; g.nexusCards ??= { cards: null, phase: null }; + g.discoveryEnabled = discovery; + if (nexus) g.nexusCards ??= { cards: null, phase: null }; + else { + assert.equal(g.nexusCards, undefined, 'Never erase a selected or played Nexus component'); + if (!independent) g = stepMixedE3EcazNexus(g, { actor: g.host, + action: { type: 'moduleOption', module: 'leaderSkills', enabled: true } }, actions); + } } else { for (const [type, enabled] of Object.entries(flags)) assert.equal(!!g[type as keyof typeof flags], enabled); assert.equal(!!g.discoveryEnabled, discovery); assert.equal(!!g.leaderSkills, skills); - assert.ok(g.nexusCards?.cards, 'An existing deal cannot acquire another Nexus deck'); + if (nexus) assert.ok(g.nexusCards?.cards, 'An existing deal cannot acquire another Nexus deck'); + else assert.equal(g.nexusCards, undefined); } const initial = mixedE3EcazNexusReload(g); if (g.status === 'lobby') { for (const p of g.players) if (!p.ready) g = stepMixedE3EcazNexus(g, { actor: p.id, action: { type: 'ready' } }, actions); + if (independent) g.ecazTreachery = true; const skillSource = LEADER_SKILL_CARDS.map(c => c.id), rest = skillSource.filter(c => !wanted.includes(c)); const skillOrder = wanted.flatMap(c => [c, rest.shift()!]).concat(rest); - const deck = treacheryDeck(g.expansions), remaining = [...deck], front: string[] = []; + const deck = [...treacheryDeck(g.expansions), ...(independent ? ecazTreacheryCards() : [])], + remaining = [...deck], front: string[] = []; for (let n = 0; n < g.players.length + (roster.includes('harkonnen') ? 1 : 0); n++) { const worthless = remaining.findIndex(c => c.kind === 'worthless'); const at = worthless >= 0 ? worthless : remaining.findIndex(c => c.kind === 'shield'); @@ -230,9 +250,12 @@ export function createMixedE3EcazNexusSetup(options: MixedE3EcazNexusOptions = { } const rolls = [...(skills ? permutation(skillSource, skillOrder) : []), ...(discovery ? Array(7).fill(0xffffffff) : []), - ...permutation(NEXUS_FACTIONS, ['ecaz', ...NEXUS_FACTIONS.filter(f => f !== 'ecaz')]), + ...(nexus ? permutation(NEXUS_FACTIONS, ['ecaz', ...NEXUS_FACTIONS.filter(f => f !== 'ecaz')]) : []), ...permutation(deck.map(c => c.id), [...front, ...remaining.map(c => c.id)]), ...spiceRolls(g)]; - const initialize = () => skills ? initializeLeaderSkillsGameForAudit(g) : initializeNexusGameForAudit(g); + const initialize = () => !nexus && !independent + ? stepMixedE3EcazNexus(g, { actor: g.host, + action: advanced ? { type: 'start', advancedPreview: true } : { type: 'start' } }, actions) + : skills ? initializeLeaderSkillsGameForAudit(g) : initializeNexusGameForAudit(g); g = options.initial ? initialize() : originalLottery(rolls, initialize); } const offered = mixedE3EcazNexusReload(g), trainers: Record = {}; @@ -279,8 +302,11 @@ export function createMixedE3EcazNexusSetup(options: MixedE3EcazNexusOptions = { return { initial, offered, afterSetup: mixedE3EcazNexusReload(g), owner, seats, trainers, ixOffer, actions }; } export function mixedE3EcazNexusInventory(g: Game): void { - const nx = g.nexusCards!.cards!; validateNexusCards(nx, g.players); - assert.deepEqual([...nx.deck, ...nx.discard, ...Object.values(nx.hands).filter(c => c !== null)].sort(), [...NEXUS_FACTIONS].sort()); + const nx = g.nexusCards?.cards; + if (nx) { + validateNexusCards(nx, g.players); + assert.deepEqual([...nx.deck, ...nx.discard, ...Object.values(nx.hands).filter(c => c !== null)].sort(), [...NEXUS_FACTIONS].sort()); + } if (g.homeworlds) homeworldGameIntegrity(g); if (g.discoveries) validateDiscoveryState(g.discoveries); if (g.leaderSkills) { @@ -291,6 +317,7 @@ export function mixedE3EcazNexusInventory(g: Game): void { ...(g.ixAuction?.cards ?? []), ...(g.richeseCache ?? []), ...(g.richeseRemoved ?? []), ...(g.auction?.cards.slice(g.auction.index + (g.currentAuctionSale ? 1 : 0)) ?? [])]; assert.deepEqual(cards.map(c => c.id).sort(), [...treacheryDeck(g.expansions), + ...(g.ecazTreachery ? ecazTreacheryCards() : []), ...(g.players.some(p => p.faction === 'richese') ? richeseCards() : [])].map(c => c.id).sort()); const homes = g.homeworlds?.custody ? homeworldForceGroups(homeworldContext(g), g.homeworlds.custody) : []; for (const p of g.players) { @@ -389,13 +416,15 @@ export function createMixedE3EcazNexusProgramme(options: MixedE3EcazNexusOptions for (const [actor, target] of [[pair[0], pair[1]], [pair[1], pair[0]]]) g = stepMixedE3EcazNexus(g, { actor, action: { type: 'alliance', target } }, actions); result.alliance = mixedE3EcazNexusReload(g); - g = advanceMixedE3EcazNexus(g, s => s.nexusCards?.phase?.stage === 'drawing' && mixedE3EcazNexusClean(s), actions, quietMixedE3EcazNexusChoice); - result.drawing = mixedE3EcazNexusReload(g); - assert.ok(g.nexusCards!.phase!.eligible.includes(f.owner)); - // The Ecaz face was selected before original Nexus creation, never reordered here. - const draw = mixedE3EcazNexusTransition(g, { actor: f.owner, action: { type: 'nexusCardChoice', turn: g.turn, - card: g.nexusCards!.cards!.hands[f.owner], choice: 'draw', ownRedraws: 0 } }, actions); - result.draws.push(draw); g = draw.after; + if (g.nexusCards) { + g = advanceMixedE3EcazNexus(g, s => s.nexusCards?.phase?.stage === 'drawing' && mixedE3EcazNexusClean(s), actions, quietMixedE3EcazNexusChoice); + result.drawing = mixedE3EcazNexusReload(g); + assert.ok(g.nexusCards!.phase!.eligible.includes(f.owner)); + // The Ecaz face was selected before original Nexus creation, never reordered here. + const draw = mixedE3EcazNexusTransition(g, { actor: f.owner, action: { type: 'nexusCardChoice', turn: g.turn, + card: g.nexusCards!.cards!.hands[f.owner], choice: 'draw', ownRedraws: 0 } }, actions); + result.draws.push(draw); g = draw.after; + } g = advanceMixedE3EcazNexus(g, s => s.phase === 5 && mixedE3EcazNexusClean(s), actions, quietMixedE3EcazNexusChoice); mixedE3EcazNexusInventory(g); result.game = g; return result; } @@ -427,16 +456,25 @@ export function createMixedE3EcazNexusCoalition(options: MixedE3EcazNexusOptions if (g.advanced || amount % 2 === 0) g = stepMixedE3EcazNexus(g, choice, f.actions); return { ...f, game: g, ally, opponent, location, ecazForces: amount, arrivals, beforeBattle, choice }; } -export function quoteMixedE3EcazNexusBattle(g: Game): BattleResolutionQuote { +export function quoteMixedE3EcazNexusBattle(g: Game, returns?: HarassWithdrawSelection): BattleResolutionQuote { const b = g.battle!, publicBattle = viewGame(g, g.host).battle!; assert.ok(b?.revealed); const side = (actor: string): ResolutionCombatant => { - const p = mixedE3EcazNexusPlayer(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), + const p = mixedE3EcazNexusPlayer(g, actor), view = viewGame(g, actor).battle!, plan = b.plans[actor]; + assert.ok(view.ownForces); + return { id: actor, faction: p.faction, ally: p.ally, spice: p.spice, hand: p.hand, plan, + leader: p.leaders.find(l => l.id === plan.leader) ?? + (g.dukeVidal?.controller === actor && plan.leader === DUKE_VIDAL_ID ? g.dukeVidal.leader : undefined), forces: view.ownForces, stronghold: view.strongholdEffects[actor], leaderSkills: g.leaderSkills?.assignments.filter(a => a.owner === actor).map(a => ({ skill: a.skill, - leader: a.leader, faceUp: !b.leaderSkillHidden?.[actor], captured: false })) }; + leader: a.leader, faceUp: !b.leaderSkillHidden?.[actor], captured: false })), + ...(view.reinforcements && (plan.weapon === 'ecaz-reinforcements' || plan.defense === 'ecaz-reinforcements') + ? { reinforcementsReserves: { + normal: p.reserves - (p.elites?.reserves ?? 0), elite: p.elites?.reserves ?? 0 } } : {}), + ...(view.harassWithdraw && (plan.weapon === 'ecaz-harass-withdraw' || plan.defense === 'ecaz-harass-withdraw') + ? { harassWithdraw: { blocked: null, forces: view.harassWithdraw.forces, + locations: view.harassWithdraw.locations, + ...(view.harassWithdraw.occupy ? { occupy: view.harassWithdraw.occupy } : {}) }, + ...(returns && Object.keys(returns).length ? { harassSelection: returns } : {}) } : {}) }; }; const context = homeworldContext(g), home = b.territory.startsWith('homeworld:') ? homeworldCombatLocation({ ...context, players: context.players.map(p => ({ ...p, diff --git a/tests/fixture-mixed-e3-moritani-nexus.ts b/tests/fixture-mixed-e3-moritani-nexus.ts index 71480bf6..142400f9 100644 --- a/tests/fixture-mixed-e3-moritani-nexus.ts +++ b/tests/fixture-mixed-e3-moritani-nexus.ts @@ -6,6 +6,8 @@ import { } from '../game/engine'; import type { FactionId } from '../game/catalog'; import { leaders, spiceDeck, treacheryDeck, type Card } from '../game/cards'; +import { ecazTreacheryCards, type EcazTreacheryCardId } from '../game/ecaz-cards'; +import { REINFORCEMENTS_CARD } from '../game/reinforcements'; import { isDefenseCard, isWeaponCard } from '../game/battle-cards'; import { territory } from '../game/board'; import { CHOAM_AUDITOR_ID } from '../game/choam-auditor'; @@ -23,6 +25,7 @@ import { homeworldGameIntegrity } from '../game/homeworld-game'; import { homeworldBattleLocation } from '../game/combat-location'; import { nativeShipmentSources } from '../game/homeworld-options'; import { homeworldShipmentChoice } from '../game/homeworld-shipment-options'; +import type { NativeReserveSelections } from '../game/homeworld-native-reserves'; import { quoteBattleResolution, type BattleResolutionQuote, type ResolutionCombatant } from '../game/battle-resolution-quote'; import type { Difficulty } from '../game/bot-profiles'; import { botActions } from '../game/bots'; @@ -51,6 +54,10 @@ export type MixedE3MoritaniOptions = { roster?: readonly FactionId[]; classics?: readonly FactionId[]; skills?: boolean; + /** Original Nexus remains selected unless explicitly disabled for all14. */ + nexus?: boolean; + /** Independent three-card variant; only the no-Nexus all14 route selects it. */ + independent?: boolean; homeworlds?: boolean; discovery?: boolean; tech?: boolean; @@ -58,7 +65,7 @@ export type MixedE3MoritaniOptions = { /** Requested skill for the trained opposing disc of the Advanced loss. */ opponentSkill?: LeaderSkillId; /** Select original starting-deal entropy; never replace a played hand. */ - startingCards?: Partial>; + startingCards?: Partial>; /** Desired physical opposing-faction card in the original undealt Traitor offer. */ traitorTarget?: string; /** Original undealt reserve prefix, before any Face Dancer reshuffle. */ @@ -174,16 +181,18 @@ export function mixedE3MoritaniInventory(g: Game): void { const auction = g.auction?.cards.slice(g.auction.index + (g.currentAuctionSale ? 1 : 0)) ?? []; const cards = [...g.deck, ...g.discard, ...g.players.flatMap(p => p.hand), ...(g.ixSetupCards ?? []), ...auction]; assert.deepEqual(cards.filter(c => !c.id.startsWith('richese-')).map(c => c.id).sort(), - treacheryDeck(g.expansions).map(c => c.id).sort()); + [...treacheryDeck(g.expansions), ...(g.ecazTreachery ? ecazTreacheryCards() : [])].map(c => c.id).sort()); if (g.players.some(p => p.faction === 'richese')) { const owned = [...(g.richeseCache ?? []), ...(g.richeseRemoved ?? []), ...cards.filter(c => c.id.startsWith('richese-'))]; assert.deepEqual(owned.map(c => c.id).sort(), richeseCards().map(c => c.id).sort()); } - validateNexusCards(g.nexusCards!.cards!, g.players); - const nexus = g.nexusCards!.cards!; - assert.deepEqual([...nexus.deck, ...nexus.discard, ...Object.values(nexus.hands).filter((c): c is FactionId => c !== null)].sort(), - [...NEXUS_FACTIONS].sort()); + if (g.nexusCards) { + validateNexusCards(g.nexusCards.cards!, g.players); + const nexus = g.nexusCards.cards!; + assert.deepEqual([...nexus.deck, ...nexus.discard, + ...Object.values(nexus.hands).filter((c): c is FactionId => c !== null)].sort(), [...NEXUS_FACTIONS].sort()); + } if (g.moritaniTerror) { assert.deepEqual(g.moritaniTerror.tokens.map(t => t.kind).sort(), [...TERROR_KINDS].sort()); assert.equal(new Set(g.moritaniTerror.tokens.map(t => t.id)).size, TERROR_KINDS.length); @@ -335,19 +344,27 @@ export function createMixedE3MoritaniLobby(options: MixedE3MoritaniOptions = {}) assert.ok(!modules.strongholdCards || advanced); const discovery = options.discovery ?? !!g.discoveryEnabled; const skills = options.skills ?? (options.initial?.status === 'setup' ? !!g.leaderSkills : false); + const nexus = options.nexus ?? true, independent = options.independent ?? false; + assert.ok(nexus || skills, 'The no-Nexus mixed route requires the original all14 entry.'); + assert.ok(!independent || !nexus && skills, 'Independent cards belong to the no-Nexus all14 route.'); assert.ok(g.status === 'lobby' || g.status === 'setup' && g.turn === 1 && g.phase === 0, 'Continue only an original fresh lobby or its undealt first-turn setup.'); if (g.status === 'lobby') { for (const [type, enabled] of Object.entries(modules)) if (!!g[type as keyof typeof modules] !== enabled) g = applyAction(g, g.host, { type, enabled }); - g.discoveryEnabled = discovery; g.nexusCards ??= { cards: null, phase: null }; - assert.deepEqual(g.nexusCards, { cards: null, phase: null }); - assert.ok(!g.ecazTreachery && !g.advancedPreview && !g.semutaPreview && !g.leaderSkills); + g.discoveryEnabled = discovery; + if (nexus) { + g.nexusCards ??= { cards: null, phase: null }; + assert.deepEqual(g.nexusCards, { cards: null, phase: null }); + } else assert.ok(!g.nexusCards, 'Never erase an already selected Nexus module.'); + assert.ok(!g.advancedPreview && !g.semutaPreview && !g.leaderSkills && !g.ecazTreachery, + 'A fresh lobby selects neither Skills nor independent cards before its real initializer.'); } else { for (const [type, enabled] of Object.entries(modules)) assert.equal(!!g[type as keyof typeof modules], enabled); assert.equal(!!g.discoveryEnabled, discovery); assert.equal(!!g.leaderSkills, skills); - assert.ok(g.nexusCards?.cards, 'A supplied original deal never acquires a new Nexus deck.'); + assert.equal(!!g.nexusCards?.cards, nexus, 'A supplied original deal keeps its selected Nexus deck or real absence.'); + assert.equal(!!g.ecazTreachery, independent); } return g; } @@ -371,7 +388,9 @@ export function createMixedE3MoritaniSetup(options: MixedE3MoritaniOptions = {}) let g = createMixedE3MoritaniLobby(options); const actions: MixedE3MoritaniStep[] = [], staging: string[] = []; const skills = g.status === 'lobby' ? options.skills ?? false : !!g.leaderSkills; + const independent = options.independent ?? false; const entry = mixedE3MoritaniEntry(options, g, skills); + assert.ok(options.nexus !== false || entry === 'leaderSkills', 'The no-Nexus route uses the all14 initializer.'); const families = (options.families ?? ['ix', 'choam']).filter(f => g.expansions.includes(f)); const moritani = g.players.find(p => p.faction === 'moritani')!.id; const nativeFaction = options.natives?.[families[0]] ?? (families[0] === 'ix' ? 'tleilaxu' : 'choam'); @@ -393,15 +412,17 @@ export function createMixedE3MoritaniSetup(options: MixedE3MoritaniOptions = {}) let ixSeatCard: string | null = null; if (g.status === 'lobby') { for (const p of g.players) if (!p.ready) g = applyAction(g, p.id, { type: 'ready' }); + // The real initializer is the only selector of the independent variant. + if (independent) { assert.ok(!g.ecazTreachery); g.ecazTreachery = true; } const initialize = () => entry === 'combined' ? initializeCombinedNexusGameForAudit(g, !!g.homeworlds) : skills ? initializeLeaderSkillsGameForAudit(g) : initializeNexusGameForAudit(g); if (options.initial) g = initialize(); else { assert.ok(!g.players.some(p => p.faction === 'beneGesserit'), 'A fresh crafted deal keeps the original prediction stage out of its named shuffle list; supply a Bene Gesserit lobby as `initial`.'); - const deck = treacheryDeck(g.expansions), remaining = [...deck]; - const take = (kind: Card['kind']) => { - const at = remaining.findIndex(c => c.kind === kind); + const deck = [...treacheryDeck(g.expansions), ...(g.ecazTreachery ? ecazTreacheryCards() : [])], remaining = [...deck]; + const take = (kind: Card['kind'] | EcazTreacheryCardId) => { + const at = remaining.findIndex(c => c.id === kind || c.kind === kind); assert.ok(at >= 0, `The original deck must hold a ${kind}.`); return remaining.splice(at, 1)[0].id; }; @@ -422,7 +443,7 @@ export function createMixedE3MoritaniSetup(options: MixedE3MoritaniOptions = {}) const rolls: number[] = []; if (skills) rolls.push(...permutation(LEADER_SKILL_CARDS.map(c => c.id), skillOrderFor(g, requested))); if (g.discoveryEnabled) rolls.push(...identity(7)); - rolls.push(...identity(NEXUS_FACTIONS.length - 1)); + if (g.nexusCards) rolls.push(...identity(NEXUS_FACTIONS.length - 1)); rolls.push(...permutation(deck.map(c => c.id), [...row, ...remaining.map(c => c.id)])); const spiceCount = spiceDeck(g.expansions.includes('ix')).length + (g.discoveryEnabled ? DISCOVERY_SPICE_CARDS.length + 1 : 0); @@ -570,7 +591,7 @@ export function createMixedE3MoritaniProgramme(options: MixedE3MoritaniOptions = result.firstMentat = firstMentat; g = advanceMixedE3Moritani(g, s => s.phase === 1 && mixedE3MoritaniClean(s), f.actions); orderMixedE3MoritaniSpice(g, true); - const closed = closeMixedE3Moritani(f, g, options.nexusCard); + const closed = g.nexusCards ? closeMixedE3Moritani(f, g, options.nexusCard) : null; if (closed) { g = closed.game; result.alliance = closed.alliance; result.drawing = closed.drawing; result.draws = [closed.draw]; } g = advanceMixedE3Moritani(g, s => s.phase === 5 && mixedE3MoritaniClean(s), f.actions); @@ -626,7 +647,7 @@ export function revealMixedE3MoritaniPlans(g: Game, plans: readonly MixedE3Morit /** Highest- or lowest-strength legal commitment for one combatant, chosen by * trying the engine's own plan validator. Never assumes a fixed dial or budget. */ function legalPlan(g: Game, actor: string, leader: string, prefer: 'max' | 'min', - extra: { weapon?: string; defense?: string } = {}): Action { + extra: { weapon?: string; defense?: string; reinforcementsSources?: NativeReserveSelections } = {}): Action { const spice = mixedE3MoritaniPlayer(g, actor).spice; const supports = prefer === 'max' ? Array.from({ length: spice + 1 }, (_, i) => spice - i) : [0]; const dials = prefer === 'max' ? Array.from({ length: 13 }, (_, i) => 12 - i) : [0]; @@ -645,7 +666,7 @@ function legalPlan(g: Game, actor: string, leader: string, prefer: 'max' | 'min' * `disc: 'held'` the native opposes with the very disc Moritani holds, which is * the only legal normal own-card Traitor declaration. */ export function stageMixedE3MoritaniLoss(f: MixedE3MoritaniSetup, state: Game, - options: { disc?: 'strongest' | 'held' } = {}): MixedE3MoritaniBattle { + options: { disc?: 'strongest' | 'held'; enemyCard?: string } = {}): MixedE3MoritaniBattle { const arrival = shipMixedE3Moritani(mixedE3MoritaniReload(state), f.native, f.location, 1, 0, f.actions); const ready = advanceMixedE3Moritani(arrival.settled, s => !s.decision, f.actions); let g = openMixedE3MoritaniBattle(ready, f.moritani, f.native, f.actions); @@ -662,14 +683,25 @@ export function stageMixedE3MoritaniLoss(f: MixedE3MoritaniSetup, state: Game, : mixedE3MoritaniPlayer(g, f.native).leaders .filter(l => !l.dead && l.id !== f.target && !trained.has(l.id)).sort((a, b) => b.strength - a.strength)[0]; const ownCard = mixedE3MoritaniPlayer(g, f.moritani).hand.find(c => isDefenseCard(c)); - const enemyCard = mixedE3MoritaniPlayer(g, f.native).hand.find(c => isWeaponCard(c)); + const enemyCard = options.enemyCard + ? mixedE3MoritaniPlayer(g, f.native).hand.find(c => c.id === options.enemyCard) + : mixedE3MoritaniPlayer(g, f.native).hand.find(c => isWeaponCard(c)); + assert.ok(!options.enemyCard || enemyCard, 'An explicitly selected card must be in the native’s original hand.'); + const feeSources = enemyCard?.id === REINFORCEMENTS_CARD && g.homeworlds + ? viewGame(g, f.native).battle?.reinforcements?.homeworldSources?.[0] : undefined; + if (enemyCard?.id === REINFORCEMENTS_CARD && g.homeworlds) + assert.ok(feeSources, 'The native must pay from its actual offered World source.'); assert.ok(ownLeader && enemyLeader, 'The original deal must supply the loss discs.'); const plans: MixedE3MoritaniStep[] = [ { actor: f.moritani, action: legalPlan(g, f.moritani, ownLeader.id, 'min', ownCard ? { defense: ownCard.id } : {}) }, { actor: f.native, action: legalPlan(g, f.native, enemyLeader.id, 'max', - enemyCard ? { weapon: enemyCard.id } : {}) }, + enemyCard ? { weapon: enemyCard.id, ...(feeSources ? { reinforcementsSources: structuredClone(feeSources) } : {}) } : {}) }, ]; + if (options.enemyCard) { + const plan = plans[1].action; assert.ok(plan.type === 'battlePlan'); + assert.equal(plan.weapon, options.enemyCard, 'Never silently drop the selected consumer card.'); + } const revealed = revealMixedE3MoritaniPlans(mixedE3MoritaniReload(g), plans, f.actions); return { arrangements, location: f.location, arrival, plans, revealed, ownLeader: ownLeader.id, enemyLeader: enemyLeader.id, target: f.target, @@ -715,7 +747,9 @@ export function quoteMixedE3MoritaniBattle(g: Game): BattleResolutionQuote { plan: b.plans[actor], leader: p.leaders.find(l => l.id === b.plans[actor].leader) ?? (g.dukeVidal?.controller === actor ? g.dukeVidal.leader : undefined), forces: view.ownForces, stronghold: view.strongholdEffects[actor], leaderSkills: (g.leaderSkills?.assignments ?? []).filter(a => a.owner === actor) - .map(a => ({ skill: a.skill, leader: a.leader, faceUp: !b.leaderSkillHidden?.[actor], captured: false })) }; + .map(a => ({ skill: a.skill, leader: a.leader, faceUp: !b.leaderSkillHidden?.[actor], captured: false })), + ...(view.reinforcements ? { reinforcementsReserves: { + normal: p.reserves - (p.elites?.reserves ?? 0), elite: p.elites?.reserves ?? 0 } } : {}) }; }; const publicBattle = viewGame(g, g.host).battle!; const home = homeworldBattleLocation(g, b.territory); @@ -726,7 +760,7 @@ export function quoteMixedE3MoritaniBattle(g: Game): BattleResolutionQuote { beneficiary: [b.attacker, b.defender].includes(actor) ? actor : mixedE3MoritaniPlayer(g, actor).ally!, called: b.traitorCalls[actor] ?? false, traitors: mixedE3MoritaniPlayer(g, actor).traitors })), participants: g.players, physicalCards: [...g.deck, ...g.discard, ...g.players.flatMap(p => p.hand)], - pendingAuditorPresent: false, pendingRetentionPresent: false, nexusMoritani: true }); + pendingAuditorPresent: false, pendingRetentionPresent: false, nexusMoritani: !!g.nexusCards }); } /** True printed Horror/Extortion/… token identities from the actual supply. */ diff --git a/tests/mixed-e3-ecaz-nexus-runtime.test.ts b/tests/mixed-e3-ecaz-nexus-runtime.test.ts index deb4c9e4..8b73f265 100644 --- a/tests/mixed-e3-ecaz-nexus-runtime.test.ts +++ b/tests/mixed-e3-ecaz-nexus-runtime.test.ts @@ -11,6 +11,8 @@ import { nexusEcazDukeAction } from '../game/nexus-ecaz-duke-options'; import { discoveryEntryMoveAction } from '../game/discovery-entry-options'; import { ownedTech } from '../game/tech-tokens'; import { quoteSpiceCollection } from '../game/board-resolution-quote'; +import { defaultHarassWithdrawAllocation } from '../game/harass-withdraw'; +import { stageEcazIndependentSkillsCard } from './fixture-ecaz-independent-skills'; import { advanceMixedE3EcazNexus as advance, createMixedE3EcazNexusCoalition as coalition, createMixedE3EcazNexusProgramme as programme, createMixedE3EcazNexusSetup as setup, @@ -187,7 +189,7 @@ void test('all-three-family six-native original setup and real alliance closing for (const advanced of [false, true]) void test(`${advanced ? 'Advanced selected Warmaster/HW/Tech/held Tabr' : 'Basic even-FORCE/no-Skills'} native mixed Ecaz coalition keeps its chosen lead, labelled support and physical payer/casualty ownership`, () => { const f = coalition({ advanced, skills: advanced, homeworlds: advanced, tech: true, strongholds: advanced }); - const d = f.game.decision; assert.ok(d?.kind === 'ecazBattleLead'); assert.equal(next(f.game), null); + 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: d.event, lead: f.opponent }); for (const t of f.arrivals) { @@ -462,7 +464,6 @@ for (const advanced of [false, true]) void test(`${advanced ? 'Advanced selected const f = coalition({ roster: ['ecaz', 'tleilaxu', 'guild', 'emperor'], advanced, skills: advanced, homeworlds: advanced, tech: true, strongholds: advanced }, 'tleilaxu'); const d = f.game.decision; assert.ok(d?.kind === 'ecazBattleLead'); - assert.equal(next(f.game), null, 'The coalition lead is always an owner decision.'); reject(f.game, f.ally, { type: 'decision', event: d.event, lead: f.owner }); reject(f.game, f.owner, { type: 'decision', event: d.event, lead: f.opponent }); for (const arrival of f.arrivals) { @@ -559,7 +560,6 @@ for (const skilled of [false, true]) void test(`original Ecaz native ${skilled ? : 1; if (d?.kind === 'sukRescue') { assert.equal(d.player, f.owner); - assert.equal(next(rescue), null, 'The rescue is an owner decision.'); assert.ok(maximum <= 3, 'The selected hidden band saves up to three counters'); assert.ok(d.options.some(option => option.kept), 'A skilled rescue keeps one physical counter in its sector'); const choice = d.options.findIndex(option => option.normal + option.elite === maximum); @@ -591,3 +591,77 @@ for (const skilled of [false, true]) void test(`original Ecaz native ${skilled ? inventory(rescue); } }); + +void test('no-Nexus Basic all14 Ecaz/Ix coalition settles real allied counters and a Tech reward without independent cards', () => { + const f = coalition({ nexus: false, advanced: false, skills: true, tech: true, + roster: ['ecaz', 'ixians', 'guild'] }); + const ready = chooseLead(f, f.owner), revealed = reveal(ready, coalitionPlans(f, ready, f.owner)); + const q = quote(revealed), done = settle(revealed); + assert.equal(q.winner, f.owner); + assert.deepEqual(q.fixedLosses, [{ owner: f.owner, normal: 2, elite: 0 }]); + assert.equal(q.casualties!.owner, f.ally); + assert.equal(player(done, f.owner).tanks - player(ready, f.owner).tanks, 2); + assert.equal(player(done, f.ally).tanks - player(ready, f.ally).tanks, 4); + assert.equal(player(done, f.opponent).tanks - player(ready, f.opponent).tanks, 8); + assert.equal(done.techTokens![ownedTech(revealed.techTokens, f.opponent)[0]].owner, f.owner); + inventory(done); +}); + +void test('no-Nexus mixed E3/E1 all14 World coalition earns Tabr and spends its own R/H sources before committed-only trained Suk rescue', () => { + const f = coalition({ nexus: false, independent: true, advanced: true, skills: true, + roster: ['ecaz', 'moritani', 'ixians', 'guild'], skill: 'suk-graduate', + homeworlds: true, tech: true, strongholds: true }, 'moritani'); + assert.equal(f.firstMentat!.after.strongholdCards!.owners.sietch_tabr, f.owner, + 'The holder earned this card from the original paid turn-one claim'); + const ready = chooseLead(f, f.owner), staging: string[] = []; + const weapon = stageEcazIndependentSkillsCard(ready, f.owner, 'ecaz-reinforcements', staging); + const defense = stageEcazIndependentSkillsCard(ready, f.owner, 'ecaz-harass-withdraw', staging); + inventory(ready); + const own = viewGame(ready, f.owner).battle!, profile = own.ecazOccupy!.profile!; + const sources = own.reinforcements?.homeworldSources?.[0]; assert.ok(sources); + const lead: MixedE3EcazNexusStep = { actor: f.owner, action: { type: 'battlePlan', + leader: f.trainers[f.owner], dial: profile.fixedEcazDial + 2, support: 2, weapon, defense, + reinforcementsSources: structuredClone(sources) } }; + const defender = player(ready, f.opponent).leaders.filter(l => !l.dead && !l.usedAt && + !ready.leaderSkills!.assignments.some(a => a.leader === l.id)).sort((a, b) => a.strength - b.strength)[0]; + assert.ok(defender); + const revealed = reveal(ready, [lead, { actor: f.opponent, + action: { type: 'battlePlan', leader: defender.id, dial: 1, support: 1 } }]); + const allocation = viewGame(revealed, f.owner).battle?.harassAllocation; + const returns = allocation ? defaultHarassWithdrawAllocation(allocation.context, allocation.dial, allocation.support) : {}; + const q = quote(revealed, returns); + assert.equal(q.winner, f.owner); + assert.deepEqual(q.fixedLosses, [{ owner: f.owner, normal: 3, elite: 0 }]); + assert.equal(q.casualties!.owner, f.ally); + assert.equal(q.sukGraduate?.mode, 'skilled'); + assert.deepEqual(q.reinforcements, [{ player: f.owner, card: 'ecaz-reinforcements', normal: 3, elite: 0 }]); + assert.deepEqual(q.strongholdIncome, [{ player: f.owner, amount: 1 }], + 'The earned Tabr card supplies an actual receipt from the opposing one-force dial'); + let g = revealed; + if (g.decision?.kind === 'harassWithdraw') g = step(g, { actor: f.owner, + action: { type: 'decision', event: g.decision.event, returns } }); + const rescue = settle(g, s => s.decision?.kind === 'sukRescue'); + const d = rescue.decision; assert.ok(d?.kind === 'sukRescue'); + assert.equal(d.player, f.owner); + assert.ok(d.options.every(option => option.normal + option.elite <= 3), + 'Suk cannot rescue the allied variable commitment, card fee or returned complement'); + const choice = d.options.findIndex(option => option.normal === 3 && option.elite === 0 && option.kept); + assert.ok(choice >= 0); + const done = settle(step(rescue, { actor: f.owner, action: { type: 'decision', event: d.event, choice } })); + const collection = done.phase === 7 + ? quoteSpiceCollection(done).receipts.find(receipt => receipt.player === f.owner)?.strongholds ?? 0 : 0; + assert.equal(player(done, f.owner).spice, player(revealed, f.owner).spice - 2 + 1 + collection, + 'Own variable support is paid and the earned Tabr receipt reaches the real purse'); + assert.equal(player(done, f.owner).reserves, player(ready, f.owner).reserves - 3 + 2 + 2, + 'The own three-reserve fee precedes two Harass returns, then two rescued counters return to reserves'); + assert.equal(player(done, f.owner).tanks, player(ready, f.owner).tanks + 3, + 'Only the non-rescuable own Reinforcements fee reaches Ecaz Tanks'); + assert.equal(player(done, f.owner).forces[f.location], 1, 'Only the selected kept Suk counter stays'); + assert.equal(player(done, f.ally).tanks, player(ready, f.ally).tanks + 2); + assert.equal(player(done, f.ally).forces[f.location], 2); + for (const card of [weapon, defense]) { + assert.equal(done.discard.filter(c => c.id === card).length, 1); + assert.ok(!player(done, f.owner).hand.some(c => c.id === card)); + } + inventory(done); +}); diff --git a/tests/mixed-e3-moritani-nexus-runtime.test.ts b/tests/mixed-e3-moritani-nexus-runtime.test.ts index e08e3306..d79ab7e1 100644 --- a/tests/mixed-e3-moritani-nexus-runtime.test.ts +++ b/tests/mixed-e3-moritani-nexus-runtime.test.ts @@ -4,6 +4,10 @@ import { viewGame, type Action, type Game } from '../game/engine'; import type { FactionId } from '../game/catalog'; import { DIFFICULTIES } from '../game/bot-profiles'; import { leaders } from '../game/cards'; +import { DUKE_VIDAL_ID } from '../game/duke-vidal'; +import { homeworldContext } from '../game/homeworld-game'; +import { homeworldForceGroups } from '../game/homeworld-custody'; +import { REINFORCEMENTS_CARD } from '../game/reinforcements'; import { territory } from '../game/board'; import { TERROR_KINDS } from '../game/moritani-terror'; import { @@ -12,7 +16,7 @@ import { createMixedE3MoritaniSetup as setup, mixedE3MoritaniClean as clean, mixedE3MoritaniCunningRequest as cunning, mixedE3MoritaniInventory as inventory, mixedE3MoritaniPlayer as player, mixedE3MoritaniPolicy as policy, mixedE3MoritaniReload as reload, - mixedE3MoritaniToken as token, nextMixedE3MoritaniStep as next, quoteMixedE3MoritaniBattle as quote, + mixedE3MoritaniToken as token, quoteMixedE3MoritaniBattle as quote, settleMixedE3MoritaniBoundary as boundary, stageMixedE3MoritaniLoss as loss, stepMixedE3Moritani as step, type MixedE3MoritaniBoundary, type MixedE3MoritaniOptions, @@ -295,5 +299,92 @@ void test('all four original policies consume the real private assassination win assert.ok(['revealed', 'declined'].includes(after.moritaniAssassinate!.opportunities.at(-1)!.stage)); assert.deepEqual(g, original, 'A rejected rival action cannot spend the original opportunity.'); } - assert.equal(next(g) ?? null, null, 'The private choice is never auto-consumed by the controller.'); +}); + +void test('no-Nexus all14 mixed Advanced Moritani loss against a native CHOAM keeps its World-sourced Reinforcements fee, technique return, bounty and single Mentat replacement', () => { + const options: MixedE3MoritaniOptions = { rules: 'advanced', families: ['choam'], natives: { choam: 'choam' }, + skills: true, nexus: false, independent: true, homeworlds: true, tech: true, strongholds: true, + roster: ['moritani', 'choam', 'richese', 'guild'], startingCards: { choam: [REINFORCEMENTS_CARD] } }; + const f = programme(options); + assert.equal(f.afterSetup.leaderSkills!.assignments.some(a => a.leader === DUKE_VIDAL_ID), false); + assert.equal(f.afterSetup.leaderSkills!.assignments.find(a => a.owner === f.native)!.leader, f.target, + 'The trained disc the loss will assassinate is the real held Traitor target.'); + physical(f.afterSetup); + assert.deepEqual(f.afterSetup.moritaniTerror!.tokens.map(t => t.kind).sort(), [...TERROR_KINDS].sort(), + 'The printed Terror supply stays physical without Nexus.'); + + const battle = loss(f, f.game, { enemyCard: REINFORCEMENTS_CARD }); + assert.equal(quote(battle.revealed).winner, f.native); + const plan = battle.plans[1].action; assert.ok(plan.type === 'battlePlan'); + assert.deepEqual(plan.reinforcementsSources, { 'homeworld:choam': { normal: 3, elite: 0 } }, + 'The fee is declared from the payer’s own native World, exactly as offered.'); + const native = (state: Game) => homeworldForceGroups(homeworldContext(state), state.homeworlds!.custody!) + .find(home => home.native === f.native)!.forces[f.native] ?? { normal: 0, elite: 0 }; + const arrangements = battle.arrangements; + let chosen: Game | undefined; + const walk = aftermath(battle.revealed, null, boundaryGame => { + const d = boundaryGame.decision; assert.ok(d?.kind === 'moritaniAssassinate'); + const action: Action = { type: 'decision', event: d.event, card: f.target }; + rejects(boundaryGame, f.native, action); + rejects(boundaryGame, f.moritani, { type: 'decision', event: d.event, card: battle.enemyLeader }); + const at = player(boundaryGame, f.native), own = player(boundaryGame, f.moritani); + chosen = step(boundaryGame, { actor: d.player, action }); + assert.equal(player(chosen, f.native).leaders.find(l => l.id === f.target)!.dead, true); + assert.equal(player(chosen, f.native).leaders.find(l => l.id === f.target)!.deaths, + at.leaders.find(l => l.id === f.target)!.deaths + 1); + assert.equal(player(chosen, f.moritani).spice, own.spice + at.leaders.find(l => l.id === f.target)!.strength, + 'The printed bounty is paid once from the bank.'); + assert.equal(player(chosen, f.native).spice, at.spice); + assert.equal(player(chosen, f.native).reserves, player(arrangements, f.native).reserves - 3, + 'The card user pays the printed three-reserve fee before any rescue.'); + assert.equal(player(chosen, f.moritani).reserves, player(arrangements, f.moritani).reserves, + 'The defeated opponent pays nothing towards the winner’s card fee.'); + assert.equal(native(chosen).normal, native(arrangements).normal - 3, + 'Exactly three counters leave the payer’s own native World reserve.'); + assert.equal(player(chosen, f.native).tanks, player(arrangements, f.native).tanks + + 3 + (player(arrangements, f.native).forces[f.location] ?? 0), + 'The fee and the payer’s surviving committed counter are the only Tanks arrivals.'); + const skill = f.afterSetup.leaderSkills!.assignments.find(a => a.leader === f.target)!.skill; + assert.equal(chosen.leaderSkills!.assignments.some(a => a.leader === f.target), false); + assert.equal(chosen.leaderSkills!.deck.filter(c => c === skill).length, 1, + 'The assassinated trained disc returns its physical skill exactly once.'); + assert.equal(totalCounters(chosen, f.native), totalCounters(boundaryGame, f.native)); + assert.equal(totalCounters(chosen, f.moritani), totalCounters(boundaryGame, f.moritani)); + return chosen; + }); + assert.ok(chosen, 'The Advanced native loss must open its private reveal choice.'); + assert.ok(walk.order.includes('assassination'), walk.order.join(',')); + assert.equal(walk.order.at(-1), 'settled'); + const feeTanks = player(chosen, f.native).tanks; + const settled = advance(walk.game, s => s.decision?.kind === 'moritaniPlacement', f.actions); + const receipt = settled.moritaniAssassinate!.opportunities.at(-1)!; + assert.equal(receipt.stage, 'replaced'); assert.equal(receipt.card, f.target); + assert.equal(receipt.replacement, chosen.traitorReserve![0]); + assert.equal(settled.traitorReserve!.length, chosen.traitorReserve!.length - 1, + 'Exactly one private Mentat replacement.'); + assert.ok(player(settled, f.moritani).traitors.includes(receipt.replacement!)); + assert.equal(player(settled, f.native).tanks, feeTanks, + 'The already-paid fee stays in the payer’s Tanks through the settled battle.'); + assert.equal(Object.hasOwn(viewGame(settled, f.native).moritaniAssassinate!.history.at(-1)!, 'replacement'), false); + physical(settled); +}); + +void test('the same no-Nexus mixed roster without independent cards keeps a real Basic continuation and grants no Advanced assassination window', () => { + const options: MixedE3MoritaniOptions = { rules: 'basic', families: ['choam'], natives: { choam: 'choam' }, + skills: true, nexus: false, roster: ['moritani', 'choam', 'guild'] }; + const f = programme(options); + assert.equal([...f.afterSetup.deck, ...f.afterSetup.discard, + ...f.afterSetup.players.flatMap(p => p.hand)].some(c => c.id.startsWith('ecaz-')), false, + 'Unselected independent cards stay out of the physical pool.'); + physical(f.afterSetup); + + const battle = loss(f, f.game); + assert.equal(quote(battle.revealed).winner, f.native); + const walk = aftermath(battle.revealed, null); + assert.equal(walk.order.at(-1), 'settled'); + assert.equal(walk.order.includes('assassination'), false, walk.order.join(',')); + assert.equal(walk.game.moritaniAssassinate, undefined); + assert.equal(walk.game.traitorReserve!.length, battle.revealed.traitorReserve!.length, + 'No Traitor is consumed or replaced without the Advanced window.'); + physical(walk.game); });