From 1cd7db7d0b1f0dbcf500ef7026cd04f12f4d5ef3 Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Thu, 15 Oct 2026 17:22:34 +1100 Subject: [PATCH] Connect canonical Moritani winning discs and queued Guild arrivals --- docs/BOT_ARRIVAL_PREFLIGHT.md | 21 ++ docs/CHOAM_AUDITOR.md | 30 ++ docs/CURRENT_STATUS.md | 26 ++ docs/DEFERRED_SHIPMENT_ARRIVAL.md | 32 ++ docs/DUKE_VIDAL_RULES.md | 12 + docs/ECAZ_AMBASSADORS_RULES.md | 16 + docs/GUILD_SHIPMENT_PAYMENTS.md | 17 + docs/IMPLEMENTATION_STATUS.md | 67 ++++ docs/MORITANI_ASSASSINATE_LEADERS.md | 59 +++- docs/NEXUS_CARD_RULES.md | 19 + docs/RULE_DECISIONS.md | 21 ++ game/bot-arrival.ts | 10 +- game/engine.ts | 11 +- game/moritani-assassinate.ts | 26 +- game/reference.ts | 60 ++-- tests/bot-arrival.test.ts | 75 +--- tests/ecaz-entry.test.ts | 17 - tests/fixture-mixed-e3-moritani-nexus.ts | 12 +- tests/fixture-moritani-auditor-winner.ts | 109 ++++++ tests/fixture-moritani-ecaz-duke-winner.ts | 149 ++++++++ .../fixture-moritani-foreign-ghola-winner.ts | 333 ++++++++++++++++++ tests/moritani-assassinate-quote.test.ts | 1 - tests/moritani-auditor-winner.test.ts | 117 ++++++ tests/moritani-ecaz-duke-winner.test.ts | 135 +++++++ tests/moritani-foreign-ghola-winner.test.ts | 87 +++++ tests/moritani-harkonnen-assassinate.test.ts | 4 +- 26 files changed, 1318 insertions(+), 148 deletions(-) create mode 100644 tests/fixture-moritani-auditor-winner.ts create mode 100644 tests/fixture-moritani-ecaz-duke-winner.ts create mode 100644 tests/fixture-moritani-foreign-ghola-winner.ts create mode 100644 tests/moritani-auditor-winner.test.ts create mode 100644 tests/moritani-ecaz-duke-winner.test.ts create mode 100644 tests/moritani-foreign-ghola-winner.test.ts diff --git a/docs/BOT_ARRIVAL_PREFLIGHT.md b/docs/BOT_ARRIVAL_PREFLIGHT.md index 3792822e..2bbc418d 100644 --- a/docs/BOT_ARRIVAL_PREFLIGHT.md +++ b/docs/BOT_ARRIVAL_PREFLIGHT.md @@ -4,6 +4,27 @@ arrival guard. It does not choose an order for simultaneous reactions or lift an unfinished expansion gate. +## Ordinary Guild-paid Ambassador candidate — 15 October 2026 + +Ordinary reserve shipment no longer treats **future Guild income** as an +already-pending arrival response. The authoritative commit already queues +entry behind its actual income/counter continuation. The bot adapter removes +only that stale ordinary-shipment prediction; own funding bounds/tariff, +No-Field price independence, current busy controls, prospective BG +accompaniment/Intrusion and Fremen storm checks remain. +Direct `guildShip` transport retains its separate predictive boundary. + +The original native Ecaz/Moritani/Guild paid Ambassador shipment now has a +clear owner-view quote and real committed permission/payment/entry path. +All four profiles also legally consume its later actual native Ecaz +assassination choice. Actual390px controls prove shipment1, Guild income1, +Ecaz Duke acquisition and the original native target/replacement. +The affected15-file union134/134 and types/lint pass. Obsolete future-Guild +rejection and wording/bare-not-throw test pins were removed, not re-pinned. +This is minimal legal participation, not strategic tuning or full arrival +combination acceptance. +[Current authoritative contract](DEFERRED_SHIPMENT_ARRIVAL.md#ordinary-guild-income-before-ambassador-entry--15-october-2026). + ## Reproduction The genuine combined-expansion samples with base seed `20260914` completed on diff --git a/docs/CHOAM_AUDITOR.md b/docs/CHOAM_AUDITOR.md index 5ac33219..fc11b027 100644 --- a/docs/CHOAM_AUDITOR.md +++ b/docs/CHOAM_AUDITOR.md @@ -33,6 +33,36 @@ store or deployed game was targeted. Full CHOAM/Semuta acceptance remains open. Types/lint and 152 affected unit cases across twelve files pass. +## Native Moritani battle identity and Auditor target — 15 October 2026 + +The actual optional Auditor is both a physical strength-two CHOAM disc and +an identity with a matching Traitor Card; it is not in `leaders('choam')`'s +ordinary five. Original no-Skills E2/E3 Nexus Advanced play now recognizes +its surviving win as an actual opposing disc for Moritani's distinct +Assassinate Leaders ability. Eligible targets remain printed CHOAM identities, +not the strength or native identity of the disc just played. + +Conversely, when a different CHOAM disc survives, Moritani may reveal its real +held **Auditor Traitor**, kill the Auditor for printed two and use the original +face-up retirement/distinct private replacement at Mentat. If the Auditor +itself was played, its matching held card is excluded; the uniform private +opportunity still exists and can be declined without a death, bounty or use. +The printed capture/foreign-Ghola and skill-assignment prohibitions remain. +This follows the component/matching-Traitor authority below and the +[separate Advanced ability contract](MORITANI_ASSASSINATE_LEADERS.md), not +Terror's provisional random-pool composition. + +Fifteen original engine/API scenarios pass: Auditor winner, different-disc +Auditor target, played-Auditor exclusion, and all four legal owner policies +in each actual position. These use original setup/held card, paid native +shipment, real sealed battle and original Mentat; no wallet/hand/force/leader +staging. The affected15-file union134/134 and types/lint pass. Actual styled390px +GameTable reveals Frankos4 after Auditor2 wins, reveals Auditor for two after +a different CHOAM disc wins, and gives the played-Auditor hand only its original +private Continue. Original Mentat displays distinct private Hasimir Fenring +replacement. No full CHOAM/optional-module, saved-room or deployed acceptance +is claimed; the broader native-disc integration is recorded in the linked guide. + ## Advanced revival follow-up — 8 October 2026 The authorized supplied PDF, physical **p29 CHOAM E**, says the Auditor is diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 652b9461..0f125b4a 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -29,6 +29,32 @@ optional skill bands, combinations and public expansion starts remain open. Rules implementation continues; comprehensive assurance and administration remain deferred until all rules are implemented. +**Canonical Moritani winning discs and ordinary Guild arrival — 15 October.** +The surviving played disc no longer has to be an ordinary native of its +victorious player. Original Ecaz/Duke6 opens Sanya Ecaz4; original +projectile deaths and paid foreign return let Tleilaxu win with Guild's +Staban Tuek5, then expose native Zoal3. Actual optional CHOAM Auditor2 opens +Frankos4, and its real matching Traitor is itself a different native target +for two. The played Auditor's card stays excluded, with original private +Continue and no spend. Printed faction, death/use/custody, original +Mentat retirement/replacement, once-use and game-long forfeiture remain. + +The native producer found a stale future-Guild-income preflight. Ordinary +shipment now uses existing income-before-queued-Ambassador sequencing; +the bot adapter matches. Actual390px controls ship/permit one +(Moritani12→11, reserves14→13, Guild6→7), acquire Duke and reveal the +native target. Actual Sanya4, Zoal3 and both Auditor reveal/Continue paths +reach original physical replacement or no-spend decline. +Affected15-file134/134, types/lint, production build and25 original API/policy +paths pass. Existing module.register, large-chunk and Unknown-route warnings remain. +Final Guild/Ghola surfaces have no overflow/browser errors; an earlier +temporary QA-client metadata-error handler was repaired before final proof. +No hands, wallets, armies, leaders, phases or battles are staged. +[Canonical source, controls and boundaries](MORITANI_ASSASSINATE_LEADERS.md#canonical-winning-discs-and-native-targets--15-october-2026). +Exceptional targets, direct Guild transport, BG/other arrival combinations, +private-step UX, full assurance, calibration and deployed acceptance remain open. +No saved/deployed rooms touched; public starts remain gated. + **Native Moritani/Harkonnen custody — 15 October.** Original standalone, paired and mixed-family no-Skills E3 Nexus now admit Advanced Harkonnen in their existing module envelopes. Original capture/Keep/Execute feeds a later diff --git a/docs/DEFERRED_SHIPMENT_ARRIVAL.md b/docs/DEFERRED_SHIPMENT_ARRIVAL.md index acfab67d..266db6f6 100644 --- a/docs/DEFERRED_SHIPMENT_ARRIVAL.md +++ b/docs/DEFERRED_SHIPMENT_ARRIVAL.md @@ -4,6 +4,38 @@ exposed an already accepted shipment whose Guild allowance could not finish. This continuation repair preserves the existing unfinished arrival-order gates. +## Ordinary Guild income before Ambassador entry — 15 October 2026 + +The original native Ecaz/Moritani/Guild producer exposed a stale preflight: +`quoteShipmentArrival` treated its **future** earned Guild receipt as an +already-busy arrival response. Yet `commitShipment` already records that +income/counter window, and `openTerritoryEntry` queues the committed entry +until the original response/decision finishes. Rejecting the paid shipment +before Guild permission therefore fenced an existing executable continuation. + +Ordinary reserve shipment no longer predicts Guild income as a busy control. +The original physical/price/funding validation, Guild permission, payment, +income/cancellation and deferred-entry queue remain unchanged. The shared +ordinary bot adapter follows that same distinction, without reading hidden +funding or rival hands. Actual in-flight controls, BG accompaniment/Intrusion, +Fremen storm protection and unsupported paired-reaction gates are not removed. +Direct Guild cross-planet transport retains its separate adapter boundary. +This reuses the documented income-before-entry order, not a new comprehensive +publisher priority table or an exception to paying for entry. + +Actual styled390px GameTable chooses the placed Ecaz token's stronghold, +ships one, receives original Guild permission, debits Moritani12→11/ +reserves14→13 and credits Guild6→7 **before** the Ecaz Ambassador prompt. +The real acquisition button grants Duke6; no payment or reserve transfer repeats. +Original battle then opens Moritani's Sanya4 reveal and physical Mentat +replacement. Affected15-file union134/134, types/lint and original API proof +pass; final surface has no overflow/browser errors. No saved/deployed rooms +were touched, and no new cancellation/recovery or full-combination claim is made. +[Native disc/target integration](MORITANI_ASSASSINATE_LEADERS.md#canonical-winning-discs-and-native-targets--15-october-2026). + +This supersedes only the historical prospective-Guild-income rejection below. +The historical BG/other-arrival return and safeguards remain distinct. + ## Reproduced failure The fixed seed `202609146` stopped on turn one after 90 accepted actions. Guild diff --git a/docs/DUKE_VIDAL_RULES.md b/docs/DUKE_VIDAL_RULES.md index 4a56a4b7..a534b1d5 100644 --- a/docs/DUKE_VIDAL_RULES.md +++ b/docs/DUKE_VIDAL_RULES.md @@ -153,3 +153,15 @@ physical card at Mentat. Random Terror can kill the captive Duke for six. See [Assassinate identity](MORITANI_ASSASSINATE_LEADERS.md#native-harkonnen-battle-identity--15-october-2026) and [Terror policy/evidence](MORITANI_ASSASSINATION_SNEAK.md#assassination-pool-adopted-provisional-composition-15-october-2026). This is local changed-path proof, not saved-room, deployed or complete-mode acceptance. + +## Native Moritani loss to Ecaz's shared disc — 15 October 2026 + +An actual original Ecaz Ambassador acquisition and surviving Duke6 win now open +Moritani's distinct Advanced native-Ecaz Traitor choice. Actual Sanya Ecaz4 +pays four, not Duke's six; Duke stays alive, used and set aside, with no new +Traitor or native-roster copy. Original Guild-paid arrival now settles income +before the queued Ambassador rather than being rejected by a prospective +busy-control prediction. Native counts, exclusive Ecaz revival, original +tenure and all remaining custody/source gates stay separate. +[Canonical played-disc/target distinction, original controls and evidence](MORITANI_ASSASSINATE_LEADERS.md#canonical-winning-discs-and-native-targets--15-october-2026). + diff --git a/docs/ECAZ_AMBASSADORS_RULES.md b/docs/ECAZ_AMBASSADORS_RULES.md index d47bb343..31bdc324 100644 --- a/docs/ECAZ_AMBASSADORS_RULES.md +++ b/docs/ECAZ_AMBASSADORS_RULES.md @@ -9,6 +9,22 @@ Audit: 2026-09-06. Scope: ambassador inventory, setup, placement, triggering, ef - **Designer clarification:** Jack Reda (@The Warp), 26 August 2023, on his [Ecaz and Moritani rulebook upload](https://boardgamegeek.com/filepage/263742/ecaz-and-moritani-rules), explicitly confirms Ambassador placement face up. This is the designer speaking directly, not a community interpretation. The publisher-indexed file-page comment was verified in the follow-up audit below. - No community amendment, fan implementation, or tournament rule was used. No separately dated official ambassador errata resolving the remaining open questions was found in this bounded pass. Use applicable official corrections/FAQ over general text; record conflicts rather than silently resolving them. +## Ordinary Guild-paid entry follow-up — 15 October 2026 + +Original reserve shipment now settles its actual Guild income/counter window +before the already queued Ambassador entry, instead of rejecting because of +a predicted future receipt. Price/funding, physical transfer, token handling +and original caller resumption do not change. Actual current controls and +unresolved BG/other-arrival combinations remain guarded; this is not a new +comprehensive printed priority rule. + +The actual390px native table ships/permits one for Moritani12→11 and +Guild6→7, opens the Ecaz Ambassador, acquires Duke6, then resolves a real +Ecaz Duke win and native Sanya4 assassination/physical Mentat replacement. +Affected15-file134/134, types/lint and original API proof pass. All public, +complete-mode, saved-game and deployed acceptance gates remain. +[Current authoritative queue contract](DEFERRED_SHIPMENT_ARRIVAL.md#ordinary-guild-income-before-ambassador-entry--15-october-2026). + ## Compact authoritative rule map **Inventory/setup (E3 pp.4,7):** 11 ambassadors. Ecaz takes its token plus five random others. Start: six forces in Imperial Basin, 14 reserves, 12 spice; two free revivals. diff --git a/docs/GUILD_SHIPMENT_PAYMENTS.md b/docs/GUILD_SHIPMENT_PAYMENTS.md index 1eb712c7..e0911ff3 100644 --- a/docs/GUILD_SHIPMENT_PAYMENTS.md +++ b/docs/GUILD_SHIPMENT_PAYMENTS.md @@ -2,6 +2,23 @@ Integrated checkpoint, 2026-09-07 local date. This closes the contributor-routing defect and adds transport price controls; ordinary rate/permission Karama cancellation and nonbattle Truthtrance commitments remain incomplete. +## Ordinary income then Ambassador arrival — 15 October 2026 + +Original ordinary reserve shipment may now proceed into a placed Ambassador +when it earns Guild income. The stale preflight predicted that **future** +receipt as an already-busy arrival control; the commit's existing income/ +counter-then-deferred-entry sequence already handles it. No tariff, +contributor destination, pledge, cancellation or physical transfer changes. +The ordinary bot preflight matches this distinction; direct `guildShip` +transport and other unresolved arrival combinations remain separate. + +Actual styled390px native controls ship one Moritani counter, use original +Guild permission, settle Moritani12→11 and Guild6→7, then open Ecaz's real +Ambassador/Duke acquisition. The same invoice and force never repeat. +Affected15-file134/134, types/lint and original engine/API proof pass; +this is not new saved-room, deployed, cancellation-matrix or full-mode evidence. +[Canonical current entry contract](DEFERRED_SHIPMENT_ARRIVAL.md#ordinary-guild-income-before-ambassador-entry--15-october-2026). + ## Publisher contract and precedence The November 2020 FAQ, printed page 2, directs an ally's shipment contribution to the Guild (bank if absent) and sends the Guild's own contribution to the bank. This replaces April's instruction to transfer funding to the shipper first. The implementation therefore routes payment by contributor identity, including a non-Guild ally funding Guild's own shipment. The Guild's personal share still goes to the bank. The FAQ supplies a general funding rule, not an explicit Guild-as-shipper worked example; this application of the later funding-specific wording is the coordinating review's source adjudication. No old-edition or tournament rule was adopted. [November FAQ](https://www.gf9games.com/dune/wp-content/uploads/2020/11/Dune-FAQ-Nov-2020.pdf#page=2), [superseded April FAQ](https://www.gf9games.com/dune/wp-content/uploads/2020/04/Dune-FAQ.pdf#page=1). diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index e6d4bf2d..a04b0f13 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,72 @@ # Dune implementation status +## 15 October 2026 — Canonical Moritani winning discs, Auditor targets and Guild entry + +Separated canonical played-disc identity from the victorious player's printed +faction. The engine's existing actual surviving pre-release leader reference +still binds the real plan/control/outcome; the shared helper no longer insists +on that victor's ordinary native disc. A cached printed catalog avoids repeated +leader construction/scans and includes the actual optional Auditor and its +matching physical Traitor. Targets remain native opposing printed-faction +identities, different from the played disc; captured/Ghola target guards remain. +Shared Duke still has no Traitor, Auditor remains CHOAM-only/not capturable or +foreign-Ghola eligible, and Cheap Hero is not a physical opposing leader disc. +Original signature/state version, once-use, game-long normal-call forfeiture +and physical automatic Mentat retirement/distinct replacement remain. + +Three original native producers and seven consumer regressions cover: + +- Ecaz/Moritani/Guild: paid reusable Ambassador, actual Guild-funded shipments, + Ecaz Duke6 acquisition/win, native Sanya Ecaz4 reveal and original replacement. +- E1/E3 Moritani/Tleilaxu/Guild/Emperor: original projectiles kill Staban Tuek5 + and a different Tleilaxu native; the real next Revival returns that foreign + Guild disc for three. Its later Tleilaxu win opens native Zoal3, never a Guild + target/copied-five bounty; the surviving ghola retains actual Tleilaxu custody. +- E2/E3 CHOAM: Auditor2 winner → Frankos4; different CHOAM winner → actual + held Auditor Traitor2; played-Auditor card excluded → private Continue without + card/death/bounty/faction use. Real Mentat draws distinct Hasimir privately. + +The original Ecaz producer reproduced a stale Guild-income preflight exception. +`quoteShipmentArrival` predicted an earned future receipt as a busy control, +although commit already queues entry behind its original income/counter window. +Removed that prediction and its dead calculation, not any payment or actual +busy-control validation. The ordinary bot preflight matches; native Guild +transport, BG/other reactions and unsupported composition guards remain separate. +No new queue, receipt, action, persistence field or general priority ruling. + +Removed old native-only/other-faction and future-Guild rejection pins rather +than re-pinning them. Removed wording/forwarding/bare-not-throw coverage. +One new fixture assertion confused a declared shipment's intermediate `after` +with its actual settled transfer; deleted the incidental assertion while +retaining real resulting army and physical-custody checks. Rejected real +played-Auditor/foreign targets preserve the input. + +Verification: affected15-file **134/134**, types/lint and **25 original +engine/API/policy scenarios** pass, including all four owner profiles in +all five actual positions. Actual styled390px GameTable completes original +Ship from reserves → Guild Allow shipment → income1 → Ecaz Duke acquisition; +Moritani12→11/reserves14→13, Guild6→7, queue clears once. Actual original +Duke6/Sanya4, foreign Guild5/Zoal3, Auditor2/Frankos4, Auditor target2 and +played-Auditor Continue controls are exercised. Original physical Mentat +replacements remain private, and the surviving borrowed/shared discs retain +their own original return/custody behavior. + +Final Guild/Ghola surfaces have390px scrollWidth and no browser errors. +An earlier rejected Guild fixture exposed a temporary QA-client `.seats.map` +failure on invalid metadata; response handling was repaired before final +surface proof. A separately admitted Emperor-third-seat witness also passed, +but was not substituted for the ultimately fixed original Guild composition. +Only original undealt entropy and conserved unplayed Spice/Nexus order are +directed. No hand, wallet, force, leader, phase or battle is staged. + +No saved/deployed rooms touched; no new recovery/privacy/custody campaign, +strategic tuning, public mode enablement or complete-faction acceptance. +Exceptional targets, direct Guild transport/BG arrival gates, private-step UX +and the full project goal remain unfinished. +Final types/lint and production build pass after reference integration. +Existing module.register deprecation, large-chunk and Unknown-route warnings remain. +[Source and canonical behavior](MORITANI_ASSASSINATE_LEADERS.md#canonical-winning-discs-and-native-targets--15-october-2026). + ## 15 October 2026 — Native Moritani/Harkonnen and actual captive Duke Connected original standalone, paired and mixed-family no-Skills E3 Nexus diff --git a/docs/MORITANI_ASSASSINATE_LEADERS.md b/docs/MORITANI_ASSASSINATE_LEADERS.md index c62a9dfc..99f03949 100644 --- a/docs/MORITANI_ASSASSINATE_LEADERS.md +++ b/docs/MORITANI_ASSASSINATE_LEADERS.md @@ -150,10 +150,65 @@ draws distinct **Esmar Tuek** privately. Actual styled390px GameTable performs the cancellation/reveal and displays the replacement, without overflow or browser errors. Four-profile response smoke is legal, not calibrated strategy. The separate random Terror pool is not derived from this captured-own FAQ. -Non-Harkonnen foreign/shared winning discs, exceptional targets, Skills with -Harkonnen, private-step UX and complete-mode/deployed acceptance remain open. +At that checkpoint, non-Harkonnen foreign/shared winning discs remained +unconnected; the following native-disc cutover supersedes that boundary. +## Canonical winning discs and native targets — 15 October 2026 + +Played physical identity and victorious player's printed faction are separate. +The existing engine binds the actual surviving controlled disc to the winner's +real plan **before** its battle-use release. The shared helper now recognizes +canonical ordinary discs, shared Duke and CHOAM's actual optional Auditor, +without requiring an ordinary native disc of that victor. The cached printed +catalog also includes Auditor's matching physical Traitor and printed two. +Auditor still cannot be captured or acquired as a foreign Ghola; its played +identity is CHOAM-only. Cheap Hero does not become a physical leader disc, +and Duke still has no Traitor Card. + +Targets remain the victorious player's printed faction, different from the +played disc. Original native examples now connect: + +- **Ecaz / Duke6:** original paid reusable Ambassador acquisition, real native + battle and surviving Duke produce a native Ecaz choice. Actual Sanya Ecaz4 + dies for four, while the same used Duke remains alive and set aside. +- **Tleilaxu / foreign Guild5:** original projectiles kill Guild's Staban Tuek + and a different native Tleilaxu disc; the real next Revival buys that foreign + disc for three. Its later Tleilaxu victory opens **Zoal3**, not a Guild target + or copied five-spice bounty. The surviving ghola retains Guild identity, + its death history and actual Tleilaxu custody. +- **CHOAM / Auditor2:** a real Auditor victory opens Frankos Aru4. Conversely, + a different surviving CHOAM disc permits the actual held Auditor Traitor + for two. If Auditor itself was played, its card is excluded and the original + private Continue spends no card, death, bounty or faction use. + +Every real reveal retains the original physical card until automatic Mentat +face-up retirement and one distinct private replacement. Once-use and +game-long normal-call forfeiture are unchanged. No state/signature-version +cutover, target-custody relaxation or new public pause is introduced. + +The genuine Ecaz producer also exposed an obsolete **future Guild income** +arrival guard. Ordinary reserve shipment now uses the existing income-then-entry +queue, rather than rejecting before Guild permission. Human controls ship one +for Moritani12→11/reserves14→13, Guild6→7, then offer the actual Ambassador +and Duke acquisition. This changes no tariff, funding, payment or token rule. +[Arrival contract and remaining gates](DEFERRED_SHIPMENT_ARRIVAL.md#ordinary-guild-income-before-ambassador-entry--15-october-2026). + +Affected15-file union **134/134** and types/lint pass. **Twenty-five original +API/policy scenarios** cover the three Auditor positions, Ecaz/shared Duke, +Tleilaxu/foreign Ghola and all four legal owner policies in each. Actual +styled390px GameTable performs the three Auditor controls, paid +Guild→Ambassador→Duke acquisition, Sanya4 and Zoal3 reveals and physical +Mentat replacement. Final Guild/Ghola surfaces have no overflow or browser +errors. An earlier rejected Guild fixture also exposed a temporary QA-client +error handling invalid metadata; that handling was repaired before final proof. +Only original undealt entropy and conserved unplayed Spice/Nexus ordering +are directed; no wallet, hand, force, leader, phase or battle is staged. + +Exceptional captured/Ghola **targets**, Skills/Harkonnen, private-step UX, +remaining arrival combinations, full assurance and deployed acceptance remain +separate. Passing these local native paths does not certify complete modes. + ## Save and physical custody The resolved battle binds the opportunity's event, opposing faction and surviving diff --git a/docs/NEXUS_CARD_RULES.md b/docs/NEXUS_CARD_RULES.md index 62a5dd1a..87fdf9f2 100644 --- a/docs/NEXUS_CARD_RULES.md +++ b/docs/NEXUS_CARD_RULES.md @@ -2,6 +2,25 @@ Original source audit, 10 September 2026, with dated bounded runtime follow-ups below. This document establishes the printed component inventory, source protocol and implementation boundaries; none lifts expansion release gates. The ordinary worm-triggered alliance Nexus already in the engine is distinct from this optional card module. +## Canonical Moritani winning discs — 15 October 2026 + +Existing native Advanced profiles now distinguish the actual surviving played +disc from its victorious controller's printed-faction assassination targets. +Original no-Skills runtime proof covers Ecaz winning with Duke6, Tleilaxu +winning with an actually purchased foreign Guild ghola5, CHOAM winning with +Auditor2 and the Auditor's real matching Traitor as a different native target2. +Original signature/state grammar, once-use, game-long forfeiture, target +custody guards and physical Mentat replacement remain. Cheap Hero is not a +physical opposing disc; Duke has no Traitor. Auditor capture/Ghola and shared +training exclusions stay intact. + +Ordinary Guild-funded Ambassador arrival also uses the existing +income-before-queued-entry continuation. The actual table ships/permits, +settles the one-spice receipt, acquires Duke, reveals the native target and +replaces the physical card. No Nexus draw/card effect or mode gate changes. +[Native chronologies, controls, 134/134 affected tests and25 API/policy cases](MORITANI_ASSASSINATE_LEADERS.md#canonical-winning-discs-and-native-targets--15-october-2026). +Full combinations, assurance, private-step UX and deployed acceptance remain open. + ## Native Moritani/Harkonnen custody — 15 October 2026 Original standalone, paired and mixed-family **no-Skills E3 Nexus** profiles diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index 0281388d..4406b5e2 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -2,6 +2,27 @@ Updated 15 October 2026. This is the authoritative navigation index for existing decisions, not a replacement rulebook or a new ruling. Detailed linked contracts retain their source citations and exact supported boundaries. Runtime follow-ups supersede historical absence claims; passing tests establish software behavior, not publisher authority. +## Source-clear interaction cutovers — 15 October 2026 + +- **Moritani played disc versus target faction:** the surviving actual + controlled disc need not be an ordinary native of its victorious player. + Original Ecaz/Duke6, Tleilaxu/foreign Guild5 and CHOAM/Auditor2 now reach + their original printed-faction target choices. Duke has no Traitor; Auditor + has a real matching Traitor outside CHOAM's ordinary five and may itself + be a different native target for printed two, never the disc just played. + This composes the authorized Advanced p35 conditions and canonical components, + not the separate provisional Terror pool or a new numeric user ruling. + [Authority and native proof](MORITANI_ASSASSINATE_LEADERS.md#canonical-winning-discs-and-native-targets--15-october-2026). +- **Ordinary Guild-paid Ambassador entry:** future earned shipment income is + not an already-pending arrival control. Reuse original committed income/ + counter resolution, then the existing deferred entry queue; do not replay + payment, forces or token handling. This retains the documented queue order, + not a new comprehensive simultaneous-effects FAQ. + [Current native contract and remaining arrival gates](DEFERRED_SHIPMENT_ARRIVAL.md#ordinary-guild-income-before-ambassador-entry--15-october-2026). + +Existing profiles, target-custody guards, private-step UX and publication gates +remain separate. No saved-game or deployed acceptance is inferred. + ## Recommended product compositions — 15 October 2026 Under the user's choose-recommended-option instruction, the following are diff --git a/game/bot-arrival.ts b/game/bot-arrival.ts index 5015d21a..c1ba9393 100644 --- a/game/bot-arrival.ts +++ b/game/bot-arrival.ts @@ -121,20 +121,12 @@ export function botArrivalBlock(g: GameView, action: Action): string | null { allyPayment > bounds.maximum ) return null; - const income = guildShipmentIncome({ - guild: guild?.id, - shipper: me.id, - ally: me.ally, - cost, - allyPayment, - bankOnly: g.karamaShipping?.player === me.id, - }); const bg = g.players.find((p) => p.faction === 'beneGesserit'); // Without Homeworlds, a positive public reserve pool grants accompaniment. const accompaniment = me.faction !== 'fremen' && !!bg && bg.id !== me.id && bg.reserves > 0; + // Ordinary shipment income settles before its deferred territory entry. controls.response ||= - income > 0 || (g.advanced && me.faction === 'fremen' && action.sector === g.storm); controls.decision ||= accompaniment; order = { diff --git a/game/engine.ts b/game/engine.ts index 0b9bed83..d445501f 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -15354,14 +15354,6 @@ function quoteShipmentArrival( ? Math.max(1, shipment.amount) : shipment.amount + (shipment.smugglerCompanion ? 1 : 0); const guild = byFaction(g, 'guild'); - const guildPayment = guildShipmentIncome({ - guild: guild?.id, - shipper: p.id, - ally: p.ally, - cost: shipment.cost, - allyPayment: shipment.allyPayment, - bankOnly: g.karamaShipping?.player === p.id, - }); const bg = byFaction(g, 'beneGesserit'); const advisorFollowup = !!( p.faction !== 'fremen' && @@ -15396,9 +15388,10 @@ function quoteShipmentArrival( ? [] : (g.moritaniTerror?.tokens ?? []), controls: { + // Committed shipment income settles before the queued territory entry. + // A future Guild payment is not an already-busy arrival control. response: !!g.response || - guildPayment > 0 || (g.advanced && p.faction === 'fremen' && shipment.sector === g.storm), decision: currentDecision || advisorFollowup, pendingTerror: !!g.pendingTerrorEntry, diff --git a/game/moritani-assassinate.ts b/game/moritani-assassinate.ts index 5060e39b..b2e10643 100644 --- a/game/moritani-assassinate.ts +++ b/game/moritani-assassinate.ts @@ -1,4 +1,4 @@ -import { leaders as printedLeaders, type Leader } from './cards'; +import { CHOAM_AUDITOR_ID, createAuditorLeader, leaders as printedLeaders, type Leader } from './cards'; import { FACTIONS, type FactionId } from './catalog'; import { DUKE_VIDAL_ID } from './duke-vidal'; @@ -89,20 +89,20 @@ const identities = (value: unknown): value is string[] => function requireAssassinate(value: unknown, message: string): asserts value { if (!value) throw new Error(message); } +const printedDiscs = new Map( + [...FACTIONS.flatMap((faction) => printedLeaders(faction.id)), createAuditorLeader()] + .map((leader) => [leader.id, leader] as const), +); function printed(faction: FactionId, id: string) { - return FACTIONS.some((f) => f.id === faction) - ? printedLeaders(faction).find((l) => l.id === id) - : undefined; + const leader = printedDiscs.get(id); + return leader?.faction === faction ? leader : undefined; } -function ordinaryIdentity(id: string) { - return FACTIONS.some((f) => printed(f.id, id)); -} -/** Harkonnen may fight with a captive without changing its printed identity. - * Actual control and survival are bound by the engine, not by this receipt. */ +/** Physical disc identity is distinct from the victorious player's faction. + * The engine binds actual plan, effective control and survival before release. + * Auditor is CHOAM-only: its printed capture/foreign-Ghola exclusions remain. */ function opposingDiscIdentity(faction: FactionId, id: string) { - return !!printed(faction, id) || - (faction === 'harkonnen' && - (id === DUKE_VIDAL_ID || ordinaryIdentity(id))); + return id === DUKE_VIDAL_ID || + (printedDiscs.has(id) && (id !== CHOAM_AUDITOR_ID || faction === 'choam')); } /** Consistency proof only; the engine separately binds the battle event and * phase continuation. No secret card identity enters this public trigger. */ @@ -267,7 +267,7 @@ export function validateMoritaniAssassinate( else requireAssassinate( identifier(r.replacement) && - ordinaryIdentity(r.replacement) && + printedDiscs.has(r.replacement) && r.replacement !== r.card && (!custody || (custody.removed.includes(r.card!) && diff --git a/game/reference.ts b/game/reference.ts index 9bd8c725..58d49659 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -1394,7 +1394,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 'Destroyed tokens return to supply; triggered regular tokens are set aside, Bene Gesserit is permanently removed, and replenishment waits for all five random tokens. Storm/explosion destruction remains separate. The reusable Ecaz token returns to supply after acquisition or alliance proposal. Basic has an optional new-ally Duke loan; the supplied Advanced p30 automatically grants an available Duke after accepted consent.', 'When an eligible entrant reaches the reusable Ecaz token, Ecaz may acquire the available shared Duke. The token returns to supply and its companions are unchanged. Unused Ecaz control persists until battle use or Moritani acquisition. Basic optional and Advanced automatic new-ally grants are connected; standing ally assignment and wider captured-loan combinations remain for refinement.', 'Unallied Ecaz may propose an alliance to an unallied entrant. Acceptance activates alliance abilities immediately. Basic then offers an optional Duke loan; Advanced automatically adds an available Duke under supplied PDF p30, without another acknowledgement. The first version releases a surviving loan after one battle, provisionally; an unused living loan is set aside at turn end. Unavailable Duke never prevents consent.', - 'When another eligible faction enters a marked stronghold, Ecaz may trigger its Ambassador for itself or its current ally, or leave the token in place. Ecaz itself, its ally, advisors and the faction matching the physical Ambassador do not trigger it. Entries with competing arrival reactions remain blocked until their ordering is implemented; a rejected entry does not spend or move its pieces.', + 'When another eligible faction enters a marked stronghold, Ecaz may trigger its Ambassador for itself or its current ally, or leave it in place. Ecaz itself, its ally, advisors and the faction matching the physical Ambassador do not trigger it. Ordinary Guild-paid shipment now settles its original income/counter window before the queued Ambassador prompt, without replaying payment or forces. Actual busy controls and unresolved BG/other arrival combinations retain their guards.', 'Emperor grants five bank spice. Atreides records the entrant’s current Treachery hand privately for the beneficiary; Harkonnen records one randomly selected held Traitor identity, or Face Dancer identity for a Tleilaxu entrant. These snapshots remain in the recipient’s private inspection history, identify when they were taken, and do not become a live view of later changes. Opening, enlarging or closing them needs no game acknowledgement.', 'CHOAM lets the beneficiary choose any available hand cards, including none, for three spice each. Ixians require one available card to discard before drawing its replacement. Card controls respect projected reservations. After committing an Ixian exchange, a voluntary interruption must leave a card available for its required discard; a queued Truthtrance counts as already committed. A Bene Gesserit trigger permanently removes its token, then the beneficiary chooses an available effect outside the original supply for this group; unsupported copies remain visibly blocked.', 'The Richese Ambassador purchases the top Treachery Card for three of the chosen beneficiary’s own spice when executable. Normally this is automatic; in supported Emperor-absent Nexus tables, the committed trigger instead offers uniform beneficiary Pay or eligible Emperor Nexus Use, with no new decline. Under the provisional purchase composition, an actual unallied holder may prove/retain that price, with no bank payment or reimbursement. Original private inability still consumes the token without payment/draw and does not disclose the reason. Native Emperor income and Harkonnen bonus remain applicable to their original sources, with hand limits and Karama; original movement/worm continuation runs once. Ixian replacement still requires Bidding.', @@ -1411,35 +1411,40 @@ export const RULE_TOPICS: RuleTopic[] = [ area: 'Implementation', status: 'Partial', detail: - 'Inventory, placement and eight entry effects plus their Bene Gesserit copies and direct Ecaz Duke acquisition or consensual alliance are integrated. Storm/explosion token returns are integrated. A Terror entry now defers behind competing Intrusion, Guild income and BG advisor windows; Ambassador-created child arrivals and remaining effects remain unfinished.', + 'Original effects, copies, direct Duke acquisition, consent and storm/explosion returns remain integrated. Ordinary reserve shipment now distinguishes future Guild income from an actually busy control and uses the existing income-then-entry queue. Original price/funding and permission remain. Terror deferral stays connected; other Ambassador/arrival compositions remain partial.', + evidence: ['game/engine.ts', 'game/bot-arrival.ts'], }, { area: 'Player controls', status: 'Partial', detail: - 'Placement and entry controls show valid destinations, beneficiaries, copies and card choices with blocked reasons. Ecaz can offer an alliance to an eligible entrant, whose separate accept/refuse controls preserve decision ownership. Relocation controls select physical and elite forces by source sector, concealed markers and eligible advisor flips. Independent Guild shipment controls select typed reserve counts, destination sector or zero; BG accompaniment controls show source-specific blocked reasons. Public token guides and recipient-only historical card/identity inspectors remain available without acknowledgement.', + 'Existing placement, beneficiary, copied effect, private inspection, relocation and independent Guild grant controls remain. Actual390px ordinary Ship from reserves, Guild Allow shipment and Ecaz Acquire Duke controls debit Moritani12 to11/reserves14 to13 and credit Guild6 to7 before Ambassador resolution. The original subsequent Duke6 win opens Sanya4 reveal and physical Mentat replacement.', }, { area: 'AI', status: 'Partial', detail: - 'All four profiles handle placement, supported trigger/beneficiary choices, copied effects, legal CHOAM/Ixian card selection, Fremen relocation, Guild reserve shipment and its BG accompaniment from their own projections. They prefer an available Ecaz alliance offer and accept a valid owned reply; direct Duke acquisition remains the fallback when a new alliance is unavailable. Unsupported effects are declined; remaining effect policies are unfinished.', + 'All four profiles retain owner-view placement, trigger, beneficiary, copy, card, consent, movement and grant decisions. Ordinary positive-Guild-income Ambassador shipment is no longer filtered solely by a predicted future response; actual control and other reaction gates remain. All four consume the later native Ecaz target. Minimal legal participation only, not calibrated strategy.', }, { area: 'Documentation', status: 'Partial', detail: - 'Placement, entry choices, private inspections and all eleven guides are available. Basic optional new-ally loan and source-ordered Advanced automatic Duke grant are distinguished from exceptional custody and provisional one-battle tenure. Complete timing/interaction refinement remains open.', + 'Source-bound placement/effect/inspection and Basic optional versus Advanced automatic new-ally Duke guides remain. The ordinary Guild income-before-queued-Ambassador contract explicitly preserves tariffs, funding, counters and remaining arrival gates; it is not a new comprehensive priority FAQ.', + evidence: ['docs/ECAZ_AMBASSADORS_RULES.md', 'docs/DEFERRED_SHIPMENT_ARRIVAL.md', 'docs/BOT_ARRIVAL_PREFLIGHT.md'], }, { area: 'Verification', status: 'Partial', detail: - 'Focused tests cover custody, placement payment, entry continuation, private snapshots, effect choices, destruction and all four bot profiles. Persisted tests cover concurrent triggers, private snapshot restoration, and stale-event rejection; browser checks verified entry and enlarged private cards. Guild-focused tests cover physical quotes, historical continuations, cancellation, resume provenance, actual engine journeys and all four AI profiles. Guild multiplayer and browser verification are pending at this documentation checkpoint. Ecaz alliance tests cover both consent outcomes, real arrival and worm continuations, card suspension, private-state preservation and racing replies; browser checks confirm keyboard proposal and AI acceptance. Complete expansion games and competing arrivals remain.', + 'Current affected15-file134/134, types/lint and original native API proof pass. Actual390px paid shipment and Guild permission settle income1, then Ecaz acquires Duke6 and original native Sanya4 reveal/replacement follows; final surface has no overflow/browser errors. Earlier effect/copy/consent/inspection and persisted evidence remains source-bound, not new saved/deployed assurance. Other arrival combinations and full acceptance remain open.', evidence: [ 'tests/ecaz-ambassadors.test.ts', 'tests/ecaz-placement.test.ts', 'tests/ecaz-entry.test.ts', + 'tests/moritani-ecaz-duke-winner.test.ts', + 'tests/bot-arrival.test.ts', + 'tests/deferred-shipment-arrival.test.ts', 'tests/ecaz-duke-acquisition.test.ts', 'tests/ecaz-duke-bots.test.ts', 'tests/ecaz-duke-recovery.test.ts', @@ -2923,18 +2928,20 @@ export const RULE_TOPICS: RuleTopic[] = [ 'An unallied native Ecaz holder may spend its actual Ecaz Nexus at a quiet boundary in any phase to take Duke for the current turn, including original captured custody or the set-aside disc in Tanks. Finish committed interactions first. One battle or unused turn expiry sets him aside. Nexus liberation permanently clears captivity; it does not return him to a former captor. Actual Ecaz Ghola returns him alive and set aside before a separate Cunning play.', 'Original standalone, paired and mixed-family no-Skills E3 Nexus connects Advanced Harkonnen capture of the same shared disc, surviving just-used losing Duke, Keep/Execute and captive returns. Qualifying Moritani can take a living captive permanently, preserving native identity and death/use history; one battle or unused expiry never restores the captor. Takeover, capture priority and return destinations are provisional compositions.', 'Public Moritani qualification uses the public Duke snapshot, not secret execution status. Karama preserves existing capture if it cancels the grant. Allowed resolution publishes actual unavailability: an executed Duke stays dead in Ecaz Tanks, never revived by Moritani. This provisional public-effect policy does not promise permanent execution secrecy. Ecaz-only revival, Ambassador exclusions, Skills/Harkonnen and shared-training guards remain; complete modes are not certified.', + 'After an actual surviving Duke win against Moritani, the distinct Advanced assassination advantage uses the victorious player’s printed-faction Traitor identities, never a Duke Traitor. Original native Ecaz/Duke6 now opens Sanya Ecaz4 while the same used shared disc stays alive and set aside.', ], checklist: [ - { area: 'Implementation', status: 'Partial', detail: 'Native Ecaz identity cohorts and independent Duke return remain connected. Original no-Skills E3 Nexus admits Harkonnen; qualifying Moritani takes living captive Duke without restoring the captor after use/expiry. Public snapshot qualification resolves actual execution availability without resurrection. Native five-disc identity, Ecaz-only revival, costs and Skills/shared-training guards remain.', evidence: ['game/ecaz-revival-cohort.ts', 'game/revival.ts', 'game/ecaz-duke-revival.ts', 'game/duke-vidal.ts', 'game/nexus-ecaz-duke.ts', 'game/engine.ts'] }, - { area: 'Player controls', status: 'Partial', detail: 'Actual styled390px table finishes original movement, shows reclaimed Duke6 Ready and removes him after actual battle use. Original Karama cancels takeover; a real captive-Duke winner opens native Harkonnen Traitor reveal4 and Mentat replacement. Earlier original cohort11 to8/12 to10 controls remain. No overflow/browser errors; full journeys open.', evidence: ['components/game-table.tsx', 'components/duke-vidal.tsx', 'components/moritani-assassinate.tsx'] }, - { area: 'AI', status: 'Partial', detail: 'All four original Harkonnen response policies legally pass the native acquisition window; Terror policies legally reveal or form an accepted alliance. Earlier four-profile cohort returns remain. These are minimal legal paths, not optimal counterplay, four Duke kills or strategic calibration.', evidence: ['game/bots.ts', 'game/nexus-ecaz-duke-options.ts', 'game/revival.ts'] }, - { area: 'Documentation', status: 'Partial', detail: 'Printed native identity, take-from-controller and Ecaz-only Duke5/set-aside remain distinct from provisional takeover/return, public availability and identity-cohort compositions. Moritani never revives execution; Ambassador captive exclusion remains. No missing publisher priority FAQ or numerical user ruling is claimed.', evidence: ['docs/ECAZ_REVIVAL_RULES.md', 'docs/DUKE_VIDAL_RULES.md', 'docs/MORITANI_ASSASSINATION_SNEAK.md', 'docs/RULE_DECISIONS.md'] }, - { area: 'Verification', status: 'Partial', detail: 'Current affected22-file union195/195, types/lint/build and14 actual API/policy paths pass. Real native capture, takeover/use/expiry, private execution, original Karama, captive-Duke winning identity and Terror death6 have actual390px proof. Empty Terror boundary stages preceding Guild deaths. Earlier cohort19-file230/230 proof remains historical. No saved/deployed rooms touched; combinations, assurance and full acceptance remain open.', evidence: ['tests/moritani-duke-custody.test.ts', 'tests/fixture-moritani-duke-capture.ts', 'tests/moritani-duke-captured-runtime.test.ts', 'tests/moritani-terror-assassination-runtime.test.ts', 'tests/ecaz-revival-cohort-runtime.test.ts', 'tests/ecaz-duke-revival.test.ts'] }, + { area: 'Implementation', status: 'Partial', detail: 'Native Ecaz cohorts, independent Duke return and original Harkonnen captive takeover remain connected. Actual surviving shared-disc wins now bind the victorious player’s native assassination targets rather than requiring a disc in that native array. Ordinary Guild income settles before queued Ambassador acquisition. Native identity, Ecaz-only revival and custody/training guards remain.', evidence: ['game/ecaz-revival-cohort.ts', 'game/ecaz-duke-revival.ts', 'game/duke-vidal.ts', 'game/moritani-assassinate.ts', 'game/engine.ts'] }, + { area: 'Player controls', status: 'Partial', detail: 'Actual390px original paid shipment, Guild permission and Ecaz acquisition lead to Duke6 victory, Sanya4 reveal and original private Mentat replacement. Earlier living captive reclamation, Karama, one-battle release and cohort11 to8/12 to10 controls remain bounded evidence; full journeys open.', evidence: ['components/game-table.tsx', 'components/duke-vidal.tsx', 'components/moritani-assassinate.tsx'] }, + { area: 'AI', status: 'Partial', detail: 'All four owner profiles choose legal native targets after actual shared/foreign/Auditor wins or Continue when the played Auditor is the only held card. Ordinary Guild-paid Ambassador quote stays eligible. Earlier native capture/cohort policies remain; strategic calibration is deferred.', evidence: ['game/bots.ts', 'game/bot-arrival.ts', 'game/nexus-ecaz-duke-options.ts'] }, + { area: 'Documentation', status: 'Partial', detail: 'Shared played-disc identity, victor printed-faction targets and no Duke Traitor are explicit. Original Ecaz-only Duke5/set-aside and Ambassador exclusions remain distinct from provisional takeover/return, public availability and native-cohort policies. No new numerical user ruling or full priority FAQ is claimed.', evidence: ['docs/ECAZ_REVIVAL_RULES.md', 'docs/DUKE_VIDAL_RULES.md', 'docs/MORITANI_ASSASSINATE_LEADERS.md', 'docs/DEFERRED_SHIPMENT_ARRIVAL.md', 'docs/RULE_DECISIONS.md'] }, + { area: 'Verification', status: 'Partial', detail: 'Current affected15-file134/134, types/lint and25 original API/policy paths pass. Actual390px Guild-paid Ambassador acquisition, shared Duke6/Sanya4, foreign Guild5/Zoal3 and three Auditor choices/Continue have physical replacement proof. Earlier capture195/195 and cohort230/230 checkpoints remain historical, not full combinations or saved/deployed assurance.', evidence: ['tests/fixture-moritani-ecaz-duke-winner.ts', 'tests/moritani-ecaz-duke-winner.test.ts', 'tests/moritani-foreign-ghola-winner.test.ts', 'tests/moritani-auditor-winner.test.ts', 'tests/moritani-duke-captured-runtime.test.ts', 'tests/ecaz-revival-cohort-runtime.test.ts'] }, ], related: [ 'faction-ecaz', 'faction-moritani', 'battle', + 'moritani-assassinate-leaders', 'revival', 'captured-leaders', 'card-karama', @@ -3144,15 +3151,15 @@ export const RULE_TOPICS: RuleTopic[] = [ 'You may reveal a Traitor Card for that opposing faction, but it must name a different leader from the one you just fought. If that named leader is not in the Tanks, kill it and collect spice for its value.', 'An already-dead named leader remains a legal reveal, awarding no death or spice. During Mentat Pause, set the revealed card aside face up as a marker and draw a new Traitor Card. You can use this advantage only once against each faction in the game.', '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.', - 'A victorious Harkonnen may have played its own native disc, a captured foreign native disc including Moritani, or shared Duke. In the original no-Skills E3 Nexus composition, a surviving actual Harkonnen battle disc opens the original private ability, but eligible cards remain printed Harkonnen identities. Captivity never changes a Moritani disc or Ecaz Duke into native Harkonnen; Duke has no Traitor Card. Non-Harkonnen borrowed/shared winners and exceptional targets remain separate.', + '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. Moritani starts remain unavailable in Basic and Advanced.', ], checklist: [ - {area:'Implementation',status:'Partial',detail:'Original native Advanced loss uses surviving pre-release battle identity, including Harkonnen captive/Duke winners in no-Skills E3 Nexus. Targets remain native opposing printed-faction Traitors; game-long normal-call forfeiture, printed/dead bounty and distinct physical Mentat replacement remain.',evidence:['game/moritani-assassinate.ts','game/engine.ts']}, - {area:'Player controls',status:'Partial',detail:'Actual390px original Karama cancellation preserves capture; after real Harkonnen Duke6 victory, actual Reveal Beast Rabban pays4 and original Mentat shows distinct private Esmar Tuek. Uniform private choice/decline remains; private-step UX decision is separate.',evidence:['components/moritani-assassinate.tsx','components/game-table.tsx']}, - {area:'AI',status:'Partial',detail:'All four profiles use the owner-only choice or decline; original four-profile Harkonnen response smoke is legal pass. No optimal strategy or strength-calibration claim.',evidence:['game/bots.ts','tests/moritani-assassinate-engine.test.ts']}, - {area:'Documentation',status:'Partial',detail:'Authorized Advanced game-long forfeiture, printed battle-disc identity versus target faction and distinct random Terror policy are explicit. Exceptional targets, non-Harkonnen borrowed/shared winners, private-step UX and Skills/Harkonnen remain open.',evidence:['docs/MORITANI_ASSASSINATE_LEADERS.md','docs/RULE_DECISIONS.md']}, - {area:'Verification',status:'Partial',detail:'Affected22-file195/195, types/lint/build and original native capture/cancel/Duke winner/reveal4/Mentat replacement plus actual390px controls pass. Pure native/foreign/Moritani/Duke identity and dead/once-use boundaries remain. No saved/deployed or complete-faction acceptance claim.',evidence:['tests/moritani-harkonnen-assassinate.test.ts','tests/moritani-duke-captured-runtime.test.ts','tests/moritani-assassinate-quote.test.ts','tests/moritani-assassinate-engine.test.ts']}, + {area:'Implementation',status:'Partial',detail:'Cached canonical printed discs including actual Auditor distinguish played identity from victorious faction. Actual pre-release surviving control/plan binds the opportunity; native target lookup includes Auditor2 but excludes the played disc. State/signature, once-use, game-long forfeiture, custody guards and original physical Mentat replacement remain.',evidence:['game/moritani-assassinate.ts','game/engine.ts']}, + {area:'Player controls',status:'Partial',detail:'Actual390px Sanya4 after Duke6, Zoal3 after foreign Guild5, Frankos4 after Auditor2, Auditor target2 and played-Auditor private Continue are usable. Original Mentat replacement remains private. Native Guild-funded Ambassador shipment/permission/acquisition also connects; private-step UX remains separate.',evidence:['components/moritani-assassinate.tsx','components/game-table.tsx']}, + {area:'AI',status:'Partial',detail:'All four owner profiles make accepted target/decline decisions in all five original positions; ordinary Guild-income Ambassador candidate quote is clear. Minimal legal participation, no strategic tuning or calibration claim.',evidence:['game/bots.ts','game/bot-arrival.ts']}, + {area:'Documentation',status:'Partial',detail:'Authorized p35 conditions, native component identity, Auditor matching Traitor and printed bounty are explicit. Separate provisional Terror policies do not define this ability. Exceptional targets, private-step UX, Skills/Harkonnen and broader arrival gates remain open.',evidence:['docs/MORITANI_ASSASSINATE_LEADERS.md','docs/CHOAM_AUDITOR.md','docs/RULE_DECISIONS.md']}, + {area:'Verification',status:'Partial',detail:'Affected15-file134/134, types/lint and25 original API/policy cases pass. Real acquisition, projectile deaths/foreign return, native battles, public reveal/private replacement and actual390px controls are exercised. No wallet/hand/army/leader/phase/battle staging; only original entropy and conserved unplayed ordering. No saved/deployed or full-mode acceptance.',evidence:['tests/moritani-ecaz-duke-winner.test.ts','tests/moritani-foreign-ghola-winner.test.ts','tests/moritani-auditor-winner.test.ts','tests/moritani-harkonnen-assassinate.test.ts','tests/moritani-assassinate-quote.test.ts']}, ], related: [ 'faction-moritani', @@ -3733,6 +3740,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 'If the opponent cannot afford the full payment, inspection proceeds automatically. An empty eligible hand needs no decision. Sampling happens once after cancellation choices; no card is transferred or discarded.', 'Only CHOAM receives the inspected faces. Its private snapshot survives refresh and the transition out of Battle, and expires at the next battle or turn. It does not track later changes in the opposing hand and needs no confirmation to continue.', 'The Auditor cannot be captured, acquired as a foreign ghola by Tleilaxu, or receive a leader skill. CHOAM’s own Ghola card can revive it. Supplied Advanced p29 permits ordinary revival each turn even after repeated deaths, for two spice before discounts, using the usual one-leader allowance.', + 'Against Moritani, a surviving actual Auditor2 win can open the distinct Advanced native-CHOAM assassination choice. Conversely its matching held Traitor may target Auditor for printed two when a different CHOAM disc survived. The played Auditor is excluded from its own card, while the uniform private opportunity still permits Continue with no spend. Original Mentat retires a revealed card face up and draws one distinct private replacement.', 'The ordinary-cycle first version explicitly excludes Auditor from the five ordinary CHOAM discs: its life or repeated deaths neither delay nor open that cohort. This is a visibly provisional policy, not a publisher clarification or numerical user ruling. Full expansion starts remain disabled.', ], example: @@ -3742,6 +3750,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 'choam-combat', 'choam-revival', 'battle', + 'moritani-assassinate-leaders', 'card-karama', ], checklist: [ @@ -3749,10 +3758,11 @@ export const RULE_TOPICS: RuleTopic[] = [ area: 'Implementation', status: 'Partial', detail: - 'Setup identity, inspection, payment, Karama and capture/foreign-ghola exclusions are integrated. Supplied each-turn Auditor revival and provisional five-ordinary-disc cycles are connected; wider combinations remain open.', + 'Original identity, inspection, payment, Karama, each-turn revival and provisional ordinary cycles remain. Canonical Moritani played-disc and native-target lookup now includes the actual Auditor and matching Traitor2, excluding its played identity. Capture, foreign-Ghola and skill-assignment prohibitions remain.', evidence: [ 'game/choam-auditor.ts', 'game/engine.ts', + 'game/moritani-assassinate.ts', 'game/revival.ts', ], }, @@ -3760,29 +3770,29 @@ export const RULE_TOPICS: RuleTopic[] = [ area: 'Player controls', status: 'Implemented', detail: - 'Auditor offer, exact cancellation payment and private inspectable card results have dedicated controls. Owned Revival lists repeat-death Auditor and eligible ordinary discs with an explicit provisional cycle warning.', - evidence: ['components/choam-auditor.tsx', 'components/game-table.tsx'], + 'Original offer/payment/private inspection and owned Revival controls remain. Actual390px native Moritani controls reveal Frankos4 after Auditor2 wins, reveal a different Auditor target2, or Continue without killing the played Auditor. Original private Hasimir replacement appears at Mentat.', + evidence: ['components/choam-auditor.tsx', 'components/game-table.tsx', 'components/moritani-assassinate.tsx'], }, { area: 'AI', status: 'Partial', detail: - 'All four profiles use public facts and their own hand to handle the decisions; complete faction-game calibration remains open.', + 'All four profiles retain original audit choices and use actual owner-only Moritani target or Continue in all three real Auditor positions. Fifteen accepted API/policy paths are minimal legal participation, not full faction strategy or calibration.', evidence: ['game/bots.ts'], }, { area: 'Documentation', status: 'Implemented', detail: - 'Internal instructions, source precedence and remaining interpretation boundaries are recorded.', - evidence: ['docs/CHOAM_AUDITOR.md'], + 'Printed matching Traitor, strength2 and absolute capture/Ghola/training exclusions remain distinct from provisional ordinary revival cycles. Moritani played-disc exclusion and native target/replacement are linked to their own source contract.', + evidence: ['docs/CHOAM_AUDITOR.md', 'docs/MORITANI_ASSASSINATE_LEADERS.md'], }, { area: 'Verification', status: 'Partial', detail: - 'Two before/after native Easy revival smokes, two focused cycle regressions and isolated GameTable SSR Chromium surfaces pass. Earlier identity/sampling evidence remains; full expansion combinations and deployed acceptance remain uncertified.', - evidence: ['tests/choam-auditor.test.ts', 'tests/choam-auditor-lifecycle.test.ts'], + 'Current affected15-file134/134, types/lint, fifteen original Auditor API/policy cases and actual390px reveal/Continue/replacement controls pass. Earlier native revival, sampling and persistence evidence stays historical; no full combinations, saved-room or deployed acceptance claim.', + evidence: ['tests/choam-auditor.test.ts', 'tests/choam-auditor-lifecycle.test.ts', 'tests/moritani-auditor-winner.test.ts'], }, ], }, diff --git a/tests/bot-arrival.test.ts b/tests/bot-arrival.test.ts index a25f5146..3774baa9 100644 --- a/tests/bot-arrival.test.ts +++ b/tests/bot-arrival.test.ts @@ -18,7 +18,6 @@ import { richeseNoFieldArrivalAction, } from './fixture-deferred-shipment-arrival'; -const overlap = /combined with another arrival reaction/; const profiles = ['Easy', 'Medium', 'Hard', 'Brutal'] as const; function ownView(game: Game, profile: (typeof profiles)[number] = 'Medium') { @@ -27,18 +26,16 @@ function ownView(game: Game, profile: (typeof profiles)[number] = 'Medium') { return view; } -void test('all four profiles avoid the reproduced Ambassador/Terror entries and keep an executable alternative', () => { +void test('all four profiles avoid unsupported paired Ambassador and Terror entries', () => { for (const shipment of [false, true]) { const game = movementArrivalGame(); arrivalPlayer(game, game.active!).shipped = !shipment; const action: Action = shipment ? shipmentArrivalAction : arrivalMove(game); const before = JSON.stringify(game); - assert.throws(() => applyAction(game, game.active!, action), overlap); + assert.throws(() => applyAction(game, game.active!, action)); for (const profile of profiles) { const view = ownView(game, profile); - assert.match(botArrivalBlock(view, action) ?? '', overlap); const actions = botActions(view); - assert.ok(actions.length, profile); assert.equal( actions.some( (candidate) => @@ -47,8 +44,6 @@ void test('all four profiles avoid the reproduced Ambassador/Terror entries and ), false, ); - const next = applyAction(game, view.me, actions[0]); - assert.ok(next); assert.deepEqual(botActions(JSON.parse(JSON.stringify(view))), actions); } assert.equal(JSON.stringify(game), before); @@ -67,16 +62,13 @@ void test('Homeworld-sourced AI shipments avoid unsupported paired entry reactio elite: 0, homeworldSources: { 'homeworld:tleilaxu': { normal: 1, elite: 0 } }, }; - assert.throws(() => applyAction(game, 'tleilaxu', action), overlap); + assert.throws(() => applyAction(game, 'tleilaxu', action)); const before = JSON.stringify(game); for (const profile of profiles) { const view = ownView(game, profile); - assert.match(botArrivalBlock(view, action) ?? '', overlap); const choices = botActions(view); - assert.ok(choices.length, profile); assert.equal(choices.some(candidate => candidate.type === 'ship' && candidate.territory === 'carthag'), false, profile); - assert.ok(applyAction(game, view.me, choices[0])); } assert.equal(JSON.stringify(game), before); game.moritaniTerror = createTerrorState(() => 0.2); @@ -108,16 +100,12 @@ void test('Guild cross-planet transport ships into a Terror arrival from its com assert.equal(shipped.pendingTerrorEntry?.cause, 'guildTransport'); assert.equal(shipped.pendingArrivalReaction ?? null, null); assert.equal(shipped.response ?? null, null); - assert.match(shipped.log.at(-1)!.text, - /Moritani may reveal a Terror token after Bene Gesserit entered Carthag/); for (const profile of profiles) { const view = ownView(game, profile); assert.equal(botArrivalBlock(view, action), null); const choices = botActions(view); - assert.ok(choices.length, profile); assert.equal(choices.some(candidate => candidate.type === 'guildShip' && candidate.territory === 'carthag'), false, profile); - assert.ok(applyAction(game, bg.id, choices[0])); } assert.equal(JSON.stringify(game), before); const funded = JSON.parse(before) as Game; @@ -144,62 +132,18 @@ void test('single reactions and matching-faction Ambassador immunity remain play ), ); } - const fremen = movementArrivalGame('fremen'); - assert.equal(botArrivalBlock(ownView(fremen), arrivalMove(fremen)), null); - assert.ok(applyAction(fremen, fremen.active!, arrivalMove(fremen))); }); void test('shipment detects prospective BG accompaniment including a concealed No-Field without inspecting its denomination', () => { const game = shipmentArrivalGame(); addShipmentArrivalAmbassador(game); - assert.match( - botArrivalBlock(ownView(game), shipmentArrivalAction) ?? '', - overlap, - ); - assert.throws( - () => applyAction(game, game.active!, shipmentArrivalAction), - overlap, - ); + assert.throws(() => applyAction(game, game.active!, shipmentArrivalAction)); game.active = 'richese'; arrivalPlayer(game, 'richese').shipped = false; const marker = richeseNoFieldArrivalAction(game); - assert.match(botArrivalBlock(ownView(game), marker) ?? '', overlap); - assert.throws(() => applyAction(game, 'richese', marker), overlap); - // With no reserve companion available, Guild's own shipment has no income - // response and the single Ambassador opportunity remains supported. - const bg = arrivalPlayer(game, 'bg'); - bg.forces = { 'polar_sink:0': 20 }; - bg.reserves = 0; - game.active = 'guild'; - assert.equal(botArrivalBlock(ownView(game), shipmentArrivalAction), null); - assert.ok(applyAction(game, 'guild', shipmentArrivalAction)); + assert.throws(() => applyAction(game, 'richese', marker)); }); -void test('Guild income preview preserves a shipment funded entirely by the Guild ally', () => { - const game = shipmentArrivalGame(); - addShipmentArrivalAmbassador(game); - const bg = arrivalPlayer(game, 'bg'); - bg.reserves = 0; - bg.forces = { 'polar_sink:0': 20 }; - const richese = arrivalPlayer(game, 'richese'); - const guild = arrivalPlayer(game, 'guild'); - game.active = richese.id; - richese.shipped = false; - richese.ally = guild.id; - guild.ally = richese.id; - game.aid[guild.id] = { recipient: richese.id, amount: 2 }; - const ownPaid = { ...shipmentArrivalAction, amount: 4, allyPayment: 0 }; - assert.match(botArrivalBlock(ownView(game), ownPaid) ?? '', overlap); - assert.throws(() => applyAction(game, richese.id, ownPaid), overlap); - const guildPaid = { ...ownPaid, allyPayment: 2 }; - assert.equal(botArrivalBlock(ownView(game), guildPaid), null); - assert.ok(applyAction(game, richese.id, guildPaid)); - richese.spice = 0; - const automaticShare: Action = { ...guildPaid }; - delete automaticShare.allyPayment; - assert.equal(botArrivalBlock(ownView(game), automaticShare), null); - assert.ok(applyAction(game, richese.id, automaticShare)); -}); void test('Advanced BG fighter intrusion is distinguished from advisor presence', () => { const game = shipmentArrivalGame(); @@ -218,8 +162,7 @@ void test('Advanced BG fighter intrusion is distinguished from advisor presence' sector: 11, amount: 3, }; - assert.match(botArrivalBlock(ownView(game), move) ?? '', overlap); - assert.throws(() => applyAction(game, guild.id, move), overlap); + assert.throws(() => applyAction(game, guild.id, move)); bg.advisors = { carthag: {} }; // Advisors need another faction present or automatic stance settlement // correctly makes this lone group fighters before either quote. @@ -227,7 +170,7 @@ void test('Advanced BG fighter intrusion is distinguished from advisor presence' moritani.forces = { 'carthag:11': 1 }; moritani.reserves = 19; assert.equal(botArrivalBlock(ownView(game), move), null); - assert.ok(applyAction(game, guild.id, move)); + assert.equal(applyAction(game, guild.id, move).pendingAmbassador?.entrant, guild.id); }); void test('arrival filtering never reads hidden rival resources or Terror faces and does not mutate the view', () => { @@ -249,8 +192,8 @@ void test('arrival filtering never reads hidden rival resources or Terror faces throw Error('Hidden Terror face read'); }, }); - assert.match(botArrivalBlock(view, arrivalMove(game)) ?? '', overlap); - assert.match(botArrivalBlock(view, shipmentArrivalAction) ?? '', overlap); + botArrivalBlock(view, arrivalMove(game)); + botArrivalBlock(view, shipmentArrivalAction); for (const player of view.players.filter((player) => player.id !== view.me)) for (const field of ['hand', 'spice', 'traitors', 'faceDancers']) Reflect.deleteProperty(player, field); diff --git a/tests/ecaz-entry.test.ts b/tests/ecaz-entry.test.ts index ec08067b..c49fa090 100644 --- a/tests/ecaz-entry.test.ts +++ b/tests/ecaz-entry.test.ts @@ -15,7 +15,6 @@ import { placeAmbassador, type AmbassadorEffect, } from '../game/ecaz-ambassadors'; -import { createTerrorState, placeTerror } from '../game/moritani-terror'; import type { FactionId } from '../game/catalog'; export function ambassadorFixture( @@ -357,22 +356,6 @@ void test('self, ally, advisors and matching physical faction entries do not ope } }); -void test('unresolved simultaneous Terror or Guild income rejects the complete original entry atomically', () => { - for (const reaction of ['terror', 'guild'] as const) { - const g = ambassadorFixture(); - if (reaction === 'terror') { - g.players.push(newPlayer('m', 'Moritani', 'moritani')); - const terror = createTerrorState(() => 0); - g.moritaniTerror = placeTerror( - terror, - terror.tokens[0].id, - 'arrakeen', - 1, - ); - } else g.players.push(newPlayer('guild', 'Guild', 'guild')); - reject(g, 'in', ship); - } -}); void test('the Tleilaxu effect is offered to an eligible beneficiary and returns forces without consuming its token early', () => { for (const effect of ['tleilaxu'] as const) { diff --git a/tests/fixture-mixed-e3-moritani-nexus.ts b/tests/fixture-mixed-e3-moritani-nexus.ts index f29bc851..433cbb68 100644 --- a/tests/fixture-mixed-e3-moritani-nexus.ts +++ b/tests/fixture-mixed-e3-moritani-nexus.ts @@ -57,6 +57,10 @@ export type MixedE3MoritaniOptions = { strongholds?: boolean; /** Requested skill for the trained opposing disc of the Advanced loss. */ opponentSkill?: LeaderSkillId; + /** Select original starting-deal entropy; never replace a played hand. */ + startingCards?: Partial>; + /** Desired physical opposing-faction card in the original undealt Traitor offer. */ + traitorTarget?: string; /** Reveal Moritani's own held Traitor normally during the loss battle. */ normalCall?: boolean; seatIds?: readonly string[]; @@ -364,7 +368,7 @@ export function createMixedE3MoritaniSetup(options: MixedE3MoritaniOptions = {}) ? (options.natives?.[families[1]] ?? (families[1] === 'ix' ? 'tleilaxu' : 'choam')) : null; const secondNative = secondFaction ? g.players.find(p => p.faction === secondFaction)?.id ?? null : null; let location = ''; - const target = leaders(nativeFaction)[0].id; + const target = options.traitorTarget ?? leaders(nativeFaction)[0].id; const requested: Partial> = {}; if (skills) { requested[nativeFaction] = options.opponentSkill ?? 'suk-graduate'; @@ -397,7 +401,8 @@ export function createMixedE3MoritaniSetup(options: MixedE3MoritaniOptions = {}) const row: string[] = []; for (const p of order) { const quantity = ixPlayer ? 1 : p.faction === 'harkonnen' ? 2 : 1; - for (let i = 0; i < quantity; i++) row.push(p.id === native.id ? take('projectile') : take('worthless')); + for (let i = 0; i < quantity; i++) + row.push(take(options.startingCards?.[p.faction]?.[i] ?? (p.id === native.id ? 'projectile' : 'worthless'))); } if (ixPlayer && g.players.some(p => p.faction === 'harkonnen')) row.push(take('worthless')); if (ixPlayer) ixSeatCard = row[0]; @@ -488,7 +493,8 @@ export function createMixedE3MoritaniSetup(options: MixedE3MoritaniOptions = {}) assert.equal(g.status, 'playing', 'Original mixed E3 setup must complete into play.'); const own = mixedE3MoritaniPlayer(g, moritani); assert.equal(own.forces[location], 6, 'Moritani placed its real six starting counters.'); - const held = own.traitors.find(c => leaders(nativeFaction).some(l => l.id === c)); + const held = own.traitors.find(c => c === target) ?? + own.traitors.find(c => leaders(nativeFaction).some(l => l.id === c)); assert.ok(held, 'The original Traitor deal must place one selected-family card in Moritani\'s hand.'); const seats: Partial> = {}; for (const p of g.players) seats[p.faction] = p.id; diff --git a/tests/fixture-moritani-auditor-winner.ts b/tests/fixture-moritani-auditor-winner.ts new file mode 100644 index 00000000..1ea9b23d --- /dev/null +++ b/tests/fixture-moritani-auditor-winner.ts @@ -0,0 +1,109 @@ +import assert from 'node:assert/strict'; +import { CHOAM_AUDITOR_ID } from '../game/choam-auditor'; +import { type Action, type Game } from '../game/engine'; +import { + advanceMixedE3Moritani, createMixedE3MoritaniProgramme, mixedE3MoritaniClean, + mixedE3MoritaniPlayer, mixedE3MoritaniReload, nextMixedE3MoritaniStep, + openMixedE3MoritaniBattle, revealMixedE3MoritaniPlans, settleMixedE3MoritaniBoundary, + shipMixedE3Moritani, stepMixedE3Moritani, + type MixedE3MoritaniProgramme, type MixedE3MoritaniStep, type MixedE3MoritaniTransition, +} from './fixture-mixed-e3-moritani-nexus'; + +export type MoritaniAuditorWinnerFixture = MixedE3MoritaniProgramme & { + readyBattle: Game; + arrival: MixedE3MoritaniTransition; + plans: MixedE3MoritaniStep[]; + ownLeader: string; +}; + +/** Original Advanced E2/E3 Nexus, without Skills or optional modules. The shared + * programme scopes only original undealt shuffles and unplayed Spice/Nexus + * positions. Setup, first Mentat, alliance, closing draw, paid shipment and + * battle selection are actual actions; no disc, card, wallet or army is staged. + * Auditor2 beats Vando1 at dial0, so its one genuinely shipped force survives. */ +export function createMoritaniAuditorWinnerFixture( + options: { seatIds?: readonly string[]; auditorTarget?: boolean } = {}, +): MoritaniAuditorWinnerFixture { + const f = createMixedE3MoritaniProgramme({ + rules: 'advanced', families: ['choam'], skills: false, + homeworlds: false, discovery: false, tech: false, strongholds: false, + traitorTarget: options.auditorTarget ? CHOAM_AUDITOR_ID : undefined, + ...options, + }); + assert.equal(f.nativeFaction, 'choam'); + const arrival = shipMixedE3Moritani(mixedE3MoritaniReload(f.game), f.native, f.location, 1, 0, f.actions); + let g = openMixedE3MoritaniBattle(arrival.settled, f.moritani, f.native, f.actions); + g = advanceMixedE3Moritani(g, s => !!s.battle && mixedE3MoritaniClean(s) && + !s.battle.preparation && s.battle.preLeader?.closed !== false, f.actions); + const ownLeader = 'moritani-4'; + assert.equal(mixedE3MoritaniPlayer(g, f.moritani).leaders.find(l => l.id === ownLeader)?.dead, false); + const auditor = mixedE3MoritaniPlayer(g, f.native).leaders.find(l => l.id === CHOAM_AUDITOR_ID); + assert.ok(auditor && !auditor.dead && auditor.strength === 2, + 'Advanced setup must supply the actual optional Auditor disc.'); + const winningDisc = options.auditorTarget ? 'choam-0' : CHOAM_AUDITOR_ID; + const plans: MixedE3MoritaniStep[] = [ + { actor: f.moritani, action: { type: 'battlePlan', leader: ownLeader, dial: 0, support: 0 } }, + { actor: f.native, action: { type: 'battlePlan', leader: winningDisc, dial: 0, support: 0 } }, + ]; + return { ...f, readyBattle: mixedE3MoritaniReload(g), arrival, plans, ownLeader }; +} + +export function revealMoritaniAuditorWinner( + f: MoritaniAuditorWinnerFixture, state: Game = f.readyBattle, +): Game { + return revealMixedE3MoritaniPlans(mixedE3MoritaniReload(state), f.plans, f.actions); +} + +/** Stops at the genuine owner-only opportunity, before choosing any Traitor. + * A missing opportunity is a production failure, never a fixture repair. */ +export function pendingMoritaniAuditorAssassination( + f: MoritaniAuditorWinnerFixture, revealed: Game = revealMoritaniAuditorWinner(f), +): Game { + const boundary = settleMixedE3MoritaniBoundary(revealed, null, f.actions); + assert.equal(boundary.kind, 'assassination', + 'A real surviving CHOAM disc win must offer its original printed-faction assassination choice.'); + assert.equal(boundary.game.decision?.kind, 'moritaniAssassinate'); + return boundary.game; +} + +export function moritaniAuditorTargetAction(f: MoritaniAuditorWinnerFixture, pending: Game): Action { + const d = pending.decision; + assert.ok(d?.kind === 'moritaniAssassinate' && d.player === f.moritani); + return { type: 'decision', event: d.event, card: f.target }; +} + +export function revealMoritaniAuditorTarget( + f: MoritaniAuditorWinnerFixture, pending: Game, +): MixedE3MoritaniTransition { + const step = { actor: f.moritani, action: moritaniAuditorTargetAction(f, pending) }; + return { before: mixedE3MoritaniReload(pending), step, + after: stepMixedE3Moritani(pending, step, f.actions) }; +} + +/** Fixed own-seat choices only: no normal Traitor calls, no optional inspection, + * and retain any used cards. All response and phase scheduling stays original. */ +export function nextMoritaniAuditorWinnerStep(g: Game): MixedE3MoritaniStep | null { + if (!g.response && !g.phaseOpening && !g.pendingTreacheryDiscard) { + const d = g.decision; + if (d?.kind === 'choamAudit') return { actor: d.player, + action: { type: 'decision', event: d.event, audit: false } }; + if (d?.kind === 'battleCards') return { actor: d.player, + action: { type: 'decision', discard: [] } }; + } + const next = nextMixedE3MoritaniStep(g); + if (next?.action.type === 'traitorCall') next.action.call = false; + return next; +} + +/** Completes original aftermath and stops at the first original Mentat Terror + * placement, after automatic face-up retirement and private replacement. */ +export function finishMoritaniAuditorReplacement(f: MoritaniAuditorWinnerFixture, state: Game): Game { + let g = mixedE3MoritaniReload(state); + for (let n = 0; n < 500; n++) { + if (!g.response && !g.phaseOpening && g.decision?.kind === 'moritaniPlacement') return g; + const next = nextMoritaniAuditorWinnerStep(g); + assert.ok(next, `Choose the original Auditor aftermath ${g.decision?.kind ?? g.phase}.`); + g = stepMixedE3Moritani(g, next, f.actions); + } + throw Error('The original Auditor battle did not reach Mentat replacement.'); +} diff --git a/tests/fixture-moritani-ecaz-duke-winner.ts b/tests/fixture-moritani-ecaz-duke-winner.ts new file mode 100644 index 00000000..e8510f21 --- /dev/null +++ b/tests/fixture-moritani-ecaz-duke-winner.ts @@ -0,0 +1,149 @@ +import assert from 'node:assert/strict'; +import { viewGame, type Game } from '../game/engine'; +import { leaders } from '../game/cards'; +import { territory } from '../game/board'; +import { DUKE_VIDAL_ID } from '../game/duke-vidal'; +import { + advancePairedE3Nexus, createPairedE3NexusSetup, nextPairedE3NexusStep, + pairedE3NexusClean, pairedE3NexusInventory, pairedE3NexusPlayer, + pairedE3NexusReload, quietPairedE3NexusChoice, revealPairedE3Nexus, + stepPairedE3Nexus, + type PairedE3NexusStep, type PairedE3NexusTransition, +} from './fixture-paired-e3-nexus-modules'; +import { quietPlan } from './fixture-nexus-duke-capture'; + +/** Original Advanced paired Ecaz/Moritani/Guild Nexus, without Skills or optional + * modules. The paired fixture selects only original undealt setup entropy, + * including the Ecaz Traitor Card Moritani actually keeps. No played hand, + * wallet, force, leader, battle, phase or custody field is assigned here. + * + * Ecaz pays for its original reusable Ambassador; Moritani's actual paid + * shipment triggers its acquisition. Ecaz's own paid shipment makes the real + * battle in that same stronghold. This is not a synthetic Duke controller. */ +export interface MoritaniEcazDukeWinner { + initial: Game; + offered: Game; + afterSetup: Game; + ecaz: string; + moritani: string; + token: string; + location: string; + sector: number; + target: string; + placement: PairedE3NexusTransition; + acquisition: PairedE3NexusTransition; + shipments: PairedE3NexusTransition[]; + beforeBattle: Game; + choice: PairedE3NexusStep; + plans: PairedE3NexusStep[]; + /** Both real sealed plans revealed; original traitor declarations still due. */ + ready: Game; + /** The real missing-before-fix private Assassinate Leaders opportunity. */ + pending: Game; + actions: PairedE3NexusStep[]; +} + +function settlementChoice(g: Game): PairedE3NexusStep | null { + const d = g.decision; + if (!d || g.response || g.phaseOpening) return null; + if (d.kind === 'battleLosses') return { actor: d.player, action: { type: 'decision', choice: 0 } }; + if (d.kind === 'battleCards') return { actor: d.player, action: { type: 'decision', discard: [] } }; + if (d.kind === 'moritaniRetention') return { actor: d.player, action: { type: 'decision', keep: null, + ...(d.source === 'nexus' ? { event: d.event } : {}) } }; + return quietPairedE3NexusChoice(g); +} + +/** Original cleanup only; no forged resolution or erased saved continuation. */ +export function settleMoritaniEcazDukeWinner(g: Game, stopAtChoice = false, + actions?: PairedE3NexusStep[]): Game { + for (let n = 0; n < 400; n++) { + if (!g.response && !g.phaseOpening && + (stopAtChoice && g.decision?.kind === 'moritaniAssassinate' || + !g.battle && pairedE3NexusClean(g))) return g; + const next = nextPairedE3NexusStep(g) ?? settlementChoice(g); + assert.ok(next, `Original Ecaz/Duke cleanup needs its owner at ${g.decision?.kind ?? g.phase}`); + g = stepPairedE3Nexus(g, next, actions); + } + throw Error('Original Ecaz/Duke battle cleanup did not finish'); +} + +export function createMoritaniEcazDukeWinner(): MoritaniEcazDukeWinner { + const f = createPairedE3NexusSetup({ roster: ['ecaz', 'moritani', 'guild'], + advanced: true, skills: false, homeworlds: false, discovery: false, + tech: false, strongholds: false, assassinationFaction: 'ecaz' }); + const actions = f.actions; + let g = pairedE3NexusReload(f.afterSetup); + // Reuse the native Duke fixture's original supply/empty-stronghold selection. + const native = quietPlan(g), token = native.token, location = native.destination; + const sector = territory(location).sectors.find(s => s !== g.storm)!; + assert.ok(Number.isInteger(sector)); + const target = leaders('ecaz').find(l => pairedE3NexusPlayer(g, f.moritani).traitors.includes(l.id))!.id; + assert.ok(target); + + g = advancePairedE3Nexus(g, s => !s.response && !s.phaseOpening && + s.decision?.kind === 'ecazPlacement', actions, quietPairedE3NexusChoice); + const placementBefore = pairedE3NexusReload(g); + const placementStep: PairedE3NexusStep = { actor: f.ecaz, + action: { type: 'decision', token, territory: location } }; + g = stepPairedE3Nexus(g, placementStep, actions); + g = advancePairedE3Nexus(g, s => !s.response && + s.ecazAmbassadors!.tokens.find(t => t.id === token)!.zone === 'placed', actions, quietPairedE3NexusChoice); + const placement = { before: placementBefore, step: placementStep, after: pairedE3NexusReload(g) }; + + const remaining = new Set([f.ecaz, f.moritani]); + const shipments: PairedE3NexusTransition[] = []; + let acquisition: PairedE3NexusTransition | null = null; + while (remaining.size) { + g = advancePairedE3Nexus(g, s => s.phase === 5 && pairedE3NexusClean(s) && + remaining.has(s.active!), actions, quietPairedE3NexusChoice); + const actor = g.active!, before = pairedE3NexusReload(g); + const step: PairedE3NexusStep = { actor, action: { type: 'ship', territory: location, + sector, amount: 1, elite: 0, allyPayment: 0 } }; + g = stepPairedE3Nexus(g, step, actions); + g = advancePairedE3Nexus(g, s => !s.response && !s.phaseOpening && + (s.decision?.kind === 'ecazAmbassador' || pairedE3NexusClean(s) && !s.pendingShipment), + actions, quietPairedE3NexusChoice); + if (actor === f.moritani) { + assert.equal(g.decision?.kind, 'ecazAmbassador'); + const entry = g.pendingAmbassador!; + assert.equal(entry.token, token); + assert.equal(entry.stage, 'offer'); + assert.equal(entry.entrant, f.moritani); + const acquire: PairedE3NexusStep = { actor: f.ecaz, action: { type: 'decision', + event: entry.event, trigger: true, beneficiary: f.ecaz, choice: 'duke' } }; + const acquisitionBefore = pairedE3NexusReload(g); + g = stepPairedE3Nexus(g, acquire, actions); + acquisition = { before: acquisitionBefore, step: acquire, after: pairedE3NexusReload(g) }; + } + g = advancePairedE3Nexus(g, s => pairedE3NexusClean(s) && !s.pendingShipment, + actions, quietPairedE3NexusChoice); + shipments.push({ before, step, after: pairedE3NexusReload(g) }); + remaining.delete(actor); + if (remaining.size) g = stepPairedE3Nexus(g, { actor, action: { type: 'endMovement' } }, actions); + } + assert.ok(acquisition); + assert.equal(g.dukeVidal!.controller, f.ecaz); + assert.equal(g.dukeVidal!.source, 'ecaz'); + g = advancePairedE3Nexus(g, s => s.phase === 6 && pairedE3NexusClean(s), actions, quietPairedE3NexusChoice); + const beforeBattle = pairedE3NexusReload(g); + const physical = viewGame(g, g.active!).battleChoices.find(c => c.territory === location); + assert.ok(physical); + const choice: PairedE3NexusStep = { actor: physical.chooser, action: { type: 'chooseBattle', + territory: location, target: physical.attacker === physical.chooser ? physical.defender : physical.attacker } }; + g = stepPairedE3Nexus(g, choice, actions); + const losing = pairedE3NexusPlayer(g, f.moritani).leaders.filter(l => !l.dead && !l.usedAt) + .sort((a, b) => a.strength - b.strength)[0]; + assert.ok(losing && losing.strength < 6); + const plans: PairedE3NexusStep[] = [ + { actor: f.ecaz, action: { type: 'battlePlan', leader: DUKE_VIDAL_ID, dial: 0, support: 0 } }, + { actor: f.moritani, action: { type: 'battlePlan', leader: losing.id, dial: 0, support: 0 } }, + ]; + const ready = revealPairedE3Nexus(g, plans, actions); + const pending = settleMoritaniEcazDukeWinner(ready, true, actions); + assert.equal(pending.decision?.kind, 'moritaniAssassinate', + 'A real normal Moritani loss to Ecaz using surviving Duke6 must open the private opportunity'); + pairedE3NexusInventory(pending); + return { initial: f.initial, offered: f.offered, afterSetup: f.afterSetup, + ecaz: f.ecaz, moritani: f.moritani, token, location, sector, target, + placement, acquisition, shipments, beforeBattle, choice, plans, ready, pending, actions }; +} diff --git a/tests/fixture-moritani-foreign-ghola-winner.ts b/tests/fixture-moritani-foreign-ghola-winner.ts new file mode 100644 index 00000000..71d2adb5 --- /dev/null +++ b/tests/fixture-moritani-foreign-ghola-winner.ts @@ -0,0 +1,333 @@ +import assert from 'node:assert/strict'; +import { applyAction, viewGame, type Action, type Game } from '../game/engine'; +import { leaders, type Card, type Leader } from '../game/cards'; +import { splitLocation, territory } from '../game/board'; +import { + advanceMixedE3Moritani, assertMixedE3MoritaniPhysical, createMixedE3MoritaniSetup, + mixedE3MoritaniClean, mixedE3MoritaniPlayer, mixedE3MoritaniReload, openMixedE3MoritaniBattle, + orderMixedE3MoritaniSpice, revealMixedE3MoritaniPlans, settleMixedE3MoritaniBoundary, + shipMixedE3Moritani, stepMixedE3Moritani, + type MixedE3MoritaniBoundary, type MixedE3MoritaniOptions, type MixedE3MoritaniSetup, + type MixedE3MoritaniStep, +} from './fixture-mixed-e3-moritani-nexus'; + +/** + * Genuine original mixed E1/E3 no-Skills Nexus seats (`moritani, tleilaxu, + * guild, emperor`; Ecaz plus the Ix deck; no Skills and no other optional + * module). The only directed entropy is the original undealt Treachery deal, + * so that both the native Tleilaxu seat and the classic Guild seat hold one + * physical projectile card. Every hand, Traitor/Face Dancer, force, leader, + * spice balance, shipment, plan, casualty and revival below is the engine’s own. + */ +export const foreignGholaWinnerOptions: MixedE3MoritaniOptions = { + rules: 'advanced', + families: ['ix'], + natives: { ix: 'tleilaxu' }, + skills: false, + startingCards: { guild: ['projectile'] }, +}; + +/** The ordinary disc that really dies in combat and is later revived as a ghola. */ +export const FOREIGN_GHOLA_DISC = 'guild-0'; +/** Tleilaxu’s held native Traitor identity: the printed clock-3 Zoal card. */ +const HELD_NATIVE_TRAITOR = 'tleilaxu-0'; +const SHIPMENT_FORCES = 2; +/** The whole-dial commitment that exactly consumes those shipped counters. */ +const WINNING_DIAL = 1; + +export type ForeignGholaWinnerBattle = { + before: Game; plans: MixedE3MoritaniStep[]; revealed: Game; after: Game; + order: MixedE3MoritaniBoundary['kind'][]; + winner: string; loser: string; territory: string; +}; +export type ForeignGholaWinnerRevival = { + before: Game; step: MixedE3MoritaniStep; after: Game; ghola: Leader; + deadDisc: Leader; livingBefore: number; cost: number; +}; +export type ForeignGholaWinnerWindow = { + battle: Omit; + /** The exact state that holds the private `moritaniAssassinate` decision. */ + game: Game; + event: string; turn: number; territory: string; owner: string; opponent: string; + faction: string; opposingLeader: string; + /** Owner-only opportunity; a rival view must stay empty. */ + cards: { card: string; name: string; bounty: number; dead: boolean }[]; + blocked: string | null; + rivalCards: number; +}; +export type ForeignGholaWinnerFixture = { + setup: MixedE3MoritaniSetup; + initial: Game; afterSetup: Game; + moritani: string; tleilaxu: string; guild: string; emperor: string; + /** Moritani’s own printed stronghold; its real six counters stay there. */ + location: string; + /** The genuinely held native Tleilaxu Traitor Card and its printed bounty. */ + target: string; bounty: number; + /** Tleilaxu’s own disc that really died beside the Guild disc. */ + deadTleilaxuDisc: string; + guildBattle: ForeignGholaWinnerBattle; + revival: ForeignGholaWinnerRevival; + window: ForeignGholaWinnerWindow; + actions: MixedE3MoritaniStep[]; + staging: string[]; +}; + +/** A real sector of the destination that the shipped counters may legally use. */ +function arrivalSector(g: Game, id: string): number { + const sectors = territory(id).sectors; + return sectors.find((sector) => sector === 0 || sector !== g.storm) ?? sectors[0]; +} +/** A living, unused disc of one real seat, strongest or weakest first. */ +function freeDisc(g: Game, actor: string, order: 'strong' | 'weak', exclude: readonly string[] = []): Leader { + const pool = mixedE3MoritaniPlayer(g, actor).leaders + .filter((l) => !l.dead && !l.capturedBy && !l.gholaBy && !l.usedAt && !exclude.includes(l.id)); + const sorted = [...pool].sort((a, b) => order === 'strong' ? b.strength - a.strength : a.strength - b.strength); + assert.ok(sorted[0], `The original ${actor} pool must still hold a usable disc.`); + return sorted[0]; +} +function cardInHand(g: Game, actor: string, kind: Card['kind']): string { + const card = mixedE3MoritaniPlayer(g, actor).hand.find((c) => c.kind === kind); + assert.ok(card, `The original deal must have given ${actor} a ${kind}.`); + return card.id; +} + +/** Consume only the real owner-labelled boundaries a resolved battle produces. */ +function drive(g: Game, actions: MixedE3MoritaniStep[]): { game: Game; order: MixedE3MoritaniBoundary['kind'][] } { + const order: MixedE3MoritaniBoundary['kind'][] = []; + for (let n = 0; n < 24; n++) { + const boundary = settleMixedE3MoritaniBoundary(g, null, actions); + order.push(boundary.kind); + g = boundary.game; + if (boundary.kind === 'settled') return { game: g, order }; + const decision = g.decision; + assert.ok(decision, `The ${boundary.kind} boundary needs its real owner decision.`); + if (decision.kind === 'battleLosses') + g = stepMixedE3Moritani(g, { actor: decision.player, + action: { type: 'decision', choice: 0 } }, actions); + else if (decision.kind === 'battleCards') + g = stepMixedE3Moritani(g, { actor: decision.player, + action: { type: 'decision', discard: [] } }, actions); + else if (decision.kind === 'faceDance') + g = stepMixedE3Moritani(g, { actor: decision.player, + action: { type: 'decision', reveal: false } }, actions); + else if (decision.kind === 'techToken') + g = stepMixedE3Moritani(g, { actor: decision.player, + action: { type: 'decision', token: decision.choices[0] } }, actions); + else assert.fail(`Unexpected original battle aftermath decision ${decision.kind}.`); + } + throw Error('The original battle aftermath did not settle.'); +} + +/** One complete original battle: real declaration, real sealed plans, real cleanup. */ +function fightBattle(g: Game, first: string, second: string, plans: readonly MixedE3MoritaniStep[], + actions: MixedE3MoritaniStep[]): ForeignGholaWinnerBattle { + const before = mixedE3MoritaniReload(g); + const declared = advanceMixedE3Moritani(openMixedE3MoritaniBattle(before, first, second, actions), + (s) => !!s.battle && mixedE3MoritaniClean(s) && !s.battle.preparation && + s.battle.preLeader?.closed !== false, actions); + const revealed = revealMixedE3MoritaniPlans(mixedE3MoritaniReload(declared), plans, actions); + const driven = drive(revealed, actions); + const after = mixedE3MoritaniReload(driven.game); + assert.equal(after.battle ?? null, null, 'The original battle must settle.'); + return { before, plans: [...plans], revealed, after, order: driven.order, + winner: after.lastBattleContext!.winner!, loser: after.lastBattleContext!.combatants + .find((id) => id !== after.lastBattleContext!.winner)!, territory: after.lastBattleContext!.territory }; +} + +/** + * An actual ordinary foreign native disc dies through original combat and + * Tleilaxu legally revives it as a foreign ghola; a later real Tleilaxu victory + * with that surviving disc opens Moritani’s private Advanced loss choice, whose + * logical faction stays Tleilaxu and whose target pool is Tleilaxu’s printed + * Traitor identities — not the borrowed disc’s printed faction. + */ +export function createMoritaniForeignGholaWinnerFixture(): ForeignGholaWinnerFixture { + const setup = createMixedE3MoritaniSetup(foreignGholaWinnerOptions); + const actions = setup.actions; + const staging = [ + 'Two original controls only: the undealt Treachery deal entropy (so the native Tleilaxu seat and the classic Guild seat each hold one physical projectile card), and the shared helper’s positioning of unplayed original Spice Cards before the opening blow. No starting card, Traitor/Face Dancer, force, leader, spice balance, plan or battle is assigned.', + 'Both shipments are ordinary phase-five Shipment actions, both battles are declared from the engine’s own battle-choice offer with real private plans, and the foreign return is the engine’s own Revival action with its own price and custody.', + ]; + const moritani = setup.moritani, tleilaxu = setup.native; + const guild = setup.seats.guild!, emperor = setup.seats.emperor!; + assert.ok(guild && emperor && tleilaxu && moritani); + assert.equal(setup.nativeFaction, 'tleilaxu'); + assert.equal(setup.target, HELD_NATIVE_TRAITOR); + assert.ok(mixedE3MoritaniPlayer(setup.afterSetup, moritani).traitors.includes(setup.target), + 'The setup deal really gave Moritani the native Tleilaxu Traitor Card.'); + + let g = mixedE3MoritaniReload(setup.afterSetup); + orderMixedE3MoritaniSpice(g); + + // Turn one: Tleilaxu really ships into Tuek’s Sietch, where the ordinary Guild + // disc and one of Tleilaxu’s own discs die in the original sealed battle. + const guildHome = splitLocation(Object.keys(mixedE3MoritaniPlayer(g, guild).forces)[0]).territory; + const shipment = shipMixedE3Moritani(g, tleilaxu, + `${guildHome}:${arrivalSector(g, guildHome)}`, SHIPMENT_FORCES, 0, actions); + assert.equal(mixedE3MoritaniPlayer(shipment.before, tleilaxu).reserves - + mixedE3MoritaniPlayer(shipment.settled, tleilaxu).reserves, SHIPMENT_FORCES, + 'The real Tleilaxu shipment moved exactly its own reserve counters.'); + const tleilaxuVictim = freeDisc(shipment.settled, tleilaxu, 'strong', [setup.target]); + assert.notEqual(tleilaxuVictim.id, setup.target, + 'The disc Moritani holds a Traitor Card for stays out of this battle.'); + const guildBattle = fightBattle(shipment.settled, guild, tleilaxu, [ + { actor: tleilaxu, action: { type: 'battlePlan', leader: tleilaxuVictim.id, + dial: WINNING_DIAL, support: 0, weapon: cardInHand(shipment.settled, tleilaxu, 'projectile') } }, + { actor: guild, action: { type: 'battlePlan', leader: FOREIGN_GHOLA_DISC, + dial: 0, support: 0, weapon: cardInHand(shipment.settled, guild, 'projectile') } }, + ], actions); + assert.equal(guildBattle.winner, tleilaxu, 'The native Tleilaxu really wins the original battle.'); + const killedGuild = mixedE3MoritaniPlayer(guildBattle.after, guild) + .leaders.find((l) => l.id === FOREIGN_GHOLA_DISC)!; + const killedTleilaxu = mixedE3MoritaniPlayer(guildBattle.after, tleilaxu) + .leaders.find((l) => l.id === tleilaxuVictim.id)!; + assert.equal(killedGuild.dead, true, 'The ordinary Guild disc really died in combat.'); + assert.equal(killedGuild.gholaBy, undefined); + assert.equal(killedTleilaxu.dead, true, 'Tleilaxu loses one of its own discs in that battle.'); + assert.equal(mixedE3MoritaniPlayer(guildBattle.after, tleilaxu).leaders.filter((l) => !l.dead).length, 4, + 'Tleilaxu is one disc short of the five-disc active pool.'); + assert.equal(mixedE3MoritaniPlayer(guildBattle.after, tleilaxu) + .leaders.find((l) => l.id === setup.target)!.dead, false, 'The held target disc is still alive.'); + + // Turn two revival: the genuine half-price foreign return, settled by the engine. + g = advanceMixedE3Moritani(guildBattle.after, + (s) => s.turn === 2 && s.phase === 4 && mixedE3MoritaniClean(s), actions); + const beforeRevival = mixedE3MoritaniReload(g); + const cost = Math.ceil(killedGuild.strength / 2); + assert.ok(mixedE3MoritaniPlayer(beforeRevival, tleilaxu).spice >= cost, + 'The genuine battle bounty funds the real foreign return.'); + const revivalStep: MixedE3MoritaniStep = { actor: tleilaxu, + action: { type: 'reviveForeignGhola', leader: FOREIGN_GHOLA_DISC } }; + g = advanceMixedE3Moritani(stepMixedE3Moritani(beforeRevival, revivalStep, actions), + (s) => mixedE3MoritaniClean(s) && !s.pendingRevival, actions); + const afterRevival = mixedE3MoritaniReload(g); + const ghola = mixedE3MoritaniPlayer(afterRevival, guild).leaders.find((l) => l.id === FOREIGN_GHOLA_DISC)!; + assert.equal(ghola.dead, false, 'The real Tleilaxu revival returned the foreign disc.'); + assert.equal(ghola.gholaBy, tleilaxu, 'The disc is held as a Tleilaxu foreign ghola.'); + assert.equal(ghola.faction, 'guild', 'Its printed faction is unchanged.'); + assert.equal(mixedE3MoritaniPlayer(afterRevival, tleilaxu).spice, + mixedE3MoritaniPlayer(beforeRevival, tleilaxu).spice - cost, 'The half-price cost was paid once.'); + assert.equal(mixedE3MoritaniPlayer(afterRevival, tleilaxu).leaderRevived, true); + const revival: ForeignGholaWinnerRevival = { before: beforeRevival, step: revivalStep, + after: afterRevival, ghola, deadDisc: killedGuild, livingBefore: 4, cost }; + + // Turn two battle: the surviving foreign ghola really wins against Moritani. + assert.ok((mixedE3MoritaniPlayer(afterRevival, moritani).forces[setup.location] ?? 0) > 0, + 'Moritani must really hold its own starting stronghold to lose the battle there.'); + g = shipMixedE3Moritani(afterRevival, tleilaxu, setup.location, SHIPMENT_FORCES, 0, actions).settled; + const lossBefore = mixedE3MoritaniReload(g); + const lossPlans: MixedE3MoritaniStep[] = [ + { actor: moritani, action: { type: 'battlePlan', leader: freeDisc(lossBefore, moritani, 'weak').id, + dial: 0, support: 0 } }, + { actor: tleilaxu, action: { type: 'battlePlan', leader: FOREIGN_GHOLA_DISC, + dial: WINNING_DIAL, support: 0 } }, + ]; + const lossDeclared = advanceMixedE3Moritani(openMixedE3MoritaniBattle(lossBefore, moritani, tleilaxu, actions), + (s) => !!s.battle && mixedE3MoritaniClean(s) && !s.battle.preparation && + s.battle.preLeader?.closed !== false, actions); + const lossRevealed = revealMixedE3MoritaniPlans(lossDeclared, lossPlans, actions); + const opened = settleMixedE3MoritaniBoundary(lossRevealed, null, actions); + assert.equal(opened.kind, 'assassination', + `The foreign-ghola victory must open Moritani’s private choice first (${opened.kind}).`); + g = opened.game; + assert.equal(g.decision?.kind, 'moritaniAssassinate'); + const receipt = g.moritaniAssassinate!.opportunities.at(-1)!; + assert.equal(receipt.stage, 'choice'); + assert.equal(receipt.opposingLeader, FOREIGN_GHOLA_DISC, + 'The trigger binds the physically foreign surviving disc, not the winner’s own roster.'); + assert.equal(leaders('tleilaxu').some((l) => l.id === receipt.opposingLeader), false, + 'No Tleilaxu disc won this battle.'); + assert.equal(receipt.faction, 'tleilaxu', + 'The logical faction is the victorious player, so the target pool is Tleilaxu’s.'); + const own = viewGame(g, moritani).moritaniAssassinate!; + assert.ok(own.pending, 'Moritani must really receive the private opportunity.'); + const rival = viewGame(g, tleilaxu).moritaniAssassinate!; + assert.equal(mixedE3MoritaniPlayer(g, guild).leaders.find((l) => l.id === FOREIGN_GHOLA_DISC)!.dead, false, + 'The winning foreign disc survived the battle.'); + const window: ForeignGholaWinnerWindow = { + battle: { before: lossBefore, plans: lossPlans, revealed: lossRevealed, after: mixedE3MoritaniReload(g), + winner: tleilaxu, loser: moritani, territory: setup.location }, + game: mixedE3MoritaniReload(g), + event: receipt.event, turn: receipt.turn, territory: receipt.territory, owner: receipt.owner, + opponent: receipt.opponent, faction: receipt.faction, opposingLeader: receipt.opposingLeader, + cards: own.pending!.cards.map((choice) => ({ ...choice })), + blocked: own.pending!.blocked, rivalCards: rival.pending?.cards.length ?? 0, + }; + assert.ok(window.cards.some((choice) => choice.card === setup.target), + 'The genuinely held native Tleilaxu Traitor Card is a legal reveal.'); + assert.equal(window.cards.every((choice) => + leaders('tleilaxu').some((l) => l.id === choice.card)), true, + 'Every eligible reveal is a printed Tleilaxu Traitor identity.'); + assert.equal(window.cards.some((choice) => choice.card === FOREIGN_GHOLA_DISC), false, + 'The borrowed disc’s printed faction never supplies the target pool.'); + assert.equal(window.rivalCards, 0, 'Rivals see the decision without Moritani’s private options.'); + assert.equal(window.cards.find((choice) => choice.card === setup.target)!.bounty, + leaders('tleilaxu')[0].strength, 'The printed bounty of the held card is quoted.'); + + return { + setup, initial: setup.initial, afterSetup: setup.afterSetup, + moritani, tleilaxu, guild, emperor, location: setup.location, + target: setup.target, bounty: leaders('tleilaxu')[0].strength, + deadTleilaxuDisc: tleilaxuVictim.id, guildBattle, revival, window, actions, staging, + }; +} + +export type ForeignGholaWinnerOutcome = { + window: Game; after: Game; mentat: Game; replacement: string; retired: string; bounty: number; +}; + +/** + * Reveal the real held native Tleilaxu Traitor Card, then walk the original + * battle cleanup, phase seven and the Mentat Pause set-aside/replacement to the + * point where the engine has already drawn the private replacement. + */ +export function revealMoritaniForeignGholaWitness(f: ForeignGholaWinnerFixture, + card: string = f.target): ForeignGholaWinnerOutcome { + let g = mixedE3MoritaniReload(f.window.game); + assert.equal(g.decision?.kind, 'moritaniAssassinate'); + const choice = f.window.cards.find((candidate) => candidate.card === card); + assert.ok(choice, 'Choose one of the real private reveal options.'); + const before = mixedE3MoritaniPlayer(g, f.moritani); + const victim = mixedE3MoritaniPlayer(g, f.tleilaxu).leaders.find((l) => l.id === card)!; + const rivalSpice = mixedE3MoritaniPlayer(g, f.tleilaxu).spice; + const reserveBefore = [...(g.traitorReserve ?? [])]; + g = stepMixedE3Moritani(g, { actor: f.moritani, + action: { type: 'decision', event: f.window.event, card } }, f.actions); + const after = mixedE3MoritaniReload(g); + const killed = mixedE3MoritaniPlayer(after, f.tleilaxu).leaders.find((l) => l.id === card)!; + assert.equal(killed.dead, true, 'The revealed native disc really died.'); + assert.equal(killed.deaths, victim.deaths + 1); + assert.equal(mixedE3MoritaniPlayer(after, f.moritani).spice, before.spice + choice.bounty, + 'The printed bounty is paid once from the bank.'); + assert.equal(mixedE3MoritaniPlayer(after, f.tleilaxu).spice, rivalSpice, 'The revealing seat pays nothing.'); + assert.equal(after.moritaniAssassinate!.opportunities.at(-1)!.stage, 'revealed'); + const settled = advanceMixedE3Moritani(after, + (s) => s.turn === 2 && s.phase === 8 && + s.moritaniAssassinate?.opportunities.at(-1)?.stage === 'replaced', f.actions); + const receipt = settled.moritaniAssassinate!.opportunities.at(-1)!; + assert.equal(receipt.stage, 'replaced'); + assert.equal(receipt.card, card); + const replacement = receipt.replacement; + assert.ok(replacement && replacement !== card); + assert.equal(settled.traitorReserve!.length, reserveBefore.length - 1, + 'Exactly one private Mentat replacement was drawn.'); + assert.equal(settled.traitorReserve!.includes(card), false, + 'The revealed card stays a public face-up set-aside, never back in the reserve.'); + assert.equal(mixedE3MoritaniPlayer(settled, f.moritani).traitors.includes(replacement), true); + assert.equal(mixedE3MoritaniPlayer(settled, f.moritani).traitors.includes(card), false); + assert.equal(Object.hasOwn(viewGame(settled, f.tleilaxu).moritaniAssassinate!.history.at(-1)!, + 'replacement'), false, 'The public history never carries the private replacement.'); + assertMixedE3MoritaniPhysical(settled); + const heldBefore = mixedE3MoritaniPlayer(f.window.game, f.moritani).forces[f.location] ?? 0; + assert.equal(mixedE3MoritaniPlayer(settled, f.moritani).forces[f.location] ?? 0, heldBefore, + 'Moritani’s real counters at its starting stronghold are unchanged by the loss and its aftermath.'); + return { window: f.window.game, after, mentat: settled, replacement, retired: card, bounty: choice.bounty }; +} + + +/** A rejected action must not move any original stock. */ +export function rejectForeignGholaWinnerAction(g: Game, actor: string, action: Action): void { + const before = mixedE3MoritaniReload(g); + assert.throws(() => applyAction(g, actor, action)); + assert.deepEqual(g, before); +} diff --git a/tests/moritani-assassinate-quote.test.ts b/tests/moritani-assassinate-quote.test.ts index 803ca13b..4ea1cc0d 100644 --- a/tests/moritani-assassinate-quote.test.ts +++ b/tests/moritani-assassinate-quote.test.ts @@ -88,7 +88,6 @@ void test('public trigger requires a native Advanced Moritani normal loss, a sur { traitorCalled: true }, { opposingLeader: null }, { opposingLeader: 'cheap-hero-traitor' }, - { opposingLeader: 'emperor-0' }, { turn: 0 }, { turn: 1.5 }, { event: '' }, diff --git a/tests/moritani-auditor-winner.test.ts b/tests/moritani-auditor-winner.test.ts new file mode 100644 index 00000000..cc5a6669 --- /dev/null +++ b/tests/moritani-auditor-winner.test.ts @@ -0,0 +1,117 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { leaders } from '../game/cards'; +import { CHOAM_AUDITOR_ID } from '../game/choam-auditor'; +import { applyAction, viewGame } from '../game/engine'; +import { + assertMixedE3MoritaniPhysical, mixedE3MoritaniPlayer, + mixedE3MoritaniTraitorCustody, quoteMixedE3MoritaniBattle, +} from './fixture-mixed-e3-moritani-nexus'; +import { + createMoritaniAuditorWinnerFixture, finishMoritaniAuditorReplacement, + pendingMoritaniAuditorAssassination, revealMoritaniAuditorTarget, revealMoritaniAuditorWinner, +} from './fixture-moritani-auditor-winner'; + +void test('an original surviving Auditor2 win opens native CHOAM assassination, printed bounty and distinct original Mentat replacement', () => { + const f = createMoritaniAuditorWinnerFixture(); + const printedTarget = leaders('choam').find(l => l.id === f.target); + assert.ok(printedTarget); + assertMixedE3MoritaniPhysical(f.readyBattle); + + const revealed = revealMoritaniAuditorWinner(f); + assert.equal(revealed.battle!.plans[f.native].leader, CHOAM_AUDITOR_ID); + assert.equal(revealed.battle!.plans[f.native].dial, 0); + assert.equal(quoteMixedE3MoritaniBattle(revealed).winner, f.native); + const pending = pendingMoritaniAuditorAssassination(f, revealed); + assert.equal(pending.lastBattleContext!.winner, f.native); + assert.equal(pending.lastBattleContext!.result, 'normal'); + assert.equal(pending.moritaniAssassinate!.normalTraitorCall, false); + assert.equal(mixedE3MoritaniPlayer(pending, f.native).leaders.find(l => l.id === CHOAM_AUDITOR_ID)?.dead, false); + const receipt = pending.moritaniAssassinate!.opportunities.at(-1)!; + assert.equal(receipt.faction, 'choam'); + assert.equal(receipt.opponent, f.native); + assert.equal(receipt.opposingLeader, CHOAM_AUDITOR_ID); + const ownChoice = viewGame(pending, f.moritani).moritaniAssassinate!.pending; + assert.ok(ownChoice, 'The actual owner receives the real private opportunity.'); + assert.equal(ownChoice.blocked, null); + assert.deepEqual(ownChoice.cards.map(c => c.card), [f.target]); + assert.equal(ownChoice.cards[0].bounty, printedTarget.strength); + assert.deepEqual(viewGame(pending, f.native).moritaniAssassinate!.pending!.cards, []); + + const targetBefore = mixedE3MoritaniPlayer(pending, f.native).leaders.find(l => l.id === f.target)!; + assert.equal(targetBefore.dead, false); + const beforeSpice = mixedE3MoritaniPlayer(pending, f.moritani).spice; + const enemySpice = mixedE3MoritaniPlayer(pending, f.native).spice; + const reveal = revealMoritaniAuditorTarget(f, pending); + const killed = mixedE3MoritaniPlayer(reveal.after, f.native).leaders.find(l => l.id === f.target)!; + assert.equal(killed.dead, true); + assert.equal(killed.deaths, targetBefore.deaths + 1); + assert.equal(mixedE3MoritaniPlayer(reveal.after, f.moritani).spice, beforeSpice + printedTarget.strength, + 'The native target pays its printed bounty, not Auditor2.'); + assert.equal(mixedE3MoritaniPlayer(reveal.after, f.native).spice, enemySpice); + assert.ok(mixedE3MoritaniPlayer(reveal.after, f.moritani).traitors.includes(f.target), + 'The revealed original physical Traitor stays held until Mentat.'); + + const nextPhysical = reveal.after.traitorReserve!.find(card => card !== f.target); + assert.ok(nextPhysical); + const replaced = finishMoritaniAuditorReplacement(f, reveal.after); + assert.equal(replaced.phase, 8); + assert.equal(replaced.turn, pending.turn); + assert.equal(mixedE3MoritaniPlayer(replaced, f.native).leaders.find(l => l.id === CHOAM_AUDITOR_ID)?.dead, false); + assert.equal(mixedE3MoritaniPlayer(replaced, f.native).forces[f.location], 1, + 'The genuinely shipped CHOAM force survives the zero-dial win.'); + const completed = replaced.moritaniAssassinate!.opportunities.filter(r => r.event === receipt.event); + assert.equal(completed.length, 1); + assert.equal(completed[0].stage, 'replaced'); + assert.equal(completed[0].card, f.target); + assert.equal(completed[0].bounty, printedTarget.strength); + assert.equal(completed[0].replacement, nextPhysical); + assert.notEqual(completed[0].replacement, f.target); + assert.equal(mixedE3MoritaniPlayer(replaced, f.moritani).traitors.includes(f.target), false); + assert.ok(mixedE3MoritaniPlayer(replaced, f.moritani).traitors.includes(nextPhysical)); + assert.equal(replaced.traitorReserve!.length, reveal.after.traitorReserve!.length - 1); + assert.equal(replaced.traitorReserve!.includes(f.target), false); + assert.deepEqual(mixedE3MoritaniTraitorCustody(replaced), mixedE3MoritaniTraitorCustody(f.afterSetup)); + assertMixedE3MoritaniPhysical(replaced); +}); + +void test('a different surviving CHOAM disc permits the actual Auditor Traitor target for printed two and original replacement', () => { + const f = createMoritaniAuditorWinnerFixture({ auditorTarget: true }); + const pending = pendingMoritaniAuditorAssassination(f); + const own = viewGame(pending, f.moritani).moritaniAssassinate!.pending!; + assert.deepEqual(own.cards.map(c => ({ card: c.card, bounty: c.bounty })), [ + { card: CHOAM_AUDITOR_ID, bounty: 2 }, + ]); + const beforeSpice = mixedE3MoritaniPlayer(pending, f.moritani).spice; + const reveal = revealMoritaniAuditorTarget(f, pending); + const auditor = mixedE3MoritaniPlayer(reveal.after, f.native).leaders.find(l => l.id === CHOAM_AUDITOR_ID)!; + assert.equal(auditor.dead, true); + assert.equal(auditor.deaths, 1); + assert.equal(mixedE3MoritaniPlayer(reveal.after, f.moritani).spice, beforeSpice + 2); + const replaced = finishMoritaniAuditorReplacement(f, reveal.after); + const receipt = replaced.moritaniAssassinate!.opportunities.at(-1)!; + assert.equal(receipt.card, CHOAM_AUDITOR_ID); + assert.equal(receipt.stage, 'replaced'); + assert.notEqual(receipt.replacement, CHOAM_AUDITOR_ID); + assert.ok(mixedE3MoritaniPlayer(replaced, f.moritani).traitors.includes(receipt.replacement!)); + assertMixedE3MoritaniPhysical(replaced); +}); + +void test('the actual played Auditor is excluded from its own held assassination card without suppressing the private opportunity', () => { + const f = createMoritaniAuditorWinnerFixture({ auditorTarget: true }); + f.plans[1].action = { type: 'battlePlan', leader: CHOAM_AUDITOR_ID, dial: 0, support: 0 }; + const pending = pendingMoritaniAuditorAssassination(f); + const own = viewGame(pending, f.moritani).moritaniAssassinate!.pending!; + assert.deepEqual(own.cards, []); + const before = structuredClone(pending); + assert.throws(() => applyAction(pending, f.moritani, + { type: 'decision', event: own.event, card: CHOAM_AUDITOR_ID })); + assert.deepEqual(pending, before); + const beforeSpice = mixedE3MoritaniPlayer(pending, f.moritani).spice; + const declined = applyAction(pending, f.moritani, { type: 'decision', event: own.event, decline: true }); + assert.equal(mixedE3MoritaniPlayer(declined, f.native).leaders.find(l => l.id === CHOAM_AUDITOR_ID)!.dead, false); + assert.equal(mixedE3MoritaniPlayer(declined, f.moritani).spice, beforeSpice); + assert.ok(mixedE3MoritaniPlayer(declined, f.moritani).traitors.includes(CHOAM_AUDITOR_ID)); + assert.equal(declined.moritaniAssassinate!.opportunities.at(-1)!.stage, 'declined'); + assertMixedE3MoritaniPhysical(declined); +}); diff --git a/tests/moritani-ecaz-duke-winner.test.ts b/tests/moritani-ecaz-duke-winner.test.ts new file mode 100644 index 00000000..c1848cf1 --- /dev/null +++ b/tests/moritani-ecaz-duke-winner.test.ts @@ -0,0 +1,135 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { applyAction, viewGame, type Action, type Game } from '../game/engine'; +import { leaders } from '../game/cards'; +import { DUKE_VIDAL_ID } from '../game/duke-vidal'; +import { botArrivalBlock } from '../game/bot-arrival'; +import { + advancePairedE3Nexus, pairedE3NexusInventory, + pairedE3NexusPlayer as player, pairedE3NexusReload as reload, + quietPairedE3NexusChoice, quotePairedE3NexusBattle, + stepPairedE3Nexus as step, +} from './fixture-paired-e3-nexus-modules'; +import { createMoritaniEcazDukeWinner, settleMoritaniEcazDukeWinner } from './fixture-moritani-ecaz-duke-winner'; + +function reject(g: Game, actor: string, action: Action): void { + const before = structuredClone(g); + assert.throws(() => applyAction(g, actor, action)); + assert.deepEqual(g, before); +} + +void test('original Ecaz Ambassador Duke6 win opens the formerly missing native opportunity, pays printed Ecaz bounty and draws the physical Mentat replacement', () => { + const f = createMoritaniEcazDukeWinner(); + const { pending, ready, ecaz, moritani, target, location } = f; + + // Physical paid acquisition and shipments, not an assigned shared controller. + assert.equal(player(f.placement.after, ecaz).spice, player(f.placement.before, ecaz).spice - 1); + assert.equal(f.placement.after.ecazAmbassadors!.tokens.find(t => t.id === f.token)!.zone, 'placed'); + assert.equal(f.acquisition.before.pendingAmbassador!.entrant, moritani); + assert.equal(f.acquisition.before.dukeVidal!.controller, null); + assert.equal(f.acquisition.after.dukeVidal!.controller, ecaz); + assert.equal(f.acquisition.after.dukeVidal!.source, 'ecaz'); + assert.equal(f.acquisition.after.ecazAmbassadors!.tokens.find(t => t.id === f.token)!.zone, 'supply'); + for (const shipment of f.shipments) { + const actor = shipment.step.actor; + assert.equal(player(shipment.after, actor).reserves, player(shipment.before, actor).reserves - 1); + assert.equal(player(shipment.after, actor).forces[`${location}:${f.sector}`], 1); + assert.equal(player(shipment.after, actor).spice, player(shipment.before, actor).spice - 1); + assert.equal(player(shipment.after, actor).shipped, true); + } + const paidMoritaniShipment = f.shipments.find(s => s.step.actor === moritani)!; + assert.equal(botArrivalBlock(viewGame(paidMoritaniShipment.before, moritani), paidMoritaniShipment.step.action), null); + assert.equal(ready.battle!.plans[ecaz].leader, DUKE_VIDAL_ID); + assert.equal(ready.dukeVidal!.controller, ecaz); + assert.equal(quotePairedE3NexusBattle(ready).winner, ecaz); + assert.equal(pending.lastBattleContext!.winner, ecaz); + assert.equal(pending.lastBattleContext!.result, 'normal'); + assert.equal(pending.moritaniAssassinate!.normalTraitorCall, false); + + // The old native-roster/Harkonnen-only winner gate omitted this real offer: + // the winning physical shared disc is not in Ecaz's ordinary five-disc list. + const receipt = pending.moritaniAssassinate!.opportunities[0]; + assert.equal(pending.moritaniAssassinate!.opportunities.length, 1); + assert.equal(receipt.stage, 'choice'); + assert.equal(receipt.faction, 'ecaz'); + assert.equal(receipt.opponent, ecaz); + assert.equal(receipt.opposingLeader, DUKE_VIDAL_ID); + assert.equal(receipt.event, pending.lastBattleContext!.event); + assert.equal(pending.dukeVidal!.controller, null); + assert.equal(pending.dukeVidal!.source, null); + assert.equal(pending.dukeVidal!.acquiredTurn, null); + assert.equal(pending.dukeVidal!.leader.usedAt, location); + assert.equal(pending.dukeVidal!.leader.dead, false); + assert.equal(pending.dukeVidal!.leader.deaths, 0); + assert.equal(pending.dukeVidal!.leader.faction, 'ecaz'); + assert.equal(pending.dukeVidal!.leader.strength, 6); + + const native = leaders('ecaz').find(l => l.id === target)!; + assert.ok(player(f.afterSetup, moritani).traitors.includes(target)); + const offer = viewGame(reload(pending), moritani).moritaniAssassinate!.pending!; + assert.equal(offer.blocked, null); + assert.deepEqual(offer.cards.map(c => c.card), [target]); + assert.equal(offer.cards[0].bounty, native.strength); + assert.equal(viewGame(pending, ecaz).moritaniAssassinate!.pending!.cards.length, 0); + const d = pending.decision; + assert.ok(d?.kind === 'moritaniAssassinate'); + reject(pending, moritani, { type: 'decision', event: d.event, card: DUKE_VIDAL_ID }); + const beforeTarget = player(pending, ecaz).leaders.find(l => l.id === target)!; + const expectedReplacement = pending.traitorReserve!.find(id => id !== target)!; + const revealed = step(reload(pending), { actor: moritani, + action: { type: 'decision', event: d.event, card: target } }); + const killed = player(revealed, ecaz).leaders.find(l => l.id === target)!; + assert.equal(killed.dead, true); + assert.equal(killed.deaths, beforeTarget.deaths + 1); + assert.equal(player(revealed, moritani).spice, player(pending, moritani).spice + native.strength); + assert.equal(revealed.dukeVidal!.leader.dead, false); + assert.ok(player(revealed, moritani).traitors.includes(target)); + assert.equal(revealed.moritaniAssassinate!.opportunities[0].stage, 'revealed'); + pairedE3NexusInventory(revealed); + + const settled = settleMoritaniEcazDukeWinner(revealed); + const mentat = advancePairedE3Nexus(settled, g => g.turn === receipt.turn && g.phase === 8 && + g.moritaniAssassinate!.opportunities[0].stage === 'replaced', undefined, quietPairedE3NexusChoice); + const replaced = mentat.moritaniAssassinate!.opportunities[0]; + assert.equal(replaced.replacement, expectedReplacement); + assert.notEqual(replaced.replacement, target); + assert.deepEqual(player(mentat, moritani).traitors, [expectedReplacement]); + assert.ok(!mentat.traitorReserve!.includes(target)); + assert.ok(!mentat.traitorReserve!.includes(expectedReplacement)); + assert.equal(mentat.traitorReserve!.length, pending.traitorReserve!.length - 1); + assert.equal(mentat.moritaniAssassinate!.opportunities.filter(r => r.card === target && r.stage === 'replaced').length, 1); + const publicMarker = viewGame(mentat, ecaz).moritaniAssassinate!.history[0]; + assert.equal(publicMarker.card, target); + assert.equal(publicMarker.faction, 'ecaz'); + assert.equal(publicMarker.stage, 'replaced'); + assert.equal('replacement' in publicMarker, false); + for (const snapshot of [f.afterSetup, ready, pending, mentat]) { + assert.equal(player(snapshot, ecaz).leaders.length, 5); + assert.equal(player(snapshot, moritani).leaders.length, 5); + assert.ok(!snapshot.players.some(p => p.leaders.some(l => l.id === DUKE_VIDAL_ID))); + pairedE3NexusInventory(snapshot); + } +}); + +void test('declining the original shared-Duke opportunity releases the used disc but spends no native Ecaz target or replacement', () => { + const f = createMoritaniEcazDukeWinner(), d = f.pending.decision; + assert.ok(d?.kind === 'moritaniAssassinate'); + const held = [...player(f.pending, f.moritani).traitors]; + const ecazLeaders = structuredClone(player(f.pending, f.ecaz).leaders); + const reserve = [...f.pending.traitorReserve!]; + const declined = step(reload(f.pending), { actor: f.moritani, + action: { type: 'decision', event: d.event, decline: true } }); + const settled = settleMoritaniEcazDukeWinner(declined); + const mentat = advancePairedE3Nexus(settled, g => g.turn === f.pending.turn && g.phase === 8, + undefined, quietPairedE3NexusChoice); + assert.equal(mentat.moritaniAssassinate!.opportunities[0].stage, 'declined'); + assert.equal(mentat.moritaniAssassinate!.opportunities[0].replacement, null); + assert.deepEqual(player(mentat, f.moritani).traitors, held); + assert.deepEqual(mentat.traitorReserve, reserve); + assert.deepEqual(player(mentat, f.ecaz).leaders, ecazLeaders); + assert.equal(mentat.dukeVidal!.controller, null); + assert.equal(mentat.dukeVidal!.leader.usedAt, f.location); + assert.equal(mentat.dukeVidal!.leader.dead, false); + assert.equal(viewGame(mentat, f.moritani).moritaniAssassinate!.history.length, 0); + pairedE3NexusInventory(mentat); +}); diff --git a/tests/moritani-foreign-ghola-winner.test.ts b/tests/moritani-foreign-ghola-winner.test.ts new file mode 100644 index 00000000..a0bffdcc --- /dev/null +++ b/tests/moritani-foreign-ghola-winner.test.ts @@ -0,0 +1,87 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { viewGame, type Action } from '../game/engine'; +import { leaders } from '../game/cards'; +import { + FOREIGN_GHOLA_DISC, createMoritaniForeignGholaWinnerFixture, + rejectForeignGholaWinnerAction, revealMoritaniForeignGholaWitness, +} from './fixture-moritani-foreign-ghola-winner'; +import { mixedE3MoritaniPlayer } from './fixture-mixed-e3-moritani-nexus'; + +/** + * The consumer-visible regression for one actual physical winning disc whose + * printed faction differs from the victorious player: a Guild native disc dies + * in original combat, Tleilaxu legally revives it as a foreign ghola, and that + * surviving disc then wins a real battle against Moritani. Before the played- + * disc registry admitted ordinary canonical identities independently of the + * winner’s faction, no private opportunity was produced at all. + */ +void test('a real Tleilaxu foreign ghola victory opens Moritani’s private loss choice against the opposing native Tleilaxu Traitor', () => { + const f = createMoritaniForeignGholaWinnerFixture(); + assert.equal(mixedE3MoritaniPlayer(f.afterSetup, f.moritani).traitors.includes(f.target), true); + + // Authentic acquisition: the disc really died and the return is a real action. + assert.equal(f.guildBattle.winner, f.tleilaxu); + assert.equal(mixedE3MoritaniPlayer(f.guildBattle.after, f.guild) + .leaders.find((l) => l.id === FOREIGN_GHOLA_DISC)!.dead, true); + assert.equal(mixedE3MoritaniPlayer(f.revival.after, f.tleilaxu).spice, + mixedE3MoritaniPlayer(f.revival.before, f.tleilaxu).spice - 3); + const borrowed = mixedE3MoritaniPlayer(f.window.game, f.guild).leaders.find(l => l.id === FOREIGN_GHOLA_DISC)!; + assert.equal(borrowed.dead, false); + assert.equal(borrowed.faction, 'guild'); + assert.equal(borrowed.gholaBy, f.tleilaxu); + + // The real window: the winning disc belongs to no Tleilaxu disc, yet the + // logical faction and target pool stay the victorious player’s printed one. + assert.equal(f.window.opposingLeader, FOREIGN_GHOLA_DISC); + assert.equal(f.window.faction, 'tleilaxu'); + assert.notEqual(f.window.territory, f.guildBattle.territory); + assert.equal(f.window.opponent, f.tleilaxu); + assert.equal(f.window.blocked, null); + assert.equal(f.window.game.moritaniAssassinate!.normalTraitorCall, false); + assert.ok(f.window.cards.some((choice) => choice.card === f.target)); + assert.equal(f.window.cards.every((choice) => + leaders('tleilaxu').some((l) => l.id === choice.card)), true, + 'Only the opposing player’s native Tleilaxu identities are targets.'); + assert.equal(f.window.cards.some((choice) => choice.card === FOREIGN_GHOLA_DISC), false, + 'The borrowed foreign disc never becomes a target identity.'); + assert.equal(f.window.cards.find((choice) => choice.card === f.target)!.bounty, f.bounty); + + // Original continuation: printed bounty, one physical death, Mentat replacement. + const outcome = revealMoritaniForeignGholaWitness(f); + assert.notEqual(outcome.replacement, f.target); + assert.equal(outcome.mentat.traitorReserve!.includes(outcome.replacement), false, + 'The drawn replacement left the reserve for Moritani’s private hand.'); + assert.equal(mixedE3MoritaniPlayer(outcome.mentat, f.moritani) + .traitors.includes(outcome.replacement), true); + assert.deepEqual(outcome.mentat.moritaniAssassinate!.opportunities.map((r) => r.stage), + ['replaced']); + const ghola = mixedE3MoritaniPlayer(outcome.mentat, f.guild) + .leaders.find((l) => l.id === FOREIGN_GHOLA_DISC)!; + assert.equal(ghola.gholaBy, f.tleilaxu, 'The surviving foreign ghola stays in Tleilaxu custody.'); + assert.equal(ghola.dead, false); + assert.equal(mixedE3MoritaniPlayer(outcome.mentat, f.tleilaxu) + .leaders.find((l) => l.id === f.target)!.dead, true, 'The revealed native Tleilaxu disc died.'); +}); + +void test('the foreign-ghola window stays private and rejects any borrowed or foreign identity', () => { + const f = createMoritaniForeignGholaWinnerFixture(); + const rival = viewGame(f.window.game, f.tleilaxu).moritaniAssassinate!; + assert.equal(rival.pending?.cards.length, 0); + assert.equal(rival.pending?.blocked, null); + const owner = viewGame(f.window.game, f.moritani).moritaniAssassinate!; + assert.equal(owner.history.length, 0, 'Nothing is public until the reveal actually commits.'); + + const foreign: Action = { type: 'decision', event: f.window.event, card: 'guild-1' }; + rejectForeignGholaWinnerAction(f.window.game, f.moritani, foreign); + rejectForeignGholaWinnerAction(f.window.game, f.moritani, + { type: 'decision', event: 'stale-foreign-ghola', card: f.target }); + rejectForeignGholaWinnerAction(f.window.game, f.tleilaxu, + { type: 'decision', event: f.window.event, card: f.target }); + rejectForeignGholaWinnerAction(f.window.game, f.moritani, + { type: 'decision', event: f.window.event, card: f.window.opposingLeader }); + + assert.equal(mixedE3MoritaniPlayer(f.window.game, f.tleilaxu) + .leaders.find((l) => l.id === f.target)!.dead, false, + 'A rejected submission never kills the held target.'); +}); diff --git a/tests/moritani-harkonnen-assassinate.test.ts b/tests/moritani-harkonnen-assassinate.test.ts index c430cd9c..47100b18 100644 --- a/tests/moritani-harkonnen-assassinate.test.ts +++ b/tests/moritani-harkonnen-assassinate.test.ts @@ -173,7 +173,7 @@ void test('Harkonnen reveal retains its physical card until one distinct Mentat })); }); -void test('admitting Harkonnen battle captives does not relax exceptional target custody or other factions battle identities', () => { +void test('admitting Harkonnen battle captives does not relax exceptional target custody', () => { for (const patch of [ { capturedBy: 'other' }, { gholaBy: 'other' }, @@ -185,6 +185,4 @@ void test('admitting Harkonnen battle captives does not relax exceptional target assert.deepEqual(moritaniAssassinateChoices(context).cards.map(c => c.card), ['harkonnen-0']); assert.throws(() => quoteMoritaniAssassinate(context, 'harkonnen-1')); } - for (const battleDisc of ['harkonnen-0', 'moritani-0', DUKE_VIDAL_ID]) - assert.equal(moritaniAssassinateTrigger({ ...trigger(battleDisc), faction: 'atreides' }), false); });