From bc37cd38fef9b822f48caa1d9fb6dda6b7ce0e09 Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Sat, 17 Oct 2026 23:38:13 +1100 Subject: [PATCH] Connect native E3 World Skills and independent cards --- docs/CURRENT_STATUS.md | 24 ++++ docs/ECAZ_OCCUPY_RULES.md | 11 ++ docs/ECAZ_TREACHERY_RULES.md | 22 +++ docs/HOMEWORLD_RUNTIME_IMPLEMENTATION.md | 17 ++- docs/IMPLEMENTATION_STATUS.md | 28 ++++ docs/LEADER_SKILLS_RUNTIME.md | 55 ++++++++ docs/MORITANI_ASSASSINATE_LEADERS.md | 8 ++ docs/MORITANI_ENTRY_TIMING.md | 11 ++ docs/MORITANI_LEADER_SKILLS.md | 10 ++ docs/RULE_DECISIONS.md | 6 +- game/engine.ts | 47 ++++--- game/leader-skill-profile.ts | 8 +- game/reference.ts | 16 +-- tests/ecaz-independent-skills-runtime.test.ts | 119 ++++++++++++++++- tests/fixture-ecaz-independent-skills.ts | 15 ++- tests/fixture-homeworld-ecaz-occupy.ts | 13 +- tests/fixture-moritani-independent-skills.ts | 109 ++++++++------- ...oritani-independent-skills-runtime.test.ts | 126 +++++++++++++++++- 18 files changed, 553 insertions(+), 92 deletions(-) diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 0d8934b8..527dfe3f 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. +**Native E3 World Skills/cards — 17 October.** Fourteen of18 original normal +World/phase starts rejected before; all18 now start. Standalone/paired +Ecaz/Moritani/classic Worlds retains optional all14/cards and original phase +modules. Paired plain World Skills needs no Nexus/card; bare World cards needs +no Skills. Native6/6, exact one-family roster, shared Duke, Advanced role and +mixed-E3/Nexus/Discovery-card guards remain. No played save is converted. + +Genuine Ecaz programmes preserve paid native World sources, earned Arrakeen, +separate fixed/variable armies, card-user fee/H and committed-only Suk. Genuine +paired Moritani loses, assassinates different unused trained Ecaz4, returns one +skill and gets one actual Mentat replacement. Real native Ecaz own-World H +rejects unchanged; real later Recruits returns2 free into Junction. Both World +arrival branches pay once, acquire untrained Duke6/Robbery and resume movement. + +Frozen39-file **459/459**, types/lint/build and actual programmes pass. The +external-ID fixture now reads pending `lobbyOptions` rather than initialized +Skills/cards; an incorrect charge for an ordinary free ground move is deleted. +Three bounded World tables exercise all four legal policies (seven battles), +reaching turn3/earlier wins. Actual390px paired five-module normal entry +completes skill/Traitor/native6 into Storm; Grumman14 and native inspectors +render without browser errors/root overflow. Local working-tree evidence is +not deployed revision-matched, full-mode, calibration or deferred assurance. +[Current source envelope and evidence](LEADER_SKILLS_RUNTIME.md#native-e3-worlds-with-independent-cards--17-october-2026). + **World Skills and selected decks — 17 October.** Eight original World/card/ Skills phase starts and five bare selected-deck starts rejected before; one selected-deck roster capability now serves Basic/Advanced without a required diff --git a/docs/ECAZ_OCCUPY_RULES.md b/docs/ECAZ_OCCUPY_RULES.md index c10cb01a..ac0e44f0 100644 --- a/docs/ECAZ_OCCUPY_RULES.md +++ b/docs/ECAZ_OCCUPY_RULES.md @@ -2,6 +2,17 @@ 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. +## Native World cards and Skills — 17 October 2026 + +Standalone/paired Ecaz/Moritani Worlds now composes independent cards with or +without Skills and original Tech/Advanced Strongholds. Paired plain World +Skills also needs neither Nexus nor cards. Original World source-bearing +shipments/fees feed the same Arrakis coalition: selected-holder bank support, +card-user fee/H, committed-only Suk and separate fixed/variable losses remain. +Actual Emperor-led World H returns native Sardaukar to Salusa. This adds no +Occupy to World locations, Duke training or new printed arithmetic. +[Current source envelope and459/459 proof](LEADER_SKILLS_RUNTIME.md#native-e3-worlds-with-independent-cards--17-october-2026). + ## Paired native Skills — 17 October 2026 Native Ecaz/Moritani/classics now retains ordinary all14 Skills without Nexus, diff --git a/docs/ECAZ_TREACHERY_RULES.md b/docs/ECAZ_TREACHERY_RULES.md index 4e319537..f512b3cf 100644 --- a/docs/ECAZ_TREACHERY_RULES.md +++ b/docs/ECAZ_TREACHERY_RULES.md @@ -457,3 +457,25 @@ are recorded in the [canonical current composition](LEADER_SKILLS_RUNTIME.md#wor Controlled lotteries/physical card positioning are labelled; no printed-number ruling, save retrofit, full-mode or deployed certification follows. +## Native E3 World cards — 17 October 2026 + +The printed independent-variant permission now also composes original +standalone/paired Ecaz/Moritani/classic Worlds with cards, with or without +all14 Skills, and original Tech3+/Advanced Strongholds2+. Paired plain World +Skills no longer needs Nexus or cards. Exact ecaz33/36, native6/6, five-disc +training, shared Duke and Advanced role limits remain; mixed E3 and +Nexus/Discovery card contexts stay separate. No new numeric ruling is adopted. + +Actual native World sources fund paid shipments and the card user's R fee, +never an ally's pool or rescued fee. Native Eca fixed/variable losses, typed +Suk/H returns, earned held support and winner Tech remain distinct. +Skill-first Moritani kills the different unused trained target, returns its +one technique and gets one actual Mentat replacement. Every own-World H ban +remains; a real native Ecaz attempt rejects unchanged. Original battle deaths +then permit actual free Recruits returns into Junction. + +Frozen39-file459/459, types/lint/build, original rule/arrival programmes and +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. + diff --git a/docs/HOMEWORLD_RUNTIME_IMPLEMENTATION.md b/docs/HOMEWORLD_RUNTIME_IMPLEMENTATION.md index 58f85956..c7d581ed 100644 --- a/docs/HOMEWORLD_RUNTIME_IMPLEMENTATION.md +++ b/docs/HOMEWORLD_RUNTIME_IMPLEMENTATION.md @@ -5,13 +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 World starts -now compose all14 and independent cards, optionally original Tech/Advanced -Strongholds. Actual E3 World cards and Nexus/Discovery remain separate. Original -typed sources, own-World fee/dial disjointness, committed-only Suk, current-rate -revival and every own-World Harass prohibition remain. No World/counter/wallet -or played history is manufactured. -[Current scope and683/683 proof](LEADER_SKILLS_RUNTIME.md#world-skills-and-selected-decks--17-october-2026). +**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). ## Mixed E1/E2 and paired E3 Nexus — 7 October 2026 diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index b03bc438..27dfa7f7 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,33 @@ # Dune implementation status +## 17 October 2026 — Native E3 World Skills/cards + +Fourteen of18 normal World/phase starts rejected before; all18 now start. +Original standalone/paired Ecaz/Moritani/classic Worlds retains all14, +independent cards and original Tech/Advanced Strongholds without incidental +Nexus, card or Skills dependencies. Exact ecaz family, native6/6, shared Duke, +Advanced role and mixed-E3/Nexus/Discovery-card guards stay. + +Genuine World-origin programmes preserve earned selected-holder support, +source-labelled card fees/H and committed-only Suk with separate allied +losses; no-Skills Emperor returns2E to Salusa. Paired Advanced Moritani kills +a different unused trained Whitmore Bludd4 once, returns Killer Medic once +and gets one actual Mentat replacement. Own-Ecaz-World H rejects unchanged; +real battle deaths then enable free Recruits2 into Junction. Both original +arrival branches consume one paid source-bearing entry, Duke6/Robbery and +resume movement without replay or a new priority ruling. + +Frozen39-file **459/459**, types/lint/build and actual programmes pass. +The external-seat fixture now recognizes real pending lobbyOptions; the +incorrect extra charge for a free ground move was deleted. Three bounded +native World tables reach turn3/earlier wins with seven battles and all four +legal policies represented. Actual390px normal paired five-module setup +trains offered Prana Bindu Adept, keeps a Traitor, deploys native6 and reaches +Storm with Grumman14, no browser errors/root overflow. Controlled cards/ +Traitors/lotteries and unopened Spice ordering remain labelled. No reset, +save/schema conversion, full-mode, deployed or deferred assurance claim. +[Canonical current composition](LEADER_SKILLS_RUNTIME.md#native-e3-worlds-with-independent-cards--17-october-2026). + ## 17 October 2026 — World Skills/cards and selected ordinary decks Eight actual World/card/Skills phase starts and five bare selected-deck starts diff --git a/docs/LEADER_SKILLS_RUNTIME.md b/docs/LEADER_SKILLS_RUNTIME.md index b235bb88..cca45e3f 100644 --- a/docs/LEADER_SKILLS_RUNTIME.md +++ b/docs/LEADER_SKILLS_RUNTIME.md @@ -7,6 +7,61 @@ 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). +## Native E3 Worlds with independent cards — 17 October 2026 + +This supersedes the earlier17 October non-World E3 card boundary below. +Fresh standalone or paired Ecaz/Moritani/classic Worlds now retains all14, +optionally independent cards and original Tech3+/Advanced Strongholds2+. +Paired plain World Skills needs neither Nexus nor these three cards. Original +native E3 World/card/phase starts also work without Skills. Exact ecaz33/36, +two through six distinct seats, printed6/6 and original setup remain; actual +mixed E3, Nexus/Discovery card contexts and unrelated roles/effects stay separate. +Advanced Skills/Harkonnen, bare Moritani phase/Harkonnen and shared-Duke +training guards remain. No new state shape or played-save retrofit. + +One original native module capability feeds live World cards and native powers, +including mandatory fresh Banker or native Ecaz/Moritani markers. Original +Worlds supply actual shipped/fee/rescue counters; they do not replace the +ordinary Arrakis coalition or create Occupy on a Homeworld. Card fees and +separate fixed Ecaz/variable allied losses retain their own sources. Every +own-World Harass ban, original pre-fee traits and committed-only Suk remain. +Native skill-first assassination, normal-call forfeiture, unused-disc bounty +and one real Mentat replacement retain their original handlers. + +Existing Ecaz/Moritani independent fixture/test pairs add default-off +`homeworlds`, source-bearing original shipments/fees and six distinct World +consumer cases. Shared setup uses actual native shipment selections, including +Kaitain/Salusa. Fresh external lobby selections use their real `lobbyOptions`, +not already initialized Skills/card fields; original seat IDs remain. +Conserved cards/Traitors and undealt lotteries are explicit inputs, never +fabricated counters, money, module owners or elapsed phases. + +Frozen39-file **459/459**, types/lint/build and actual programmes pass. Fourteen +of18 scoped normal World/phase starts rejected before; all18 start afterward. +Actual earned Arrakeen pays Bank2/own0; native Ecaz fee3 stays in Tanks while +H returns2 and Suk saves only committed2 (keep1/return1). Allied Moritani loses +its own2. Plain paired World Skills lets Moritani lead without cards/Nexus. +No-Skills Emperor lead withdraws2E to native Salusa after a separate3N fee. + +Actual paired Advanced World Moritani loses, returns2N, pays its own3N fee +and assassinates a different unused trained Whitmore Bludd4: one death, +one returned Killer Medic and one actual Mentat replacement. Actual native +Ecaz own-World H rejects without mutation; subsequent original battle deaths +let Guild play Recruits and revive2 free into native Junction (13→15). + +Actual Basic no-Skills card and Advanced all14 World phase entries also retain +the [original arrival queue](MORITANI_ENTRY_TIMING.md#paired-world-card-entries--17-october-2026): +one source-labelled paid Guild arrival, both storm-order branches, untrained +Duke6/Robbery and resumed movement, without inventing Nexus or a new priority. +Three bounded normal World tables exercise all four legal policies through +turn3 or earlier wins (seven battles); targeted programmes supply the detailed +card/power proof, not natural acquisition claims. Real390px paired five-module +normal entry assigns offered Prana Bindu Adept, keeps a Traitor, places native6 +and reaches Storm with Grumman14 and Ecaz/Junction/Stronghold inspectors, +no browser errors/root overflow. Local working-tree evidence is not deployed +revision-matched, full-mode, calibration or deferred assurance acceptance. + + ## World Skills and selected decks — 17 October 2026 Fresh ordinary all14 setup now validates selected ordinary decks and actual diff --git a/docs/MORITANI_ASSASSINATE_LEADERS.md b/docs/MORITANI_ASSASSINATE_LEADERS.md index 4fe1fb70..4eb3359d 100644 --- a/docs/MORITANI_ASSASSINATE_LEADERS.md +++ b/docs/MORITANI_ASSASSINATE_LEADERS.md @@ -7,6 +7,14 @@ 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. +**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 +trained native target dies once, returns its skill once and yields one actual +Mentat replacement. Basic, normal-call forfeiture, shared Duke and original +Advanced role boundaries remain. +[Current World scope and459/459 proof](LEADER_SKILLS_RUNTIME.md#native-e3-worlds-with-independent-cards--17-october-2026). + ## Authority and Advanced duration cutover The [GF9 Ecaz & Moritani rulebook](https://www.gf9games.com/dune/wp-content/uploads/EcazMoritani-Rulebook-LOWRES.pdf), diff --git a/docs/MORITANI_ENTRY_TIMING.md b/docs/MORITANI_ENTRY_TIMING.md index 5470d2ca..5abb35de 100644 --- a/docs/MORITANI_ENTRY_TIMING.md +++ b/docs/MORITANI_ENTRY_TIMING.md @@ -2,6 +2,17 @@ Reviewed 2026-09-06. This supplements `MORITANI_TERROR_RULES.md`; it does not enable combined-faction play or certify an implementation order. +## Paired World card entries — 17 October 2026 + +Original paired native World/cards now retains the same queue with or without +Skills and original Tech/Advanced Strongholds. Real Basic no-Skills and +Advanced all14 World programmes place original Ecaz/Robbery tokens, pay once +for one Guild counter from native Junction and resolve both storm-order +branches. Duke6 stays untrained; Robbery transfers2/3 after the separate +one-spice shipment. One counter remains, both effects finish and movement +resumes. This extends capability, not the provisional priority below. +[Current459/459 envelope and qualified proof](LEADER_SKILLS_RUNTIME.md#native-e3-worlds-with-independent-cards--17-october-2026). + ## Paired Skills and independent-card entries — 17 October 2026 The existing serialized native queue now admits paired Ecaz/Moritani/classic diff --git a/docs/MORITANI_LEADER_SKILLS.md b/docs/MORITANI_LEADER_SKILLS.md index 9eed8491..dd046ec2 100644 --- a/docs/MORITANI_LEADER_SKILLS.md +++ b/docs/MORITANI_LEADER_SKILLS.md @@ -5,6 +5,16 @@ 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. +## Native World continuation — 17 October 2026 + +Standalone/paired World Skills now retains optional independent cards and +original phase modules without a Nexus dependency; bare native World cards +also work without Skills. Real World-origin shipments, separate native fee/H +returns, different unused trained-disc assassination and one Mentat replacement +retain original custody. Basic gains no assassination; shared Duke and +Advanced role restrictions remain. +[Current World contract and459/459 proof](LEADER_SKILLS_RUNTIME.md#native-e3-worlds-with-independent-cards--17-october-2026). + ## Paired native E3 Skills — 17 October 2026 Both native factions now compose ordinary all14 Skills without Nexus, diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index c293ff25..69b28639 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -668,9 +668,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, with original Tech3+ and/or Advanced Strongholds2+. Classic/E1/E2 also retains original Homeworlds; unused Ecaz selection is not a native E3 seat. Original36/38/50, cache, setup/custody, separate Banker/Tech, own-World fee/dial disjointness, committed-only Suk and every own-World H ban remain. Actual E3 World cards, Nexus/Discovery, Advanced E3/Harkonnen, shared Duke, mixed E3 and effect-specific guards stay separate. No new numeric rule or save retrofit. | [Current World/selected-deck composition](LEADER_SKILLS_RUNTIME.md#world-skills-and-selected-decks--17-october-2026), [physical card sources](ECAZ_TREACHERY_RULES.md) | +| 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 card rosters including Homeworlds, optionally all14 Skills. Without Worlds, standalone/paired E3 all14 retains the same phase modules. Original first-Storm/end-Mentat owners, separate delayed income and physical card funding stay; fees/H returns are not revival/shipment and +2 is not supported force. No new priority ruling. Actual E3 World cards, Nexus/Discovery and mixed-E3 guards remain. | [Original phase contract](ECAZ_TREACHERY_RULES.md#original-phase-module-composition--16-october-2026), [current all14 World composition](LEADER_SKILLS_RUNTIME.md#world-skills-and-selected-decks--17-october-2026), [native World composition](ECAZ_TREACHERY_RULES.md#native-world-phase-modules--17-october-2026) | +| 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) | | 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) | @@ -723,7 +723,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, Tech3–6 Basic/Advanced and Advanced Strongholds2–6, optionally both. No artificial Nexus/card dependency remains. Actual earned selected-holder support, separate allied armies, committed-only Suk, skill-first assassination/normal-call forfeiture, printed bounty, winner Tech and one Mentat replacement reuse original handlers. Native five discs, untrained Duke and Advanced Harkonnen/mixed-family/other-envelope guards stay; no fake owner or new income/numeric ruling. | [Current source envelope and evidence](LEADER_SKILLS_RUNTIME.md#independent-cards-with-original-phase-modules--17-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) | | 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/game/engine.ts b/game/engine.ts index 55c0d17e..0afa4097 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -8589,12 +8589,13 @@ function moritaniAssassinateModeSupported(g: Game) { (f.expansion === 'base' || g.expansions.includes(f.expansion)))) && (!g.nexusCards || (!!g.leaderSkills && standaloneE3NexusLeaderSkillsProfile(g))) && (!g.leaderSkills || advancedMoritaniLeaderSkillsProfile(g)) && - (!g.strongholdCards || e3StrongholdFactionProfile(g) || + (!g.strongholdCards || e3StrongholdFactionProfile(g) || independentCardModulesProfile(g) || (!!g.leaderSkills && strongholdLeaderSkillsProfile(g))) && - (!g.homeworlds || g.homeworldOccupationPreview === true || + (!g.homeworlds || g.homeworldOccupationPreview === true || ecazHomeworldProfile(g) || (!!g.leaderSkills && homeworldLeaderSkillsProfile(g))) && !g.discoveryEnabled && !g.discoveries && - (!g.techTokens || nativeFactionTechProfile(g) || (!!g.leaderSkills && nativeTechLeaderSkillsProfile(g))); + (!g.techTokens || nativeFactionTechProfile(g) || independentCardModulesProfile(g) || + (!!g.leaderSkills && nativeTechLeaderSkillsProfile(g))); } function moritaniAssassinateContext(g: Game, receipt: MoritaniAssassinateReceipt) { const p = getPlayer(g,receipt.owner), opponent = getPlayer(g,receipt.opponent); @@ -10532,6 +10533,8 @@ function ecazOccupyCompositionSupported(g: Game): boolean { g.players.some(p => p.faction === 'ecaz')) return true; if (g.discoveries && nativeDiscoveryLeaderSkillsProfile(g) && g.players.some(p => p.faction === 'ecaz')) return true; + if (g.homeworlds && !g.leaderSkills && g.ecazOccupyPreview === true && + ecazHomeworldProfile(g) && byFaction(g, 'ecaz')) return true; // Native World reserve/threshold rules do not replace the ordinary Arrakis // coalition. Reuse that battle only within the original Ecaz/classic setups. if (g.homeworlds && g.expansions.length === 1 && g.expansions[0] === 'ecaz' && @@ -10554,8 +10557,9 @@ function ecazOccupyCompositionSupported(g: Game): boolean { (['ecaz', 'moritani', 'ixians', 'tleilaxu', 'choam', 'richese'].includes(p.faction) && g.expansions.includes(faction(p.faction).expansion))) && !g.homeworlds && !g.nexusCards && !g.leaderSkills && - (!g.strongholdCards || e3StrongholdFactionProfile(g)) && - (!g.techTokens || nativeFactionTechProfile(g)) && !g.discoveryEnabled && !g.discoveries && + (!g.strongholdCards || e3StrongholdFactionProfile(g) || independentCardModulesProfile(g)) && + (!g.techTokens || nativeFactionTechProfile(g) || independentCardModulesProfile(g)) && + !g.discoveryEnabled && !g.discoveries && !g.discoveryStash && !g.greatMaker && (g.ecazTreachery === undefined || g.ecazTreachery === true); } @@ -10708,13 +10712,13 @@ 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 native E3 Worlds remain gated.'); + 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.'); requireFreshBaseRuntime(state); requireFreshFactionInventory(state); } if (state.homeworlds) { requireRule(homeworlds && state.homeworlds.custody === null, - 'Homeworld Leader Skills require a fresh supported classic or native E1/E2/standalone E3 lobby with original modules and undealt custody.'); + 'Homeworld Leader Skills require a fresh supported classic or native roster with original modules and undealt custody.'); requireFreshBaseRuntime(state); requireFreshFactionInventory(state); } @@ -10748,16 +10752,23 @@ function independentCardRoster(g: Game): boolean { return family === 'base' || g.expansions.includes(family); }); } +/** World/phase compositions retain the original one-family native E3 envelope. */ +function independentCardModuleRoster(g: Game): boolean { + return independentCardRoster(g) && + (!g.players.some(player => player.faction === 'ecaz' || player.faction === 'moritani') || + (g.expansions.length === 1 && g.expansions[0] === 'ecaz')); +} function ecazHomeworldProfile(g: Game): boolean { if (g.homeworlds && g.leaderSkills && !g.sandtrout && independentTreacheryLeaderSkillsProfile(g)) return true; - return !!g.homeworlds && g.ecazTreachery === true && independentCardRoster(g) && - !g.players.some(player => player.faction === 'ecaz' || player.faction === 'moritani') && + return !!g.homeworlds && g.ecazTreachery === true && independentCardModuleRoster(g) && g.players.length >= 2 && g.players.length <= 6 && !g.nexusCards && !g.leaderSkills && ((!g.techTokens && !g.strongholdCards) || independentCardModulesProfile(g)) && !g.discoveryEnabled && !g.discoveries && !g.discoveryStash && !g.greatMaker && !g.sandtrout && - !g.ecazOccupyPreview && !g.semutaPreview && !g.kullPreview && !g.nexusKullPreview && - !g.richeseBetrayalPreview && !g.guildBetrayalPreview && !g.moritaniAssassinatePreview && + (!g.ecazOccupyPreview || !!byFaction(g, 'ecaz')) && + (!g.moritaniAssassinatePreview || (g.advanced && !!byFaction(g, 'moritani'))) && + !g.semutaPreview && !g.kullPreview && !g.nexusKullPreview && + !g.richeseBetrayalPreview && !g.guildBetrayalPreview && !g.spiceBankerIncomePreview && !g.nexusIxianReplacementPreview && !g.nexusIxianBetrayalPreview && !g.nexusHarkonnenBetrayalPreview; } @@ -10765,14 +10776,16 @@ function ecazHomeworldProfile(g: Game): boolean { function independentCardModulesProfile(g: Game): boolean { if ((g.techTokens || g.strongholdCards) && g.leaderSkills && !g.sandtrout && independentTreacheryLeaderSkillsProfile(g)) return true; - if (!g.ecazTreachery || (!g.techTokens && !g.strongholdCards) || !independentCardRoster(g) || - g.players.some(player => player.faction === 'ecaz' || player.faction === 'moritani') || + if (!g.ecazTreachery || (!g.techTokens && !g.strongholdCards) || !independentCardModuleRoster(g) || g.players.length < (g.techTokens ? 3 : 2) || g.players.length > 6 || (g.strongholdCards && !g.advanced) || + (g.advanced && !!byFaction(g, 'moritani') && !!byFaction(g, 'harkonnen')) || g.nexusCards || g.leaderSkills || g.discoveryEnabled || g.discoveries || g.discoveryStash || g.greatMaker || g.sandtrout || - g.ecazOccupyPreview || g.semutaPreview || g.kullPreview || g.nexusKullPreview || - g.richeseBetrayalPreview || g.guildBetrayalPreview || g.moritaniAssassinatePreview || + (g.ecazOccupyPreview && !byFaction(g, 'ecaz')) || + (g.moritaniAssassinatePreview && (!g.advanced || !byFaction(g, 'moritani'))) || + g.semutaPreview || g.kullPreview || g.nexusKullPreview || + g.richeseBetrayalPreview || g.guildBetrayalPreview || g.spiceBankerIncomePreview || g.nexusIxianReplacementPreview || g.nexusIxianBetrayalPreview || g.nexusHarkonnenBetrayalPreview) return false; return true; @@ -10784,7 +10797,7 @@ export function initializeEcazTreacheryGameForAudit(state: Game): Game { g.ecazTreachery = true; const homeworlds = !!g.homeworlds; requireRule(!homeworlds || ecazHomeworldProfile(g), - 'Independent cards with Homeworlds need a fresh classic or selected Ix/CHOAM roster with only original Tech or Advanced Strongholds.'); + 'Independent cards with Homeworlds need a fresh supported selected-deck roster with original Tech or Advanced Strongholds.'); const initialized = initializeSetupGameForAudit(g, homeworlds, false, false, false, false, false, true, true); if (byFaction(initialized, 'ecaz')) initialized.ecazOccupyPreview = true; if (moritaniAssassinateModeSupported(initialized)) initializeMoritaniAssassinateState(initialized); @@ -16269,6 +16282,8 @@ function ambassadorEffectBlock( function arrivalOverlapModeSupported(g: Game): boolean { if ((mixedE3NexusLeaderSkillsProfile(g) || mixedE3NexusProfile(g)) && byFaction(g, 'ecaz') && byFaction(g, 'moritani')) return true; + if (g.ecazTreachery && byFaction(g, 'ecaz') && byFaction(g, 'moritani') && + (ecazHomeworldProfile(g) || independentCardModulesProfile(g))) return true; return (!!g.leaderSkills && pairedE3LeaderSkillsProfile(g)) || pairedE3NexusLeaderSkillsProfile(g) || pairedE3NexusProfile(g) || (!!byFaction(g, 'ecaz') && !!byFaction(g, 'moritani') && g.players.every(player => CLASSIC_FACTIONS[player.faction] === true || diff --git a/game/leader-skill-profile.ts b/game/leader-skill-profile.ts index 787ae6c0..6d18ec95 100644 --- a/game/leader-skill-profile.ts +++ b/game/leader-skill-profile.ts @@ -188,12 +188,12 @@ function pairedE3LeaderSkillRoster(game: LeaderSkillProfile): boolean { return ecaz && moritani; } -/** Both original E3 natives with original phase modules; independent cards are optional. */ +/** Both original E3 natives with original World/phase modules; cards are optional. */ 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.homeworlds || game.nexusCards || game.discoveries || game.discoveryEnabled || + game.nexusCards || game.discoveries || game.discoveryEnabled || !e3LeaderSkillModulesSupported(game) || game.discoveryStash || game.greatMaker || game.semutaPreview || game.advancedPreview || (game.moritaniAssassinatePreview && !game.advanced) || @@ -504,8 +504,8 @@ export function independentTreacheryLeaderSkillsProfile(game: LeaderSkillProfile } if (!selected) return false; } - // Actual E3 seats retain their one-family, non-World and Advanced Harkonnen boundaries. - if (nativeE3 && (!e3 || game.homeworlds || + // 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 || !players.some(player => player.faction === 'moritani'))) return false; diff --git a/game/reference.ts b/game/reference.ts index 802e04bc..4aebc94e 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 card Worlds and Nexus/Discovery combinations 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 these original Advanced phase modules, optionally independent cards, with exact ecaz33/36, native five-disc training and original roster 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 and shared-Duke assignment remain guarded.', - 'Fresh independent Treachery previews compose classic or selected Ix/CHOAM native/base rosters, including original Homeworlds, with Advanced Strongholds2–6 and optional Tech3–6. 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; other overlays stay guarded.', + '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.', '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. Classic/E1/E2 Worlds may also select the independent cards; native E3 card Worlds and Nexus/Discovery retain their own 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 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.', '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 or selected Ix/CHOAM native/base independent-card previews, including Homeworlds, retain unused Tech3–6 Basic/Advanced and optional Advanced Strongholds2–6. 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; other overlays stay separate.', + '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.', ], 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. Independent cards may join original Tech3+/Advanced Strongholds2+ in this envelope. Original no-card Nexus/Discovery and native E3 World profiles retain their separate roster/effect limits. Actual starting hands, Ix card-before-training and ordinary native discs 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 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.', '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 Worlds compose original all14 and the independent36/38/50-card setup, optionally Tech3+/Advanced Strongholds2+. 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. Native E3, Nexus/Discovery and other effect-specific guards remain.', + '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.', '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.', @@ -4931,7 +4931,7 @@ export const RULE_TOPICS: RuleTopic[] = [ '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, with original Tech3+/Advanced Strongholds2+. Classic/E1/E2 Worlds also retain the cards and phase modules. 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. Native E3 card Worlds, Nexus/Discovery, actual mixed E3 and special-role guards remain.', + '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.', '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.', diff --git a/tests/ecaz-independent-skills-runtime.test.ts b/tests/ecaz-independent-skills-runtime.test.ts index 46eebfb9..acb24de0 100644 --- a/tests/ecaz-independent-skills-runtime.test.ts +++ b/tests/ecaz-independent-skills-runtime.test.ts @@ -1,11 +1,14 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import type { Game } from '../game/engine'; +import { applyAction, type Game } from '../game/engine'; import { treacheryDeck } from '../game/cards'; import { ecazTreacheryCards } from '../game/ecaz-cards'; import { HARASS_WITHDRAW_CARD } from '../game/harass-withdraw'; import { REINFORCEMENTS_CARD } from '../game/reinforcements'; import { ownedTech } from '../game/tech-tokens'; +import { homeworldContext } from '../game/homeworld-game'; +import { homeworldForceGroups } from '../game/homeworld-custody'; +import { DUKE_VIDAL_ID } from '../game/duke-vidal'; import { assertEcazIndependentSkillsCustody, createEcazIndependentSkillsFixture, ecazIndependentSkillsPlayer as player, @@ -410,3 +413,117 @@ for (const lead of ['ecaz', 'ally'] as const) { assertEcazIndependentSkillsCustody(end); }); } + +void test('native World coalition pays Ecaz’s source-labelled fee before committed-only Suk rescue and settles earned Arrakeen and Tech', () => { + const f = createEcazIndependentSkillsFixture({ + homeworlds: true, paired: true, tech: true, strongholds: true, + }); + const native = (game: Game) => homeworldForceGroups(homeworldContext(game), game.homeworlds!.custody!) + .find(home => home.native === f.ecaz)!.forces[f.ecaz]; + const plan = f.plans[0]; + assert.deepEqual(plan.action.reinforcementsSources, + { 'homeworld:ecaz': { normal: 3, elite: 0 } }); + const before = structuredClone(f.beforePlans); + assert.throws(() => applyAction(f.beforePlans, f.ecaz, { + ...plan.action, reinforcementsSources: { 'homeworld:moritani': { normal: 3, elite: 0 } }, + }), 'A coalition partner’s native World cannot fund the Ecaz card user’s fee'); + assert.deepEqual(f.beforePlans, before); + const shipment = f.actions.find(step => step.actor === f.ecaz && step.action.type === 'ship')!; + assert.deepEqual(shipment.action.homeworldSources, + { 'homeworld:ecaz': { normal: 4, elite: 0 } }); + assert.equal(native(f.afterFirstMentat!).normal, native(f.afterSetup).normal - 4, + 'The four real paid native counters hold Arrakeen through END Mentat'); + assert.equal(f.afterFirstMentat!.strongholdCards!.owners.arrakeen, f.ecaz); + const quote = quoteEcazIndependentSkillsBattle(f); + const payment = quote.payments.find(item => item.player === f.ecaz)!; + assert.equal(payment.bankSupport, 2); + assert.equal(payment.ownPayment, 0, 'The earned Arrakeen card actually supports the selected lead'); + assert.deepEqual(quote.harassWithdraw![0].returned, { normal: 2, elite: 0 }); + const rescue = settleEcazIndependentSkillsBattle(f, { stop: 'rescue' }); + assert.ok(rescue.decision?.kind === 'sukRescue'); + assert.equal(rescue.decision.player, f.ecaz); + assert.deepEqual(rescue.decision.options.filter(option => option.kept), + [{ normal: 1, elite: 0, kept: { key: f.location, kind: 'normal' } }, + { normal: 2, elite: 0, kept: { key: f.location, kind: 'normal' } }]); + assert.equal(player(rescue, f.ecaz).tanks - player(f.game, f.ecaz).tanks, 3, + 'The source-labelled fee is already in Tanks and never joins the two committed rescue losses'); + assert.equal(native(rescue).normal, native(f.game).normal - 1, + 'Three native fee counters leave before the two undialed counters return to the same World'); + assert.equal(player(rescue, f.ally).tanks - player(f.game, f.ally).tanks, 2, + 'Moritani’s variable army losses cannot enter Ecaz’s rescue'); + assert.equal(player(rescue, f.ecaz).spice, player(f.game, f.ecaz).spice, + 'Held-card bank support settles before Collection, without charging Ecaz'); + const end = settleEcazIndependentSkillsBattle(f, { state: rescue }); + assert.equal(end.phase, 7); + assert.equal(end.lastBattleContext?.winner, f.ecaz); + assert.equal(end.lastBattleContext?.sukRescue?.completed, true); + assert.deepEqual(end.lastBattleContext?.sukRescue?.physical, { + player: f.ecaz, territory: f.territory, source: 'arrakis', + saved: { normal: 2, elite: 0 }, kept: { key: f.location, kind: 'normal' }, + destinations: { 'homeworld:ecaz': { normal: 1, elite: 0 } }, tanks: { normal: 0, elite: 0 }, + }); + assert.equal(native(end).normal, native(f.game).normal); + assert.equal(at(end, f.ecaz, f.location), 1); + assert.equal(player(end, f.ecaz).tanks - player(f.game, f.ecaz).tanks, 3); + const enemyTokens = ownedTech(f.game.techTokens, f.opponent); + assert.equal(enemyTokens.length, 1); + assert.equal(end.techTokens![enemyTokens[0]].owner, f.ecaz); + disposedLeadCards(f, end); + pairedArmies(f, end); + physicalSource(end, 36); + assertEcazIndependentSkillsCustody(end); +}); + +void test('paired plain World all14 keeps the native Moritani lead and separate Ecaz fixed army through battle continuation', () => { + const f = createEcazIndependentSkillsFixture({ + homeworlds: true, paired: true, independent: false, cards: [], lead: 'ally', + }); + assert.equal(f.beforeLead.decision?.kind, 'ecazBattleLead'); + assert.equal(f.plans[0].actor, f.ally); + assert.equal(player(f.beforeLead, f.ecaz).ally, f.ally); + const quote = quoteEcazIndependentSkillsBattle(f); + assert.deepEqual(quote.fixedLosses, [{ owner: f.ecaz, normal: 2, elite: 0 }]); + assert.equal(quote.casualties?.owner, f.ally); + assert.equal(quote.sukGraduate, undefined, + 'Ecaz’s native skill cannot rescue its fixed army while Moritani leads'); + assert.ok(f.game.leaderSkills!.assignments.every(assignment => assignment.leader !== DUKE_VIDAL_ID)); + const end = settleEcazIndependentSkillsBattle(f); + assert.equal(end.phase, 7); + assert.equal(end.lastBattleContext?.winner, f.ally); + assert.equal(end.lastBattleContext?.sukRescue, undefined); + for (const actor of [f.ecaz, f.ally]) { + assert.equal(player(end, actor).tanks - player(f.game, actor).tanks, 2); + assert.equal(player(end, actor).reserves, player(f.game, actor).reserves); + assert.equal(at(end, actor, f.location), 2); + } + pairedArmies(f, end); + physicalSource(end, 33); + assertEcazIndependentSkillsCustody(end); +}); + +void test('no-Skills World independent cards retain the native Emperor-led coalition and typed Sardaukar withdrawal', () => { + const f = createEcazIndependentSkillsFixture({ homeworlds: true, skills: false, lead: 'ally' }); + assert.equal(f.beforeLead.decision?.kind, 'ecazBattleLead'); + assert.equal(f.plans[0].actor, f.ally); + const sources = f.game.battle!.plans[f.cardUser].reinforcementsSources!; + assert.ok(Object.keys(sources).every(world => world === 'homeworld:emperor' || world === 'homeworld:emperor:salusa')); + assert.equal(Object.values(sources).reduce((sum, forces) => sum + forces.normal, 0), 3); + const quote = quoteEcazIndependentSkillsBattle(f); + assert.deepEqual(quote.harassWithdraw![0].returned, { normal: 0, elite: 2 }); + assert.deepEqual(quote.harassWithdraw![0].locations, { [f.location]: { normal: 0, elite: 2 } }); + assert.equal(quote.sukGraduate, undefined); + const end = settleEcazIndependentSkillsBattle(f); + assert.equal(end.phase, 7); + assert.equal(end.lastBattleContext?.winner, f.ally); + assert.equal(end.lastBattleContext?.sukRescue, undefined); + assert.equal(player(end, f.ally).reserves - player(f.game, f.ally).reserves, -1); + assert.equal(player(end, f.ally).tanks - player(f.game, f.ally).tanks, 5, + 'Three own native fee counters and two variable commitment losses remain distinct from Ecaz'); + assert.equal(player(end, f.ally).elites!.reserves - player(f.game, f.ally).elites!.reserves, 2); + assert.equal(player(end, f.ally).elites!.tanks, player(f.game, f.ally).elites!.tanks); + assert.equal(player(end, f.ecaz).tanks - player(f.game, f.ecaz).tanks, 2); + assert.equal(at(end, f.ecaz, f.location), 2); + disposedLeadCards(f, end); + physicalSource(end, 36); + assertEcazIndependentSkillsCustody(end); +}); diff --git a/tests/fixture-ecaz-independent-skills.ts b/tests/fixture-ecaz-independent-skills.ts index 82dc04e5..7fb617c3 100644 --- a/tests/fixture-ecaz-independent-skills.ts +++ b/tests/fixture-ecaz-independent-skills.ts @@ -13,6 +13,7 @@ import { import { REINFORCEMENTS_CARD } from '../game/reinforcements'; import type { SukRescueOption } from '../game/suk-graduate'; import { quoteBattleResolution, type ResolutionCombatant } from '../game/battle-resolution-quote'; +import { homeworldGameIntegrity } from '../game/homeworld-game'; import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions'; import { chooseEcazOccupyLead, finishEcazOccupySetup, stageEcazOccupyCard } from './fixture-ecaz-occupy'; import { createHomeworldEcazOccupyFixture } from './fixture-homeworld-ecaz-occupy'; @@ -32,6 +33,8 @@ export type EcazIndependentSkillsOptions = { skills?: boolean; /** The independent physical three-card variant; `false` is the printed no-card control. */ independent?: boolean; + /** Original native World setup, selected before start; off by default. */ + homeworlds?: boolean; /** Original optional modules, both off by default; Strongholds require Advanced. */ tech?: boolean; strongholds?: boolean; @@ -143,6 +146,7 @@ export function stageEcazIndependentSkillsCard(game: Game, actor: string, /** The admitted original card source (thirty-six with the independent variant), * all14 skills and ordinary counters. */ export function assertEcazIndependentSkillsCustody(game: Game): void { + if (game.homeworlds) homeworldGameIntegrity(game); if (game.leaderSkills) validateLeaderSkills(game.leaderSkills, game.players); const sum = (values: Record) => Object.values(values).reduce((a, b) => a + b, 0); for (const player of game.players) { @@ -263,7 +267,7 @@ export function createEcazIndependentSkillsFixture( ...LEADER_SKILL_CARDS.map(card => card.id).filter(id => id !== 'suk-graduate')]; const draws = skills ? permutationDraws(LEADER_SKILL_CARDS.map(card => card.id), order) : []; const shared = entropy(() => createHomeworldEcazOccupyFixture({ - advanced, skills, independent, homeworlds: false, + advanced, skills, independent, homeworlds: options.homeworlds ?? false, tech: options.tech ?? false, strongholds: options.strongholds ?? false, ...(options.paired ? { allyFaction: 'moritani' as const } : {}), ...(skills ? { finishSetup: (initial: Game) => trainEcazSeat(initial, picks) } : {}), @@ -295,7 +299,6 @@ export function createEcazIndependentSkillsFixture( state => !!state.battle && ecazIndependentSkillsClean(state) && !state.battle.preparation && state.battle.preLeader?.closed !== false, actions, policy); const afterLead = structuredClone(game); - const beforePlans = structuredClone(game); const cardUser = lead === 'ecaz' ? shared.ecaz : shared.ally; const cardIds = cardKinds.map(kind => stageEcazIndependentSkillsCard(game, cardUser, ECAZ_INDEPENDENT_SKILLS_CARD_IDS[kind], staging)); @@ -318,12 +321,18 @@ export function createEcazIndependentSkillsFixture( conserveTraitor(game, shared.opponent, traitor, staging); callers.push(shared.opponent); } + const feeSources = cardKinds.includes('reinforcements') && game.homeworlds + ? viewGame(game, cardUser).battle?.reinforcements?.homeworldSources?.[0] : undefined; + if (cardKinds.includes('reinforcements') && game.homeworlds) + assert.ok(feeSources, 'Choose an actual offered native World source for the card user’s fee'); const plans: EcazIndependentSkillsStep[] = [ { actor: cardUser, action: { type: 'battlePlan', dial: profile.fixedEcazDial + variable, - support, allyPayment: 0, leader: planLeader, ...own } }, + support, allyPayment: 0, leader: planLeader, ...own, + ...(feeSources ? { reinforcementsSources: structuredClone(feeSources) } : {}) } }, { actor: shared.opponent, action: { type: 'battlePlan', dial: 0, support: 0, leader: defender, weapon: null, defense: null } }, ]; + const beforePlans = structuredClone(game); for (const plan of plans) { actions.push(structuredClone(plan)); game = stepEcazIndependentSkills(game, plan); diff --git a/tests/fixture-homeworld-ecaz-occupy.ts b/tests/fixture-homeworld-ecaz-occupy.ts index b625c533..39f385bd 100644 --- a/tests/fixture-homeworld-ecaz-occupy.ts +++ b/tests/fixture-homeworld-ecaz-occupy.ts @@ -3,6 +3,7 @@ import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, ty import { botActions } from '../game/bots'; import { territory } from '../game/board'; import { finishEcazOccupySetup, type EcazOccupyFixture } from './fixture-ecaz-occupy'; +import { nativeShipmentSources } from '../game/homeworld-options'; export type HomeworldEcazStep = { actor: string; action: Action }; export type HomeworldEcazOccupyFixture = EcazOccupyFixture & { @@ -58,6 +59,14 @@ export function createHomeworldEcazOccupyFixture(options: { actions.push({ actor, action: structuredClone(action) }); game = applyAction(game, actor, action); }; + const ship = (actor: string, action: Action) => { + if (homeworlds) { + const sources = nativeShipmentSources(viewGame(game, actor), Number(action.amount), Number(action.elite ?? 0)); + assert.ok(sources, 'The original native World pools must fund the typed shipment'); + action.homeworldSources = sources; + } + act(actor, action); + }; if (homeworlds) act('ecaz', { type: 'homeworlds', enabled: true }); if (options.tech) act('ecaz', { type: 'techTokens', enabled: true }); if (options.strongholds) act('ecaz', { type: 'strongholdCards', enabled: true }); @@ -92,7 +101,7 @@ export function createHomeworldEcazOccupyFixture(options: { if (!homeworldEcazClean(game)) { step(); continue; } const actor = game.active!; if (strongholdTerritory && actor === 'ecaz' && !game.players.find(player => player.id === actor)!.shipped) { - act(actor, { type: 'ship', territory: strongholdTerritory, + ship(actor, { type: 'ship', territory: strongholdTerritory, sector: territory(strongholdTerritory).sectors[0], amount: 4, elite: 0, allyPayment: 0, smuggler: false }); reach(homeworldEcazClean); } @@ -118,7 +127,7 @@ export function createHomeworldEcazOccupyFixture(options: { if (!homeworldEcazClean(game)) { step(); continue; } const actor = game.active!; if (!shipped.has(actor) && !(strongholdTerritory && actor === 'ecaz')) { - act(actor, { type: 'ship', territory: battleTerritory, sector, + ship(actor, { type: 'ship', territory: battleTerritory, sector, amount: actor === 'guild' ? 2 : 4, elite: actor === ally && ally === 'emperor' && (advanced || homeworlds) ? 2 : 0, allyPayment: 0 }); shipped.add(actor); diff --git a/tests/fixture-moritani-independent-skills.ts b/tests/fixture-moritani-independent-skills.ts index 3f25bd11..3f1c412d 100644 --- a/tests/fixture-moritani-independent-skills.ts +++ b/tests/fixture-moritani-independent-skills.ts @@ -8,7 +8,7 @@ import { botActions } from '../game/bots'; import type { Difficulty } from '../game/bot-profiles'; import { ecazTreacheryCards } from '../game/ecaz-cards'; import { withoutEcazLoyalty } from '../game/ecaz-loyalty'; -import { LEADER_SKILL_CARDS, type LeaderSkillId } from '../game/leader-skill-cards'; +import type { LeaderSkillId } from '../game/leader-skill-cards'; import { validateLeaderSkills } from '../game/leader-skills'; import { traitorDeck } from '../game/traitors'; import { @@ -19,6 +19,9 @@ import { REINFORCEMENTS_CARD } from '../game/reinforcements'; import { quoteIndependentSkillsBattle } from './fixture-independent-skills-battle'; import { holdMoritaniStrongholdsTraitor } from './fixture-moritani-strongholds'; import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions'; +import { skillOfferRolls } from './fixture-independent-card-modules'; +import { homeworldGameIntegrity } from '../game/homeworld-game'; +import { nativeShipmentSources } from '../game/homeworld-options'; export type MoritaniIndependentSkillsStep = { actor: string; action: Action }; /** 'guild' keeps the classic Heighliner opponent; 'harkonnen' is the native @@ -31,6 +34,8 @@ export type MoritaniIndependentSkillsOptions = { skills?: boolean; /** The independent three-card Ecaz Treachery variant of printed p. 18. */ cards?: boolean; + /** Original native World custody, selected before the ordinary lobby start. */ + homeworlds?: boolean; /** Original optional modules, both off by default; Strongholds require Advanced. */ tech?: boolean; strongholds?: boolean; @@ -65,6 +70,8 @@ export type MoritaniIndependentSkillsFixture = { beforeSplit: Game; split: MoritaniIndependentSkillsStep; afterSplit: Game; + /** A paid native-pool shipment before the original opposing board move. */ + shipment: { before: Game; step: MoritaniIndependentSkillsStep; after: Game } | null; entry: MoritaniIndependentSkillsStep; afterEntry: Game; beforeBattle: Game; @@ -122,21 +129,6 @@ function entropy(run: () => T, rolls: readonly number[] = []): T { else Reflect.deleteProperty(crypto, 'getRandomValues'); } } -/** Only the original fourteen-card lottery is solved; later native draws keep - * a fixed maximum value, and every roll is restored afterwards. */ -function skillRolls(chosen: readonly LeaderSkillId[]): number[] { - const source = LEADER_SKILL_CARDS.map((card) => card.id); - const rest = source.filter((id) => !chosen.includes(id)); - const desired = chosen.flatMap((id) => [id, rest.shift()!]).concat(rest); - const work = [...source], rolls: number[] = []; - for (let index = work.length - 1; index > 0; index--) { - const from = work.indexOf(desired[index]); - assert.ok(from >= 0 && from <= index); - rolls.push(Math.floor((from + 0.5) * 0x100000000 / (index + 1))); - [work[index], work[from]] = [work[from], work[index]]; - } - return rolls; -} export function stepMoritaniIndependentSkills(game: Game, step: MoritaniIndependentSkillsStep): Game { return entropy(() => applyAction(game, step.actor, step.action)); } @@ -168,6 +160,10 @@ export function nextMoritaniIndependentSkillsStep( event: decision.event, hide: policy.hide?.[decision.player] ?? false } }; if (decision?.kind === 'mentatQuestion') return { actor: decision.player, action: { type: 'decision', event: decision.event, decline: true } }; + if (decision?.kind === 'homeworldDefense') + return { actor: decision.player, action: { type: 'decision', event: decision.event, use: false } }; + if (decision?.kind === 'homeworldRevivalDeployment') + return { actor: decision.player, action: { type: 'decision', event: decision.event, decline: true } }; if (decision?.kind === 'rihani') return { actor: decision.player, action: { type: 'decision', event: decision.event, draw: false } }; if (decision?.kind === 'battleLosses') @@ -212,6 +208,7 @@ const counterTotal = (forces: Record) => /** Conserved physical custody: counters, the real 33/36-card inventory, all * fourteen skills and every Traitor identity, including Assassinate removal. */ export function assertMoritaniIndependentSkillsCustody(game: Game): void { + if (game.homeworlds) homeworldGameIntegrity(game); if (game.leaderSkills) validateLeaderSkills(game.leaderSkills, game.players); const removed = (game.moritaniAssassinate?.opportunities ?? []) .filter((receipt) => receipt.stage === 'replaced').map((receipt) => receipt.card!); @@ -266,10 +263,13 @@ function board(opponent: MoritaniIndependentSkillsOpponent | 'ecaz') { export function createMoritaniIndependentSkillsFixture( options: MoritaniIndependentSkillsOptions = {}, ): MoritaniIndependentSkillsFixture { - const advanced = (options.rules ?? 'advanced') === 'advanced'; - const skills = options.skills ?? false; - const cards = options.cards ?? false; - const opponentFaction = options.paired ? 'ecaz' : (options.opponent ?? 'guild'); + const advanced = (options.rules ?? (options.initial?.advanced === false ? 'basic' : 'advanced')) === 'advanced'; + const skills = options.skills || !!options.initial?.lobbyOptions?.leaderSkills || !!options.initial?.leaderSkills; + const cards = options.cards || !!options.initial?.lobbyOptions?.ecazTreachery || !!options.initial?.ecazTreachery; + const worlds = options.homeworlds || !!options.initial?.homeworlds; + const paired = options.paired || !!options.initial?.players.some((seat) => seat.faction === 'ecaz'); + const opponentFaction = paired ? 'ecaz' : (options.opponent ?? + (options.initial?.players.some((seat) => seat.faction === 'harkonnen') ? 'harkonnen' : 'guild')); assert.ok(!skills || opponentFaction !== 'harkonnen', 'Leader Skills keep the Harkonnen native roster out of this composition.'); const plan = board(opponentFaction); @@ -292,12 +292,13 @@ export function createMoritaniIndependentSkillsFixture( assert.equal(game.advanced, advanced); assert.deepEqual(game.expansions, ['ecaz']); assert.equal(game.status, 'lobby', 'Continue only a fresh lobby of this exact composition.'); - assert.ok(!game.homeworlds && !game.nexusCards && !game.discoveryEnabled, - 'Only the original E3/classic composition and optional Tech/Strongholds are accepted.'); - assert.ok(!game.techTokens || options.tech, - 'A preselected original Tech module needs tech:true.'); - assert.ok(!game.strongholdCards || options.strongholds, - 'A preselected original Stronghold module needs strongholds:true.'); + assert.ok(!game.nexusCards && !game.discoveryEnabled, + 'Only the original E3/classic composition and optional World/Tech/Stronghold modules are accepted.'); + assert.deepEqual(game.players.map((seat) => seat.faction).sort(), ['moritani', opponentFaction, 'emperor'].sort(), + 'Preserve the original native three-faction roster.'); + const moritani = game.players.find((seat) => seat.faction === 'moritani')!.id; + const opponent = game.players.find((seat) => seat.faction === opponentFaction)!.id; + const third = game.players.find((seat) => seat.faction === 'emperor')!.id; const actions: MoritaniIndependentSkillsStep[] = [], staging: string[] = []; const act = (actor: string, action: Action) => { actions.push({ actor, action: structuredClone(action) }); @@ -305,40 +306,44 @@ export function createMoritaniIndependentSkillsFixture( return game; }; const initial = structuredClone(game); - if (skills) act('moritani', { type: 'moduleOption', module: 'leaderSkills', enabled: true }); - if (cards) act('moritani', { type: 'moduleOption', module: 'ecazTreachery', enabled: true }); - if (options.tech && !game.techTokens) act('moritani', { type: 'techTokens', enabled: true }); - if (options.strongholds && !game.strongholdCards) act('moritani', { type: 'strongholdCards', enabled: true }); + if (worlds && !game.homeworlds) act(game.host, { type: 'homeworlds', enabled: true }); + if (skills && !game.lobbyOptions?.leaderSkills && !game.leaderSkills) + act(game.host, { type: 'moduleOption', module: 'leaderSkills', enabled: true }); + if (cards && !game.lobbyOptions?.ecazTreachery && !game.ecazTreachery) + act(game.host, { type: 'moduleOption', module: 'ecazTreachery', enabled: true }); + if (options.tech && !game.techTokens) act(game.host, { type: 'techTokens', enabled: true }); + if (options.strongholds && !game.strongholdCards) act(game.host, { type: 'strongholdCards', enabled: true }); for (const player of game.players) if (!player.ready) act(player.id, { type: 'ready' }); - const start: MoritaniIndependentSkillsStep = { actor: 'moritani', + const start: MoritaniIndependentSkillsStep = { actor: game.host, action: { type: 'start', ...(advanced ? { advancedPreview: true as const } : {}) } }; actions.push(structuredClone(start)); game = entropy(() => applyAction(game, start.actor, start.action), - skills ? skillRolls([chosen.moritani, chosen[opponentFaction], chosen.emperor]) : []); + skills ? skillOfferRolls(game, chosen) : []); staging.push('Normal lobby start through the real module options; the Advanced opt-in is the ordinary lobby action, never a development profile.'); if (skills) staging.push('Solved only the original fourteen-card Leader Skill lottery with real Fisher-Yates draws; offers, deck and later native shuffles are untouched.'); const offered = structuredClone(game); for (let count = 0; game.status === 'setup' && count < 200; count++) { if (game.setupStage === 'leaderSkills') { const actor = Object.keys(game.leaderSkills!.offers)[0]; + const faction = moritaniIndependentSkillsPlayer(game, actor).faction; const offer = game.leaderSkills!.offers[actor]; const view = viewGame(game, actor).leaderSkills!; const leader = moritaniIndependentSkillsPlayer(game, actor).leaders - .filter((disc) => disc.id === trainedLeader[actor] && + .filter((disc) => disc.id === trainedLeader[faction] && view.eligibleLeaders.some((eligible) => eligible.id === disc.id))[0]; assert.ok(leader, 'The requested original leader must be eligible for this offer'); - assert.ok(offer.cards.includes(chosen[actor]), 'The requested original skill must be in the real offer'); - act(actor, { type: 'leaderSkill', event: offer.event, skill: chosen[actor], leader: leader.id }); + assert.ok(offer.cards.includes(chosen[faction]), 'The requested original skill must be in the real offer'); + act(actor, { type: 'leaderSkill', event: offer.event, skill: chosen[faction], leader: leader.id }); continue; } if (game.setupStage === 'forces' && opponentFaction === 'ecaz' && - viewGame(game, 'ecaz').setupPending.includes('ecaz')) { - act('ecaz', { type: 'ecazSetup', placements: { [plan.from]: 6 } }); + viewGame(game, opponent).setupPending.includes(opponent)) { + act(opponent, { type: 'ecazSetup', placements: { [plan.from]: 6 } }); continue; } if (game.setupStage === 'traitors' || game.players.some((player) => player.traitorChoices.length)) { const player = game.players.find((seat) => seat.traitorChoices.length)!; - const keep = player.id === 'moritani' + const keep = player.id === moritani ? (player.traitorChoices.find((id) => id.startsWith(`${opponentFaction}-`)) ?? player.traitorChoices[0]) : player.traitorChoices[0]; act(player.id, { type: 'traitor', leader: keep }); @@ -354,9 +359,6 @@ export function createMoritaniIndependentSkillsFixture( act(step.actor, step.action); } assert.equal(game.status, 'playing'); - const moritani = 'moritani'; - const opponent = game.players.find((seat) => seat.faction === opponentFaction)!.id; - const third = game.players.find((seat) => seat.faction === 'emperor')!.id; assert.equal(moritaniIndependentSkillsPlayer(game, moritani).reserves, 14); assert.equal(!!game.moritaniAssassinate, advanced, 'The printed Advanced assassination producer follows the real rules mode alone.'); @@ -390,6 +392,22 @@ export function createMoritaniIndependentSkillsFixture( const afterSplit = structuredClone(game); game = advanceMoritaniIndependentSkills(game, (state) => state.phase === 5 && state.active === opponent && moritaniIndependentSkillsClean(state), actions); + let shipment: MoritaniIndependentSkillsFixture['shipment'] = null; + if (worlds) { + const before = structuredClone(game); + const sources = nativeShipmentSources(viewGame(game, opponent), 1, 0); + assert.ok(sources, 'The opponent ships its own real native World normal counter.'); + const step: MoritaniIndependentSkillsStep = { actor: opponent, action: { + type: 'ship', territory: plan.battle, sector: plan.sectors[1], amount: 1, elite: 0, + allyPayment: 0, homeworldSources: sources } }; + game = stepMoritaniIndependentSkills(game, step); + actions.push(structuredClone(step)); + game = advanceMoritaniIndependentSkills(game, + (state) => state.phase === 5 && state.active === opponent && !state.pendingShipment && + !state.pendingHomeworldShipment && moritaniIndependentSkillsClean(state), actions); + shipment = { before, step, after: structuredClone(game) }; + staging.push('The opponent paid for a real normal-counter shipment from its original native World before moving its original starting army.'); + } const entrySector = plan.sectors[1]; assert.notEqual(game.storm, entrySector, 'The real opponent entry must not enter the storm sector.'); const entry: MoritaniIndependentSkillsStep = { actor: opponent, action: { @@ -417,10 +435,11 @@ export function createMoritaniIndependentSkillsFixture( const winnerWeapon = conserveCard(game, opponent, (card) => card.kind === 'worthless', staging); const plans: MoritaniIndependentSkillsStep[] = [ { actor: moritani, action: { type: 'battlePlan', dial: 2, support: 0, leader: loser, - ...(reinforcements && harass ? { weapon: REINFORCEMENTS_CARD, defense: HARASS_WITHDRAW_CARD } : {}) } }, + ...(reinforcements && harass ? { weapon: REINFORCEMENTS_CARD, defense: HARASS_WITHDRAW_CARD, + ...(worlds ? { reinforcementsSources: { 'homeworld:moritani': { normal: 3, elite: 0 } } } : {}) } : {}) } }, { actor: opponent, action: { type: 'battlePlan', - dial: options.paired ? (cards ? 4 : 2) : (cards ? 1 : 0), - support: options.paired && advanced ? (cards ? 4 : 2) : 0, + dial: paired ? (cards ? 4 : 2) : (cards ? 1 : 0), + support: paired && advanced ? (cards ? 4 : 2) : 0, leader: winner, weapon: winnerWeapon.id } }, ]; const window = structuredClone(game); @@ -436,7 +455,7 @@ export function createMoritaniIndependentSkillsFixture( assert.ok(!cards || footer, 'The committed Reinforcements fee plus Harass return must be a real physical choice'); const returns = footer ? defaultHarassWithdrawAllocation(footer.context, footer.dial, footer.support) : null; const fixture: MoritaniIndependentSkillsFixture = { - initial, offered, afterSetup, beforeSplit, split, afterSplit, entry, afterEntry, beforeBattle, + initial, offered, afterSetup, beforeSplit, split, afterSplit, shipment, entry, afterEntry, beforeBattle, battleStep, game: window, plans, revealed, returns, returnsContext: footer?.context ?? null, pending: structuredClone(revealed), moritani, opponent, third, territory: plan.battle, first: `${plan.battle}:${plan.sectors[0]}`, second: `${plan.battle}:${plan.sectors[1]}`, diff --git a/tests/moritani-independent-skills-runtime.test.ts b/tests/moritani-independent-skills-runtime.test.ts index bd51cec6..ea072923 100644 --- a/tests/moritani-independent-skills-runtime.test.ts +++ b/tests/moritani-independent-skills-runtime.test.ts @@ -1,9 +1,11 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { applyAction, viewGame, type Action, type Game } from '../game/engine'; +import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine'; import { HARASS_WITHDRAW_CARD } from '../game/harass-withdraw'; import { REINFORCEMENTS_CARD } from '../game/reinforcements'; import { ownedTech } from '../game/tech-tokens'; +import { homeworldContext } from '../game/homeworld-game'; +import { homeworldForceGroups } from '../game/homeworld-custody'; import { advanceMoritaniIndependentSkills, assertMoritaniIndependentSkillsCustody, @@ -28,6 +30,9 @@ const owned = (fixture: MoritaniIndependentSkillsFixture, game: Game, identity: player(game, fixture.opponent).leaders.find((leader) => leader.id === identity)!; const choiceOf = (game: Game, viewer: string) => viewGame(game, viewer).moritaniAssassinate!.pending!; +const pool = (game: Game, actor: string) => + homeworldForceGroups(homeworldContext(game), game.homeworlds!.custody!) + .find((home) => home.native === actor)!.forces[actor]; void test('Advanced standalone Moritani keeps the printed Assassinate power through the real lobby, the real loss and one held unused Traitor', () => { const f = createMoritaniIndependentSkillsFixture({ rules: 'advanced', cards: true, skills: true }); @@ -40,7 +45,6 @@ void test('Advanced standalone Moritani keeps the printed Assassinate power thro assert.deepEqual(f.afterSetup.moritaniAssassinateCallEvents, []); assert.deepEqual(f.afterSetup.expansions, ['ecaz']); assert.equal(f.afterSetup.ecazTreachery, true); - assert.equal(f.afterSetup.homeworlds, undefined); assert.equal(f.afterSetup.leaderSkills!.assignments.length, 3); assert.equal(moritani.spice, 12); assert.equal(moritani.reserves, 14); @@ -449,7 +453,6 @@ void test('paired Advanced Skills with original phase modules pays the printed E const f = createMoritaniIndependentSkillsFixture({ paired: true, rules: 'advanced', skills: true, tech: true, strongholds: true }); assert.equal(f.pending.lastBattleContext!.winner, f.opponent); - assert.equal(f.initial.nexusCards, undefined); assert.equal(f.returns, null); assert.equal(f.cards.reinforcements, null); assert.equal(f.cards.harass, null); @@ -476,3 +479,120 @@ void test('paired Advanced Skills with original phase modules pays the printed E defeatedTokens.includes(id) && token.owner === f.opponent).length, 1); assertMoritaniIndependentSkillsCustody(settled); }); + +void test('Advanced native World cards pay their real normal source fee and assassinate a different unused trained disc before one Mentat replacement', () => { + let initial = createGame('WORLDASSASSINATE', newPlayer('native-owner', 'Moritani', 'moritani'), true, ['ecaz']); + joinGame(initial, newPlayer('native-rival', 'Guild', 'guild')); + joinGame(initial, newPlayer('native-court', 'Emperor', 'emperor')); + for (const action of [ + { type: 'homeworlds', enabled: true }, + { type: 'moduleOption', module: 'leaderSkills', enabled: true }, + { type: 'moduleOption', module: 'ecazTreachery', enabled: true }, + { type: 'techTokens', enabled: true }, + { type: 'strongholdCards', enabled: true }, + ] as const) initial = applyAction(initial, initial.host, action); + const f = createMoritaniIndependentSkillsFixture({ initial }); + const shipment = f.shipment!; + assert.deepEqual(pool(shipment.after, f.opponent), + { normal: pool(shipment.before, f.opponent).normal - 1, elite: 0 }); + assert.equal(player(shipment.after, f.opponent).spice, player(shipment.before, f.opponent).spice - 1); + assert.equal(player(f.afterEntry, f.opponent).forces[f.second], 6); + const plan = f.plans.find((step) => step.actor === f.moritani)!; + if (plan.action.type !== 'battlePlan') throw new Error('Expected the real Moritani plan.'); + reject(f.game, f.moritani, { ...plan.action, + reinforcementsSources: { 'homeworld:guild': { normal: 3, elite: 0 } } }); + reject(f.game, f.moritani, { ...plan.action, + reinforcementsSources: { 'homeworld:moritani': { normal: 2, elite: 0 } } }); + assert.equal(f.pending.lastBattleContext!.winner, f.opponent); + assert.equal(f.pending.lastBattleContext!.result, 'normal'); + assert.deepEqual(pool(f.pending, f.moritani), + { normal: pool(f.revealed, f.moritani).normal - 3 + 2, elite: 0 }); + assert.equal(player(f.pending, f.moritani).tanks, 7); + assert.deepEqual(player(f.pending, f.moritani).forces, {}); + assert.deepEqual(player(f.pending, f.opponent).forces, { [f.second]: 4 }); + assert.deepEqual(f.returns, { [f.first]: { normal: 2, elite: 0 } }); + const assignment = f.pending.leaderSkills!.assignments.find((entry) => entry.leader === f.traitorTarget)!; + assert.ok(assignment); + assert.notEqual(f.traitorTarget, f.winnerLeader); + assert.equal(owned(f, f.pending, f.traitorTarget).usedAt, undefined); + const spent = chooseMoritaniIndependentSkillsAssassination(f.pending, f.traitorTarget); + const target = owned(f, spent, f.traitorTarget); + assert.equal(target.dead, true); + assert.equal(target.deaths, 1); + assert.equal(target.usedAt, undefined); + assert.equal(owned(f, spent, f.winnerLeader).dead, false); + assert.deepEqual(spent.leaderSkills!.deck, [...f.pending.leaderSkills!.deck, assignment.skill]); + assert.equal(spent.leaderSkills!.assignments.some((entry) => entry.leader === target.id), false); + assert.equal(player(spent, f.moritani).spice, player(f.pending, f.moritani).spice + target.strength); + assert.deepEqual(player(spent, f.moritani).traitors, [f.traitorTarget]); + const beforeMentat = finishMoritaniIndependentSkillsBattle(spent, { winnerDiscard: 'played' }, + (game) => game.phase === 7 && moritaniIndependentSkillsClean(game)); + assert.equal(beforeMentat.moritaniAssassinate!.opportunities[0].stage, 'revealed'); + const settled = finishMoritaniIndependentSkillsBattle(beforeMentat); + const receipt = settled.moritaniAssassinate!.opportunities[0]; + assert.equal(receipt.stage, 'replaced'); + assert.equal(receipt.bounty, target.strength); + assert.ok(receipt.replacement && receipt.replacement !== f.traitorTarget); + assert.deepEqual(player(settled, f.moritani).traitors, [receipt.replacement]); + assert.equal(settled.traitorReserve!.length, f.pending.traitorReserve!.length - 1); + assert.equal(settled.leaderSkills!.deck.filter((skill) => skill === assignment.skill).length, 1); + assert.deepEqual(pool(settled, f.moritani), pool(f.pending, f.moritani)); + assertMoritaniIndependentSkillsCustody(settled); +}); + +void test('paired native Worlds keep Ecaz Loyalty and the shared Duke outside an unused trained-target assassination', () => { + const f = createMoritaniIndependentSkillsFixture({ paired: true, homeworlds: true, + rules: 'advanced', skills: true, cards: true, tech: true, strongholds: true }); + const loyalty = f.afterSetup.ecazLoyalty!; + assert.notEqual(f.traitorTarget, loyalty.card); + assert.notEqual(f.traitorTarget, f.winnerLeader); + assert.notEqual(f.traitorTarget, 'duke-vidal'); + assert.equal(f.pending.leaderSkills!.assignments.some((entry) => entry.leader === 'duke-vidal'), false); + assert.deepEqual(pool(f.shipment!.after, f.opponent), + { normal: pool(f.shipment!.before, f.opponent).normal - 1, elite: 0 }); + assert.equal(player(f.shipment!.after, f.opponent).spice, + player(f.shipment!.before, f.opponent).spice - 2); + assert.equal(player(f.afterEntry, f.opponent).forces[f.second], 7); + assert.deepEqual(player(f.pending, f.opponent).forces, { [f.second]: 3 }); + assert.equal(player(f.pending, f.opponent).spice, player(f.game, f.opponent).spice - 4, + 'support uses the real prebattle wallet after original optional phase income'); + const assignment = f.pending.leaderSkills!.assignments.find((entry) => entry.leader === f.traitorTarget)!; + const spent = chooseMoritaniIndependentSkillsAssassination(f.pending, f.traitorTarget); + assert.equal(owned(f, spent, f.traitorTarget).usedAt, undefined); + assert.equal(owned(f, spent, f.traitorTarget).dead, true); + assert.equal(owned(f, spent, f.winnerLeader).dead, false); + assert.equal(spent.moritaniAssassinate!.opportunities[0].bounty, 4); + assert.equal(player(spent, f.moritani).spice, player(f.pending, f.moritani).spice + 4); + assert.deepEqual(spent.leaderSkills!.deck, [...f.pending.leaderSkills!.deck, assignment.skill]); + const settled = finishMoritaniIndependentSkillsBattle(spent, { winnerDiscard: 'played' }); + const receipt = settled.moritaniAssassinate!.opportunities[0]; + assert.equal(receipt.stage, 'replaced'); + assert.notEqual(receipt.replacement, loyalty.card); + assert.equal(settled.traitorReserve!.length, f.pending.traitorReserve!.length - 1); + assert.equal(settled.leaderSkills!.deck.filter((skill) => skill === assignment.skill).length, 1); + assert.deepEqual(settled.ecazLoyalty, loyalty); + assert.deepEqual(settled.dukeVidal, f.afterSetup.dukeVidal); + assertMoritaniIndependentSkillsCustody(settled); +}); + +void test('Basic native Worlds return the typed Harass survivors but cannot assassinate the unused trained Ecaz target', () => { + const f = createMoritaniIndependentSkillsFixture({ paired: true, homeworlds: true, + rules: 'basic', skills: true, cards: true }); + assert.equal(f.pending.lastBattleContext!.winner, f.opponent); + assert.equal(f.pending.lastBattleContext!.result, 'normal'); + assert.deepEqual(f.returns, { [f.first]: { normal: 4, elite: 0 } }); + assert.deepEqual(pool(f.pending, f.moritani), + { normal: pool(f.revealed, f.moritani).normal - 3 + 4, elite: 0 }); + assert.equal(player(f.pending, f.moritani).tanks, 5); + assert.deepEqual(player(f.pending, f.moritani).forces, {}); + assert.deepEqual(player(f.pending, f.opponent).forces, { [f.second]: 3 }); + assert.equal(f.pending.moritaniAssassinate, undefined); + reject(f.pending, f.moritani, { type: 'decision', card: f.traitorTarget }); + const settled = finishMoritaniIndependentSkillsBattle(f.pending); + assert.equal(owned(f, settled, f.traitorTarget).dead, false); + assert.equal(owned(f, settled, f.traitorTarget).usedAt, undefined); + assert.deepEqual(settled.leaderSkills, f.pending.leaderSkills); + assert.deepEqual(player(settled, f.moritani).traitors, [f.traitorTarget]); + assert.deepEqual(pool(settled, f.moritani), pool(f.pending, f.moritani)); + assertMoritaniIndependentSkillsCustody(settled); +});