From 17adffa6a7e352ea07b276f29898eec07266fb3e Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Sat, 17 Oct 2026 21:12:35 +1100 Subject: [PATCH] Connect paired E3 Skills without Nexus and retain native arrival independence Reuse original paired roster validation with plain all14 native setup and optional independent cards. Preserve real coalition, trained death and Mentat paths; extend the existing paid Ambassador/Terror queue to paired Skills and card-only factions. Frozen29-file323/323, types/lint/build, native programmes and390px entry pass. No save conversion or deployment. --- docs/CURRENT_STATUS.md | 23 +++- docs/ECAZ_OCCUPY_RULES.md | 9 ++ docs/ECAZ_TREACHERY_RULES.md | 20 ++- docs/IMPLEMENTATION_STATUS.md | 30 +++++ docs/LEADER_SKILLS_RUNTIME.md | 36 +++++- docs/MORITANI_ASSASSINATE_LEADERS.md | 8 +- docs/MORITANI_ENTRY_TIMING.md | 21 ++++ docs/MORITANI_LEADER_SKILLS.md | 15 ++- docs/RULE_DECISIONS.md | 4 +- game/engine.ts | 7 +- game/leader-skill-profile.ts | 33 ++++- game/reference.ts | 8 +- tests/advanced-moritani-skills.test.ts | 28 +---- tests/ecaz-independent-skills-runtime.test.ts | 117 ++++++++++++++++++ tests/fixture-ecaz-independent-skills.ts | 7 +- tests/fixture-homeworld-ecaz-occupy.ts | 18 ++- tests/fixture-moritani-independent-skills.ts | 43 +++++-- ...oritani-independent-skills-runtime.test.ts | 113 +++++++++++++++++ tests/moritani-skills-integration.test.ts | 25 +--- tests/paired-e3-skills-arrival.test.ts | 101 +++++++++++++++ 20 files changed, 573 insertions(+), 93 deletions(-) create mode 100644 tests/paired-e3-skills-arrival.test.ts diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 280d4b60..526db1ff 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -29,10 +29,31 @@ optional skill bands and combinations remain open. Rules implementation continues; comprehensive assurance and administration remain deferred until all rules are implemented. +**Paired E3 Skills and native arrivals — 17 October.** Native Ecaz+Moritani/ +classics all14 Skills no longer requires Nexus Cards, Basic/Advanced2–6, +optionally independent cards only. Four normal plain/card starts failed before +the profile cutover. Shared original roster validation retains native6/6, +Loyalty, five-disc assignment, untrained Duke and Advanced Harkonnen/mixed-family +guards. No World/phase-module envelope or played-save history is fabricated. + +Actual paired coalitions retain card-user fees/returns, separate native armies +and committed-only Suk; actual Moritani loss reveals unused trained Ecaz4, +returns one skill and gets one Mentat replacement. Four policies choose legal +native leads and assassination. Native Ambassador/Terror overlap also works +without Nexus for these Skills profiles and card-only factions: real placements, +one paid arrival, both storm-order branches, Duke6/Robbery and resumed movement. + +Frozen29-file **323/323**, types/lint/build and genuine runtime programmes pass. +Four paired all14 table smokes reach turn3 or an earlier win; targeted programmes +supply assassination/overlap proof. Actual390px paired normal entry completes +training, Loyalty/Traitors and both native setups into Storm without errors or +overflow. Existing provisional arithmetic/timing and final acceptance limits remain. +[Paired scope and evidence](LEADER_SKILLS_RUNTIME.md#paired-e3-without-nexus--17-october-2026). + **Standalone E3 independent cards/native defaults — 17 October.** Fresh standalone Ecaz OR Moritani/classic all14 Skills now retains the three independent cards without other modules. Original Advanced Skills/Harkonnen, -E3 pair/mixed Skills and shared-Duke guards remain. Plain/card Ecaz initializes +E3 mixed-family Skills and shared-Duke guards remain. Plain/card Ecaz initializes printed Basic/Advanced coalition participation; supported native Advanced Moritani initializes assassination without an artificial Nexus dependency. No played-save history is synthesized. diff --git a/docs/ECAZ_OCCUPY_RULES.md b/docs/ECAZ_OCCUPY_RULES.md index f864051e..d51ea995 100644 --- a/docs/ECAZ_OCCUPY_RULES.md +++ b/docs/ECAZ_OCCUPY_RULES.md @@ -2,6 +2,15 @@ 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. +## Paired native Skills — 17 October 2026 + +Native Ecaz/Moritani/classics now retains ordinary all14 Skills without Nexus, +optionally independent cards only, in Basic/Advanced. Actual native6/6, +selected lead, card-user fee/return and fixed versus variable losses remain +separate; trained Ecaz Suk rescues only its committed own losses. Native +Moritani contributes normal counters rather than invented Imperial elites. +[Paired source scope and323/323 proof](LEADER_SKILLS_RUNTIME.md#paired-e3-without-nexus--17-october-2026). + ## Standalone Ecaz Leader Skills — 17 October 2026 The original native Ecaz/classic Skills profile now reaches the same Arrakis diff --git a/docs/ECAZ_TREACHERY_RULES.md b/docs/ECAZ_TREACHERY_RULES.md index ed0c1db4..e9011fb3 100644 --- a/docs/ECAZ_TREACHERY_RULES.md +++ b/docs/ECAZ_TREACHERY_RULES.md @@ -354,8 +354,8 @@ persistence/privacy assurance or a deployed revision. The same printed independent-variant permission now composes original Ecaz OR Moritani/classic all14 Skills, Basic/Advanced2–6, exact ecaz36, without other modules. Native offers/assignment, Loyalty, Traitors, starting forces and card -custody stay source-ordered. Advanced Harkonnen, E3 native pairs/mixed families, -shared-Duke training and other module combinations retain their Skills guards. +custody stay source-ordered. Advanced Harkonnen, mixed families, shared-Duke +training and other module combinations retain their Skills guards. Fresh plain/card Ecaz starts mark native coalition participation in Basic as well as Advanced. Basic no longer rejects Occupy merely because cards were @@ -403,3 +403,19 @@ private fallback cards, not named weapons. Both normal/trained postures preserve the hand and seal a legal plan in declared conserved positions. See the [inventory contract](MENTAT_QUESTION.md#selected-independent-card-inventory--17-october-2026). No deployed, full-module, strategic calibration or deferred assurance claim follows. + +## Paired E3 Skills and native arrival independence — 17 October 2026 + +Ecaz+Moritani/classics now also uses all14 Skills without Nexus, optionally +independent cards, Basic/Advanced2–6 and no other modules. Original native6/6, +Loyalty, assignment, separate card-user costs/armies and one native Mentat +replacement remain; Advanced Harkonnen/shared-Duke/mixed-family guards stay. +[Canonical paired programme and323/323 evidence](LEADER_SKILLS_RUNTIME.md#paired-e3-without-nexus--17-october-2026). + +The printed variant independence also removes the unrelated card-only +Ambassador/Terror entry guard. Actual Basic paid entry was rejected before; +native placements now reach both reactions and original movement without +requiring Skills. The earlier storm-order policy, actual control/effect-specific +guards and physical payment/force rules are unchanged, not newly adjudicated. +[Native queue and genuine proof](MORITANI_ENTRY_TIMING.md#paired-skills-and-independent-card-entries--17-october-2026). + diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index f461900f..3bfcc7b2 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,35 @@ # Dune implementation status +## 17 October 2026 — Paired native E3 Skills without Nexus + +Four actual paired Basic/Advanced plain/card Skills starts rejected before the +cutover. One original Ecaz/Moritani roster validator now serves Nexus and plain +paired capability; native Basic/Advanced predicates and ordinary consumers reuse +it without manufacturing Nexus state. Native6/6, Loyalty, five-disc assignment, +untrained Duke, original module envelopes and Advanced Harkonnen guards remain. + +Existing genuine programmes now cover native Moritani as variable coalition +army/card user and Ecaz as the actual winning assassination faction. Card +fees/returns/committed-only Suk stay separate; unused trained Ecaz4 returns one +skill, pays printed4 and yields one actual Mentat replacement. Original source +entropy and conserved cards/Traitors are explicit, not fabricated armies/payments. + +Genuine native token placements also exercise the existing Ambassador/Terror +queue without Nexus. Both storm-order branches commit one Guild shipment, +acquire untrained Duke6, resolve Robbery and resume movement without replay. +Card-only Basic entry separately rejected before, then passes after removing +the unrelated variant guard. No new priority/numeric ruling or receipt exists. + +Frozen29-file **323/323**, types/lint/build, actual native programmes and four +legal policies pass. Four bounded paired all14 tables reach turn3 or earlier +wins; their ten natural battles contain no assassination, so targeted programmes +supply that proof. Actual390px normal paired entry completes training, +Loyalty/Traitors/native6+6 into Storm without browser errors/root overflow. +Obsolete software exclusion pins are deleted, not re-pinned. Existing build +warnings and complete-mode/deployed/deferred assurance limits remain. +[Current scope](LEADER_SKILLS_RUNTIME.md#paired-e3-without-nexus--17-october-2026), +[existing provisional arrival order](MORITANI_ENTRY_TIMING.md#paired-skills-and-independent-card-entries--17-october-2026). + ## 17 October 2026 — Native E3 independent Skills and printed defaults Fresh standalone Ecaz OR Moritani/classic all14 Skills admits the independent diff --git a/docs/LEADER_SKILLS_RUNTIME.md b/docs/LEADER_SKILLS_RUNTIME.md index 13ec7221..511500ba 100644 --- a/docs/LEADER_SKILLS_RUNTIME.md +++ b/docs/LEADER_SKILLS_RUNTIME.md @@ -7,6 +7,40 @@ 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). +## Paired E3 without Nexus — 17 October 2026 + +Fresh native Ecaz+Moritani/classics all14 Skills now uses its original setup +without an artificial Nexus dependency, Basic/Advanced2–6, optionally independent +cards (ecaz33/36) and no other modules. One physical-roster validator serves +both original paired Nexus and plain Skills predicates. No Nexus hand is +created; native6/6, Loyalty, ordinary assignment discs, mandatory Banker and +native powers remain. Shared Duke never trains. Advanced Harkonnen, mixed +families and other plain-pair module combinations retain their guards. + +The existing Ecaz programme now uses native Moritani as its actual variable +army/optional lead: native normal counters, own fee/returns and separate fixed +Ecaz losses remain. Trained Ecaz Suk rescues committed own losses, never reserve +fees or the ally. The Moritani programme now loses against actual Ecaz, reveals +one different held unused trained Ecaz4, returns one skill and replaces the +actual Traitor once at Mentat. Basic and no-card controls remain native. + +Existing fixtures/tests `fixture-ecaz-independent-skills.ts`, +`ecaz-independent-skills-runtime.test.ts`, +`fixture-moritani-independent-skills.ts` and +`moritani-independent-skills-runtime.test.ts` extend `paired:true`; original +standalone defaults stay. `tests/paired-e3-skills-arrival.test.ts` additionally +uses genuine placements/paid entry for both native reactions, including +card-only factions without Skills. See the +[unchanged provisional queue policy](MORITANI_ENTRY_TIMING.md#paired-skills-and-independent-card-entries--17-october-2026). + +Frozen29-file323/323, types/lint/build, paired runtime programmes and four-policy +lead/assassination actions pass. Four bounded paired all14 tables reach turn3 +or earlier wins; targeted programmes supply assassination/overlap proof. +Actual390px normal paired training, Loyalty/Traitors and both native force +setups reach Storm without errors/overflow. No played-game conversion, +calibration, complete-mode, deployed or deferred assurance claim follows. + + ## Standalone E3 independent cards — 17 October 2026 Fresh native Ecaz OR Moritani/classic all14 Skills now also admits independent @@ -14,7 +48,7 @@ Recruits, Reinforcements and Harass & Withdraw, Basic/Advanced2–6, exact ecaz3 and no other modules. The shared independent/native capability feeds setup, card previews, skill effects, ordinary bankers and legal AI. Original native offers, five assignment discs, Loyalty/Traitors and starting forces remain; -Advanced Harkonnen, E3 pairs/mixed families and shared-Duke training stay guarded. +Advanced Harkonnen, mixed families and shared-Duke training stay guarded. Ecaz retains real allied Arrakis lead selection and separate fixed/variable armies. Advanced Moritani keeps skill-first winner outcomes, printed unused-disc diff --git a/docs/MORITANI_ASSASSINATE_LEADERS.md b/docs/MORITANI_ASSASSINATE_LEADERS.md index af53145b..fd05e3c9 100644 --- a/docs/MORITANI_ASSASSINATE_LEADERS.md +++ b/docs/MORITANI_ASSASSINATE_LEADERS.md @@ -55,10 +55,10 @@ advantage with their actual distinct Ix/CHOAM/Ecaz family decks, including Harkonnen, the Ecaz pair and existing Homeworld occupation. Existing module profiles still govern Nexus, Discovery, Tech and Strongholds. Independent cards do not require an unrelated Nexus module or suppress assassination. -With Skills, the original standalone Moritani/classic roster still excludes -Advanced Harkonnen and mixed/pair E3. It now also permits the independent -three-card variant without other modules; see -[the all14 native composition](MORITANI_LEADER_SKILLS.md#independent-treachery-composition--17-october-2026). +With Skills, standalone or paired native Ecaz/Moritani/classic setups now work +without Nexus, optionally the independent three-card variant and no other +modules. Advanced Harkonnen, mixed families and other module envelopes retain +their guards; see [the paired native composition](MORITANI_LEADER_SKILLS.md#paired-native-e3-skills--17-october-2026). Winner Suk rescue and Rihani choice finish first; the unspent assassination receipt waits, then opens its original private choice. Already completed casualties cannot replay after reveal/decline. A different eligible trained diff --git a/docs/MORITANI_ENTRY_TIMING.md b/docs/MORITANI_ENTRY_TIMING.md index 44acfced..7496a527 100644 --- a/docs/MORITANI_ENTRY_TIMING.md +++ b/docs/MORITANI_ENTRY_TIMING.md @@ -2,6 +2,27 @@ Reviewed 2026-09-06. This supplements `MORITANI_TERROR_RULES.md`; it does not enable combined-faction play or certify an implementation order. +## Paired Skills and independent-card entries — 17 October 2026 + +The existing serialized native queue now admits paired Ecaz/Moritani/classic +all14 Skills without Nexus, optionally independent cards and no other modules. +Bare faction/card-only entries also retain it; selecting the printed independent +variant does not require selecting Skills or suppress native reactions. +This extends software capability, not the provisional storm-order policy below. +Original in-flight control and effect-specific guards remain. + +`tests/paired-e3-skills-arrival.test.ts` branches from genuine normal setup, +uses actual native placements and one paid Guild arrival, then triggers +untrained Duke6 and Robbery in the original queue. Basic encounters Terror +first and Advanced Ambassador first; both settle each originally eligible +effect once, retain one arrived counter and resume movement. Card-only Basic +was reproduced rejecting before the guard repair and now follows that same +physical continuation. After its one-spice shipment, Basic Guild4 transfers2 +or Advanced Guild5 transfers3 to Moritani; payment never replays. Actors and +printed effects are not inferred from the active player. Frozen29-file323/323, +types/lint/build and runtime programmes pass; no saved/deployed/full-priority acceptance follows. + + ## Finding **The examined primary sources do not establish a complete priority order for Terror, Intrusion, accompanying advisors, shipment income and arrival storm losses.** No retrieved classic-game rule assigns these conflicts to storm order or to the moving player. This is a bounded research result, not a claim that no designer clarification exists anywhere. diff --git a/docs/MORITANI_LEADER_SKILLS.md b/docs/MORITANI_LEADER_SKILLS.md index ee586561..43c803ff 100644 --- a/docs/MORITANI_LEADER_SKILLS.md +++ b/docs/MORITANI_LEADER_SKILLS.md @@ -1,10 +1,21 @@ # Moritani with Leader Skills Connected development prototype, updated 17 October 2026. Supported standalone -Moritani/classic Basic/Advanced two–six-seat profiles use all fourteen physical +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. +## Paired native E3 Skills — 17 October 2026 + +Both native factions now compose ordinary all14 Skills without Nexus, +optionally independent cards only. Genuine native6/6 and Ecaz Loyalty precede +the actual battle. Moritani loses against Ecaz, reveals unused trained Ecaz4, +returns its exact skill once and receives one physical replacement at Mentat. +The existing fixture/runtime pair exposes `paired:true`; standalone defaults, +Basic without assassination and original Advanced Harkonnen/Duke guards stay. +[Current scope, controls and323/323 proof](LEADER_SKILLS_RUNTIME.md#paired-e3-without-nexus--17-october-2026). + + ## Independent Treachery composition — 17 October 2026 Fresh standalone Moritani/classic all14 Skills now retains the independent @@ -12,7 +23,7 @@ Recruits, Reinforcements and Harass & Withdraw variant, Basic/Advanced2–6, without other modules. Original36-card setup, five eligible assignment discs, private offers, Traitors and six-force placement remain. Advanced initializes the native assassination advantage; Basic does not. The original Advanced -Harkonnen, shared-Duke, mixed-family and paired-E3 Skills guards remain. +Harkonnen, shared-Duke and mixed-family Skills guards remain. Card fees/returns and winner skills continue before native assassination. The fee is three card-user reserves, never rescued battle dial; a successful diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index 31003b5d..634ae900 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -668,7 +668,7 @@ 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 faction-independent variant permission composes fresh classic, selected E1/E2 native/base, or standalone Ecaz OR Moritani/classic all14 Skills, Basic/Advanced2–6, without other modules. Original decks36/38/50, native setup and skill/card custody stay. E3 Advanced Harkonnen, shared-Duke, native-pair/mixed-family Skills and other module envelopes remain guarded. Bare no-Skills card rosters retain every selected known family even without Ecaz seated; this does not widen E3 World/phase modules or change provisional fee/return timing. | [Native physical sources](ECAZ_TREACHERY_RULES.md#native-e1e2-leader-skills-composition--17-october-2026), [standalone E3](ECAZ_TREACHERY_RULES.md#standalone-e3-leader-skills-composition--17-october-2026) | +| Independent Treachery / all14 Skills | Printed E3 faction-independent variant permission composes fresh classic, selected E1/E2 native/base, or standalone/paired Ecaz/Moritani/classic all14 Skills, Basic/Advanced2–6, without other modules. Original decks36/38/50, native setup and skill/card custody stay. E3 Advanced Harkonnen, shared-Duke, mixed-family Skills and other module envelopes remain guarded. Bare no-Skills card rosters retain every selected known family even without Ecaz seated; E3 World/phase modules and provisional fee/return timing are unchanged. | [Native physical sources](ECAZ_TREACHERY_RULES.md#native-e1e2-leader-skills-composition--17-october-2026), [paired E3](LEADER_SKILLS_RUNTIME.md#paired-e3-without-nexus--17-october-2026) | | 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 E3 variant independence permits unused Tech3–6 Basic/Advanced and/or Strongholds2–6 Advanced with clean classic/selected Ix/CHOAM native/base rosters, including original Homeworlds. Original setup, delayed income and earned custody stay. R fee-before-Harass is provisional; returns are not free Revival/shipment and +2 is not supported force. Native exact fees, current rates/unlimited allowances, marker funding and aftercare retain their separate contracts. Skills, Nexus, Discovery, E3 mixtures and unrelated previews remain gated. | [Phase composition](ECAZ_TREACHERY_RULES.md#original-phase-module-composition--16-october-2026), [native World composition](ECAZ_TREACHERY_RULES.md#native-world-phase-modules--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) | @@ -701,7 +701,7 @@ dedicated retrieved worked FAQs. [Contract and remaining limits](HOMEWORLD_OCCUP | Truthtrance hand categories | Count current physical hand custody by explicitly named primary role; alternate battle slots and faction powers do not reclassify cards. Only an aggregate answer is disclosed. | [Hand inventory](TRUTHTRANCE_HAND_INVENTORY.md) | | Moritani Advanced assassination preview | After an eligible battle loss, a different opposing-faction Traitor Card may be revealed even if its leader is already dead (zero bounty). Face-up set-aside and one private replacement occur during Mentat; one use per faction. Normal-call duration, uniform hidden-eligibility UX and exceptional combinations remain explicit. | [Runtime/source contract](MORITANI_ASSASSINATE_LEADERS.md) | | Moritani Extortion at Mentat | The user selected normal Terror placement/relocation first, including any Karama response; only then collect the reserved five bank spice and approach other players in storm order for a possible three-spice payment. Payment removes the token; all declines recover it with rotated hidden supply IDs. The relative placement timing is a user-selected interpretation, not a publisher ruling. | [Publisher sequence and selected timing](MORITANI_EXTORTION_RULES.md#mentat-ordering-selected-interpretation-not-a-publisher-ruling) | -| Ecaz Ambassador / Moritani Terror shared entry | In bounded classic plus Ecaz/Moritani Basic/Advanced games and the exact original paired native Nexus envelopes, both originally eligible owners resolve optional reactions in current storm order; committed entry, payment and counters do not replay. Later alliances do not erase the original trigger; physical effect prerequisites are rechecked. This remains an explicit provisional interpretation, not a located publisher priority ruling. A genuine BG fighter companion is independently eligible; advisors remain exempt. Committed entry waits for its own income, Intrusion/advisor or storm controls. Actual in-flight reactions and unsupported paired/module/child combinations retain their guards; native own-arrival support is not universal overlap acceptance. | [Primary-source gap and selected policy](MORITANI_ENTRY_TIMING.md#selected-bounded-overlap-order--29-september-2026), [exact paired composition](MORITANI_ENTRY_TIMING.md#paired-native-nexus-composition--7-october-2026), [deferred Terror entry](MORITANI_ENTRY_TIMING.md#deferred-arrival-entry-behind-the-arrivals-own-interactions--7-october-2026), [native Ambassador continuation](DEFERRED_SHIPMENT_ARRIVAL.md#native-own-arrival-continuations--15-october-2026) | +| Ecaz Ambassador / Moritani Terror shared entry | Bounded classic plus Ecaz/Moritani Basic/Advanced, native paired Skills with optional independent cards and the original paired Nexus envelopes retain both originally eligible owners in current storm order; committed entry/payment/counters do not replay. Bare card-only factions need no Skills. Later alliances do not erase the trigger; physical effect prerequisites are rechecked. This remains provisional, not publisher priority. Genuine BG fighters qualify independently; advisors are exempt. Entry waits for its own income, Intrusion/advisor or storm controls; actual in-flight and unsupported effect/module/child guards remain. | [Source gap and selected policy](MORITANI_ENTRY_TIMING.md#selected-bounded-overlap-order--29-september-2026), [paired native Skills/cards](MORITANI_ENTRY_TIMING.md#paired-skills-and-independent-card-entries--17-october-2026), [original Nexus](MORITANI_ENTRY_TIMING.md#paired-native-nexus-composition--7-october-2026), [deferred entry](MORITANI_ENTRY_TIMING.md#deferred-arrival-entry-behind-the-arrivals-own-interactions--7-october-2026) | | Fremen/Ecaz final-turn victory | Basic keeps reciprocal cooccupation at Tabr only. Authorized supplied Advanced physical p32 Fremen I permits both Tabr and Habbanya when Fremen/Ecaz actually coexist. Solitary/nonallied Ecaz and third parties still block; ordinary winner/prediction/fallback order is preserved. | [Current source cutover](ECAZ_VICTORY_RULES.md) | | Truthtrance recorded knowledge | Questions compare an already recorded BG prediction, active submitted storm dial or actually known native Fremen forecast. Missing knowledge is unknown; only the aggregate answer is published. No future promise or unknown deck peek is created. | [Connected predicates and evidence](TRUTHTRANCE_KNOWLEDGE.md) | | Nexus lifecycle | Twelve physical cards, phase draw/replacement and alliance forfeiture; native Cunning, absent-faction Secret Ally and Betrayal have separate effects. No blanket Karama immunity. | [Common source contract](NEXUS_CARD_RULES.md), [lifecycle runtime](NEXUS_CARD_RUNTIME.md) | diff --git a/game/engine.ts b/game/engine.ts index ce5567c5..4ffe0ba0 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -1,7 +1,7 @@ import { classicNexusModulesProfile, mixedE1E2NexusProfile, mixedE3NexusProfile, pairedE3NexusProfile, pairedNexusModulesProfile, singleE1E2NexusProfile, standaloneE3NexusProfile } from './nexus-module-profile'; import { nativeFactionTechProfile } from './faction-module-profile'; import { classicDiscoveryLeaderSkillsProfile, classicDiscoveryNexusProfile, homeworldDiscoveryProfile, discoveryModeSupported, discoveryModuleProfile, mixedE1E2DiscoveryNexusProfile, mixedE3DiscoveryNexusProfile, pairedDiscoveryNexusProfile, pairedE3DiscoveryNexusProfile, singleE1E2DiscoveryNexusProfile, standaloneE3DiscoveryNexusProfile } from './discovery-module-profile'; -import { advancedMoritaniLeaderSkillsProfile, advancedNativeLeaderSkillsProfile, homeworldLeaderSkillsProfile, independentTreacheryLeaderSkillsProfile, classicNexusLeaderSkillsProfile, classicTechLeaderSkillsProfile, guildBetrayalNexusLeaderSkillsProfile, mixedE1E2NexusLeaderSkillsProfile, mixedE3NexusLeaderSkillsProfile, nativeDiscoveryLeaderSkillsProfile, nativeEcazLeaderSkillsProfile, nativeExpansionLeaderSkillsProfile, nativeTechLeaderSkillsProfile, ordinaryLeaderSkillModeSupported, pairedE3NexusLeaderSkillsProfile, pairedNexusLeaderSkillsProfile, singleE1E2NexusLeaderSkillsProfile, standaloneE3NexusLeaderSkillsProfile, strongholdLeaderSkillsProfile } from './leader-skill-profile'; +import { advancedMoritaniLeaderSkillsProfile, advancedNativeLeaderSkillsProfile, homeworldLeaderSkillsProfile, independentTreacheryLeaderSkillsProfile, classicNexusLeaderSkillsProfile, classicTechLeaderSkillsProfile, guildBetrayalNexusLeaderSkillsProfile, mixedE1E2NexusLeaderSkillsProfile, mixedE3NexusLeaderSkillsProfile, nativeDiscoveryLeaderSkillsProfile, nativeEcazLeaderSkillsProfile, nativeExpansionLeaderSkillsProfile, nativeTechLeaderSkillsProfile, ordinaryLeaderSkillModeSupported, pairedE3LeaderSkillsProfile, pairedE3NexusLeaderSkillsProfile, pairedNexusLeaderSkillsProfile, singleE1E2NexusLeaderSkillsProfile, standaloneE3NexusLeaderSkillsProfile, strongholdLeaderSkillsProfile } from './leader-skill-profile'; import { bribeTimingBlock, maximumBribe, type BribeOptions } from './bribe-options'; import { quoteSpicePlacement, stormExposesTerritory, stormSectorAfter, wormConsumesForces } from './disaster-rules'; import { isStormCardDistance, type StormCardComponent } from './storm-cards'; @@ -16264,14 +16264,13 @@ function ambassadorEffectBlock( function arrivalOverlapModeSupported(g: Game): boolean { if ((mixedE3NexusLeaderSkillsProfile(g) || mixedE3NexusProfile(g)) && byFaction(g, 'ecaz') && byFaction(g, 'moritani')) return true; - return pairedE3NexusLeaderSkillsProfile(g) || pairedE3NexusProfile(g) || + return (!!g.leaderSkills && pairedE3LeaderSkillsProfile(g)) || pairedE3NexusLeaderSkillsProfile(g) || pairedE3NexusProfile(g) || (!!byFaction(g, 'ecaz') && !!byFaction(g, 'moritani') && g.players.every(player => CLASSIC_FACTIONS[player.faction] === true || player.faction === 'ecaz' || player.faction === 'moritani') && g.expansions.every(expansion => expansion === 'ecaz') && !g.homeworlds && !g.nexusCards && !g.leaderSkills && - !g.discoveryEnabled && !g.techTokens && !g.strongholdCards && - !g.ecazTreachery); + !g.discoveryEnabled && !g.techTokens && !g.strongholdCards); } function arrivalOverlapIntegrity(g: Game) { const record = g.pendingArrivalOverlap; diff --git a/game/leader-skill-profile.ts b/game/leader-skill-profile.ts index ce2b152a..ed14a0ea 100644 --- a/game/leader-skill-profile.ts +++ b/game/leader-skill-profile.ts @@ -166,6 +166,10 @@ export function mixedE1E2NexusLeaderSkillsProfile(game: LeaderSkillProfile): boo export function pairedE3NexusLeaderSkillsProfile(game: LeaderSkillProfile): boolean { if (game.expansions.length !== 1 || game.expansions[0] !== 'ecaz' || !nexusLeaderSkillModulesSupported(game, game.advanced === true, true)) return false; + return pairedE3LeaderSkillRoster(game); +} + +function pairedE3LeaderSkillRoster(game: LeaderSkillProfile): boolean { const players = game.players!; let ecaz = false, moritani = false; for (let i = 0; i < players.length; i++) { @@ -184,6 +188,20 @@ export function pairedE3NexusLeaderSkillsProfile(game: LeaderSkillProfile): bool return ecaz && moritani; } +/** Both original E3 natives without an unrelated module; independent 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 || + (!noOtherLeaderSkillModules(game) && !independentTreacheryLeaderSkillsProfile(game)) || + game.discoveryStash || game.greatMaker || game.semutaPreview || game.advancedPreview || + (game.moritaniAssassinatePreview && !game.advanced) || + game.kullPreview || game.nexusKullPreview || game.guildBetrayalPreview || + game.richeseBetrayalPreview || game.nexusIxianReplacementPreview || + game.nexusIxianBetrayalPreview || game.nexusHarkonnenBetrayalPreview) return false; + return pairedE3LeaderSkillRoster(game); +} + /** Original two/three selected families with native E3, Nexus and optional original modules. */ export function mixedE3NexusLeaderSkillsProfile(game: LeaderSkillProfile): boolean { return nexusLeaderSkillModulesSupported(game, game.advanced === true, true) && @@ -304,6 +322,7 @@ export function strongholdLeaderSkillsProfile(game: LeaderSkillProfile): boolean /** Bounded integration of the ordinary Moritani roster, not Advanced assassination. */ export function basicMoritaniLeaderSkillsProfile(game: LeaderSkillProfile): boolean { + if (game.advanced === false && pairedE3LeaderSkillsProfile(game)) return true; return game.advanced === false && game.expansions.length === 1 && game.expansions[0] === 'ecaz' && e3LeaderSkillModulesSupported(game) && !!game.players?.some(player => player.faction === 'moritani') && @@ -313,6 +332,7 @@ export function basicMoritaniLeaderSkillsProfile(game: LeaderSkillProfile): bool /** Existing bounded Advanced assassination roster, composed with native skills. */ export function advancedMoritaniLeaderSkillsProfile(game: LeaderSkillProfile): boolean { + if (game.advanced === true && pairedE3LeaderSkillsProfile(game)) return true; const players = game.players; if (game.advanced !== true || game.expansions.length !== 1 || game.expansions[0] !== 'ecaz' || !players || players.length < 2 || players.length > 6 || @@ -338,6 +358,7 @@ export function advancedMoritaniLeaderSkillsProfile(game: LeaderSkillProfile): b /** Ecaz's five native discs; the separate temporarily controlled Duke is not an assignment disc. */ export function nativeEcazLeaderSkillsProfile(game: LeaderSkillProfile): boolean { + if (pairedE3LeaderSkillsProfile(game)) return true; const players = game.players; if (typeof game.advanced !== 'boolean' || game.expansions.length !== 1 || game.expansions[0] !== 'ecaz' || !players || players.length < 2 || players.length > 6 || @@ -508,7 +529,7 @@ export function homeworldLeaderSkillsProfile(game: LeaderSkillProfile): boolean return true; } -/** Independent cards with classic, selected E1/E2 or standalone E3 all14 setup. */ +/** Independent cards with classic, selected E1/E2 or standalone/paired E3 all14 setup. */ export function independentTreacheryLeaderSkillsProfile(game: LeaderSkillProfile): boolean { const e3 = game.expansions.length === 1 && game.expansions[0] === 'ecaz'; if (game.ecazTreachery !== true || game.expansions.length > 2 || @@ -525,21 +546,21 @@ export function independentTreacheryLeaderSkillsProfile(game: LeaderSkillProfile for (let j = 0; j < i; j++) if (game.expansions[j] === game.expansions[i]) return false; } const players = game.players; - let e3Natives = 0; + let nativeE3 = false; for (let i = 0; i < players.length; i++) { for (let j = 0; j < i; j++) if (players[j].faction === players[i].faction) return false; let selected = false; for (const faction of FACTIONS) if (faction.id === players[i].faction) { selected = faction.expansion === 'base' || game.expansions.includes(faction.expansion); - if (faction.expansion === 'ecaz') e3Natives++; + if (faction.expansion === 'ecaz') nativeE3 = true; break; } if (!selected) return false; } - // Preserve the original standalone native Skills/Duke roster boundaries. - if (e3 && (e3Natives > 1 || (e3Natives > 0 && game.advanced && - players.some(player => player.faction === 'harkonnen')))) return false; + // Preserve the native Advanced Skills/Harkonnen and shared-Duke boundaries. + if (e3 && nativeE3 && game.advanced && + players.some(player => player.faction === 'harkonnen')) return false; if (game.moritaniAssassinatePreview && (!e3 || !game.advanced || !players.some(player => player.faction === 'moritani'))) return false; return true; diff --git a/game/reference.ts b/game/reference.ts index 1f266943..203338b2 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -1429,7 +1429,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 'Before any initial Traitor Cards are drawn, randomly select one of the five ordinary Ecaz Traitor Cards and set it aside face up. Everyone may inspect it. It remains outside the deck for the rest of the game, including later Traitor and Face Dancer draws.', 'Loyalty is automatic and cannot be stopped with Karama. It creates no player choice, payment or acknowledgment. The card names a leader, but does not remove, kill, transfer or otherwise change that leader disc. Duke Vidal has no Traitor Card and is never a candidate.', 'In new Advanced development games with Ecaz, the table displays the selected card before the Traitor choices. Inspect traitor enlarges its identity; refreshing or resuming the saved game keeps the same card. Basic games and games without native Ecaz do not use this power.', - 'Older saved games that have already dealt their Traitor Cards retain that inventory; they do not gain a retroactive Loyalty draw. Bounded native Ecaz Leader Skills retain this original draw after public assignment and now support their original allied Arrakis Occupy without an unrelated module. Unsupported Moritani/shared-Duke and other roster compositions remain guarded.', + 'Older saved games that have already dealt their Traitor Cards retain that inventory; they do not gain a retroactive Loyalty draw. Standalone or paired native Ecaz/Moritani all14 Skills retains the original draw after public assignment, with optional independent cards and no unrelated modules. Ordinary allied Arrakis Occupy remains connected; shared-Duke training, Advanced Harkonnen and unsupported roster/module combinations stay guarded.', ], checklist: [ { area: 'Implementation', status: 'Partial', detail: 'One persisted native Ecaz card is selected before genuine initial dealing, excluded from setup and Nexus deck construction, and validated against all known circulating or retired Traitor locations. Older saves stay unchanged. Broader combinations remain gated.', evidence: ['game/ecaz-loyalty.ts', 'game/engine.ts'] }, @@ -3236,7 +3236,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 'A Mentat replacement is an ordinary physical Traitor Card, including the original Ix Hero identity when present. After original owned initialization, retire the victim permanently. The replacement itself may later be legally exchanged, returned and drawn again; keep its old receipt unchanged and count one live card, not one per historical reference.', 'The authorized revised Advanced PDF, physical page35, requires that you have not revealed your Traitor Card normally this game. A normal own-card reveal therefore forfeits Assassinate Leaders for the rest of this game, across turns and opponents. Existing state preserves that forfeiture; Karama has no effect against the ability. This is the authorized source, not a new GF9 erratum.', 'The surviving actual played disc need not be native to its victorious controller. Native Ecaz/Duke6, Tleilaxu/foreign Guild5, CHOAM/Auditor2 and Harkonnen captives use the winner’s printed-faction targets. Captivity or Ghola control never rewrites native identity. Auditor has a matching Traitor outside CHOAM’s ordinary five; it can be a different native target for two but not the disc just played. Duke has no Traitor, and Cheap Hero is not a physical opposing disc. Exceptional captured/Ghola targets remain separate.', - 'Terror Assassination chooses a random leader when its token is triggered. It does not use this battle-loss condition, different-leader Traitor Card or once-per-faction allowance. Fresh supported plain/card Advanced Moritani starts initialize this printed advantage automatically; Basic never gains it. Independent cards also compose standalone Moritani/classic all14 Skills without other modules, retaining the Advanced Harkonnen and E3 pair/mixed Skills guards. Played saves do not gain invented prior-call history.', + 'Terror Assassination chooses a random leader when its token is triggered. It does not use this battle-loss condition, different-leader Traitor Card or once-per-faction allowance. Fresh supported plain/card Advanced Moritani starts initialize this printed advantage automatically; Basic never gains it. Independent cards also compose standalone or paired Ecaz/Moritani/classic all14 Skills without other modules, retaining Advanced Harkonnen and mixed-family Skills guards. Played saves do not gain invented prior-call history.', ], checklist: [ {area:'Implementation',status:'Partial',detail:'Canonical played-disc/native target semantics remain. Moritani replacement accepts physical Hero separately; permanent victim cards join the shared removed census, while historical replacement IDs may move/recur legally. Exchange, Face Dancer, Betrayal and Rihani consumers bind their own source zone. Current authorization/census, revealed-card reservation and original parent remain.',evidence:['game/moritani-assassinate.ts','game/nexus-traitor-exchange.ts','game/nexus-face-dancers.ts','game/nexus-harkonnen-betrayal.ts','game/rihani-decipherer.ts','game/engine.ts']}, @@ -4929,9 +4929,9 @@ export const RULE_TOPICS: RuleTopic[] = [ steps: [ 'Fresh all14 Leader Skills preserves original classic, supported E1/E2 or standalone Ecaz OR Moritani setup with Homeworlds/Discovery/Tech3+/Advanced Strongholds under each envelope. Explicit --nexus-cards additionally admits single/paired one-family E1/E2, BOTH selected Ix+CHOAM native families/union47 and standalone OR paired Ecaz/Moritani/ecaz33 with classics. Basic/Advanced2–6, original Ix pre-training offer, ordinary native discs and actual hands remain; Auditor/shared Duke never train. Winning normal/trained Suk uses source-labelled native, visitor or nested fixed-loss pools; native saved counters stay at that home and external Emperor returns allocate original homes. Original phase-end industry, held benefits, cleanup/Tech before Face Dance and native assassination remain; unreconciled bands, mixed E3/three-family, public starts and played-save conversion stay separate.', '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 base opponents including Harkonnen. Its separate bounded Advanced classic/non-Harkonnen roster now composes the original assassination preview and all fourteen skills: winner Suk rescue and Rihani choice finish first, then an eligible different trained disc may be assassinated with its exact card return and printed bounty. No casualties repeat; original set-aside and one private Mentat replacement remain. See Moritani with Leader Skills for the exact scope.', + 'Basic Moritani retains classic opponents including Harkonnen. Standalone or paired Ecaz/Moritani Skills without other modules, optionally independent cards, retains native setup and the Advanced non-Harkonnen assassination roster. Winner Suk rescue and Rihani choice finish first, then an eligible different trained disc may be assassinated with its exact skill return and printed bounty. No casualties repeat; original set-aside and one private Mentat replacement remain. Shared-Duke training and mixed-family Skills stay guarded.', 'Fresh Advanced profiles compose native Ixians, Tleilaxu, CHOAM and/or Richese with classic opponents and distinct required family decks. All fourteen skills and native starts remain. Auditor stays unskilled; cyborg casualties, original CHOAM support and Richese capped marker revelation remain physical. Foreign revival creates no new skill. Supplied Advanced p34 excludes Richese auctions from Ixian Technology; own mixed No-Field, captured replacement, Advanced Atreides Suk/KH and unrelated overlays retain their guards.', - 'The independent Recruits, Reinforcements and Harass & Withdraw cards may be selected with all fourteen Skills in fresh classic, selected Ix/CHOAM native/base or standalone Ecaz OR Moritani/classic Basic/Advanced games, without other modules. Original36/38/50 decks, Richese cache, native setup, free-revival exceptions and paid Cyborg prices stay. Card fee/withdrawal/Suk and ordinary Smuggler, Planetologist and Sandmaster capabilities share admission. Ecaz retains selected-lead/separate armies; Advanced Moritani keeps skill-first assassination and one Mentat replacement. Advanced Harkonnen, E3 native-pair/mixed Skills and other module guards remain.', + 'The independent Recruits, Reinforcements and Harass & Withdraw cards may be selected with all fourteen Skills in fresh classic, selected Ix/CHOAM native/base or standalone/paired Ecaz/Moritani/classic Basic/Advanced games, without other modules. Original36/38/50 decks, Richese cache, native setup, free-revival exceptions and paid Cyborg prices stay. Card fee/withdrawal/Suk and ordinary Smuggler, Planetologist and Sandmaster capabilities share admission. Ecaz retains selected-lead/separate armies; Advanced Moritani keeps skill-first assassination and one Mentat replacement. Advanced Harkonnen, mixed E3 families and other module guards remain.', 'Fresh three-through-six-seat Basic/Advanced development tables may compose all fourteen Skills with original Tech Tokens: classic factions, supported E1/E2 native families, or standalone Ecaz/Moritani with the exact ecaz deck and original roster exclusions. Starting cards/cache, skill/Traitor choices and printed/first-Storm assignment stay genuine. Existing leader-skills/banker-income entry preserves unused selected tokens; mixed E3 families, other overlays, public activation and saved-game conversion remain separate.', 'Fresh Advanced classic or supported native two-through-six-seat skill tables may retain six original Stronghold Cards, with Tech at three or more seats. Original end-Mentat/local held effects remain. Arrakeen changes actual payer cost, not rescue or Banker eligibility; native CHOAM income retains bank support. Physical defenses, normal disciplines, holder ties and original dial/Worthless earnings remain. Standalone Ecaz Duke/Arrakis coalition and Moritani assassination compose original rules; mixed E3, exceptional copied defenses, active skill/Stone and other guards stay separate.', '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/advanced-moritani-skills.test.ts b/tests/advanced-moritani-skills.test.ts index 3d0226b3..154ef79b 100644 --- a/tests/advanced-moritani-skills.test.ts +++ b/tests/advanced-moritani-skills.test.ts @@ -353,31 +353,9 @@ void test('original Enemy of My Enemy and ally retention exclude a consumed nati assert.deepEqual(normalizeAutomaticGame(reload(game)), game); }); -void test('new native admission preserves public, Harkonnen assassination, foreign family/module and Advanced Atreides Suk assignment', () => { - const make = (faction: 'atreides' | 'harkonnen' | 'ixians' | 'ecaz' = 'atreides', expansions: Game['expansions'] = ['ecaz']) => { - const game = createGame('MORIGATE', newPlayer('original-m', 'Moritani', 'moritani'), true, expansions); - joinGame(game, newPlayer('original-other', faction, faction)); - return game; - }; - reject(make(), 'original-m', { type: 'start' }); - for (const game of [make('harkonnen'), make('ixians', ['ecaz', 'ix']), make('ecaz'), make('atreides', ['ecaz', 'choam'])]) { - game.players.forEach(p => { p.ready = true; }); - const before = reload(game); - assert.throws(() => initializeLeaderSkillsGameForAudit(game)); - assert.deepEqual(game, before); - } - // Native Discovery Leader Skills are now admitted for this fresh Moritani - // lobby; the foreign-family and combined-expansion lobbies still reject. - const discoveryLobby = make(); - discoveryLobby.players.forEach(p => { p.ready = true; }); - discoveryLobby.discoveryEnabled = true; - const discoveryBefore = reload(discoveryLobby); - assert.ok(initializeLeaderSkillsGameForAudit(discoveryLobby).leaderSkills); - assert.deepEqual(discoveryLobby, discoveryBefore, 'The lobby is never mutated.'); - // Basic Moritani keeps its original Harkonnen-compatible skills profile; it - // does not acquire the Advanced-only assassination preview. - const basicLobby = make('harkonnen'); - basicLobby.advanced = false; +void test('Basic native Harkonnen keeps four Traitors and Advanced Atreides can assign an offered Suk Graduate', () => { + const basicLobby = createGame('MORIGATE', newPlayer('original-m', 'Moritani', 'moritani'), false, ['ecaz']); + joinGame(basicLobby, newPlayer('original-other', 'Harkonnen', 'harkonnen')); basicLobby.players.forEach(p => { p.ready = true; }); const basic = completeAdvancedNativeSkillsSetup(initializeLeaderSkillsGameForAudit(basicLobby), { family: 'moritani' }); assert.equal(basic.advanced, false); diff --git a/tests/ecaz-independent-skills-runtime.test.ts b/tests/ecaz-independent-skills-runtime.test.ts index 6cc5cb64..7b567f86 100644 --- a/tests/ecaz-independent-skills-runtime.test.ts +++ b/tests/ecaz-independent-skills-runtime.test.ts @@ -9,6 +9,7 @@ import { assertEcazIndependentSkillsCustody, createEcazIndependentSkillsFixture, ecazIndependentSkillsPlayer as player, quoteEcazIndependentSkillsBattle, settleEcazIndependentSkillsBattle, + type EcazIndependentSkillsFixture, } from './fixture-ecaz-independent-skills'; const at = (game: Game, id: string, key: string) => player(game, id).forces[key] ?? 0; @@ -23,6 +24,26 @@ function physicalSource(game: Game, count: number): void { ...(game.ecazTreachery ? ecazTreacheryCards() : [])].map(card => card.id).sort()); } +/** Both native starting armies stay physical outside the genuinely shipped battle. */ +function pairedArmies(f: EcazIndependentSkillsFixture, end: Game): void { + for (const game of [f.afterSetup, f.game, end]) { + assert.equal(at(game, f.ecaz, 'imperial_basin:9'), 6); + assert.equal(at(game, f.ally, 'polar_sink:0'), 6); + } + assert.equal(at(f.game, f.ally, f.location), 4); + assert.equal(player(f.game, f.ally).reserves, player(f.afterSetup, f.ally).reserves - 4, + 'Moritani ships four of its own reserves, not its original six or Imperial elites'); +} + +function disposedLeadCards(f: EcazIndependentSkillsFixture, end: Game): void { + assert.equal(end.lastBattleContext?.winnerDiscards?.completed, true); + assert.deepEqual(end.lastBattleContext?.winnerDiscards?.cards.slice().sort(), f.cardIds.slice().sort()); + for (const id of f.cardIds) { + assert.equal(player(end, f.cardUser).hand.some(card => card.id === id), false); + assert.equal(end.discard.filter(card => card.id === id).length, 1); + } +} + for (const advanced of [false, true]) { void test(`original independent cards with all14 ${advanced ? 'Advanced' : 'Basic'} reach the printed lead choice, and the trained Ecaz Suk rescues only its own committed losses`, () => { const f = createEcazIndependentSkillsFixture({ advanced, lead: 'ecaz' }); @@ -242,3 +263,99 @@ for (const advanced of [false, true]) { }); } + +for (const advanced of [false, true]) { + void test(`native paired no-card all14 ${advanced ? 'Advanced Moritani lead keeps Ecaz losses separate' : 'Basic Ecaz lead rescues its fixed commitment'} through coalition cleanup`, () => { + const f = createEcazIndependentSkillsFixture({ + paired: true, advanced, independent: false, cards: [], lead: advanced ? 'ally' : 'ecaz', + }); + physicalSource(f.initial, 33); + physicalSource(f.game, 33); + 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.reinforcements, undefined); + assert.equal(quote.harassWithdraw, undefined); + const end = settleEcazIndependentSkillsBattle(f); + assert.equal(end.phase, 7); + assert.equal(end.lastBattleContext?.winner, f.cardUser); + assert.equal(end.lastBattleContext?.result, 'normal'); + assert.equal(end.lastBattleContext?.sukRescue?.completed, advanced ? undefined : true); + assert.equal(change(player(f.game, f.ecaz).tanks, player(end, f.ecaz).tanks), advanced ? 2 : 0); + assert.equal(change(player(f.game, f.ecaz).reserves, player(end, f.ecaz).reserves), advanced ? 0 : 1); + assert.equal(at(end, f.ecaz, f.location), advanced ? 2 : 3); + assert.equal(change(player(f.game, f.ally).tanks, player(end, f.ally).tanks), 2); + assert.equal(change(player(f.game, f.ally).reserves, player(end, f.ally).reserves), 0); + assert.equal(at(end, f.ally, f.location), 2); + assert.equal(change(player(f.game, f.opponent).tanks, player(end, f.opponent).tanks), 2); + assert.equal(at(end, f.opponent, f.location), 0); + pairedArmies(f, end); + assertEcazIndependentSkillsCustody(end); + }); +} + +void test('native paired Ecaz lead pays its own fee, returns its undialed fixed army and rescues only its committed losses', () => { + const f = createEcazIndependentSkillsFixture({ paired: true }); + physicalSource(f.initial, 36); + const quote = quoteEcazIndependentSkillsBattle(f); + assert.deepEqual(quote.reinforcements, + [{ player: f.ecaz, card: REINFORCEMENTS_CARD, normal: 3, elite: 0 }]); + assert.deepEqual(quote.harassWithdraw![0].returned, { normal: 2, elite: 0 }); + assert.deepEqual(quote.harassWithdraw![0].locations, { [f.location]: { normal: 2, elite: 0 } }); + assert.equal(quote.casualties?.owner, f.ally); + const rescue = settleEcazIndependentSkillsBattle(f, { stop: 'rescue' }); + assert.ok(rescue.decision?.kind === 'sukRescue'); + assert.equal(rescue.decision.player, f.ecaz); + assert.equal(rescue.decision.mode, 'skilled'); + 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' } }]); + // Fee is already in Ecaz's Tanks; its undialed pair is already home. Neither + // the fee nor Moritani's two casualties enter this actual rescue offer. + assert.equal(change(player(f.game, f.ecaz).tanks, player(rescue, f.ecaz).tanks), 3); + assert.equal(change(player(f.game, f.ecaz).reserves, player(rescue, f.ecaz).reserves), -1); + assert.equal(at(rescue, f.ecaz, f.location), 2, 'only the committed pair awaits the rescue choice'); + assert.equal(change(player(f.game, f.ally).tanks, player(rescue, f.ally).tanks), 2); + 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.equal(change(player(f.game, f.ecaz).tanks, player(end, f.ecaz).tanks), 3); + assert.equal(change(player(f.game, f.ecaz).reserves, player(end, f.ecaz).reserves), 0); + assert.equal(at(end, f.ecaz, f.location), 1); + assert.equal(change(player(f.game, f.ally).tanks, player(end, f.ally).tanks), 2); + assert.equal(change(player(f.game, f.ally).reserves, player(end, f.ally).reserves), 0); + assert.equal(at(end, f.ally, f.location), 2); + assert.equal(change(player(f.game, f.opponent).tanks, player(end, f.opponent).tanks), 2); + disposedLeadCards(f, end); + pairedArmies(f, end); + physicalSource(end, 36); + assertEcazIndependentSkillsCustody(end); +}); + +void test('native paired Basic Moritani lead pays and withdraws its normal variable army without merging Ecaz fixed losses', () => { + const f = createEcazIndependentSkillsFixture({ paired: true, advanced: false, lead: 'ally' }); + const quote = quoteEcazIndependentSkillsBattle(f); + assert.deepEqual(quote.reinforcements, + [{ player: f.ally, card: REINFORCEMENTS_CARD, normal: 3, elite: 0 }]); + assert.deepEqual(quote.harassWithdraw![0].returned, { normal: 2, elite: 0 }); + assert.deepEqual(quote.harassWithdraw![0].locations, { [f.location]: { normal: 2, elite: 0 } }); + assert.deepEqual(quote.fixedLosses, [{ owner: f.ecaz, normal: 2, elite: 0 }]); + 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(change(player(f.game, f.ally).reserves, player(end, f.ally).reserves), -1); + assert.equal(change(player(f.game, f.ally).tanks, player(end, f.ally).tanks), 5); + assert.equal(at(end, f.ally, f.location), 0); + assert.equal(change(player(f.game, f.ecaz).reserves, player(end, f.ecaz).reserves), 0); + assert.equal(change(player(f.game, f.ecaz).tanks, player(end, f.ecaz).tanks), 2); + assert.equal(at(end, f.ecaz, f.location), 2); + assert.equal(player(end, f.ecaz).leaders.find(leader => leader.id === f.trainer)!.dead, false); + assert.equal(change(player(f.game, f.opponent).tanks, player(end, f.opponent).tanks), 2); + disposedLeadCards(f, end); + pairedArmies(f, end); + physicalSource(end, 36); + assertEcazIndependentSkillsCustody(end); +}); diff --git a/tests/fixture-ecaz-independent-skills.ts b/tests/fixture-ecaz-independent-skills.ts index 80cf7892..d01b53ee 100644 --- a/tests/fixture-ecaz-independent-skills.ts +++ b/tests/fixture-ecaz-independent-skills.ts @@ -18,14 +18,16 @@ import { chooseEcazOccupyLead, finishEcazOccupySetup, stageEcazOccupyCard } from import { createHomeworldEcazOccupyFixture } from './fixture-homeworld-ecaz-occupy'; /** Printed E3 shared rule (docs/ECAZ_TREACHERY_RULES.md): the three-card variant - * is independent of faction selection and other variants, so a standalone Ecaz - * seat with classic opponents keeps it; all14 Leader Skills is the separate + * is independent of faction selection and other variants, so standalone Ecaz + * and the native Ecaz/Moritani pair keep it; all14 Leader Skills is the separate * optional module documented in docs/LEADER_SKILLS_RULES.md. Card timings and the * fee-before-return order stay provisional as recorded there, not new rulings. */ export type EcazIndependentSkillsStep = { actor: string; action: Action }; export type EcazIndependentSkillsCard = 'reinforcements' | 'harass'; export type EcazIndependentSkillsOptions = { advanced?: boolean; + /** Native Ecaz/Moritani seats; the default keeps the standalone Emperor ally. */ + paired?: boolean; /** All14 skills; Basic and Advanced are both genuine native bands. */ skills?: boolean; /** The independent physical three-card variant; `false` is the printed no-card control. */ @@ -255,6 +257,7 @@ export function createEcazIndependentSkillsFixture( const draws = skills ? permutationDraws(LEADER_SKILL_CARDS.map(card => card.id), order) : []; const shared = entropy(() => createHomeworldEcazOccupyFixture({ advanced, skills, independent, homeworlds: false, + ...(options.paired ? { allyFaction: 'moritani' as const } : {}), ...(skills ? { finishSetup: (initial: Game) => trainEcazSeat(initial, picks) } : {}), }), draws); if (skills) { diff --git a/tests/fixture-homeworld-ecaz-occupy.ts b/tests/fixture-homeworld-ecaz-occupy.ts index 54a215d8..3ede2943 100644 --- a/tests/fixture-homeworld-ecaz-occupy.ts +++ b/tests/fixture-homeworld-ecaz-occupy.ts @@ -15,6 +15,8 @@ export function nextHomeworldEcazStep(game: Game): HomeworldEcazStep | null { action: { type: 'passResponse' } }; if (game.decision?.kind === 'ecazPlacement') return { actor: game.decision.player, action: { type: 'decision', decline: true } }; + if (game.decision?.kind === 'moritaniPlacement') + return { actor: game.decision.player, action: { type: 'decision', decline: true } }; if (!game.phaseOpening && !game.decision && game.phase === 0 && game.stormPending === null) { const actor = game.stormDialers.find(id => game.stormDials[id] === undefined); if (actor) return { actor, action: { type: 'stormDial', amount: game.turn === 1 ? 0 : 1 } }; @@ -36,6 +38,7 @@ export function nextHomeworldEcazStep(game: Game): HomeworldEcazStep | null { * phases, alliances, World custody and offered skills follow the real actions. */ export function createHomeworldEcazOccupyFixture(options: { advanced?: boolean; skills?: boolean; independent?: boolean; + allyFaction?: 'emperor' | 'moritani'; /** No-World control uses the same genuine alliance/Arrakis route. */ homeworlds?: boolean; /** Select only actual offered setup choices; the captured initial stays untouched. */ @@ -43,8 +46,9 @@ export function createHomeworldEcazOccupyFixture(options: { } = {}): HomeworldEcazOccupyFixture { const advanced = options.advanced ?? true; const homeworlds = options.homeworlds ?? true; + const ally = options.allyFaction ?? 'emperor'; let game = createGame('WORLDECAZOCCUPY', newPlayer('ecaz', 'Ecaz', 'ecaz'), advanced, ['ecaz']); - joinGame(game, newPlayer('emperor', 'Emperor', 'emperor')); + joinGame(game, newPlayer(ally, ally === 'emperor' ? 'Emperor' : 'Moritani', ally)); joinGame(game, newPlayer('guild', 'Guild', 'guild')); const actions: HomeworldEcazStep[] = []; const act = (actor: string, action: Action) => { @@ -86,11 +90,13 @@ export function createHomeworldEcazOccupyFixture(options: { orderBlow(false, 1); if (advanced) orderBlow(false, 2); reach(g => g.phase === 1 && g.nexus && !g.spiceWindow && !g.spiceResolution && homeworldEcazClean(g)); - act('ecaz', { type: 'alliance', target: 'emperor' }); - act('emperor', { type: 'alliance', target: 'ecaz' }); + act('ecaz', { type: 'alliance', target: ally }); + act(ally, { type: 'alliance', target: 'ecaz' }); reach(g => g.phase === 5 && homeworldEcazClean(g)); const battleTerritory = ['arrakeen', 'sietch_tabr', 'habbanya_ridge_sietch'] - .find(id => territory(id).sectors[0] !== game.storm)!; + .find(id => territory(id).sectors[0] !== game.storm && + !game.players.some(player => Object.entries(player.forces).some(([key, amount]) => + amount > 0 && key.startsWith(`${id}:`))))!; const sector = territory(battleTerritory).sectors[0]; const location = `${battleTerritory}:${sector}`; const shipped = new Set(); @@ -100,7 +106,7 @@ export function createHomeworldEcazOccupyFixture(options: { if (!shipped.has(actor)) { act(actor, { type: 'ship', territory: battleTerritory, sector, amount: actor === 'guild' ? 2 : 4, - elite: actor === 'emperor' && (advanced || homeworlds) ? 2 : 0, allyPayment: 0 }); + elite: actor === ally && ally === 'emperor' && (advanced || homeworlds) ? 2 : 0, allyPayment: 0 }); shipped.add(actor); reach(homeworldEcazClean); } @@ -112,6 +118,6 @@ export function createHomeworldEcazOccupyFixture(options: { act(choice.chooser, { type: 'chooseBattle', territory: battleTerritory, target: choice.attacker === choice.chooser ? choice.defender : choice.attacker }); if (!options.skills) assert.equal(game.decision?.kind, 'ecazBattleLead'); - return { game, initial, afterSetup, actions, ecaz: 'ecaz', ally: 'emperor', opponent: 'guild', + return { game, initial, afterSetup, actions, ecaz: 'ecaz', ally, opponent: 'guild', territory: battleTerritory, location, ecazForces: 4 }; } diff --git a/tests/fixture-moritani-independent-skills.ts b/tests/fixture-moritani-independent-skills.ts index 0d175489..e2c03fd7 100644 --- a/tests/fixture-moritani-independent-skills.ts +++ b/tests/fixture-moritani-independent-skills.ts @@ -7,6 +7,7 @@ import { treacheryDeck, type Card } from '../game/cards'; 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 { validateLeaderSkills } from '../game/leader-skills'; import { traitorDeck } from '../game/traitors'; @@ -31,6 +32,9 @@ export type MoritaniIndependentSkillsOptions = { /** The independent three-card Ecaz Treachery variant of printed p. 18. */ cards?: boolean; opponent?: MoritaniIndependentSkillsOpponent; + /** Native Ecaz replaces the classic opponent; both factions keep printed setup. + * `opponent` remains the standalone-only Guild/Harkonnen selector. */ + paired?: boolean; /** Conserved Traitor identity Moritani really holds: the unused target disc * (default) or the winning committed disc, for a real normal Traitor call. */ traitor?: 'target' | 'winner'; @@ -174,7 +178,7 @@ export function nextMoritaniIndependentSkillsStep( if (decision?.kind === 'capturedLeader') return { actor: decision.player, action: { type: 'decision', mode: policy.capture?.captured ?? 'keep' } }; - if (decision?.kind === 'moritaniPlacement') + if (decision?.kind === 'moritaniPlacement' || decision?.kind === 'ecazPlacement') return { actor: decision.player, action: { type: 'decision', decline: true } }; if (decision?.kind === 'moritaniSpecialKarama') return { actor: decision.player, action: { type: 'decision', event: decision.event, decline: true } }; @@ -222,7 +226,8 @@ export function assertMoritaniIndependentSkillsCustody(game: Game): void { .map((card) => card.id).sort(), 'Every physical Treachery Card must stay in one known zone.'); assert.deepEqual([...(game.traitorReserve ?? []), ...game.players.flatMap((player) => player.traitors), ...removed].sort(), - traitorDeck(game.players).sort(), 'Every physical Traitor Card must stay in one known zone.'); + withoutEcazLoyalty(traitorDeck(game.players), game.ecazLoyalty).sort(), + 'Every circulating or assassinated Traitor stays physical; Loyalty remains outside the deck.'); } /** Conserved physical card custody; no purchase, adoption or minted identity. */ function conserveCard(game: Game, actor: string, match: (card: Card) => boolean, staging: string[]): Card { @@ -244,25 +249,27 @@ function conserveCard(game: Game, actor: string, match: (card: Card) => boolean, staging.push(`Conserved original ${card.id}: ${source.label} → ${actor} hand; not a purchase or native deal`); return card; } -function board(opponent: MoritaniIndependentSkillsOpponent) { +function board(opponent: MoritaniIndependentSkillsOpponent | 'ecaz') { + if (opponent === 'ecaz') + return { battle: 'false_wall_east', from: 'imperial_basin:9', amount: 6, sectors: [8, 9] }; return opponent === 'guild' ? { battle: 'pasty_mesa', from: 'tueks_sietch:5', amount: 5, sectors: [5, 6] } : { battle: 'imperial_basin', from: 'carthag:11', amount: 10, sectors: [11, 10] }; } -/** Genuine normal lobby start (no audit profile seam) for a standalone - * Moritani/classic independent three-card app with optional all-fourteen skills. */ +/** Genuine normal lobby start (no audit profile seam) for a standalone or native + * paired E3/classic composition with optional all-fourteen skills and cards. */ 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.opponent ?? 'guild'; - assert.ok(!skills || opponentFaction === 'guild', + const opponentFaction = options.paired ? 'ecaz' : (options.opponent ?? 'guild'); + assert.ok(!skills || opponentFaction !== 'harkonnen', 'Leader Skills keep the Harkonnen native roster out of this composition.'); const plan = board(opponentFaction); - const target = opponentFaction === 'guild' ? 'guild-1' : 'harkonnen-1'; - const winner = opponentFaction === 'guild' ? 'guild-0' : 'harkonnen-0'; + const target = `${opponentFaction}-1`; + const winner = `${opponentFaction}-0`; const loser = 'moritani-4'; const heldIdentity = (options.traitor ?? 'target') === 'winner' ? winner : target; const chosen: Record = { @@ -314,6 +321,11 @@ export function createMoritaniIndependentSkillsFixture( act(actor, { type: 'leaderSkill', event: offer.event, skill: chosen[actor], leader: leader.id }); continue; } + if (game.setupStage === 'forces' && opponentFaction === 'ecaz' && + viewGame(game, 'ecaz').setupPending.includes('ecaz')) { + act('ecaz', { 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' @@ -339,6 +351,15 @@ export function createMoritaniIndependentSkillsFixture( assert.equal(!!game.moritaniAssassinate, advanced, 'The printed Advanced assassination producer follows the real rules mode alone.'); if (skills) assert.equal(game.leaderSkills!.assignments.find((entry) => entry.owner === opponent)!.leader, target); + if (opponentFaction === 'ecaz') { + const ecaz = moritaniIndependentSkillsPlayer(game, opponent); + assert.equal(ecaz.reserves, 14); + assert.deepEqual(ecaz.forces, { [plan.from]: 6 }); + assert.notEqual(game.ecazLoyalty?.card, target, + 'The trained unused native target must have a circulating physical Traitor.'); + assert.notEqual(game.ecazLoyalty?.card, heldIdentity, + 'Never exchange the face-up Loyalty card into a hand.'); + } holdMoritaniStrongholdsTraitor(game, moritani, heldIdentity, staging); const afterSetup = structuredClone(game); game = advanceMoritaniIndependentSkills(game, @@ -387,7 +408,9 @@ export function createMoritaniIndependentSkillsFixture( const plans: MoritaniIndependentSkillsStep[] = [ { actor: moritani, action: { type: 'battlePlan', dial: 2, support: 0, leader: loser, ...(reinforcements && harass ? { weapon: REINFORCEMENTS_CARD, defense: HARASS_WITHDRAW_CARD } : {}) } }, - { actor: opponent, action: { type: 'battlePlan', dial: cards ? 1 : 0, support: 0, + { actor: opponent, action: { type: 'battlePlan', + dial: options.paired ? (cards ? 4 : 2) : (cards ? 1 : 0), + support: options.paired && advanced ? (cards ? 4 : 2) : 0, leader: winner, weapon: winnerWeapon.id } }, ]; const window = structuredClone(game); diff --git a/tests/moritani-independent-skills-runtime.test.ts b/tests/moritani-independent-skills-runtime.test.ts index d14f9ef9..11f28ffe 100644 --- a/tests/moritani-independent-skills-runtime.test.ts +++ b/tests/moritani-independent-skills-runtime.test.ts @@ -341,3 +341,116 @@ void test('a real normal Traitor call forfeits the printed power for the game an assertMoritaniIndependentSkillsCustody(second); }); + +void test('Advanced paired Skills/cards loss returns selected sectors, then assassinates one unused native Ecaz disc before Mentat replacement', () => { + const f = createMoritaniIndependentSkillsFixture({ paired: true, rules: 'advanced', skills: true, cards: true }); + const loyal = f.afterSetup.ecazLoyalty!.card!; + assert.notEqual(f.traitorTarget, loyal); + assert.notEqual(f.traitorTarget, f.winnerLeader); + assert.notEqual(f.traitorTarget, 'duke-vidal'); + assert.equal(owned(f, f.pending, f.traitorTarget).usedAt, undefined); + assert.equal(f.afterEntry.players.find((seat) => seat.id === f.opponent)!.forces[f.second], 6); + assert.deepEqual(player(f.pending, f.moritani).traitors, [f.traitorTarget]); + assertMoritaniIndependentSkillsCustody(f.afterSetup); + + // Pick the other real sector, not the fixture's default allocation. The fee + // consumes three of Moritani's own reserves before two undialed forces return. + const returns = { [f.second]: { normal: 2, elite: 0 } }; + const offered = advanceMoritaniIndependentSkills(f.revealed, + (game) => game.decision?.kind === 'harassWithdraw'); + const decision = offered.decision; + if (decision?.kind !== 'harassWithdraw') throw new Error('Expected the real sector-return choice.'); + const returned = stepMoritaniIndependentSkills(offered, { actor: decision.player, + action: { type: 'decision', event: decision.event, returns } }); + const pending = resolveMoritaniIndependentSkillsBattle(f, returned); + const quote = quoteMoritaniIndependentSkillsBattle({ ...f, returns }); + assert.equal(quote.result, 'normal'); + assert.equal(quote.winner, f.opponent); + assert.deepEqual(quote.scores, { attacker: 5, defender: 8 }); + assert.deepEqual(quote.harassWithdraw![0].locations, returns); + assert.equal(quote.payments.find((payment) => payment.player === f.opponent)!.ownPayment, 4); + assert.equal(player(pending, f.opponent).spice, player(f.afterSetup, f.opponent).spice - 4); + assert.deepEqual(player(pending, f.opponent).forces, { [f.second]: 2 }); + assert.equal(player(pending, f.moritani).reserves, player(f.afterSetup, f.moritani).reserves - 3 + 2); + assert.equal(player(pending, f.moritani).tanks, 7); + assert.deepEqual(player(pending, f.moritani).forces, {}); + assert.equal(pending.lastBattleContext!.winner, f.opponent); + assert.deepEqual(choiceOf(pending, f.moritani).cards.map((card) => card.card), [f.traitorTarget]); + + const skillBefore = pending.leaderSkills!; + const targetSkill = skillBefore.assignments.find((entry) => entry.leader === f.traitorTarget)!; + const spent = chooseMoritaniIndependentSkillsAssassination(pending, f.traitorTarget); + const target = owned(f, spent, f.traitorTarget); + const receipt = spent.moritaniAssassinate!.opportunities[0]; + 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.equal(receipt.opposingLeader, f.winnerLeader); + assert.equal(receipt.bounty, target.strength); + assert.equal(receipt.bounty, 4); + assert.equal(player(spent, f.moritani).spice, player(pending, f.moritani).spice + target.strength); + assert.deepEqual(spent.leaderSkills!.assignments, + skillBefore.assignments.filter((entry) => entry !== targetSkill)); + assert.deepEqual(spent.leaderSkills!.deck, [...skillBefore.deck, targetSkill.skill]); + assert.equal(spent.decision?.kind, 'battleCards'); + assert.equal(spent.decision?.player, f.opponent); + assert.equal(spent.discard.some((card) => card.id === f.cards.winnerWeapon), false); + assertMoritaniIndependentSkillsCustody(spent); + + const beforeMentat = finishMoritaniIndependentSkillsBattle(spent, { winnerDiscard: 'played' }, + (game) => game.phase === 7 && moritaniIndependentSkillsClean(game)); + assert.equal(beforeMentat.discard.filter((card) => card.id === f.cards.winnerWeapon).length, 1); + assert.equal(beforeMentat.moritaniAssassinate!.opportunities[0].stage, 'revealed'); + assert.deepEqual(player(beforeMentat, f.moritani).traitors, [f.traitorTarget]); + const settled = finishMoritaniIndependentSkillsBattle(beforeMentat); + const replacement = settled.moritaniAssassinate!.opportunities[0]; + assert.equal(settled.moritaniAssassinate!.opportunities.length, 1); + assert.equal(replacement.stage, 'replaced'); + assert.ok(replacement.replacement && replacement.replacement !== f.traitorTarget); + assert.notEqual(replacement.replacement, loyal); + assert.deepEqual(player(settled, f.moritani).traitors, [replacement.replacement]); + assert.equal(settled.traitorReserve!.length, pending.traitorReserve!.length - 1); + assert.equal(settled.players.flatMap((seat) => seat.traitors).includes(f.traitorTarget), false); + assert.equal(settled.leaderSkills!.deck.filter((skill) => skill === targetSkill.skill).length, 1); + assert.deepEqual(settled.ecazLoyalty, f.afterSetup.ecazLoyalty); + assertMoritaniIndependentSkillsCustody(settled); +}); + +void test('Basic paired Skills/cards loss leaves the unused trained Ecaz disc and held Traitor intact through Mentat', () => { + const f = createMoritaniIndependentSkillsFixture({ paired: true, rules: 'basic', skills: true, cards: true }); + assert.equal(f.pending.lastBattleContext!.winner, f.opponent); + assert.equal(f.pending.lastBattleContext!.result, 'normal'); + assert.equal(f.pending.phase, 7); + assert.ok(moritaniIndependentSkillsClean(f.pending)); + assert.equal(f.pending.moritaniAssassinate, undefined); + assert.deepEqual(f.returns, { [f.first]: { normal: 4, elite: 0 } }); + assert.equal(player(f.pending, f.moritani).reserves, player(f.afterSetup, f.moritani).reserves - 3 + 4); + assert.equal(player(f.pending, f.moritani).tanks, 5); + 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.equal(settled.discard.filter((card) => card.id === f.cards.winnerWeapon).length, 1); + assertMoritaniIndependentSkillsCustody(settled); +}); + +void test('paired Advanced Skills without independent cards still pays the printed Ecaz assassination bounty', () => { + const f = createMoritaniIndependentSkillsFixture({ paired: true, rules: 'advanced', skills: true }); + assert.equal(f.pending.lastBattleContext!.winner, f.opponent); + assert.equal(f.returns, null); + assert.equal(f.cards.reinforcements, null); + assert.equal(f.cards.harass, null); + assert.equal(player(f.pending, f.moritani).reserves, player(f.afterSetup, f.moritani).reserves); + assert.equal(player(f.pending, f.moritani).tanks, 6); + const physical = [...f.pending.deck, ...f.pending.discard, ...f.pending.players.flatMap((seat) => seat.hand)]; + assert.equal(physical.length, 33); + assert.equal(physical.some((card) => card.id === REINFORCEMENTS_CARD || card.id === HARASS_WITHDRAW_CARD), false); + const spent = chooseMoritaniIndependentSkillsAssassination(f.pending, f.traitorTarget); + assert.equal(owned(f, spent, f.traitorTarget).dead, true); + assert.equal(spent.moritaniAssassinate!.opportunities[0].bounty, 4); + assert.equal(player(spent, f.moritani).spice, player(f.pending, f.moritani).spice + 4); + assert.equal(spent.leaderSkills!.assignments.some((entry) => entry.leader === f.traitorTarget), false); + assertMoritaniIndependentSkillsCustody(spent); +}); diff --git a/tests/moritani-skills-integration.test.ts b/tests/moritani-skills-integration.test.ts index 43f70790..3072bceb 100644 --- a/tests/moritani-skills-integration.test.ts +++ b/tests/moritani-skills-integration.test.ts @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { applyAction, createGame, initializeLeaderSkillsGameForAudit, joinGame, newPlayer, normalizeAutomaticGame, viewGame, type Game } from '../game/engine'; +import { applyAction, normalizeAutomaticGame, viewGame, type Game } from '../game/engine'; import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards'; import { planetologistMoveDraft } from '../components/planetologist-movement'; import { TERRITORIES, distance, location, splitLocation } from '../game/board'; @@ -33,29 +33,6 @@ void test('every canonical skill can be dealt and publicly assigned to Basic Mor } }); -void test('Moritani skill starts retain immutable Ecaz and optional-module rejection', () => { - // Native Discovery Leader Skills are now admitted for this fresh lobby. - const discovery = createGame('MORIGATE', newPlayer('m', 'Moritani', 'moritani'), false, ['ecaz']); - joinGame(discovery, newPlayer('a', 'Atreides', 'atreides')); - discovery.players.forEach(p => { p.ready = true; }); - discovery.discoveryEnabled = true; - assert.ok(initializeLeaderSkillsGameForAudit(discovery).leaderSkills); - for (const change of [ - (g: Game) => { g.players[1] = newPlayer('a', 'Ecaz', 'ecaz'); }, - (g: Game) => { g.expansions.push('choam'); }, - (g: Game) => { g.discoveryEnabled = true; g.expansions.push('choam'); }, - (g: Game) => { g.discoveryEnabled = true; g.players[1] = newPlayer('a', 'Ecaz', 'ecaz'); }, - ]) { - const lobby = createGame('MORIGATE', newPlayer('m', 'Moritani', 'moritani'), false, ['ecaz']); - joinGame(lobby, newPlayer('a', 'Atreides', 'atreides')); - lobby.players.forEach(p => { p.ready = true; }); - change(lobby); - const before = JSON.stringify(lobby); - assert.throws(() => initializeLeaderSkillsGameForAudit(lobby)); - assert.equal(JSON.stringify(lobby), before); - } -}); - void test('all four profiles legally choose from the same private Moritani setup offer', () => { const game = initializedMoritaniSkillsOffers({ requestedSkill: 'planetologist' }); for (const difficulty of DIFFICULTIES) { diff --git a/tests/paired-e3-skills-arrival.test.ts b/tests/paired-e3-skills-arrival.test.ts new file mode 100644 index 00000000..702a2551 --- /dev/null +++ b/tests/paired-e3-skills-arrival.test.ts @@ -0,0 +1,101 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { applyAction, viewGame, type Action, type Game } from '../game/engine'; +import { createEcazIndependentSkillsFixture } from './fixture-ecaz-independent-skills'; +import { homeworldEcazClean, nextHomeworldEcazStep } from './fixture-homeworld-ecaz-occupy'; + +/** Branch from the genuine original setup snapshot, not its later battle. + * Only unopened land-card order is controlled. Native token declarations, + * both response windows, phase readiness and the paid shipment are actual actions. */ +function pairedArrival(advanced: boolean, skills: boolean, independent: boolean) { + const fixture = createEcazIndependentSkillsFixture({ paired: true, advanced, skills, + independent, cards: independent ? ['reinforcements', 'harass'] : [] }); + let game = structuredClone(fixture.afterSetup); + const act = (actor: string, action: Action) => { game = applyAction(game, actor, action); }; + const step = () => { + if (homeworldEcazClean(game) && game.phase === 5) + act(game.active!, { type: 'endMovement' }); + else { + const next = nextHomeworldEcazStep(game); + assert.ok(next, `Native paired continuation at ${game.turn}/${game.phase}/${game.decision?.kind}`); + act(next.actor, next.action); + } + }; + const reach = (until: (state: Game) => boolean) => { + for (let n = 0; n < 450 && !until(game); n++) step(); + assert.ok(until(game)); + }; + for (let index = 0; index < (advanced ? 4 : 2); index++) { + const at = game.spiceDeck.findIndex((card, position) => position >= index && + 'territory' in card && !card.discovery); + assert.ok(at >= index); + game.spiceDeck.splice(index, 0, game.spiceDeck.splice(at, 1)[0]); + } + reach(state => state.turn === 1 && !state.response && state.decision?.kind === 'ecazPlacement'); + const ambassador = game.ecazAmbassadors!.tokens.find(token => token.effect === 'ecaz' && token.zone === 'supply')!.id; + act(fixture.ecaz, { type: 'decision', token: ambassador, territory: 'arrakeen' }); + reach(state => !state.response && state.ecazAmbassadors!.tokens.find(token => token.id === ambassador)!.zone === 'placed'); + reach(state => state.turn === 1 && !state.response && state.decision?.kind === 'moritaniPlacement'); + const terror = game.moritaniTerror!.tokens.find(token => token.kind === 'robbery' && token.status === 'available')!.id; + act(fixture.ally, { type: 'decision', token: terror, territory: 'arrakeen' }); + reach(state => state.turn === 2 && state.phase === 5 && state.active === fixture.opponent && homeworldEcazClean(state)); + const before = structuredClone(game); + const shipment: Action = { type: 'ship', territory: 'arrakeen', sector: 10, amount: 1, elite: 0, allyPayment: 0 }; + act(fixture.opponent, shipment); + reach(state => !!state.pendingArrivalOverlap); + return { fixture, game, before, shipment, ambassador, terror }; +} + +for (const { advanced, skills, independent } of [ + { advanced: false, skills: true, independent: false }, + { advanced: true, skills: true, independent: true }, + { advanced: false, skills: false, independent: true }, +]) + void test(`${advanced ? 'Advanced' : 'Basic'} paired ${skills ? 'Skills' : 'native factions'} ${independent ? 'with independent cards' : 'without extra cards'} settles both earned reactions without re-paying shipment`, () => { + const f = pairedArrival(advanced, skills, independent), { ecaz, ally: moritani, opponent: guild } = f.fixture; + let game = f.game; + const player = (state: Game, id: string) => state.players.find(seat => seat.id === id)!; + const before = player(f.before, guild), moritaniBefore = player(f.before, moritani).spice; + const first = game.pendingArrivalOverlap!.first; + const seen: string[] = []; + for (let n = 0; n < 40 && (game.pendingArrivalOverlap || game.pendingAmbassador || + game.pendingTerrorEntry || !homeworldEcazClean(game)); n++) { + let actor: string, action: Action; + if (game.response) { + const next = nextHomeworldEcazStep(game)!; + actor = next.actor; action = next.action; + } else if (game.decision?.kind === 'ecazAmbassador') { + seen.push('ambassador'); actor = ecaz; + action = { type: 'decision', event: game.pendingAmbassador!.event, + trigger: true, choice: 'duke', beneficiary: ecaz }; + } else if (game.decision?.kind === 'moritaniTerror') { + const entry = viewGame(game, moritani).terrorEntry!; + assert.ok('stage' in entry); + seen.push(`terror:${entry.stage}`); actor = moritani; + action = entry.stage === 'offer' ? { type: 'decision', reveal: true } + : { type: 'decision', choice: 'spice' }; + } else throw Error(`Unexpected arrival continuation ${game.decision?.kind}`); + game = applyAction(game, actor, action); + } + assert.deepEqual(seen, first === 'ambassador' + ? ['ambassador', 'terror:offer', 'terror:robbery'] + : ['terror:offer', 'terror:robbery', 'ambassador']); + const robbery = Math.ceil((before.spice - 1) / 2); + assert.equal(player(game, guild).spice, before.spice - 1 - robbery); + assert.equal(player(game, moritani).spice, moritaniBefore + robbery); + assert.equal(player(game, guild).reserves, before.reserves - 1); + assert.equal(player(game, guild).forces['arrakeen:10'], 1); + assert.equal(game.dukeVidal!.controller, ecaz); + assert.equal(game.dukeVidal!.leader.strength, 6); + assert.ok(!game.leaderSkills?.assignments.some(assignment => assignment.leader === 'duke-vidal')); + assert.equal(game.moritaniTerror!.tokens.find(token => token.id === f.terror)!.status, 'removed'); + assert.equal(game.ecazAmbassadors!.tokens.find(token => token.id === f.ambassador)!.zone, 'supply'); + assert.equal(viewGame(game, guild).arrivalOverlap, null); + assert.equal(game.active, guild); + const settled = structuredClone(game); + assert.throws(() => applyAction(game, guild, f.shipment)); + assert.deepEqual(game, settled); + game = applyAction(game, guild, { type: 'endMovement' }); + assert.equal(player(game, guild).reserves, before.reserves - 1); + assert.equal(player(game, guild).forces['arrakeen:10'], 1); + });