From a24600ae1d0360bdbe1c3d224e36b15dfcd8f14b Mon Sep 17 00:00:00 2001
From: Quintine <15965533+Quintine@users.noreply.github.com>
Date: Thu, 15 Oct 2026 13:24:47 +1100
Subject: [PATCH] Connect original Duke capture and Ecaz Nexus liberation
---
components/game-table.tsx | 9 +
components/nexus-ecaz-duke.tsx | 6 +-
docs/CURRENT_STATUS.md | 17 +
docs/DUKE_VIDAL_RULES.md | 33 +-
docs/ECAZ_DUKE_ACQUISITION.md | 12 +
docs/ECAZ_REVIVAL_RULES.md | 18 +
docs/IMPLEMENTATION_STATUS.md | 41 ++
docs/NEXUS_CARD_RULES.md | 13 +
docs/NEXUS_ECAZ_RULES.md | 60 +-
docs/RULE_DECISIONS.md | 21 +-
game/battle-aftermath-quote.ts | 12 +
game/duke-vidal.ts | 195 +++++-
game/ecaz-duke-acquisition.ts | 7 +-
game/ecaz-duke-revival.ts | 14 +-
game/engine.ts | 151 +++--
game/nexus-ecaz-duke.ts | 178 +++++-
game/reference.ts | 14 +-
tests/duke-captured-custody.test.ts | 183 ++++++
tests/duke-vidal-engine.test.ts | 18 -
tests/fixture-nexus-duke-capture.ts | 732 +++++++++++++++++++++++
tests/nexus-duke-capture-runtime.test.ts | 234 ++++++++
tests/nexus-ecaz-duke-captured.test.ts | 233 ++++++++
tests/nexus-ecaz-duke-controls.test.ts | 97 ---
tests/nexus-ecaz-duke-quote.test.ts | 19 +-
24 files changed, 2082 insertions(+), 235 deletions(-)
create mode 100644 tests/duke-captured-custody.test.ts
create mode 100644 tests/fixture-nexus-duke-capture.ts
create mode 100644 tests/nexus-duke-capture-runtime.test.ts
create mode 100644 tests/nexus-ecaz-duke-captured.test.ts
delete mode 100644 tests/nexus-ecaz-duke-controls.test.ts
diff --git a/components/game-table.tsx b/components/game-table.tsx
index 40de9145..dd617f9a 100644
--- a/components/game-table.tsx
+++ b/components/game-table.tsx
@@ -3352,6 +3352,15 @@ export function GameTable({
your own leaders are dead, a retained captive returns
immediately.
+ {g.decision.leader === 'duke-vidal' && (
+
+ Duke remains the one shared Ecaz disc. A surviving captive
+ battle use sets him aside; execution sends him to Ecaz’s
+ Tanks. Only Ecaz may revive him. The provisional captured
+ return policy preserves unused, unexpired prior custody
+ on an early release. Ecaz Nexus reclaim ends captivity.
+
+ )}
{actionButton('Retain captive', {
type: 'decision',
mode: 'keep',
diff --git a/components/nexus-ecaz-duke.tsx b/components/nexus-ecaz-duke.tsx
index 167560fc..9b301e68 100644
--- a/components/nexus-ecaz-duke.tsx
+++ b/components/nexus-ecaz-duke.tsx
@@ -15,9 +15,9 @@ export function NexusEcazDuke({ game, act, busy }: Props) {
return (
Ecaz Nexus · Cunning · Duke Prad Vidal
- Current public controller: {offer.dukeController ? controller?.name ?? 'another player' : 'none'}.
- Spend and discard your physical Ecaz Nexus card to take the uncaptured, non-Ghola Duke for this turn, even from Moritani or the Tanks. A Tanked acquisition revives the same shared disc without a separate paid revival and preserves his death history. If unused, this temporary control expires at turn end; using him in battle sets the shared Duke aside.
- This independent resurrection clears his prior battle-use stamp and permits a new battle use. Taking a living Duke does not clear a battle-use stamp. That use composition is provisional; capture and Ghola acquisition/return destinations remain unsupported, as does Duke battle use with Advanced Harkonnen.
+ Known controller: {offer.dukeController ? controller?.name ?? 'another player' : 'none'}.
+ Spend your physical Ecaz Nexus card to take the one shared Duke this turn, including from actual capture or the Tanks. Capture reclaim ends captivity. A Tanked acquisition preserves his death history without payment or an ordinary revival slot. Battle use or unused turn expiry sets him aside.
+ Provisional return policy: no previous captor is restored after Nexus use. Independent Tanks resurrection clears prior battle use; taking a living Duke does not. Only Ecaz may revive him, including with a real Ghola card; that return is set aside before a separate Cunning play. Foreign-Ghola custody is not created. Wider profile and combination gates remain.
{offer.blocked && {offer.blocked}
}
{ if (!busy && action) act(action); }}>
diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md
index 52d45b65..86cda218 100644
--- a/docs/CURRENT_STATUS.md
+++ b/docs/CURRENT_STATUS.md
@@ -29,6 +29,23 @@ optional skill bands, combinations and public expansion starts remain open.
Rules implementation continues; comprehensive assurance and administration
remain deferred until all rules are implemented.
+**Original captured Duke and legal Ghola composition — 14 October.**
+Standalone Ecaz/classic no-Skills Nexus now connects the actual Advanced
+Harkonnen lottery, including the surviving just-used losing Duke, Keep/Execute,
+captive use/all-native-dead returns and Cunning liberation of the same disc.
+Capture priority and return destinations are labelled provisional; native
+secret capture/execution remains private. Actual Ecaz Ghola returns Duke
+set aside before separate Cunning; foreign-Ghola revival stays forbidden.
+Related13-file union127/127, types/lint/build pass. Actual390px controls retain,
+liberate, select Duke6 and resolve once into Collection; used Execute pays2.
+Real Ghola then separate Cunning retains death history and no ordinary slot.
+Thirteen actual API scenarios also pass, including Cunning after both
+retention/execution variants, original captive returns and unused expiry.
+No overflow/errors or saved/deployed room touched. Obsolete SSR/wiring/wording
+pins are removed, not re-pinned. Broader Skills/paired/timing, assurance,
+calibration and publication remain open; existing build warnings remain.
+[Current source, return policy and proof](NEXUS_ECAZ_RULES.md#original-captured-duke-and-ecaz-ghola-composition--14-october-2026).
+
**Emperor original native bank purchase — 14 October.** Normal bank retention
now uses original native Nexus roster/module profiles and their own canonical
optional envelopes, preserving exact prior classic admission. Uniform public
diff --git a/docs/DUKE_VIDAL_RULES.md b/docs/DUKE_VIDAL_RULES.md
index 0477f441..e071dbb6 100644
--- a/docs/DUKE_VIDAL_RULES.md
+++ b/docs/DUKE_VIDAL_RULES.md
@@ -33,8 +33,8 @@ The [base Harkonnen capture rules, p. 17](https://www.gf9games.com/dunegame/wp-c
| Combination | Evidence boundary and safe implementation scope |
| --------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Moritani acquisition from captured or ghola custody | Ecaz’s Ambassador explicitly excludes both; Moritani’s acquisition excludes only Tanks and says to take Duke from any controller. Do not silently copy the Ecaz exclusions as a confirmed Moritani rule. The interaction with concealed capture and the apparent ghola restriction needs an explicit supported policy. |
-| Duke used in a lost battle against Harkonnen | One-battle tenure and postbattle capture both apply. No retrieved Duke-specific text orders release versus selection into the capture pool. Removing him first changes the random pool; keeping him first can change custody. Gate this combination until resolved rather than choosing by handler order. |
-| Returning a captured Duke | Native identity is Ecaz, but temporary prior custody may be Moritani or a loaned ally. Base capture return plus Duke’s special acquisition restrictions do not explicitly settle active-control versus set-aside destination in every combination. Preserve prior custody rather than discarding that information. |
+| Duke used in a lost battle against Harkonnen | Original standalone Ecaz/no-Skills Nexus now retains the eligible surviving disc in the real capture pool through a validated release witness. This capture-before-final-return composition is provisional; see the current follow-up below. |
+| Returning a captured Duke | Current provisional policy restores an unused valid prior tenure on native early return; used/dead/expired tenure is set aside. Nexus liberation clears captivity rather than restoring the captor later. Broader captured Moritani acquisition remains unresolved. |
| Loan to the new Ecaz ally | Basic has an optional same-disc first version after accepted Ambassador alliance consent. The authorized supplied Advanced PDF, physical p30 Ecaz A, instead adds an available Duke automatically after consent and sets an unused disc aside at turn end; that path is now connected without another acknowledgement. One-battle release is still provisional. Capture and alliance-break interactions remain for refinement. |
| Ecaz co-occupied battles | Ecaz’s Occupy rules distinguish participating forces from the faction chosen to fight. Duke eligibility excludes battles involving Ecaz, but no direct combined example decides every Ecaz-ally designation case. Ordinary faction-pair battle lists alone do not certify the full Occupy interaction. |
| Revival alternatives | Ecaz’s Tleilaxu Ambassador effect and sharing an Ambassador benefit may interact with the absolute Ecaz-only restriction. No retrieved specific exception permits another faction to revive Duke. Keep that restriction unless a primary clarification establishes otherwise. |
@@ -76,3 +76,32 @@ accounting, ordinary slot and one physical death history remain separate from
the Ghola card's free return. Native first-cohort five-in-Tanks eligibility is
supported; repeated six-disc cycles and exceptional custody stay guarded.
See [Ecaz revival](ECAZ_REVIVAL_RULES.md#selected-product-interpretation-28-september-2026).
+
+## Original capture and legal Ghola composition — 14 October 2026
+
+The standalone Ecaz/classic Nexus envelope without Skills now includes
+original Advanced Harkonnen capture, Keep/Execute, captive battle use and
+all-native-Harkonnen-dead return for the separate disc. This supersedes the
+historical blanket exclusion above only within that already admitted profile.
+The original Ambassador acquisition and Ecaz-only revival use the same scope;
+paired Moritani/Harkonnen, shared-disc training and unrelated combinations
+are not opened.
+
+The original capture pool retains a surviving just-used losing Duke through
+a validated battle-release witness. Prior tenure is retained underneath
+captivity; private selection/execution remains concealed from unrelated seats.
+Execution pays two spice, records at least two deaths and returns the dead
+disc to Ecaz's Tanks. A real public use/return, Nexus liberation or revival
+clears the private snapshot rather than exposing server-only custody fields.
+
+The selected **provisional** native return policy restores only unused,
+unexpired prior control; otherwise the disc is set aside. Actual Nexus
+liberation removes captivity permanently and ends after one battle or the
+turn, never returning Duke to the old captor. This is a recommended product
+composition, not an inspected publisher priority/return ruling.
+
+Ecaz's actual Ghola returns Duke alive and set aside, without ordinary slot
+use; separate Cunning can then acquire him. Only Ecaz revives him: no
+Tleilaxu-owned foreign Ghola is manufactured. Native identity, strength6,
+five-disc faction rosters, absent Traitor card and death history remain.
+[Current controls, verification and remaining gates](NEXUS_ECAZ_RULES.md#original-captured-duke-and-ecaz-ghola-composition--14-october-2026).
diff --git a/docs/ECAZ_DUKE_ACQUISITION.md b/docs/ECAZ_DUKE_ACQUISITION.md
index 7477d139..99e78ab4 100644
--- a/docs/ECAZ_DUKE_ACQUISITION.md
+++ b/docs/ECAZ_DUKE_ACQUISITION.md
@@ -4,6 +4,18 @@ Integrated 7 September 2026. Ecaz can now choose **Acquire Duke Vidal for Ecaz**
The direct acquisition and tenure follow the publisher’s [Ecaz & Moritani rules, pp.7–9](https://www.gf9games.com/dune/wp-content/uploads/EcazMoritani-Rulebook-LOWRES.pdf). Fresh indexed publisher retrieval rechecked eligibility outside Tanks, captured or ghola custody. The prior [Duke source audit](DUKE_VIDAL_RULES.md) and [remaining effects readiness review](ECAZ_REMAINING_EFFECTS_READINESS.md) record the separate unresolved combinations. This implements the direct self-acquisition choice; it does not certify the token’s alliance and loan alternatives, full Ecaz or a complete expansion game.
+## Original standalone Advanced Harkonnen follow-up — 14 October 2026
+
+The already admitted standalone Ecaz/classic no-Skills Nexus envelope now
+supports the original available-living Duke acquisition with Harkonnen.
+Captured, Ghola and dead discs remain unavailable to the Ambassador itself.
+Subsequent real Battle capture/return and independent Nexus liberation are
+connected without creating a sixth native leader or Traitor card. Their
+priority/return composition is explicitly provisional; broader roster/Skills
+guards remain. This supersedes only that envelope's blanket Advanced-Harkonnen
+exclusion in the historical checkpoint below.
+[Current native controls and verification](NEXUS_ECAZ_RULES.md#original-captured-duke-and-ecaz-ghola-composition--14-october-2026).
+
## Basic new-ally loan first version — 8 October 2026
After the reusable Ambassador alliance is accepted in Basic, Ecaz receives a
diff --git a/docs/ECAZ_REVIVAL_RULES.md b/docs/ECAZ_REVIVAL_RULES.md
index e729a18e..a00cc79c 100644
--- a/docs/ECAZ_REVIVAL_RULES.md
+++ b/docs/ECAZ_REVIVAL_RULES.md
@@ -24,6 +24,24 @@ available, without making a repeated six-disc algorithm up. Captured, Ghola
or concealed shared-disc custody and Advanced Harkonnen combinations retain
their existing uniform boundary.
+## Captured return and legal Ghola composition — 14 October 2026
+
+The original standalone Ecaz/classic no-Skills Nexus envelope now admits
+Advanced Harkonnen. A real executed captive Duke returns dead to Ecaz's
+Tanks with at least two deaths; only the exact validated returned-dead
+private capture snapshot is accepted, not arbitrary concealed live custody.
+Normal Ecaz-only revival and a separate actual Cunning can use that returned
+disc under their own original costs and allowances.
+
+Actual Ecaz Ghola returns the same Duke alive and set aside, preserves deaths,
+clears prior use and spends no ordinary leader slot. A separate quiet Cunning
+can acquire it. Tleilaxu foreign-Ghola revival remains expressly forbidden;
+no foreign custody is staged to pretend that the printed exceptional source
+has a legal producer. Repeated six-disc cycles, shared training and broader
+roster/optional combinations remain separate. This supersedes only the
+historical blanket captured-return/Advanced-Harkonnen boundary in that scope.
+[Actual native and 390px proof](NEXUS_ECAZ_RULES.md#original-captured-duke-and-ecaz-ghola-composition--14-october-2026).
+
## Explicit expansion rules
Ecaz may revive Duke for **5 spice regardless of how many leaders are dead**. Its ordinary leaders become revivable with **five leaders in the Tanks, including Duke**. The clarification expressly permits this while Ecaz still holds one living leader, regardless of which leader that is. Only Ecaz may revive Duke, including through the Ghola Treachery Card. [GF9 Ecaz & Moritani, printed pp. 8–9](https://www.gf9games.com/dune/wp-content/uploads/EcazMoritani-Rulebook-LOWRES.pdf)
diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md
index 06952733..cdb7f955 100644
--- a/docs/IMPLEMENTATION_STATUS.md
+++ b/docs/IMPLEMENTATION_STATUS.md
@@ -1,5 +1,46 @@
# Dune implementation status
+## 14 October 2026 — Original captured Duke and legal Ecaz Ghola composition
+
+Connected the existing standalone Ecaz/classic no-Skills Nexus profile to
+Advanced Harkonnen's real capture lottery, including an eligible surviving
+just-used losing Duke across one-battle release. Keep preserves prior tenure;
+Execute pays original two spice and returns the same dead disc to Ecaz Tanks
+with at least two deaths. Real captive battle use and all-native-Harkonnen-dead
+return are connected. Original private capture/execution snapshots stay
+server-only; projected custody and private action tokens retain native secrecy.
+
+Actual Cunning liberates the shared captive or returns the Tanked disc,
+spending one physical Nexus without spice, forces or ordinary revival slot.
+Provisional return policy removes captivity permanently, then sets aside on
+one-battle use or unused turn expiry. Original early captive return restores
+only unused valid prior tenure. Capture ordering/return are recommended
+product composition, not newly retrieved publisher or numerical user rulings.
+
+Binding Ecaz-only revival remains. Actual Ecaz Ghola returns the same Duke
+alive and set aside before a separate Cunning acquisition; no forbidden
+Tleilaxu-owned foreign Ghola is manufactured. Native five-disc rosters, no
+Duke Traitor, strength6 and physical death history remain. No broader paired,
+Skills/training, arbitrary-phase or mode gate is opened.
+
+Frozen related13-file union127/127, types/lint/build pass. Actual production
+GameTable at390px retains a real captive, spends Cunning, seals Duke6 and
+resolves the original battle/inspection suffix into Collection with Duke
+set aside once. Used-captive Execute pays2; actual Ghola controls return
+the dead Duke before separate Cunning. No overflow/browser errors or saved/
+deployed room mutation. Initial smoke assumptions about automatic empty
+Battle closure were corrected with real pending battles, not engine pauses.
+Thirteen actual native API scenarios pass: unused/just-used Keep/Execute
+and subsequent Cunning, liberated battle use, unused expiry, captive use,
+all-five-native-dead return and real Ecaz Ghola then separate Cunning.
+Smoke assertions distinguish original execution bounty from later Collection
+income and choose a genuinely unrelated audience, not the captor.
+Obsolete SSR wording/action-copy, incidental defaults and obsolete blanket
+capture gates are deleted, not re-pinned. Proof receipts remain outside Git;
+existing build warnings remain. Broad assurance, calibration and complete
+combination/deployed/publication acceptance stay open.
+[Canonical current source and evidence](NEXUS_ECAZ_RULES.md#original-captured-duke-and-ecaz-ghola-composition--14-october-2026).
+
## 14 October 2026 — Emperor Nexus original native bank admission
Removed the classic-only prototype bank fence in favor of existing canonical
diff --git a/docs/NEXUS_CARD_RULES.md b/docs/NEXUS_CARD_RULES.md
index 7bfbe00a..cdafc4ea 100644
--- a/docs/NEXUS_CARD_RULES.md
+++ b/docs/NEXUS_CARD_RULES.md
@@ -2,6 +2,19 @@
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.
+## Original captured Duke Cunning — 14 October 2026
+
+Standalone Ecaz/classic no-Skills Nexus now connects the real Advanced
+Harkonnen capture lottery, Keep/Execute, captive use/native-death returns
+and Cunning liberation for the same shared disc. Capture priority and return
+destinations are explicit provisional recommendations. Actual Ecaz Ghola
+returns Duke set aside before separate Cunning; the binding Ecaz-only revival
+rule still forbids a Tleilaxu-owned foreign Ghola. Quiet Battle timing,
+paired Moritani/Harkonnen and shared training gates are not broadened.
+[Source, actual controls and changed-path proof](NEXUS_ECAZ_RULES.md#original-captured-duke-and-ecaz-ghola-composition--14-october-2026).
+Historical standalone no-Skills Duke exclusions below are superseded only
+within this scope, not for Skills or other guarded combinations.
+
## Recommended BG Cunning card exceptions — 14 October 2026
The printed any/all conversion now permits fresh accompanied and storm-bound
diff --git a/docs/NEXUS_ECAZ_RULES.md b/docs/NEXUS_ECAZ_RULES.md
index 6b1e8293..46a72e8e 100644
--- a/docs/NEXUS_ECAZ_RULES.md
+++ b/docs/NEXUS_ECAZ_RULES.md
@@ -82,6 +82,59 @@ historical save authenticity. The bounded Duke Cunning path below is separate;
Secret Ally's exceptional leader scope and audience remain unsettled beyond
the bounded runtime below.
+## Original captured Duke and Ecaz Ghola composition — 14 October 2026
+
+The existing standalone Ecaz/classic, no-Skills Nexus envelope now connects
+Advanced Harkonnen's actual postbattle lottery to the one shared Duke. This
+does not open paired Moritani/Harkonnen or shared-Duke training. The original
+Ecaz Ambassador can first acquire an available living Duke in that envelope;
+it still cannot take a captive or Ghola.
+
+The lottery includes an eligible unused Duke and a surviving Duke just used
+in the losing battle. A validated battle witness preserves that candidate
+across the original one-battle release. **This ordering is provisional
+composition**, not a publisher-specific priority ruling. Keeping the captive
+overlays its prior tenure rather than duplicating a disc or native roster.
+Execution pays the original two spice and returns the dead disc to Ecaz's
+Tanks with at least two deaths. Original secret capture/execution visibility
+remains; the retained underlay snapshot is server-only, not a projected field
+or a private-offer token leak.
+
+At the existing quiet Battle boundary, actual Cunning liberates a captured
+living Duke or revives the returned Tanked disc. The physical Nexus is spent
+once, without spice, forces or an ordinary leader-revival allowance. The
+**provisional return policy** clears captivity permanently: one battle or
+unused turn expiry sets Nexus custody aside, never back to the captor.
+Original captive use likewise releases the disc; when all native Harkonnen
+leaders die, an unused, still-valid prior tenure is restored, otherwise it
+is set aside. A living already-used disc gains no fresh same-turn use.
+
+Only Ecaz may revive Duke, including with the actual Ghola Treachery Card.
+The native Ghola returns that same disc alive and set aside, retaining deaths
+and clearing prior use; a separate actual Cunning may then acquire it.
+**No foreign-Ghola Duke is produced or admitted.** This legal composition does
+not claim implementation of a forbidden Tleilaxu-owned Ghola source.
+
+The frozen thirteen-file related union passes **127/127**, with types, lint
+and production build. Actual production GameTable controls at **390px** retain
+the captive, spend Cunning, select Duke6, resolve a real battle and release
+once into Collection; a separate used-captive execution pays two spice.
+Actual Ghola controls return the dead Duke before a separate Nexus spend.
+No horizontal overflow or browser errors were observed. These are isolated
+in-memory paths, not saved-room, deployed, full-combination or recovery
+acceptance. Existing build warnings remain; broad assurance stays deferred.
+Thirteen additional actual native API scenarios pass: unused/just-used
+Keep and Execute, Cunning after each, liberated battle use, unused turn
+expiry, captive use, all-five-native-dead return and actual Ecaz Ghola
+then separate Cunning. They verify original action costs separately from
+later Collection, one identity, no native/Traitor duplication and actual
+returned-dead privacy. Proof JSON remains outside Git.
+See [native scenario regressions](../tests/nexus-duke-capture-runtime.test.ts),
+[custody transitions](../tests/duke-captured-custody.test.ts) and
+[receipt/privacy boundaries](../tests/nexus-ecaz-duke-captured.test.ts).
+The older checkpoints below retain their historical scope; their capture
+and standalone no-Skills Advanced-Harkonnen exclusions are superseded here.
+
## Bounded native Cunning: Duke Vidal
When Ecaz and Moritani are both seated in a three-to-six-player Basic or
@@ -176,9 +229,10 @@ of the Tanks follow-up:
genuine Ecaz/Moritani setup, physical draw/spend, Basic and Advanced transfer,
ordinary leader projection, actual end-turn expiry, blocked custody,
Advanced-Harkonnen rejection, stale commands and historical integrity.
-[Owner/bot controls](../tests/nexus-ecaz-duke-controls.test.ts) and
-[in-memory SQLite recovery](../tests/nexus-ecaz-duke-recovery.test.ts) cover
-private availability, four legal AI profiles, restart and competing writes.
+The historical [in-memory SQLite recovery](../tests/nexus-ecaz-duke-recovery.test.ts)
+records restart and competing-write coverage. The old SSR wording/action-copy
+control test has been removed, not re-pinned; current custody controls are
+proved through the actual GameTable and native engine paths above.
A local read-only browser fixture displayed the owner action and no rival
offer; no room was written. The final offline suite passed 5,623 tests across
712 files; scoped lint, isolated type diagnostics outside the concurrent
diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md
index b1e14182..b54a9069 100644
--- a/docs/RULE_DECISIONS.md
+++ b/docs/RULE_DECISIONS.md
@@ -66,6 +66,25 @@ These decisions do not open public module/faction or complete-mode gates.
These decisions do not open public module/faction or complete-mode gates.
+## Recommended shared-Duke capture composition — 14 October 2026
+
+Use original Advanced Harkonnen capture for the existing standalone
+Ecaz/classic no-Skills Nexus profile. Preserve the eligible surviving
+just-used losing Duke in the lottery across temporary one-battle release.
+Retain prior tenure underneath capture; original early return restores it
+only while unused and valid, otherwise sets the disc aside. Nexus liberation
+clears captivity permanently, with one-battle/current-turn tenure and no
+later captor restoration. These ordering/return choices are **provisional
+product composition**, not newly retrieved publisher rulings or numeric
+user decisions.
+
+Keep the binding Ecaz-only revival rule. An actual Ecaz Ghola returns Duke
+alive and set aside; a separate Cunning may claim it. Do not manufacture a
+Tleilaxu-owned foreign-Ghola Duke to exercise an illegal source. Preserve
+original secret capture/execution visibility, two-spice bounty and death
+history. Broader roster/Skills/phase boundaries remain.
+[Source, native lifecycle and verification](NEXUS_ECAZ_RULES.md#original-captured-duke-and-ecaz-ghola-composition--14-october-2026).
+
## Emperor original native bank admission — 14 October 2026
Apply the existing printed retained-price purchase to original normal bank
@@ -653,7 +672,7 @@ These are unresolved in the cited records. Do not infer an answer from elapsed t
| CHOAM Secret Ally battle timing | The printed after-victory inspection is optional and the Nexus identity stays secret. The connected prototype chooses a uniform use-or-continue window for every winner when Nexus is enabled and CHOAM absent; nonholders pass, while a holder may decide after the result. This privacy-preserving prompt is a product inference, not publisher text. A holder-only pause would reveal custody and a precommitment would remove the printed choice. Ordinary Karama cancellation remains unestablished by retrieved sources; no response is invented. | [After-victory inspection boundary](NEXUS_CHOAM_SECRET_ALLY.md#after-victory-inspection-audit) |
| Moritani Secret Ally battle timing | The printed after-loss retention is optional and the Nexus card remains secret. The connected prototype offers the same choice to every loser when Moritani is absent and at least one publicly revealed played card is retainable; a loser with no eligible public card has no useful choice, independent of hidden custody. Only an unallied holder may spend the physical card. This prompt is a privacy-preserving product inference, not publisher timing text. Native Moritani alliance retention and its Karama response remain separate; no generic Secret Ally counter is invented. | [Nexus retention boundary](NEXUS_CARD_RULES.md#moritani-secret-ally-retention-timing) |
| Ecaz Secret Ally traitor inquiry | The printed panel asks one selected player whether they hold any of the holder's leaders as Traitors, without identifying a leader; it specifies neither native-versus-controlled scope nor answer audience. The connected prototype chooses native printed faction leaders (including dead/captured), ordinary Traitor Cards rather than Face Dancers, and a holder-only historical yes/no. This is a privacy-preserving product interpretation, **not** a publisher ruling. Foreign Ghola/captive scope and table-wide disclosure remain release boundaries. | [Ecaz inquiry source and bounded runtime](NEXUS_ECAZ_RULES.md#bounded-secret-ally-traitor-inquiry-runtime) |
-| Ecaz native Nexus Cunning timing | The printed Cunning takes Duke for this turn even from capture, Tanks or Ghola, without a phase. The quiet Battle prototype now covers living control or set-aside Tanks. Tanks preserve deaths and reset use under a provisional independent-resurrection policy; this is not a numerical user ruling. Captured/Ghola return destinations and Advanced Harkonnen remain guarded; ordinary Ambassador and paid-revival custody are unchanged. | [Source and bounded runtime](NEXUS_ECAZ_RULES.md#bounded-native-cunning-duke-vidal) |
+| Ecaz native Nexus Cunning timing | Printed Cunning takes Duke for this turn even from capture, Tanks or Ghola, without a phase. Quiet Battle remains a product boundary. Original standalone Ecaz/no-Skills Advanced-Harkonnen capture and liberation now use provisional capture-before-final-release and no-captor-restoration policies. Tanks preserve deaths and independently reset use. Actual Ecaz Ghola returns set aside before separate Cunning; foreign-Ghola revival remains forbidden by Ecaz-only permission. Paired/Skills and broader timing stay guarded. | [Current source and bounded runtime](NEXUS_ECAZ_RULES.md#original-captured-duke-and-ecaz-ghola-composition--14-october-2026) |
| Emperor Secret Ally purchase timing | Uniform buyer/committed-beneficiary confirmation prevents a holder-only pause disclosing hidden eligibility; no beneficiary veto. Original classic/native bank admission, Richese cache/Advanced Black Market, Ambassador and joint special Karama keep distinct sources. Uniform timing and seller/direct compositions remain visibly provisional recommendations, not publisher timing rulings. Old bank receipt stays unchanged; strict legacy physical revival never broadens. Full combinations remain open; CHOAM closing sales/trades are not direct purchases. | [Native bank source](NEXUS_EMPEROR_RULES.md#original-native-bank-auction-admission--14-october-2026), [Ambassador](NEXUS_EMPEROR_RULES.md#recommended-original-ambassador-purchase--14-october-2026), [seller](NEXUS_EMPEROR_RULES.md#recommended-richese-seller-unpaid-purchase--14-october-2026), [joint special](NEXUS_EMPEROR_RULES.md#recommended-joint-richese-special-purchase--14-october-2026) |
| Discovery prototype inferences | Free entry uses storm order and ordinary source-storm restrictions; these are general-rule inferences. Great Maker currently follows ordinary first-turn worm skipping and Sandtrout suppression; an exhausted token type creates no replacement token. These preserve current source interpretation and physical inventory but are not separately confirmed rulings or complete combined-module certification. | [Interpretations for refinement](DISCOVERY_PROTOTYPE.md#interpretations-retained-for-refinement) |
| Other Homeworld combinations | Population/force splitting, removal custody, replacement shortages, income accounting and placement ordering remain local to their detailed contracts. | [Homeworld rules](HOMEWORLD_RULES.md), [occupation](HOMEWORLD_OCCUPATION_RULES.md) and the effect documents above |
diff --git a/game/battle-aftermath-quote.ts b/game/battle-aftermath-quote.ts
index e479b4f3..7cd2e087 100644
--- a/game/battle-aftermath-quote.ts
+++ b/game/battle-aftermath-quote.ts
@@ -9,6 +9,7 @@ import {
import type { MoritaniRetention } from './moritani-retention';
import { TECH_TOKENS, type TechId, type TechState } from './tech-tokens';
import { CHEAP_HERO_TRAITOR } from './traitors';
+import { DUKE_VIDAL_ID, type DukeReleasedCaptureWitness } from './duke-vidal';
export class BattleAftermathQuoteError extends Error {
constructor(message: string) {
@@ -67,6 +68,7 @@ export type AftermathCapture = {
player: string;
loser: string;
territory: string;
+ duke?: DukeReleasedCaptureWitness;
};
export type AftermathAuditor = {
owner: string;
@@ -374,10 +376,20 @@ function calculate(input: BattleAftermathInput): BattleAftermathQuote {
'Only the winning Harkonnen combatant may capture from this loser.',
);
winner(captor.id);
+ let duke: DukeReleasedCaptureWitness | undefined;
+ if (value.duke !== undefined) {
+ requireAftermath(record(value.duke) &&
+ Object.keys(value.duke).sort().join(',') === 'deaths,usedAt' &&
+ whole(value.duke.deaths) && value.duke.usedAt === value.territory &&
+ context.kind === 'modern' && input.leaderIds.includes(DUKE_VIDAL_ID),
+ 'The capture opportunity lost its original released shared Duke.');
+ duke = { deaths: value.duke.deaths, usedAt: value.duke.usedAt as string };
+ }
return {
player: captor.id,
loser: loser.id,
territory: territory(value.territory),
+ ...(duke ? { duke } : {}),
};
}
function auditor(
diff --git a/game/duke-vidal.ts b/game/duke-vidal.ts
index c398bd4e..7eb3d2fc 100644
--- a/game/duke-vidal.ts
+++ b/game/duke-vidal.ts
@@ -7,8 +7,22 @@ export type DukeState = {
controller: string | null;
acquiredTurn: number | null;
source: DukeSource | null;
+ /** Server-only original underlay retained across private capture and execution. */
+ captureVisibility?: {
+ controller: string | null;
+ acquiredTurn: number | null;
+ source: DukeSource | null;
+ };
};
+/** A losing battle's living disc, recorded before its one-battle release. */
+export type DukeReleasedCaptureWitness = { deaths: number; usedAt: string };
+
+/** Server-only effective custody. Projected clients must use projected leader fields. */
+export function dukeController(state: DukeState): string | null {
+ return state.leader.capturedBy ?? state.leader.gholaBy ?? state.controller;
+}
+
/** One shared disc, deliberately outside every native roster and traitor inventory. */
export function createDukeVidal(): DukeState {
return {
@@ -32,6 +46,7 @@ function cloneDuke(state: DukeState): DukeState {
controller: state.controller,
acquiredTurn: state.acquiredTurn,
source: state.source,
+ ...(state.captureVisibility ? { captureVisibility: { ...state.captureVisibility } } : {}),
};
}
function validTurn(turn: number) {
@@ -39,11 +54,150 @@ function validTurn(turn: number) {
throw new Error('Choose a valid turn for Duke Vidal custody.');
}
+function validDisc(state: DukeState) {
+ const leader = state.leader;
+ if (leader.id !== DUKE_VIDAL_ID || leader.faction !== 'ecaz' ||
+ leader.strength !== 6 || typeof leader.dead !== 'boolean' ||
+ !Number.isSafeInteger(leader.deaths) || leader.deaths < 0 ||
+ (leader.usedAt !== undefined && (typeof leader.usedAt !== 'string' || !leader.usedAt)))
+ throw new Error('Duke Vidal lost his shared physical identity or death history.');
+}
+
+function validUnderlay(state: Pick, turn: number) {
+ if (state.controller === null
+ ? state.acquiredTurn !== null || state.source !== null
+ : typeof state.controller !== 'string' || !state.controller.trim() ||
+ !Number.isSafeInteger(state.acquiredTurn) || state.acquiredTurn! < 1 ||
+ state.acquiredTurn! > turn || !['moritani', 'ecaz', 'ecazNexus', 'ally'].includes(state.source!))
+ throw new Error('Duke Vidal lost his underlying custody.');
+}
+
+function validCaptive(state: DukeState, turn: number) {
+ validDisc(state);
+ validUnderlay(state, turn);
+ const { leader } = state;
+ const hidden = leader.concealed;
+ const visibility = state.captureVisibility;
+ if (!visibility || visibility.controller !== state.controller ||
+ visibility.acquiredTurn !== state.acquiredTurn || visibility.source !== state.source)
+ throw new Error('Duke Vidal lost his original captured visibility.');
+ if (!leader.capturedBy || typeof leader.capturedBy !== 'string' ||
+ !leader.capturedBy.trim() || leader.capturedBy === state.controller || leader.gholaBy ||
+ (hidden && (hidden.captor !== leader.capturedBy ||
+ typeof hidden.controller !== 'string' || !hidden.controller.trim() ||
+ hidden.controller === hidden.captor || hidden.dead !== false ||
+ !Number.isSafeInteger(hidden.deaths) || hidden.deaths < 0 ||
+ hidden.deaths !== leader.deaths - (leader.dead ? 1 : 0) ||
+ (hidden.usedAt !== undefined && (typeof hidden.usedAt !== 'string' || !hidden.usedAt)) ||
+ (state.controller !== null && hidden.controller !== state.controller))))
+ throw new Error('Duke Vidal lost his original captured custody.');
+}
+
+/** A secretly executed captive is back in the Tanks, not still controlled by its captor.
+ * Keep this exact snapshot until a real public return; it is not foreign-Ghola custody. */
+export function returnedDukeCapture(state: DukeState): boolean {
+ const { leader } = state;
+ const hidden = leader.concealed;
+ const visibility = state.captureVisibility;
+ if (!visibility) return false;
+ try {
+ validUnderlay(visibility, Number.MAX_SAFE_INTEGER);
+ } catch {
+ return false;
+ }
+ return leader.id === DUKE_VIDAL_ID && leader.faction === 'ecaz' &&
+ leader.strength === 6 && leader.dead === true &&
+ Number.isSafeInteger(leader.deaths) && leader.deaths > 0 &&
+ leader.usedAt === undefined && leader.capturedBy === undefined &&
+ leader.gholaBy === undefined && state.controller === null &&
+ state.acquiredTurn === null && state.source === null &&
+ !!hidden && typeof hidden.captor === 'string' && !!hidden.captor.trim() &&
+ typeof hidden.controller === 'string' && !!hidden.controller.trim() &&
+ hidden.controller !== hidden.captor && hidden.dead === false &&
+ Number.isSafeInteger(hidden.deaths) && hidden.deaths >= 0 &&
+ Math.max(2, hidden.deaths + 1) === leader.deaths &&
+ (visibility.controller === null || visibility.controller === hidden.controller) &&
+ (hidden.usedAt === undefined || (typeof hidden.usedAt === 'string' && !!hidden.usedAt));
+}
+
+/** Provisional capture policy: retain an unused disc's underlying tenure.
+ * A just-released battle disc instead requires the caller's original battle witness. */
+export function captureDuke(
+ state: DukeState, captor: string, loser: string, turn: number,
+ territory: string, releasedWitness?: DukeReleasedCaptureWitness,
+): DukeState {
+ validTurn(turn);
+ validDisc(state);
+ validUnderlay(state, turn);
+ if (typeof captor !== 'string' || !captor.trim() ||
+ typeof loser !== 'string' || !loser.trim() || captor === loser ||
+ typeof territory !== 'string' || !territory.trim() ||
+ state.leader.dead || state.leader.capturedBy || state.leader.gholaBy ||
+ state.leader.concealed || state.captureVisibility)
+ throw new Error('Choose the original living losing Duke for capture.');
+ if (releasedWitness
+ ? state.controller !== null || state.acquiredTurn !== null || state.source !== null ||
+ !Number.isSafeInteger(releasedWitness.deaths) ||
+ releasedWitness.deaths !== state.leader.deaths ||
+ releasedWitness.usedAt !== territory || state.leader.usedAt !== territory
+ : dukeController(state) !== loser || !!state.leader.usedAt ||
+ (state.source !== 'ecaz' && state.acquiredTurn !== turn))
+ throw new Error('Duke Vidal no longer matches the original losing battle custody.');
+ const captured = cloneDuke(state);
+ captured.captureVisibility = {
+ controller: state.controller, acquiredTurn: state.acquiredTurn, source: state.source,
+ };
+ captured.leader.capturedBy = captor;
+ captured.leader.concealed = {
+ captor, controller: loser, dead: false, deaths: state.leader.deaths,
+ ...(state.leader.usedAt === undefined ? {} : { usedAt: state.leader.usedAt }),
+ };
+ return captured;
+}
+
+/** Early unused release restores valid prior tenure, never native-roster custody.
+ * Death, battle use and expired temporary tenure all return the same disc set aside. */
+export function releaseCapturedDuke(
+ state: DukeState, captor: string, turn: number,
+): DukeState {
+ validTurn(turn);
+ validCaptive(state, turn);
+ if (captor !== state.leader.capturedBy)
+ throw new Error('Only the actual captor can release Duke Vidal.');
+ const released = cloneDuke(state);
+ delete released.leader.capturedBy;
+ delete released.leader.concealed;
+ delete released.captureVisibility;
+ if (released.leader.dead || released.leader.usedAt ||
+ (released.source !== 'ecaz' && released.acquiredTurn !== turn))
+ return consumeDuke(released);
+ return released;
+}
+
+/** Original Harkonnen execution returns the actual dead disc to Ecaz's Tanks.
+ * The caller pays the original two-spice bounty; unrelated seats retain their snapshot. */
+export function executeCapturedDuke(
+ state: DukeState, captor: string, turn: number,
+): DukeState {
+ validTurn(turn);
+ validCaptive(state, turn);
+ if (state.leader.dead || captor !== state.leader.capturedBy || !state.leader.concealed)
+ throw new Error('Only the actual captor can execute the original living Duke.');
+ const executed = cloneDuke(state);
+ executed.leader.dead = true;
+ executed.leader.deaths = Math.max(2, executed.leader.deaths + 1);
+ delete executed.leader.usedAt;
+ delete executed.leader.capturedBy;
+ executed.controller = null;
+ executed.acquiredTurn = null;
+ executed.source = null;
+ return executed;
+}
+
/**
* The caller authorizes the faction power and resolves any applicable Karama.
* Ecaz Nexus Cunning independently revives the set-aside shared disc from Tanks.
- * Captured/ghola acquisition remains an unresolved implementation boundary,
- * not a verified blanket restriction on the printed powers.
+ * Ecaz Nexus Cunning liberates an actual captive; no foreign-Ghola source is legal.
*/
export function acquireDuke(
state: DukeState,
@@ -52,22 +206,35 @@ export function acquireDuke(
source: DukeSource,
): DukeState {
validTurn(turn);
+ validDisc(state);
+ validUnderlay(state, turn);
if (typeof controller !== 'string' || !controller.trim())
throw new Error('Choose a player to control Duke Vidal.');
if (!['moritani', 'ecaz', 'ecazNexus', 'ally'].includes(source))
throw new Error('Choose a valid source of Duke Vidal custody.');
if (state.leader.dead && source !== 'ecazNexus')
throw new Error('Duke Vidal is in the Tanks and cannot be acquired by this source.');
- if (state.leader.capturedBy || state.leader.gholaBy)
- throw new Error(
- 'Acquiring Duke Vidal from captured or ghola custody is still being implemented.',
- );
+ if (state.leader.gholaBy)
+ throw new Error('Duke Vidal cannot be acquired from foreign Ghola custody.');
+ if (state.leader.capturedBy) {
+ if (source !== 'ecazNexus' || state.leader.dead)
+ throw new Error('Only Ecaz Nexus Cunning can acquire the living captured Duke.');
+ validCaptive(state, turn);
+ } else if ((state.leader.concealed || state.captureVisibility) &&
+ !(source === 'ecazNexus' && returnedDukeCapture(state))) {
+ throw new Error('Duke Vidal has unresolved concealed custody.');
+ }
if (state.leader.dead &&
(state.controller !== null || state.acquiredTurn !== null || state.source !== null))
throw new Error('Duke Vidal must be set aside in the Tanks before Ecaz Nexus acquisition.');
if (source === 'ecazNexus' && !state.leader.dead && state.leader.usedAt)
throw new Error('Duke Vidal has already been used in battle this turn.');
const acquired = cloneDuke(state);
+ if (source === 'ecazNexus') {
+ delete acquired.leader.capturedBy;
+ delete acquired.leader.concealed;
+ delete acquired.captureVisibility;
+ }
if (state.leader.dead) {
acquired.leader.dead = false;
// An independent resurrection permits a new battle use without erasing deaths.
@@ -81,12 +248,14 @@ export function acquireDuke(
/** The battle caller resolves leader death and bounty before releasing temporary control. */
export function consumeDuke(state: DukeState): DukeState {
- return {
- ...cloneDuke(state),
- controller: null,
- acquiredTurn: null,
- source: null,
- };
+ const released = cloneDuke(state);
+ delete released.leader.capturedBy;
+ delete released.leader.concealed;
+ delete released.captureVisibility;
+ released.controller = null;
+ released.acquiredTurn = null;
+ released.source = null;
+ return released;
}
/**
@@ -127,7 +296,7 @@ export const DUKE_VIDAL_RULES = Object.freeze({
/** Developer metadata: the guide describes rules beyond this module's custody helpers. */
implementation: Object.freeze({
custody: 'implemented',
- capture: 'not-implemented',
+ capture: 'original-harkonnen-provisional-return',
revival: 'ecaz-only-set-aside',
ambassadorAcquisition: 'ecaz-self-implemented',
ambassadorAlliance: 'consent-implemented',
diff --git a/game/ecaz-duke-acquisition.ts b/game/ecaz-duke-acquisition.ts
index 39d79dba..975f8489 100644
--- a/game/ecaz-duke-acquisition.ts
+++ b/game/ecaz-duke-acquisition.ts
@@ -1,8 +1,10 @@
import type { Game } from './engine';
import type { FactionId } from './catalog';
import { acquireDuke, DUKE_VIDAL_ID, type DukeState } from './duke-vidal';
+import type { FactionModuleProfile } from './faction-module-profile';
+import { standaloneE3NexusProfile } from './nexus-module-profile';
-export type EcazDukeContext = {
+export type EcazDukeContext = Partial & {
status: Game['status'];
turn: number;
advanced: boolean;
@@ -54,7 +56,8 @@ export function quoteEcazDukeAcquisition(
'context',
);
requireDuke(
- !(g.advanced && g.players.some((p) => p.faction === 'harkonnen')),
+ !(g.advanced && g.players.some((p) => p.faction === 'harkonnen')) ||
+ (!!g.expansions && standaloneE3NexusProfile(g as FactionModuleProfile)),
tableBlock,
'table',
);
diff --git a/game/ecaz-duke-revival.ts b/game/ecaz-duke-revival.ts
index d37ce6c9..3cd7902a 100644
--- a/game/ecaz-duke-revival.ts
+++ b/game/ecaz-duke-revival.ts
@@ -1,9 +1,11 @@
import type { Game, Player } from './engine';
import type { Leader } from './cards';
-import { DUKE_VIDAL_ID, type DukeState } from './duke-vidal';
+import { DUKE_VIDAL_ID, returnedDukeCapture, type DukeState } from './duke-vidal';
+import type { FactionModuleProfile } from './faction-module-profile';
+import { standaloneE3NexusProfile } from './nexus-module-profile';
export const DUKE_REVIVAL_NORMAL_COST = 5 as const;
-export type EcazDukeRevivalContext = {
+export type EcazDukeRevivalContext = Partial & {
status: Game['status'];
turn: number;
advanced: boolean;
@@ -82,7 +84,8 @@ export function resolveEcazDukeRevival(
'owner',
);
requireDuke(
- !(g.advanced && g.players.some((p) => p.faction === 'harkonnen')),
+ !(g.advanced && g.players.some((p) => p.faction === 'harkonnen')) ||
+ (!!g.expansions && standaloneE3NexusProfile(g as FactionModuleProfile)),
'table',
);
const duke = g.dukeVidal;
@@ -108,7 +111,10 @@ export function resolveEcazDukeRevival(
(typeof leader.usedAt === 'string' && leader.usedAt.length > 0)) &&
leader.capturedBy === undefined &&
leader.gholaBy === undefined &&
- leader.concealed === undefined,
+ (leader.concealed === undefined ||
+ (returnedDukeCapture(duke) &&
+ g.players.some(player => player.id === leader.concealed?.captor && player.faction === 'harkonnen') &&
+ g.players.some(player => player.id === leader.concealed?.controller))),
);
requireDuke(
g.players.every(
diff --git a/game/engine.ts b/game/engine.ts
index fbcb81ce..1ecc7b49 100644
--- a/game/engine.ts
+++ b/game/engine.ts
@@ -460,6 +460,11 @@ import {
acquireDuke,
consumeDuke,
expireDuke,
+ dukeController,
+ captureDuke,
+ releaseCapturedDuke,
+ executeCapturedDuke,
+ type DukeReleasedCaptureWitness,
DUKE_VIDAL_ID,
type DukeState,
} from './duke-vidal';
@@ -2186,7 +2191,7 @@ export type Game = {
} | null;
hajr: string[];
decision: Decision | null;
- pendingCapture: { player: string; loser: string; territory: string } | null;
+ pendingCapture: { player: string; loser: string; territory: string; duke?: DukeReleasedCaptureWitness } | null;
moritaniRetention?: MoritaniRetention | null;
response: ResponseWindow | null;
wormRides: string[];
@@ -17452,7 +17457,7 @@ function ecazAllianceQuote(g: Game, owner: string, entrant: string) {
}
}
function ecazAmbassadorLoanBlock(g: Game, owner: Player, entrant: Player) {
- if (g.advanced && byFaction(g, 'harkonnen'))
+ if (g.advanced && byFaction(g, 'harkonnen') && !standaloneE3NexusProfile(g))
return 'Advanced Duke loans with Harkonnen capture remain guarded.';
if (owner.ally !== entrant.id || entrant.ally !== owner.id)
return 'The new Ecaz alliance is no longer current.';
@@ -18494,11 +18499,7 @@ function offerMoritaniDuke(g: Game) {
g.dukeVidal ??= createDukeVidal();
const duke = g.dukeVidal;
if (duke.leader.dead || duke.controller === owner.id) return false;
- if (
- duke.leader.capturedBy ||
- duke.leader.gholaBy ||
- (g.advanced && byFaction(g, 'harkonnen'))
- ) {
+ if (duke.leader.capturedBy || duke.leader.gholaBy) {
log(
g,
'Duke Vidal acquisition with exceptional capture or ghola custody is still being implemented.',
@@ -20583,13 +20584,7 @@ function controlledLeaders(g: Game, p: Player) {
owner.leaders.filter((l) => controlsLeader(p, l)),
);
const duke = g.dukeVidal;
- if (
- duke &&
- duke.controller === p.id &&
- !duke.leader.capturedBy &&
- !duke.leader.gholaBy &&
- !(g.advanced && byFaction(g, 'harkonnen'))
- )
+ if (duke && !duke.leader.gholaBy && dukeController(duke) === p.id)
native.push(duke.leader);
return native;
}
@@ -20603,25 +20598,47 @@ function returnCaptives(g: Game, captor: Player) {
delete l.concealed;
returned++;
}
+ if (g.dukeVidal?.leader.capturedBy === captor.id) {
+ g.dukeVidal = releaseCapturedDuke(g.dukeVidal, captor.id, g.turn);
+ returned++;
+ }
if (returned)
log(
g,
`${captor.name} returned ${returned} captive leaders because all native leaders are dead.`,
);
}
-function captureCandidates(g: Game, loser: string, t: string) {
- return controlledLeaders(g, getPlayer(g, loser)).filter(
- (l) => !isAuditorLeader(l) && !l.dead && (!l.usedAt || l.usedAt === t),
- );
+/** Original random pool; the optional witness preserves a just-used living
+ * losing Duke after his separate one-battle release, never a second disc. */
+export function captureCandidates(g: Game, loser: string, t: string,
+ released?: DukeReleasedCaptureWitness): Leader[] {
+ const candidates = controlledLeaders(g, getPlayer(g, loser)).filter(
+ (leader) => !isAuditorLeader(leader) && !leader.dead && (!leader.usedAt || leader.usedAt === t));
+ if (released) {
+ const duke = g.dukeVidal;
+ requireRule(duke && Object.keys(released).sort().join(',') === 'deaths,usedAt' &&
+ Number.isSafeInteger(released.deaths) && released.deaths >= 0 &&
+ released.usedAt === t && duke.leader.id === DUKE_VIDAL_ID && !duke.leader.dead &&
+ !duke.leader.capturedBy && !duke.leader.gholaBy &&
+ duke.leader.deaths === released.deaths && duke.leader.usedAt === released.usedAt &&
+ duke.controller === null && duke.acquiredTurn === null && duke.source === null,
+ 'The original released Duke capture source changed before selection.');
+ requireRule(!candidates.some(leader => leader.id === DUKE_VIDAL_ID),
+ 'The original shared Duke cannot occupy two capture slots.');
+ candidates.push(duke.leader);
+ }
+ return candidates;
+}
+function leaderHiddenFrom(g: Game, leader: Leader, viewer: string): boolean {
+ const concealed = leader.concealed;
+ if (!concealed) return false;
+ const owner = g.players.find(player => player.faction === leader.faction);
+ return ![concealed.controller ?? owner?.id, concealed.captor].includes(viewer) &&
+ !(getPlayer(g, viewer).faction === 'tleilaxu' && leader.dead && !leader.capturedBy);
}
function projectLeader(g: Game, l: Leader, viewer: string): Leader {
const { concealed, capturedBy, ...visible } = l;
- const owner = g.players.find((p) => p.faction === l.faction);
- if (
- concealed &&
- ![concealed.controller ?? owner?.id, concealed.captor].includes(viewer) &&
- !(getPlayer(g, viewer).faction === 'tleilaxu' && l.dead && !l.capturedBy)
- ) {
+ if (concealed && leaderHiddenFrom(g, l, viewer)) {
const result = {
...visible,
dead: concealed.dead,
@@ -20633,6 +20650,16 @@ function projectLeader(g: Game, l: Leader, viewer: string): Leader {
}
return { ...visible, ...(capturedBy ? { capturedBy } : {}) };
}
+function projectedSharedDuke(g: Game, viewer: string):
+ Pick | null {
+ const duke = g.dukeVidal;
+ if (!duke) return null;
+ const leader = projectLeader(g, duke.leader, viewer);
+ const visibility = leaderHiddenFrom(g, duke.leader, viewer) ? duke.captureVisibility : undefined;
+ const controller = visibility ? visibility.controller : leader.capturedBy ?? leader.gholaBy ?? duke.controller;
+ return { controller, acquiredTurn: visibility ? visibility.acquiredTurn : duke.acquiredTurn,
+ source: visibility ? visibility.source : duke.source, leader: { ...leader, controller } };
+}
/** Public alternatives deliberately never read a foreign No-Field token value. */
function stonePublicPools(
g: Game,
@@ -23001,8 +23028,12 @@ function resolveBattle(g: Game, retreat?: DiplomatRetreatSelection) {
] as [Player, Plan, Leader | undefined][]) {
if (l && !l.dead) l.usedAt = b.territory;
if (l?.capturedBy === p.id) {
- delete l.capturedBy;
- delete l.concealed;
+ if (l.id === DUKE_VIDAL_ID && g.dukeVidal)
+ g.dukeVidal = releaseCapturedDuke(g.dukeVidal, p.id, g.turn);
+ else {
+ delete l.capturedBy;
+ delete l.concealed;
+ }
}
if (plan.kwisatz) {
p.kwisatz ??= { dead: false };
@@ -23021,12 +23052,18 @@ function resolveBattle(g: Game, retreat?: DiplomatRetreatSelection) {
if (harkonnen) returnCaptives(g, harkonnen);
if (g.advanced && winner?.faction === 'harkonnen') {
const loser = winner.id === a.id ? d : a;
- if (captureCandidates(g, loser.id, b.territory).length)
+ const candidates = captureCandidates(g, loser.id, b.territory);
+ if (candidates.length) {
+ const losingPlan = winner.id === a.id ? dp : ap;
g.pendingCapture = {
player: winner.id,
loser: loser.id,
territory: b.territory,
+ ...(losingPlan.leader === DUKE_VIDAL_ID &&
+ candidates.some(leader => leader.id === DUKE_VIDAL_ID)
+ ? { duke: { deaths: g.dukeVidal!.leader.deaths, usedAt: b.territory } } : {}),
};
+ }
}
if (winner) {
const loser = winner.id === a.id ? d : a;
@@ -25631,21 +25668,27 @@ function finishResponse(g: Game, canceled: boolean, settlement?: BureaucratSettl
g.pendingCapture = null;
if (canceled) finishBattle(g);
else {
- const candidates = captureCandidates(g, capture.loser, capture.territory);
- const l = shuffle(candidates)[0];
+ const candidates = captureCandidates(g, capture.loser, capture.territory, capture.duke);
+ const selected = shuffle(candidates)[0];
+ const l = selected?.id === DUKE_VIDAL_ID
+ ? (g.dukeVidal = captureDuke(g.dukeVidal!, capture.player, capture.loser,
+ g.turn, capture.territory, capture.duke)).leader : selected;
if (!l) {
finishBattle(g);
return;
}
- const owner = g.players.find((p) => p.leaders.includes(l))!;
- l.concealed = {
- captor: capture.player,
- controller: capture.loser,
- dead: l.dead,
- deaths: l.deaths,
- usedAt: l.usedAt,
- };
- l.capturedBy = capture.player;
+ const owner = l.id === DUKE_VIDAL_ID ? getPlayer(g, capture.loser) :
+ g.players.find((player) => player.leaders.includes(l))!;
+ if (l.id !== DUKE_VIDAL_ID) {
+ l.concealed = {
+ captor: capture.player,
+ controller: capture.loser,
+ dead: l.dead,
+ deaths: l.deaths,
+ usedAt: l.usedAt,
+ };
+ l.capturedBy = capture.player;
+ }
const capturedSkill = g.leaderSkills?.assignments.find((a) => a.leader === l.id);
// This unique card and its leader were publicly assigned. The expansion
// requires the card to accompany the captive, identifying that capture.
@@ -30840,13 +30883,19 @@ function applyActionInner(
action.mode === 'keep' || action.mode === 'execute',
'Choose keep or execute for the captured leader.',
);
- const owner = getPlayer(g, decision.owner),
- l = owner.leaders.find((l) => l.id === decision.leader)!;
+ const owner = getPlayer(g, decision.owner);
+ const l = decision.leader === DUKE_VIDAL_ID ? g.dukeVidal?.leader :
+ owner.leaders.find((leader) => leader.id === decision.leader);
+ requireRule(l?.capturedBy === p.id, 'This captive is no longer controlled by its original captor.');
if (action.mode === 'execute') {
- l.dead = true;
- l.deaths = Math.max(2, l.deaths + 1);
- delete l.usedAt;
- delete l.capturedBy;
+ if (l.id === DUKE_VIDAL_ID && g.dukeVidal)
+ g.dukeVidal = executeCapturedDuke(g.dukeVidal, p.id, g.turn);
+ else {
+ l.dead = true;
+ l.deaths = Math.max(2, l.deaths + 1);
+ delete l.usedAt;
+ delete l.capturedBy;
+ }
p.spice += 2;
log(g, `${p.name} executed a captive for 2 spice.`);
} else {
@@ -33365,6 +33414,7 @@ export function viewGame(state: Game, id: string) {
settleAdvisors(g);
normalizeBattle(g);
const me = getPlayer(g, id);
+ const sharedDuke = projectedSharedDuke(g, id);
const choamPlayer = byFaction(g, 'choam');
let victoryProgress: StrongholdProgress[] = [];
let fremenVictory: FremenVictoryProgress | null = null;
@@ -33480,9 +33530,7 @@ export function viewGame(state: Game, id: string) {
rihani: projectedRihani(g, id),
advanced: g.advanced,
advancedPreview: g.advancedPreview === true,
- dukeVidal: g.dukeVidal
- ? { ...g.dukeVidal, leader: projectLeader(g, g.dukeVidal.leader, id) }
- : null,
+ dukeVidal: sharedDuke,
moritaniTerror: g.moritaniTerror
? projectTerror(g.moritaniTerror, me.faction === 'moritani')
: null,
@@ -34189,12 +34237,9 @@ export function viewGame(state: Game, id: string) {
.filter((c) => c.revealed)
.map((c) => c.leader),
leaders: [
- ...(g.dukeVidal?.controller === p.id &&
- !g.dukeVidal.leader.capturedBy &&
- !g.dukeVidal.leader.gholaBy &&
- !(g.advanced && byFaction(g, 'harkonnen'))
- ? [{ ...g.dukeVidal.leader, controller: p.id }]
- : []),
+ ...(sharedDuke?.controller === p.id &&
+ (!sharedDuke.leader.capturedBy || p.id === id) && !sharedDuke.leader.gholaBy
+ ? [{ ...sharedDuke.leader, controller: p.id }] : []),
...p.leaders.map((l) => projectLeader(g, l, id)),
...g.players
.filter((owner) => owner.id !== p.id)
diff --git a/game/nexus-ecaz-duke.ts b/game/nexus-ecaz-duke.ts
index d5887aa6..cd1d2758 100644
--- a/game/nexus-ecaz-duke.ts
+++ b/game/nexus-ecaz-duke.ts
@@ -1,6 +1,6 @@
import type { FactionId } from './catalog';
import type { Game } from './engine';
-import { acquireDuke, DUKE_VIDAL_ID, type DukeSource } from './duke-vidal';
+import { acquireDuke, dukeController, DUKE_VIDAL_ID, type DukeSource } from './duke-vidal';
import { nexusCardMode } from './nexus-cards';
import { nexusCleanPlayBlocked } from './nexus-play-boundary';
import { mixedE3NexusLeaderSkillsProfile, pairedE3NexusLeaderSkillsProfile, standaloneE3NexusLeaderSkillsProfile } from './leader-skill-profile';
@@ -19,7 +19,7 @@ type DukeBefore = {
};
/** A completed physical card play, not a second copy of the Duke's current custody. */
-export type NexusEcazDukeReceipt = {
+type NexusEcazDukeLivingReceipt = {
event: string;
owner: string;
ownerFaction: 'ecaz';
@@ -35,6 +35,31 @@ export type NexusEcazDukeReceipt = {
signature: string;
};
+/** Version two exists only for a real captive, retaining its private original underlay. */
+export type NexusEcazDukeCapturedReceipt = Omit & {
+ version: 2;
+ before: Omit & {
+ leader: {
+ id: typeof DUKE_VIDAL_ID;
+ dead: false;
+ deaths: number;
+ usedAt: null;
+ capturedBy: string;
+ gholaBy: null;
+ concealed: {
+ captor: string; controller: string; dead: false; deaths: number; usedAt: string | null;
+ } | null;
+ };
+ };
+ after: NexusEcazDukeLivingReceipt['after'] & {
+ leader: {
+ id: typeof DUKE_VIDAL_ID; dead: false; deaths: number; usedAt: null;
+ capturedBy: null; gholaBy: null; concealed: null;
+ };
+ };
+};
+export type NexusEcazDukeReceipt = NexusEcazDukeLivingReceipt | NexusEcazDukeCapturedReceipt;
+
export function nexusEcazDukeEvent(
turn: number, phase: number, owner: string, sequence: number,
beforeController: string | null, beforeAcquiredTurn: number | null,
@@ -44,6 +69,20 @@ export function nexusEcazDukeEvent(
}
export function nexusEcazDukeSignature(receipt: NexusEcazDukeReceipt): string {
+ if ('version' in receipt) {
+ const leader = receipt.before.leader;
+ const hidden = leader.concealed;
+ const after = receipt.after;
+ return JSON.stringify(['nexusEcazDukeCapturedReceipt', receipt.version,
+ receipt.event, receipt.owner, receipt.ownerFaction, receipt.card, receipt.mode,
+ receipt.roster, receipt.advanced, receipt.turn, receipt.phase, receipt.sequence,
+ receipt.before.controller, receipt.before.acquiredTurn, receipt.before.source,
+ leader.id, leader.dead, leader.deaths, leader.usedAt, leader.capturedBy, leader.gholaBy,
+ hidden && [hidden.captor, hidden.controller, hidden.dead, hidden.deaths, hidden.usedAt],
+ after.controller, after.acquiredTurn, after.source,
+ after.leader.id, after.leader.dead, after.leader.deaths, after.leader.usedAt,
+ after.leader.capturedBy, after.leader.gholaBy, after.leader.concealed]);
+ }
return JSON.stringify(['nexusEcazDukeReceipt', receipt.event, receipt.owner,
receipt.ownerFaction, receipt.card, receipt.mode, receipt.roster,
receipt.advanced, receipt.turn, receipt.phase, receipt.sequence,
@@ -61,10 +100,14 @@ export function quoteNexusEcazDuke(g: Game, owner: string, automaticPending = fa
if (!holder || holder.faction !== 'ecaz' ||
g.nexusCards?.cards?.hands[owner] !== 'ecaz') return null;
const duke = g.dukeVidal;
+ const hidden = duke?.leader.concealed;
+ const masked = !!hidden && owner !== hidden.captor && owner !== hidden.controller;
+ const eventCustody = masked && duke?.captureVisibility ? duke.captureVisibility : duke;
const roster = g.players.map(player => player.faction);
let blocked: string | null = null;
- if (g.advanced && g.players.some(player => player.faction === 'harkonnen'))
- blocked = 'Duke Vidal battle use is unavailable with Advanced Harkonnen.';
+ if (g.advanced && g.players.some(player => player.faction === 'harkonnen') &&
+ !standaloneE3NexusProfile(g))
+ blocked = 'Duke Vidal battle use is unavailable with Advanced Harkonnen in this profile.';
else if (!nexusEcazDukeModeSupported(g) || nexusCardMode('ecaz', holder.faction, roster) !== 'cunning')
blocked = 'Ecaz Nexus Cunning requires a supported original paired or standalone Ecaz Nexus profile.';
else if (holder.ally) blocked = 'Use Ecaz Nexus Cunning while unallied.';
@@ -72,28 +115,38 @@ export function quoteNexusEcazDuke(g: Game, owner: string, automaticPending = fa
blocked = 'Use Ecaz Nexus Cunning at the Battle phase boundary.';
else if (nexusCleanPlayBlocked(g, automaticPending))
blocked = 'Finish the current interaction before using Ecaz Nexus Cunning.';
- else if (!duke || duke.leader.id !== DUKE_VIDAL_ID ||
- duke.leader.capturedBy || duke.leader.gholaBy)
- blocked = 'Only an uncaptured, non-Ghola Duke Vidal can be acquired.';
- else if (duke.leader.dead &&
- (duke.controller !== null || duke.acquiredTurn !== null || duke.source !== null))
- blocked = 'Duke Vidal must be set aside in the Tanks before Ecaz Nexus acquisition.';
- else if (!duke.leader.dead && duke.leader.usedAt)
- blocked = 'Duke Vidal has already been used in battle this turn.';
- else if (duke.controller === owner)
- blocked = 'Ecaz already controls Duke Vidal.';
+ else if (!duke || duke.leader.id !== DUKE_VIDAL_ID || duke.leader.gholaBy)
+ blocked = 'Only the shared non-Ghola Duke Vidal can be acquired.';
+ else if (duke.leader.capturedBy && (!g.advanced ||
+ !g.players.some(player => player.id === duke.leader.capturedBy && player.faction === 'harkonnen')))
+ blocked = 'Duke Vidal lacks an original Harkonnen capture source.';
+ else {
+ try {
+ acquireDuke(duke, owner, g.turn, 'ecazNexus');
+ if (dukeController(duke) === owner) blocked = 'Ecaz already controls Duke Vidal.';
+ } catch {
+ blocked = duke.leader.usedAt && !duke.leader.dead
+ ? 'Duke Vidal has already been used in battle this turn.'
+ : 'Duke Vidal is unavailable for Ecaz Nexus acquisition.';
+ }
+ }
return {
event: nexusEcazDukeEvent(g.turn, 6, owner, g.nexusEcazDukeHistory?.length ?? 0,
- duke?.controller ?? null, duke?.acquiredTurn ?? null),
+ eventCustody?.controller ?? null, eventCustody?.acquiredTurn ?? null),
blocked,
- dukeController: duke?.controller ?? null,
+ dukeController: duke
+ ? masked
+ ? hidden!.controller ?? duke.controller
+ : dukeController(duke)
+ : null,
};
}
export function nexusEcazDukeModeSupported(g: Game): boolean {
if ((mixedE3NexusProfile(g) || standaloneE3NexusProfile(g) || pairedE3NexusProfile(g)) &&
g.players.some(player => player.faction === 'ecaz') &&
- !g.sandtrout && !(g.advanced && g.players.some(player => player.faction === 'harkonnen')))
+ !g.sandtrout && (!(g.advanced && g.players.some(player => player.faction === 'harkonnen')) ||
+ standaloneE3NexusProfile(g)))
return !!g.nexusCards?.cards && g.players.length >= 3;
if (g.leaderSkills && (mixedE3NexusLeaderSkillsProfile(g) || standaloneE3NexusLeaderSkillsProfile(g) || pairedE3NexusLeaderSkillsProfile(g)) &&
g.players.some(player => player.faction === 'ecaz') && !g.sandtrout)
@@ -118,9 +171,41 @@ export function createNexusEcazDukeReceipt(
if (!offer || offer.blocked || sequence !== (g.nexusEcazDukeHistory?.length ?? 0))
throw new Error(offer?.blocked ?? 'Ecaz Nexus Cunning is unavailable.');
const duke = g.dukeVidal!;
+ // History records actual server custody, not an unrelated owner's masked action token.
+ const event = nexusEcazDukeEvent(g.turn, 6, owner, sequence, duke.controller, duke.acquiredTurn);
const after = acquireDuke(duke, owner, g.turn, 'ecazNexus');
+ if (duke.leader.capturedBy) {
+ const hidden = duke.leader.concealed;
+ const receipt: NexusEcazDukeCapturedReceipt = {
+ version: 2,
+ event, owner, ownerFaction: 'ecaz', card: 'ecaz', mode: 'cunning',
+ roster: g.players.map(player => player.faction), advanced: g.advanced,
+ turn: g.turn, phase: 6, sequence,
+ before: {
+ controller: duke.controller, acquiredTurn: duke.acquiredTurn, source: duke.source,
+ leader: {
+ id: DUKE_VIDAL_ID, dead: false, deaths: duke.leader.deaths, usedAt: null,
+ capturedBy: duke.leader.capturedBy, gholaBy: null,
+ concealed: hidden ? {
+ captor: hidden.captor, controller: hidden.controller!, dead: false,
+ deaths: hidden.deaths, usedAt: hidden.usedAt ?? null,
+ } : null,
+ },
+ },
+ after: {
+ controller: after.controller!, acquiredTurn: after.acquiredTurn!, source: 'ecazNexus',
+ leader: {
+ id: DUKE_VIDAL_ID, dead: false, deaths: after.leader.deaths, usedAt: null,
+ capturedBy: null, gholaBy: null, concealed: null,
+ },
+ },
+ signature: '',
+ };
+ receipt.signature = nexusEcazDukeSignature(receipt);
+ return receipt;
+ }
const receipt: NexusEcazDukeReceipt = {
- event: offer.event, owner, ownerFaction: 'ecaz', card: 'ecaz', mode: 'cunning',
+ event, owner, ownerFaction: 'ecaz', card: 'ecaz', mode: 'cunning',
roster: g.players.map(player => player.faction), advanced: g.advanced,
turn: g.turn, phase: 6, sequence,
before: {
@@ -146,6 +231,10 @@ const turn = (value: unknown): value is number =>
export function validateNexusEcazDuke(
g: Game, receipt: NexusEcazDukeReceipt, index: number,
): void {
+ if (object(receipt) && 'version' in receipt) {
+ validateCapturedReceipt(g, receipt as NexusEcazDukeCapturedReceipt, index);
+ return;
+ }
const before = receipt?.before;
const after = receipt?.after;
const leader = before?.leader;
@@ -188,3 +277,56 @@ export function validateNexusEcazDuke(
receipt.signature !== nexusEcazDukeSignature(receipt))
throw new Error('Ecaz Nexus Cunning lost its original Duke custody or physical play.');
}
+
+function validateCapturedReceipt(g: Game, receipt: NexusEcazDukeCapturedReceipt, index: number): void {
+ const before = receipt.before;
+ const leader = before?.leader;
+ const hidden = leader?.concealed;
+ const after = receipt.after;
+ const returned = after?.leader;
+ const controller = g.players.find(player => player.id === before?.controller);
+ const captor = g.players.find(player => player.id === leader?.capturedBy);
+ if (!keys(receipt, 'advanced,after,before,card,event,mode,owner,ownerFaction,phase,roster,sequence,signature,turn,version') ||
+ receipt.version !== 2 ||
+ !object(before) || !keys(before, 'acquiredTurn,controller,leader,source') ||
+ !object(leader) || !keys(leader, 'capturedBy,concealed,dead,deaths,gholaBy,id,usedAt') ||
+ !object(after) || !keys(after, 'acquiredTurn,controller,leader,source') ||
+ !object(returned) || !keys(returned, 'capturedBy,concealed,dead,deaths,gholaBy,id,usedAt') ||
+ !nexusEcazDukeModeSupported(g) ||
+ !g.players.some(player => player.id === receipt.owner && player.faction === 'ecaz') ||
+ receipt.ownerFaction !== 'ecaz' || receipt.card !== 'ecaz' || receipt.mode !== 'cunning' ||
+ !Array.isArray(receipt.roster) || receipt.roster.length !== g.players.length ||
+ receipt.roster.some((faction, position) => faction !== g.players[position]?.faction) ||
+ nexusCardMode('ecaz', receipt.ownerFaction, receipt.roster) !== 'cunning' ||
+ typeof receipt.advanced !== 'boolean' || receipt.advanced !== g.advanced ||
+ !turn(receipt.turn) || receipt.turn > g.turn ||
+ receipt.phase !== 6 || (receipt.turn === g.turn && g.phase < 6) ||
+ !Number.isSafeInteger(index) || index < 0 || receipt.sequence !== index ||
+ leader.id !== DUKE_VIDAL_ID || leader.dead !== false ||
+ !Number.isSafeInteger(leader.deaths) || leader.deaths < 0 ||
+ leader.usedAt !== null || leader.gholaBy !== null ||
+ !captor || captor.faction !== 'harkonnen' || !receipt.advanced ||
+ leader.capturedBy === receipt.owner || leader.capturedBy === before.controller ||
+ (before.controller === null
+ ? before.acquiredTurn !== null || before.source !== null
+ : !controller || !turn(before.acquiredTurn) || before.acquiredTurn > receipt.turn ||
+ !['moritani', 'ecaz', 'ecazNexus', 'ally'].includes(before.source as string) ||
+ (before.source === 'moritani' && controller.faction !== 'moritani') ||
+ ((before.source === 'ecaz' || before.source === 'ecazNexus') && controller.faction !== 'ecaz')) ||
+ !(hidden === null || (object(hidden) &&
+ keys(hidden, 'captor,controller,dead,deaths,usedAt') &&
+ hidden.captor === leader.capturedBy && hidden.dead === false &&
+ hidden.deaths === leader.deaths &&
+ (hidden.usedAt === null || (typeof hidden.usedAt === 'string' && hidden.usedAt.length > 0)) &&
+ hidden.controller !== hidden.captor &&
+ g.players.some(player => player.id === hidden.controller) &&
+ (before.controller === null || hidden.controller === before.controller))) ||
+ returned.id !== DUKE_VIDAL_ID || returned.dead !== false ||
+ returned.deaths !== leader.deaths || returned.usedAt !== null ||
+ returned.capturedBy !== null || returned.gholaBy !== null || returned.concealed !== null ||
+ after.controller !== receipt.owner || after.acquiredTurn !== receipt.turn || after.source !== 'ecazNexus' ||
+ receipt.event !== nexusEcazDukeEvent(receipt.turn, receipt.phase, receipt.owner, receipt.sequence,
+ before.controller, before.acquiredTurn) ||
+ receipt.signature !== nexusEcazDukeSignature(receipt))
+ throw new Error('Ecaz Nexus Cunning lost its original captured Duke custody or physical play.');
+}
diff --git a/game/reference.ts b/game/reference.ts
index d998af86..00f09c74 100644
--- a/game/reference.ts
+++ b/game/reference.ts
@@ -4561,7 +4561,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'The separate fresh local leader-skills --nexus-cards entry combines classic base33/all14 Skills/all12 Nexus in Basic or Advanced, two through six seats, optionally adding original Discovery7/8 through --discoveries. Original Tech requires three or more seats; Stronghold Cards require Advanced. Starting hands/offers, actual end-Spice closing draws, first-Storm Tech and end-Mentat held custody remain. Extra/ordinary free-three returns earn original phase-end Axlotl without changing skill custody. Emperor Cunning composes skill posture, support and Suk rescue; Arrakeen reduces payer cost, not committed support or rescue eligibility. Winner cleanup precedes mandatory Tech reward. Unrelated overlays and public starts remain guarded.',
'The original all14 skill/Nexus envelope admits single or paired one-family E1/E2, BOTH selected Ix+CHOAM native families/union47, and standalone OR paired E3/ecaz33 with classics, Basic/Advanced2–6, optional Homeworlds/Discovery7/8/Tech3+/Advanced Strongholds. Full Suboid support composes winning typed fixed-loss Suk before equal actual Cyborg Tank substitution. Held subsidy changes debit, not rescue entitlement. Original cleanup/winner Tech precedes Face Dance; the replaced trainer dies/returns its skill once without another bounty, while earlier held rewards stay with the original winner. Original Auditor exclusion, signed Richese invoices, CHOAM fuel, native cache and pending-effect guards remain.',
'Standalone OR paired Ecaz/Moritani original skill/Nexus entries use ecaz33/all14/all12 and classics, Basic/Advanced2–6, optional Homeworlds/Discovery/Tech3+/Advanced Strongholds. Each original roster/capture guard remains, including Advanced Harkonnen exclusion and no shared-Duke training. Quiet living/unclaimed Duke Cunning and native Advanced assassination use original distinct discs and exact continuation. In paired tables, native Ecaz may be a different opposing-faction assassination target; normal foreign-world Traitor prohibition does not become an assassination ban. Exceptional Duke custody, mixed E3/three-family, enhanced Terror/HMS/Grumman and pending effects remain separate.',
- 'The original nexus entry also admits standalone Ecaz OR Moritani with classics, ecaz33/all12 and NO Skills, Basic/Advanced2–6, optionally Homeworlds, Discovery7/8, Tech3+ and Advanced Strongholds2+. Original Traitors/native setup precede a qualifying closing draw, with no starting Nexus hand. Quiet Duke Cunning stays separate from shared-disc training; no-Skills Ecaz may face Advanced Harkonnen without lifting the Cunning exclusion. Advanced Moritani excludes Harkonnen, retains post-loss private assassination/normal-call forfeiture and Mentat replacement, and uses supply-only Terror Cunning. Original Ecaz-selected coalition losses/payments and source-local held benefits/Tech remain. A sole loser token transfers automatically after cleanup; a real multi-token choice remains mandatory. Other native/mixed families, borrowed free-return/companion and relocation rulings, public starts and save conversion remain separate.',
+ 'The original nexus entry also admits standalone Ecaz OR Moritani with classics, ecaz33/all12 and NO Skills, Basic/Advanced2–6, optionally Homeworlds, Discovery7/8, Tech3+ and Advanced Strongholds2+. Original Traitors/native setup precede a qualifying closing draw, with no starting Nexus hand. No-Skills Ecaz facing Advanced Harkonnen now connects shared-Duke acquisition, original capture/return and Cunning liberation without shared-disc training. Advanced Moritani still excludes Harkonnen, retains post-loss private assassination/normal-call forfeiture and Mentat replacement, and uses supply-only Terror Cunning. Original Ecaz-selected coalition losses/payments and source-local held benefits/Tech remain. A sole loser token transfers automatically after cleanup; a real multi-token choice remains mandatory. Other native/mixed families, borrowed free-return/companion and relocation rulings, public starts and save conversion remain separate.',
'Atreides Cunning adds a second, different opposing plan element after the first native answer. Both remain binding. Use it in your own battle before sealing your plan; Karama may cancel the extra attempt while preserving the first disclosure.',
'With Atreides absent, Secret Ally inspects one opposing element in your battle. A sealed plan supplies only that selected answer automatically. Otherwise the opponent chooses an answer that permits a legal plan. Other players receive no private answer.',
'The No-Field restriction names Atreides, so Cunning cannot inspect that dial. A non-Atreides Secret Ally holder may request it without learning the token denomination or other plan elements.',
@@ -4600,7 +4600,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'When Moritani is absent, a defeated combatant with an eligible publicly played card receives the same postbattle choice whether or not they hold Moritani Nexus. An unallied holder may spend it to retain one played card that a winner could keep; other played cards follow ordinary discard. Without a publicly eligible card there is no prompt. The choice is after the battle result and before later income/capture; the public prompt protects hidden custody and is an interface inference. This Secret Ally effect has no invented ordinary Karama response. Native Moritani allied retention remains a different, cancelable ability.',
'With actual Advanced Stronghold Cards, Arrakeen bank support changes the real payer debit, not personal income. Emperor Cunning temporary Sardaukar remain physically ordinary counters and retain ordinary Tanks custody. Original losing Moritani retention precedes mandatory original-winner Tech, then CHOAM inspection excludes used opposing cards even if retained. Ordinary city Collection remains separate; none of these windows repeats the original support, card cost or casualties.',
'Ecaz Betrayal is available to an unallied holder with Ecaz seated and reciprocally allied in a shared Arrakis territory. At the opening of Shipment and Movement, choose one such territory; the ally returns its entire normal and starred force group from every sector there to reserves, leaving Ecaz forces in place. The Nexus card is spent before a Karama response. Cancellation preserves the board but not the card. Only the base/Ecaz Nexus roster without combined modules is supported.',
- 'Ecaz Cunning lets an unallied native holder spend its physical Nexus at a quiet Battle boundary to take the existing uncaptured, non-Ghola Duke for this turn, including a Moritani-controlled living disc or the set-aside disc in Tanks. Tanks acquisition preserves death history, revives the same disc and clears its old battle-use stamp under an explicit provisional independent-resurrection policy, without a paid revival charge or ordinary allowance. Original standalone OR paired Ecaz/Moritani/classic profiles keep their supported optional modules. The shared Duke stays outside ordinary training and is set aside after use or at turn end. Captured/Ghola custody, Advanced Harkonnen and unqualified reaction timing remain guarded; the quiet Battle boundary is product timing, not a printed restriction.',
+ 'Ecaz Cunning lets an unallied native holder spend its physical Nexus at a quiet Battle boundary to take the existing Duke for this turn, including living control, original captured custody or the set-aside disc in Tanks. Tanks preserve deaths and independently clear old use without spice or ordinary allowance; a living already-used disc gains no fresh use. Standalone no-Skills Ecaz/Advanced Harkonnen now connects original capture Keep/Execute and native return. Provisional liberation clears captivity permanently; use or turn expiry sets Duke aside, never back to the captor. Actual Ecaz Ghola returns Duke alive/set aside before separate Cunning; foreign-Ghola revival remains forbidden. Original other profile/module and shared-training guards remain. Quiet Battle timing and capture priority/return are product interpretations, not publisher-specific rulings.',
'Fremen Betrayal has two bounded advance-declaration alternatives for an unallied holder in a classic Nexus table. Before the first Spice Blow or worm appearance, spend the physical card to prevent Fremen ordinary and Cunning worm rides for the turn; worm survival, destruction and Nexus still resolve. This clean timing is a product boundary, not an official post-appearance priority ruling. Instead, before Fremen move during Shipment and Movement, spend it to suppress their native two-territory advantage for that turn while ordinary movement and independent ornithopters remain. Only one alternative can be used even if the card is recycled later that turn. Owner controls show the currently available choice; combined modules and reactive post-worm timing remain gated.',
'When Ecaz is absent, an unallied holder may spend its Nexus card at a quiet play boundary to ask whether any one player, including themself, holds one of the holder’s native faction leaders as an ordinary Traitor Card. The owner receives a private yes/no answer saved at the moment of use; no name or count is disclosed. Dead or captured native leaders count, but foreign Gholas, Duke Vidal, Cheap Hero and Face Dancers do not. Native-leader scope and private-only audience are explicit product interpretations, not publisher rulings; exceptional controlled-leader and public-disclosure combinations remain open.',
'When Richese is absent, its Secret Ally replaces your ordinary reserve shipment with up to five physical forces charged as one. Select the explicit shipment checkbox; forces, elite identity, destination restrictions, Homeworld reserve sources and normal movement remain unchanged. Fremen remain free within their normal reinforcement range; Guild and an active Karama rate retain their existing tariff and payment routing. This is not a concealed No-Field or an extra shipment.',
@@ -4615,11 +4615,11 @@ export const RULE_TOPICS: RuleTopic[] = [
],
related: ['ecaz-modules', 'spice-blow', 'alliance-funding', 'choam-worthless', 'choam-kull', 'nexus-choam-kull', 'nexus-richese-betrayal', 'nexus-guild-betrayal', 'nexus-ixian-replacement', 'nexus-ixian-betrayal'],
checklist: [
- {area:'Implementation',status:'Partial',detail:"Existing independent revival and seller/Ambassador/joint special sources remain. Normal bank purchase now admits canonical native Nexus public profiles and their optional envelopes, preserving exact old classic admission. Actual own positive price, no ally share, native card/capacity/bonus/next inspection and unchanged bank receipt remain; no subsidy or Richese cache/seller source is invented. Strict legacy physical revival stays classic. Complete combinations and assurance are open.",evidence:['game/nexus-cards.ts', 'game/nexus-card-phase.ts', 'game/nexus-traitor-exchange.ts', 'game/traitor-declarations.ts', 'game/nexus-face-dancers.ts', 'game/nexus-suboids.ts', 'game/nexus-advisors.ts', 'game/nexus-sardaukar-options.ts', 'game/nexus-fremen-revival.ts', 'game/nexus-fremen-cunning.ts', 'game/nexus-ecaz-betrayal.ts', 'game/nexus-ecaz-inquiry.ts', 'game/nexus-ecaz-duke.ts', 'game/duke-vidal.ts', 'game/nexus-emperor-secret-ally.ts', 'game/choam-power-options.ts', 'game/nexus-moritani-options.ts', 'game/nexus-moritani-betrayal.ts', 'game/nexus-richese-options.ts', 'game/nexus-richese-betrayal.ts', 'game/nexus-guild-cunning-options.ts', 'game/nexus-guild-secret-ally-options.ts', 'game/nexus-module-profile.ts', 'game/engine.ts', 'game/nexus-tleilaxu-revival.ts', 'game/nexus-borrowed-voice.ts', 'game/combat-response-quote.ts', 'game/karama-context.ts', 'game/nexus-fremen-protection.ts', 'game/admin-seat-ai-shape.ts', 'game/richese-cancellation.ts', 'game/bot-mobility.ts', 'game/planetologist-movement.ts', 'game/revival.ts', 'game/nexus-fremen-revival-options.ts','game/nexus-emperor-payment.ts', 'game/nexus-emperor-secret-ally.ts', 'game/revival-resume.ts', 'game/revival-cancellation.ts', 'game/homeworld-revival-deployment.ts', 'game/homeworld-revival-return.ts', "game/nexus-emperor-purchase.ts", "game/auction-continuation-quote.ts", "game/nexus-emperor-purchase.ts", "game/ambassador-phase.ts", "game/ambassador-resume.ts"]},
- {area:'Player controls',status:'Partial',detail:"Original uniform bank winner payment and eligible private Nexus Use now work for native rosters; no hidden-face-only pause. Existing Pay/Karama, native bonus and next inspection controls are reused. Actual390px Richese5 to5/Ecaz12 to12 preserve Homeworld/Tech, Basic Tleilaxu5 to5 and wrong-face CHOAM Pay14 to12 preserve one card and next index. No overflow/errors. Existing seller/extra/joint controls remain; full-mode acceptance stays open.",evidence:['components/nexus-cards.tsx', 'components/nexus-traitors.tsx', 'components/nexus-tleilaxu.tsx', 'components/nexus-suboids.tsx', 'components/nexus-advisors.tsx', 'components/nexus-sardaukar.tsx', 'components/choam-power-cost.tsx', 'components/moritani-terror.tsx', 'components/nexus-moritani-betrayal.tsx', 'components/nexus-ecaz-betrayal.tsx', 'components/nexus-ecaz-inquiry.tsx', 'components/nexus-ecaz-duke.tsx', 'components/nexus-richese-betrayal.tsx', 'components/game-table.tsx', 'components/nexus-tleilaxu-revival.tsx', 'components/battle-preparation.tsx', 'components/nexus-fremen-revival.tsx','components/nexus-emperor-payment.tsx', 'components/nexus-emperor-secret-ally.tsx', "components/nexus-emperor-seller-purchase.tsx", "components/nexus-emperor-ambassador-purchase.tsx", "components/richese-special-karama.tsx"]},
- {area:'AI',status:'Partial',detail:"Existing minimal bank adapter uses the actual shared legal projection for native buyers, or original Pay for an irrelevant face, without rival private inspection. Genuine Easy CHOAM continuations cover both faces. Existing extra/seller/joint adapters and conservative budgets remain; strategy and four-tier calibration are deferred and unfinished.",evidence:['game/nexus-card-options.ts', 'game/nexus-traitor-options.ts', 'game/nexus-tleilaxu-options.ts', 'game/nexus-suboid-options.ts', 'game/nexus-advisor-options.ts', 'game/nexus-sardaukar-options.ts', 'game/choam-power-options.ts', 'game/nexus-moritani-options.ts', 'game/nexus-moritani-betrayal-options.ts', 'game/nexus-ecaz-betrayal-options.ts', 'game/nexus-ecaz-inquiry-options.ts', 'game/nexus-ecaz-duke-options.ts', 'game/nexus-richese-options.ts', 'game/bot-nexus-richese-betrayal.ts', 'game/nexus-guild-cunning-options.ts', 'game/bots.ts', 'game/nexus-fremen-revival-options.ts','game/nexus-emperor-payment.ts', 'game/nexus-emperor-secret-ally-options.ts']},
- {area:'Documentation',status:'Partial',detail:"Printed bank purchase is no longer fenced to classic prototypes; canonical native admission and exact old classic parity are explicit. Uniform payment timing retains its provisional privacy recommendation, not a new publisher ruling. Legacy physical revival remains separately guarded. Seller/Ambassador/joint interpretations, unsupported native aliases and full-mode gates remain distinct.",evidence:['docs/NEXUS_CARD_RULES.md', 'docs/NEXUS_ATREIDES_RULES.md', 'docs/NEXUS_HARKONNEN_RULES.md', 'docs/NEXUS_TLEILAXU_RULES.md', 'docs/NEXUS_IXIAN_CUNNING_RULES.md', 'docs/NEXUS_CUNNING_RUNTIME.md', 'docs/NEXUS_BENE_GESSERIT_RULES.md', 'docs/NEXUS_ADVISOR_RUNTIME.md', 'docs/NEXUS_EMPEROR_RULES.md', 'docs/NEXUS_SARDAUKAR_RUNTIME.md', 'docs/NEXUS_FREMEN_RULES.md', 'docs/NEXUS_ECAZ_RULES.md', 'docs/NEXUS_CHOAM_RULES.md', 'docs/NEXUS_CHOAM_RUNTIME.md', 'docs/NEXUS_MORITANI_RULES.md', 'docs/NEXUS_MORITANI_RUNTIME.md', 'docs/NEXUS_RICHESE_RUNTIME.md', 'docs/NEXUS_RICHESE_RULES.md', 'docs/NEXUS_GUILD_CUNNING_RUNTIME.md', 'docs/NEXUS_GUILD_RULES.md', "docs/NEXUS_EMPEROR_SECRET_ALLY_RUNTIME.md", "docs/RULE_DECISIONS.md", "docs/RICHESE_AMBASSADOR_RULES.md", "docs/RICHESE_AMBASSADOR_IMPLEMENTATION.md", "docs/RICHESE_ACQUISITION_RULES.md"]},
- {area:'Verification',status:'Partial',detail:"Native bank affected16-file union217/217, types/lint/build and thirty-six actual API profiles pass: all six natives Basic/Advanced Pay/Use and Advanced Homeworld/Tech Pay/Use. One actual card, original wallets/module inventory and next-lot inspection remain. Four actual390px surfaces prove Richese5 to5/Ecaz12 to12 with modules unchanged, Basic Tleilaxu5 to5 and Guild-card CHOAM Pay14 to12; actual Atreides sees next Maula Pistol. No overflow/errors or saved/deployed rooms touched. Forced-response and five-disc Auditor-exclusion fixture pins were deleted. Prior joint112/112, Ambassador111/111, seller108/108 and other evidence remains; broad assurance/deployed gates stay closed.",evidence:['tests/mixed-nexus-modules-runtime.test.ts', 'tests/paired-e3-nexus-modules-runtime.test.ts', 'tests/sample-custody.test.ts', 'tests/ambassador-terror-overlap-engine.test.ts', 'tests/single-nexus-e1-runtime.test.ts', 'tests/single-nexus-e2-runtime.test.ts', 'tests/e3-nexus-ecaz-runtime.test.ts', 'tests/e3-nexus-moritani-runtime.test.ts', 'tests/homeworld-classic-nexus-skills-runtime.test.ts', 'tests/homeworld-classic-discovery-nexus-runtime.test.ts', 'tests/nexus-cards.test.ts', 'tests/nexus-card-engine.test.ts', 'tests/nexus-fremen-cunning.test.ts', 'tests/nexus-fremen-cunning-recovery.test.ts', 'tests/nexus-fremen-cunning-summon.test.ts', 'tests/nexus-fremen-cunning-summon-recovery.test.ts', 'tests/nexus-ecaz-betrayal-quotes.test.ts', 'tests/nexus-ecaz-betrayal.test.ts', 'tests/nexus-ecaz-betrayal-recovery.test.ts', 'tests/nexus-ecaz-inquiry.test.ts', 'tests/nexus-ecaz-duke-quote.test.ts', 'tests/nexus-ecaz-duke-engine.test.ts', 'tests/nexus-ecaz-duke-controls.test.ts', 'tests/nexus-ecaz-duke-recovery.test.ts', 'tests/nexus-traitor-recovery.test.ts', 'tests/nexus-face-dancer-engine.test.ts', 'tests/nexus-suboids-engine.test.ts', 'tests/normal-free-support.test.ts', 'tests/nexus-suboid-controls.test.ts', 'tests/nexus-advisor-controls.test.ts', 'tests/nexus-advisor-bots.test.ts', 'tests/nexus-sardaukar-controls.test.ts', 'tests/nexus-sardaukar-bots.test.ts', 'tests/nexus-choam-controls.test.ts', 'tests/nexus-choam-bots.test.ts', 'tests/nexus-moritani-controls.test.ts', 'tests/nexus-moritani-bots.test.ts', 'tests/nexus-moritani-betrayal.test.ts', 'tests/nexus-richese-controls.test.ts', 'tests/nexus-richese-bots.test.ts', 'tests/nexus-richese-betrayal-engine.test.ts', 'tests/nexus-richese-betrayal-recovery.test.ts', 'tests/nexus-guild-cunning-controls.test.ts', 'tests/nexus-guild-cunning-bots.test.ts', 'tests/nexus-module-payments-runtime.test.ts', 'tests/nexus-module-battles-runtime.test.ts', 'tests/paired-ix-nexus-modules-runtime.test.ts', 'tests/paired-choam-nexus-modules-runtime.test.ts', 'tests/classic-nexus-skills-payments-runtime.test.ts', 'tests/classic-nexus-skills-battles-runtime.test.ts', 'tests/nexus-skills-modules-payments-runtime.test.ts', 'tests/nexus-skills-modules-battles-runtime.test.ts', 'tests/paired-ix-nexus-skills-modules-runtime.test.ts', 'tests/paired-choam-nexus-skills-modules-runtime.test.ts', 'tests/ecaz-nexus-skills-modules-runtime.test.ts', 'tests/moritani-nexus-skills-modules-runtime.test.ts', 'docs/NEXUS_CARD_RULES.md', 'tests/nexus-tleilaxu-revival.test.ts', 'tests/fixture-nexus-tleilaxu-secret-ally.ts', 'tests/nexus-tleilaxu-secret-ally-runtime.test.ts', 'tests/nexus-borrowed-voice.test.ts', 'tests/fixture-nexus-borrowed-voice.ts', 'tests/nexus-borrowed-voice-runtime.test.ts', 'tests/nexus-fremen-protection.test.ts', 'tests/fixture-nexus-fremen-protection.ts', 'tests/nexus-fremen-protection-runtime.test.ts', 'tests/fixture-stronghold-factions.ts', 'tests/fixture-nexus-advisor-fresh.ts', 'tests/nexus-advisor-fresh-runtime.test.ts', 'tests/fixture-nexus-advisor-storm.ts', 'tests/nexus-advisor-storm-runtime.test.ts', 'docs/NEXUS_ADVISOR_RUNTIME.md', 'tests/nexus-fremen-free-quota.test.ts', 'tests/fixture-nexus-fremen-free-quota.ts', 'tests/nexus-fremen-free-quota-runtime.test.ts', 'tests/nexus-fremen-revival.test.ts','tests/nexus-emperor-payment.test.ts','tests/fixture-nexus-emperor-payment.ts','tests/nexus-emperor-payment-runtime.test.ts','docs/NEXUS_EMPEROR_RULES.md', 'tests/nexus-emperor-extra-source.test.ts', 'tests/nexus-emperor-extra-continuation.test.ts', 'tests/fixture-nexus-emperor-extra.ts', 'tests/nexus-emperor-extra-runtime.test.ts', 'tests/fixture-single-nexus-e1.ts', 'docs/NEXUS_EMPEROR_SECRET_ALLY_RUNTIME.md', "tests/fixture-nexus-emperor-seller.ts", "tests/nexus-emperor-seller-purchase.test.ts", "tests/nexus-emperor-seller-runtime.test.ts", "tests/fixture-nexus-emperor-ambassador.ts", "tests/nexus-emperor-ambassador-purchase.test.ts", "tests/nexus-emperor-ambassador-runtime.test.ts", "tests/fixture-nexus-emperor-special.ts", "tests/nexus-emperor-special-purchase.test.ts", "tests/nexus-emperor-special-runtime.test.ts", "tests/karama-intents.test.ts", "tests/fixture-nexus-emperor-bank.ts", "tests/nexus-emperor-bank-profile.test.ts", "tests/nexus-emperor-bank-runtime.test.ts"]},
+ {area:"Implementation",status:'Partial',detail:"Original standalone Ecaz/classic no-Skills Nexus now connects the actual Advanced Harkonnen lottery, including the just-used losing Duke, retained tenure, Keep/Execute, captive battle use/all-native-dead return and real Cunning liberation. Exact private snapshots preserve original secret capture/execution. Actual Ecaz Ghola returns set aside before separate Cunning; foreign revival remains forbidden. Prior bank/seller/Ambassador/joint and revival sources remain. Broader Skills/paired/timing and full-combination assurance stay open.",evidence:['game/nexus-cards.ts', 'game/nexus-card-phase.ts', 'game/nexus-traitor-exchange.ts', 'game/traitor-declarations.ts', 'game/nexus-face-dancers.ts', 'game/nexus-suboids.ts', 'game/nexus-advisors.ts', 'game/nexus-sardaukar-options.ts', 'game/nexus-fremen-revival.ts', 'game/nexus-fremen-cunning.ts', 'game/nexus-ecaz-betrayal.ts', 'game/nexus-ecaz-inquiry.ts', 'game/nexus-ecaz-duke.ts', 'game/duke-vidal.ts', 'game/nexus-emperor-secret-ally.ts', 'game/choam-power-options.ts', 'game/nexus-moritani-options.ts', 'game/nexus-moritani-betrayal.ts', 'game/nexus-richese-options.ts', 'game/nexus-richese-betrayal.ts', 'game/nexus-guild-cunning-options.ts', 'game/nexus-guild-secret-ally-options.ts', 'game/nexus-module-profile.ts', 'game/engine.ts', 'game/nexus-tleilaxu-revival.ts', 'game/nexus-borrowed-voice.ts', 'game/combat-response-quote.ts', 'game/karama-context.ts', 'game/nexus-fremen-protection.ts', 'game/admin-seat-ai-shape.ts', 'game/richese-cancellation.ts', 'game/bot-mobility.ts', 'game/planetologist-movement.ts', 'game/revival.ts', 'game/nexus-fremen-revival-options.ts','game/nexus-emperor-payment.ts', 'game/nexus-emperor-secret-ally.ts', 'game/revival-resume.ts', 'game/revival-cancellation.ts', 'game/homeworld-revival-deployment.ts', 'game/homeworld-revival-return.ts', "game/nexus-emperor-purchase.ts", "game/auction-continuation-quote.ts", "game/nexus-emperor-purchase.ts", "game/ambassador-phase.ts", "game/ambassador-resume.ts", "game/battle-aftermath-quote.ts", "game/ecaz-duke-acquisition.ts", "game/ecaz-duke-revival.ts"]},
+ {area:"Player controls",status:'Partial',detail:"Actual390px production controls retain/execute the shared captive, liberate with one Nexus, select Duke6 in a real battle and release once through Collection. Separate actual Ecaz Ghola returns set aside before a distinct Cunning spend. Original bank/seller/Ambassador/joint controls remain; no overflow/errors observed. Wider timing, shared training and complete journeys remain open.",evidence:['components/nexus-cards.tsx', 'components/nexus-traitors.tsx', 'components/nexus-tleilaxu.tsx', 'components/nexus-suboids.tsx', 'components/nexus-advisors.tsx', 'components/nexus-sardaukar.tsx', 'components/choam-power-cost.tsx', 'components/moritani-terror.tsx', 'components/nexus-moritani-betrayal.tsx', 'components/nexus-ecaz-betrayal.tsx', 'components/nexus-ecaz-inquiry.tsx', 'components/nexus-ecaz-duke.tsx', 'components/nexus-richese-betrayal.tsx', 'components/game-table.tsx', 'components/nexus-tleilaxu-revival.tsx', 'components/battle-preparation.tsx', 'components/nexus-fremen-revival.tsx','components/nexus-emperor-payment.tsx', 'components/nexus-emperor-secret-ally.tsx', "components/nexus-emperor-seller-purchase.tsx", "components/nexus-emperor-ambassador-purchase.tsx", "components/richese-special-karama.tsx"]},
+ {area:"AI",status:'Partial',detail:"Existing minimal captive and Duke policies reuse projected actual shared-disc ownership and availability without a second native roster. Four-profile existing Ecaz/Duke source regressions remain legal under the connected native path. No strategic or calibration work is claimed; prior purchase/revival adapters remain.",evidence:['game/nexus-card-options.ts', 'game/nexus-traitor-options.ts', 'game/nexus-tleilaxu-options.ts', 'game/nexus-suboid-options.ts', 'game/nexus-advisor-options.ts', 'game/nexus-sardaukar-options.ts', 'game/choam-power-options.ts', 'game/nexus-moritani-options.ts', 'game/nexus-moritani-betrayal-options.ts', 'game/nexus-ecaz-betrayal-options.ts', 'game/nexus-ecaz-inquiry-options.ts', 'game/nexus-ecaz-duke-options.ts', 'game/nexus-richese-options.ts', 'game/bot-nexus-richese-betrayal.ts', 'game/nexus-guild-cunning-options.ts', 'game/bots.ts', 'game/nexus-fremen-revival-options.ts','game/nexus-emperor-payment.ts', 'game/nexus-emperor-secret-ally-options.ts']},
+ {area:"Documentation",status:'Partial',detail:"Printed capture/Tanks Cunning and Ecaz-only revival are distinguished from provisional capture-before-final-release and return destinations. Legal Ecaz Ghola then separate Cunning does not manufacture forbidden foreign custody. Standalone no-Skills Advanced Harkonnen is connected, not paired/Skills or arbitrary timing. Prior bank/seller/direct source interpretations and full-mode gates remain.",evidence:['docs/NEXUS_CARD_RULES.md', 'docs/NEXUS_ATREIDES_RULES.md', 'docs/NEXUS_HARKONNEN_RULES.md', 'docs/NEXUS_TLEILAXU_RULES.md', 'docs/NEXUS_IXIAN_CUNNING_RULES.md', 'docs/NEXUS_CUNNING_RUNTIME.md', 'docs/NEXUS_BENE_GESSERIT_RULES.md', 'docs/NEXUS_ADVISOR_RUNTIME.md', 'docs/NEXUS_EMPEROR_RULES.md', 'docs/NEXUS_SARDAUKAR_RUNTIME.md', 'docs/NEXUS_FREMEN_RULES.md', 'docs/NEXUS_ECAZ_RULES.md', 'docs/NEXUS_CHOAM_RULES.md', 'docs/NEXUS_CHOAM_RUNTIME.md', 'docs/NEXUS_MORITANI_RULES.md', 'docs/NEXUS_MORITANI_RUNTIME.md', 'docs/NEXUS_RICHESE_RUNTIME.md', 'docs/NEXUS_RICHESE_RULES.md', 'docs/NEXUS_GUILD_CUNNING_RUNTIME.md', 'docs/NEXUS_GUILD_RULES.md', "docs/NEXUS_EMPEROR_SECRET_ALLY_RUNTIME.md", "docs/RULE_DECISIONS.md", "docs/RICHESE_AMBASSADOR_RULES.md", "docs/RICHESE_AMBASSADOR_IMPLEMENTATION.md", "docs/RICHESE_ACQUISITION_RULES.md", "docs/DUKE_VIDAL_RULES.md", "docs/ECAZ_DUKE_ACQUISITION.md", "docs/ECAZ_REVIVAL_RULES.md"]},
+ {area:"Verification",status:'Partial',detail:"Captured-Duke related13-file union127/127, types/lint/build pass. Actual390px Keep, used Execute2, Cunning liberation, Duke6 battle/release into Collection and original Ecaz Ghola then separate Cunning preserve one shared identity, death history, original costs and no ordinary leader slot. No overflow/browser errors or saved/deployed rooms touched. Incidental SSR/wiring/wording tests are removed, not re-pinned. Prior bank/seller/joint evidence remains; broad assurance and full-mode acceptance stay open.",evidence:['tests/mixed-nexus-modules-runtime.test.ts', 'tests/paired-e3-nexus-modules-runtime.test.ts', 'tests/sample-custody.test.ts', 'tests/ambassador-terror-overlap-engine.test.ts', 'tests/single-nexus-e1-runtime.test.ts', 'tests/single-nexus-e2-runtime.test.ts', 'tests/e3-nexus-ecaz-runtime.test.ts', 'tests/e3-nexus-moritani-runtime.test.ts', 'tests/homeworld-classic-nexus-skills-runtime.test.ts', 'tests/homeworld-classic-discovery-nexus-runtime.test.ts', 'tests/nexus-cards.test.ts', 'tests/nexus-card-engine.test.ts', 'tests/nexus-fremen-cunning.test.ts', 'tests/nexus-fremen-cunning-recovery.test.ts', 'tests/nexus-fremen-cunning-summon.test.ts', 'tests/nexus-fremen-cunning-summon-recovery.test.ts', 'tests/nexus-ecaz-betrayal-quotes.test.ts', 'tests/nexus-ecaz-betrayal.test.ts', 'tests/nexus-ecaz-betrayal-recovery.test.ts', 'tests/nexus-ecaz-inquiry.test.ts', 'tests/nexus-ecaz-duke-quote.test.ts', 'tests/nexus-ecaz-duke-engine.test.ts', 'tests/nexus-ecaz-duke-recovery.test.ts', 'tests/nexus-traitor-recovery.test.ts', 'tests/nexus-face-dancer-engine.test.ts', 'tests/nexus-suboids-engine.test.ts', 'tests/normal-free-support.test.ts', 'tests/nexus-suboid-controls.test.ts', 'tests/nexus-advisor-controls.test.ts', 'tests/nexus-advisor-bots.test.ts', 'tests/nexus-sardaukar-controls.test.ts', 'tests/nexus-sardaukar-bots.test.ts', 'tests/nexus-choam-controls.test.ts', 'tests/nexus-choam-bots.test.ts', 'tests/nexus-moritani-controls.test.ts', 'tests/nexus-moritani-bots.test.ts', 'tests/nexus-moritani-betrayal.test.ts', 'tests/nexus-richese-controls.test.ts', 'tests/nexus-richese-bots.test.ts', 'tests/nexus-richese-betrayal-engine.test.ts', 'tests/nexus-richese-betrayal-recovery.test.ts', 'tests/nexus-guild-cunning-controls.test.ts', 'tests/nexus-guild-cunning-bots.test.ts', 'tests/nexus-module-payments-runtime.test.ts', 'tests/nexus-module-battles-runtime.test.ts', 'tests/paired-ix-nexus-modules-runtime.test.ts', 'tests/paired-choam-nexus-modules-runtime.test.ts', 'tests/classic-nexus-skills-payments-runtime.test.ts', 'tests/classic-nexus-skills-battles-runtime.test.ts', 'tests/nexus-skills-modules-payments-runtime.test.ts', 'tests/nexus-skills-modules-battles-runtime.test.ts', 'tests/paired-ix-nexus-skills-modules-runtime.test.ts', 'tests/paired-choam-nexus-skills-modules-runtime.test.ts', 'tests/ecaz-nexus-skills-modules-runtime.test.ts', 'tests/moritani-nexus-skills-modules-runtime.test.ts', 'docs/NEXUS_CARD_RULES.md', 'tests/nexus-tleilaxu-revival.test.ts', 'tests/fixture-nexus-tleilaxu-secret-ally.ts', 'tests/nexus-tleilaxu-secret-ally-runtime.test.ts', 'tests/nexus-borrowed-voice.test.ts', 'tests/fixture-nexus-borrowed-voice.ts', 'tests/nexus-borrowed-voice-runtime.test.ts', 'tests/nexus-fremen-protection.test.ts', 'tests/fixture-nexus-fremen-protection.ts', 'tests/nexus-fremen-protection-runtime.test.ts', 'tests/fixture-stronghold-factions.ts', 'tests/fixture-nexus-advisor-fresh.ts', 'tests/nexus-advisor-fresh-runtime.test.ts', 'tests/fixture-nexus-advisor-storm.ts', 'tests/nexus-advisor-storm-runtime.test.ts', 'docs/NEXUS_ADVISOR_RUNTIME.md', 'tests/nexus-fremen-free-quota.test.ts', 'tests/fixture-nexus-fremen-free-quota.ts', 'tests/nexus-fremen-free-quota-runtime.test.ts', 'tests/nexus-fremen-revival.test.ts','tests/nexus-emperor-payment.test.ts','tests/fixture-nexus-emperor-payment.ts','tests/nexus-emperor-payment-runtime.test.ts','docs/NEXUS_EMPEROR_RULES.md', 'tests/nexus-emperor-extra-source.test.ts', 'tests/nexus-emperor-extra-continuation.test.ts', 'tests/fixture-nexus-emperor-extra.ts', 'tests/nexus-emperor-extra-runtime.test.ts', 'tests/fixture-single-nexus-e1.ts', 'docs/NEXUS_EMPEROR_SECRET_ALLY_RUNTIME.md', "tests/fixture-nexus-emperor-seller.ts", "tests/nexus-emperor-seller-purchase.test.ts", "tests/nexus-emperor-seller-runtime.test.ts", "tests/fixture-nexus-emperor-ambassador.ts", "tests/nexus-emperor-ambassador-purchase.test.ts", "tests/nexus-emperor-ambassador-runtime.test.ts", "tests/fixture-nexus-emperor-special.ts", "tests/nexus-emperor-special-purchase.test.ts", "tests/nexus-emperor-special-runtime.test.ts", "tests/karama-intents.test.ts", "tests/fixture-nexus-emperor-bank.ts", "tests/nexus-emperor-bank-profile.test.ts", "tests/nexus-emperor-bank-runtime.test.ts", "tests/duke-captured-custody.test.ts", "tests/nexus-ecaz-duke-captured.test.ts", "tests/fixture-nexus-duke-capture.ts", "tests/nexus-duke-capture-runtime.test.ts"]},
],
},
{
diff --git a/tests/duke-captured-custody.test.ts b/tests/duke-captured-custody.test.ts
new file mode 100644
index 00000000..837509a8
--- /dev/null
+++ b/tests/duke-captured-custody.test.ts
@@ -0,0 +1,183 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import {
+ acquireDuke, captureDuke, consumeDuke, createDukeVidal, dukeController,
+ executeCapturedDuke, expireDuke, releaseCapturedDuke, returnedDukeCapture, type DukeState,
+} from '../game/duke-vidal';
+
+void test('capture overrides effective control without replacing the unused original tenure or death history', () => {
+ for (const source of ['ecaz', 'moritani', 'ecazNexus', 'ally'] as const) {
+ const original = acquireDuke(createDukeVidal(), 'loser', 3, source);
+ original.leader.deaths = 2;
+ const before = structuredClone(original);
+ const captured = captureDuke(original, 'harkonnen', 'loser', 3, 'arrakeen');
+ assert.equal(dukeController(captured), 'harkonnen');
+ assert.deepEqual(original, before);
+ assert.deepEqual(releaseCapturedDuke(captured, 'harkonnen', 3), original);
+ assert.equal(dukeController(captured), 'harkonnen', 'pure release does not mutate the captured input');
+ }
+});
+
+void test('captivity preserves expired underlay but early release restores only still-valid tenure', () => {
+ for (const source of ['ecaz', 'moritani', 'ecazNexus', 'ally'] as const) {
+ const held = acquireDuke(createDukeVidal(), 'loser', 2, source);
+ const captured = captureDuke(held, 'harkonnen', 'loser', 2, 'carthag');
+ const afterTurn = expireDuke(captured, 2);
+ assert.deepEqual(afterTurn, captured);
+ const returned = releaseCapturedDuke(afterTurn, 'harkonnen', 3);
+ assert.equal(dukeController(returned), source === 'ecaz' ? 'loser' : null);
+ assert.equal(returned.source, source === 'ecaz' ? 'ecaz' : null);
+ assert.equal(returned.leader.capturedBy, undefined);
+ assert.equal(returned.leader.concealed, undefined);
+ if (source !== 'ecaz') {
+ assert.equal(returned.acquiredTurn, null);
+ const renewed = acquireDuke(returned, 'ecaz', 3, 'ecaz');
+ assert.equal(dukeController(renewed), 'ecaz');
+ }
+ }
+});
+
+void test('the original just-used losing Duke remains capturable after release only with a matching battle witness', () => {
+ const used = acquireDuke(createDukeVidal(), 'loser', 4, 'moritani');
+ used.leader.deaths = 3;
+ used.leader.usedAt = 'arrakeen';
+ const released = consumeDuke(used);
+ const witness = { deaths: 3, usedAt: 'arrakeen' };
+ assert.throws(() => captureDuke(released, 'h', 'loser', 4, 'arrakeen'));
+ assert.throws(() => captureDuke(used, 'h', 'loser', 4, 'arrakeen', witness));
+ const captured = captureDuke(released, 'h', 'loser', 4, 'arrakeen', witness);
+ assert.equal(dukeController(captured), 'h');
+ assert.throws(() => acquireDuke(captured, 'ecaz', 4, 'ecazNexus'));
+ const returned = releaseCapturedDuke(captured, 'h', 4);
+ assert.equal(dukeController(returned), null);
+ assert.equal(returned.leader.usedAt, 'arrakeen');
+ assert.equal(returned.leader.deaths, 3);
+ for (const [territory, evidence] of [
+ ['carthag', witness],
+ ['arrakeen', { deaths: 2, usedAt: 'arrakeen' }],
+ ['arrakeen', { deaths: 3, usedAt: 'carthag' }],
+ ] as const) {
+ assert.throws(() => captureDuke(released, 'h', 'loser', 4, territory, evidence));
+ }
+});
+
+void test('captive use and execution set aside the same disc rather than restoring an active native Ecaz leader', () => {
+ const original = acquireDuke(createDukeVidal(), 'ecaz', 1, 'ecaz');
+ original.leader.deaths = 2;
+ const captured = captureDuke(original, 'h', 'ecaz', 2, 'arrakeen');
+ for (const dead of [false, true]) {
+ const resolved = structuredClone(captured);
+ resolved.leader.usedAt = 'carthag';
+ resolved.leader.dead = dead;
+ if (dead) resolved.leader.deaths++;
+ const returned = consumeDuke(releaseCapturedDuke(resolved, 'h', 2));
+ assert.equal(dukeController(returned), null);
+ assert.equal(returned.leader.dead, dead);
+ assert.equal(returned.leader.deaths, dead ? 3 : 2);
+ assert.equal(returned.leader.capturedBy, undefined);
+ assert.equal(returned.leader.concealed, undefined);
+ if (dead) {
+ const resurrected = acquireDuke(returned, 'ecaz', 2, 'ecazNexus');
+ assert.equal(resurrected.leader.dead, false);
+ assert.equal(resurrected.leader.deaths, 3);
+ assert.equal(resurrected.leader.usedAt, undefined);
+ assert.equal(resurrected.source, 'ecazNexus');
+ }
+ }
+});
+
+void test('a secretly executed returned disc is revivable without treating its privacy snapshot as active captivity', () => {
+ const captured = captureDuke(acquireDuke(createDukeVidal(), 'loser', 2, 'ally'),
+ 'h', 'loser', 2, 'arrakeen');
+ const before = structuredClone(captured);
+ const returned = executeCapturedDuke(captured, 'h', 2);
+ const hidden = captured.leader.concealed;
+ assert.deepEqual(captured, before, 'execution must not mutate the original capture');
+ assert.equal(returned.controller, null);
+ assert.equal(returned.source, null);
+ assert.equal(returned.acquiredTurn, null);
+ assert.equal(returned.leader.deaths, 2, 'original first-life Harkonnen execution advances to two deaths');
+ assert.equal(returned.leader.capturedBy, undefined);
+ assert.throws(() => executeCapturedDuke(captured, 'wrong', 2));
+ assert.throws(() => executeCapturedDuke(returned, 'h', 2));
+ assert.equal(returnedDukeCapture(returned), true);
+ assert.equal(dukeController(returned), null);
+ const revived = acquireDuke(returned, 'ecaz', 2, 'ecazNexus');
+ assert.equal(revived.leader.dead, false);
+ assert.equal(revived.leader.deaths, 2);
+ assert.equal(revived.leader.concealed, undefined);
+ assert.equal(revived.leader.capturedBy, undefined);
+ assert.equal(revived.captureVisibility, undefined);
+ assert.equal(revived.source, 'ecazNexus');
+ assert.deepEqual(returned.leader.concealed, hidden);
+ for (const mutate of [
+ (state: DukeState) => { state.leader.dead = false; },
+ (state: DukeState) => { state.leader.concealed!.deaths += 2; },
+ (state: DukeState) => { state.leader.gholaBy = 'tleilaxu'; },
+ (state: DukeState) => { state.leader.usedAt = 'carthag'; },
+ (state: DukeState) => { state.controller = 'loser'; },
+ (state: DukeState) => { state.captureVisibility!.controller = 'other'; },
+ (state: DukeState) => { state.captureVisibility!.source = null; },
+ (state: DukeState) => { delete state.captureVisibility; },
+ ]) {
+ const broken = structuredClone(returned);
+ mutate(broken);
+ assert.equal(returnedDukeCapture(broken), false);
+ assert.throws(() => acquireDuke(broken, 'ecaz', 2, 'ecazNexus'));
+ }
+});
+
+void test('Cunning liberates actual captivity, never saves a captor to restore after its own expiry', () => {
+ const held = acquireDuke(createDukeVidal(), 'ecaz', 1, 'ecaz');
+ held.leader.deaths = 4;
+ const captive = captureDuke(held, 'h', 'ecaz', 3, 'arrakeen');
+ const liberated = acquireDuke(captive, 'ecaz', 3, 'ecazNexus');
+ assert.equal(dukeController(liberated), 'ecaz');
+ assert.equal(liberated.source, 'ecazNexus');
+ assert.equal(liberated.leader.capturedBy, undefined);
+ assert.equal(liberated.leader.concealed, undefined);
+ assert.equal(liberated.leader.deaths, 4);
+ assert.equal(dukeController(expireDuke(liberated, 3)), null);
+ assert.equal(dukeController(captive), 'h');
+ for (const source of ['ecaz', 'moritani', 'ally'] as const)
+ assert.throws(() => acquireDuke(captive, 'other', 3, source));
+ const foreign = structuredClone(captive);
+ foreign.leader.gholaBy = 'tleilaxu';
+ assert.throws(() => acquireDuke(foreign, 'ecaz', 3, 'ecazNexus'));
+});
+
+void test('capture and return reject wrong original controller, invalid tenure and corrupted physical custody without mutation', () => {
+ const held = acquireDuke(createDukeVidal(), 'loser', 2, 'moritani');
+ assert.throws(() => captureDuke(held, 'h', 'other', 2, 'arrakeen'));
+ assert.throws(() => captureDuke(held, 'h', 'loser', 3, 'arrakeen'));
+ const mutations: ((state: DukeState) => void)[] = [
+ state => { state.leader.dead = true; },
+ state => { state.leader.capturedBy = 'already'; },
+ state => { state.leader.gholaBy = 'tleilaxu'; },
+ state => { state.leader.id = 'replacement'; },
+ state => { state.leader.deaths = -1; },
+ state => { state.acquiredTurn = 3; },
+ state => { state.source = null; },
+ ];
+ for (const mutate of mutations) {
+ const broken = structuredClone(held);
+ mutate(broken);
+ const before = structuredClone(broken);
+ assert.throws(() => captureDuke(broken, 'h', 'loser', 2, 'arrakeen'));
+ assert.deepEqual(broken, before);
+ }
+ const captive = captureDuke(held, 'h', 'loser', 2, 'arrakeen');
+ assert.throws(() => releaseCapturedDuke(captive, 'other', 2));
+ for (const mutate of [
+ (state: DukeState) => { state.leader.concealed!.captor = 'other'; },
+ (state: DukeState) => { state.leader.concealed!.controller = 'other'; },
+ (state: DukeState) => { state.leader.concealed!.deaths++; },
+ (state: DukeState) => { state.captureVisibility!.source = 'ecaz'; },
+ (state: DukeState) => { delete state.captureVisibility; },
+ ]) {
+ const broken = structuredClone(captive);
+ mutate(broken);
+ assert.throws(() => releaseCapturedDuke(broken, 'h', 2));
+ assert.throws(() => acquireDuke(broken, 'ecaz', 2, 'ecazNexus'));
+ }
+});
diff --git a/tests/duke-vidal-engine.test.ts b/tests/duke-vidal-engine.test.ts
index 7a2d0afc..fd201e9d 100644
--- a/tests/duke-vidal-engine.test.ts
+++ b/tests/duke-vidal-engine.test.ts
@@ -295,24 +295,6 @@ void test('unused Duke expires at turn end and a later eligible turn can acquire
assert.equal(reacquired.dukeVidal!.leader.id, DUKE_VIDAL_ID);
});
-void test('advanced Harkonnen configurations uniformly gate unresolved Duke capture before acquiring', () => {
- for (const hasCaptive of [false, true]) {
- const g = fixture('harkonnen');
- g.advanced = true;
- if (hasCaptive) {
- g.dukeVidal!.leader.capturedBy = 'e';
- g.dukeVidal!.leader.concealed = {
- captor: 'e',
- controller: 'm',
- dead: false,
- deaths: 0,
- };
- }
- const done = finishMovement(g);
- assert.notEqual(done.response?.kind, 'moritaniDuke');
- assert.equal(done.dukeVidal!.controller, null);
- }
-});
void test('every AI profile can legally plan with its shared Duke when no native leader remains alive', () => {
for (const difficulty of DIFFICULTIES) {
diff --git a/tests/fixture-nexus-duke-capture.ts b/tests/fixture-nexus-duke-capture.ts
new file mode 100644
index 00000000..0a8b8182
--- /dev/null
+++ b/tests/fixture-nexus-duke-capture.ts
@@ -0,0 +1,732 @@
+import assert from 'node:assert/strict';
+import {
+ applyAction, captureCandidates, createGame, handLimit, initializeNexusGameForAudit, joinGame,
+ newPlayer, viewGame, type Action, type Game, type Player,
+} from '../game/engine';
+import type { FactionId } from '../game/catalog';
+import { leaders, treacheryDeck, type Card, type Leader } from '../game/cards';
+import {
+ DUKE_VIDAL_ID, dukeController, type DukeReleasedCaptureWitness, type DukeState,
+} from '../game/duke-vidal';
+import { traitorDeck } from '../game/traitors';
+import { TERRITORIES, territory } from '../game/board';
+import { NEXUS_FACTIONS } from '../game/nexus-cards';
+import { nexusEcazDukeAction } from '../game/nexus-ecaz-duke-options';
+import { validateAmbassadors } from '../game/ecaz-ambassadors';
+import { e3NexusEcazClean, e3NexusEcazReload, nextE3NexusEcazStep, orderE3NexusEcazSpice } from './fixture-e3-nexus-ecaz';
+import { withClassicDiscoveryNexusLottery } from './fixture-discovery-classic-nexus';
+
+/**
+ * Genuine standalone original Advanced Ecaz/Harkonnen Nexus tables (roster
+ * Ecaz + Harkonnen + Guild + Fremen, no Skills, no optional modules) for the
+ * shared Duke's real capture, liberation and death custody.
+ *
+ * Every action is issued by a live owner: the paid Ecaz Ambassador placement,
+ * the other seat's real arrival, the real Battle declaration and sealed plans,
+ * the real capture response, the closing Nexus draw and real card actions.
+ * Nothing is staged: no hand, wallet, force, played card, controller, captive
+ * marker or clock is assigned. The single scoped exception is the capture
+ * lottery, which selects entropy for the ORIGINAL shuffle over this case's
+ * actual emitted candidate pool (the engine's own `captureCandidates`).
+ */
+
+export type NexusDukeStep = { actor: string; action: Action };
+export type NexusDukeTransition = { before: Game; step: NexusDukeStep; after: Game };
+export type NexusDukeResolution = { before: Game; after: Game };
+export type NexusDukeCaptureDeclaration = { player: string; loser: string; territory: string };
+
+/** Exactly the fields this fixture's driver consumes, plus its own progress. */
+export type NexusDukePlan = {
+ owner: string;
+ captor: string;
+ entrant: string;
+ others: string[];
+ /** The reusable physical Ecaz Ambassador token and where Ecaz really places it. */
+ token: string;
+ destination: string;
+ /** True only until the first placement commits; the token then returns to supply. */
+ place: boolean;
+ /** Answer to a real capture offer and to the real retained/executed choice. */
+ capture: 'accept' | 'decline';
+ mode: 'keep' | 'execute';
+ /** Exact next real shipments this plan issues, keyed by the acting seat. */
+ ship: Record;
+ /** One real shipment per seat per turn. */
+ shipped: Record;
+ /** How many real Bidding lots this plan bids one spice for. */
+ bid: number;
+ /** The turn whose Bidding window this plan already bid in. */
+ lastBid: string;
+};
+
+/** A driver plan that issues no optional action; used before Ecaz's supply exists. */
+export function emptyNexusDukePlan(): NexusDukePlan {
+ return { owner: '', captor: '', entrant: '', others: [], token: '', destination: '',
+ place: false, capture: 'accept', mode: 'keep', ship: {}, shipped: {}, bid: 0, lastBid: '' };
+}
+
+export const nexusDukeClean = (game: Game): boolean => e3NexusEcazClean(game);
+export const nexusDukeReload = (game: Game): Game => e3NexusEcazReload(game);
+
+export function nexusDukePlayer(game: Game, id: string): Player {
+ const player = game.players.find((candidate) => candidate.id === id);
+ assert.ok(player, `Missing original Nexus Duke seat ${id}`);
+ return player;
+}
+export function nexusDukeSeat(game: Game, faction: FactionId): Player {
+ const player = game.players.find((candidate) => candidate.faction === faction);
+ assert.ok(player, `Missing original ${faction} seat`);
+ return player;
+}
+
+/** Original undealt Treachery slot selection: which physical card each private
+ * setup deal receives. A matcher never invents a card; it only chooses among
+ * the undealt originals. */
+export type NexusDukeDeal = (faction: FactionId) => readonly (string | ((card: Card) => boolean))[];
+
+const slot = (spec: string | ((card: Card) => boolean) | undefined) => {
+ if (typeof spec === 'function') return spec;
+ const wanted = spec ?? 'worthless';
+ return (card: Card) => card.kind === wanted || card.effect === wanted;
+};
+const isWeapon = (card: Card | undefined): card is Card =>
+ !!card && (card.kind === 'projectile' || card.kind === 'poison');
+const defaultDeal: NexusDukeDeal = (faction) =>
+ faction === 'harkonnen' ? ['worthless', 'shield'] : ['worthless'];
+const poisonDeal: NexusDukeDeal = (faction) =>
+ faction === 'harkonnen' ? ['worthless', 'poison'] : ['worthless'];
+
+function permutation(original: readonly T[], desired: readonly T[]): number[] {
+ const current = [...original], rolls: number[] = [];
+ for (let i = current.length - 1; i > 0; i--) {
+ const j = current.indexOf(desired[i]);
+ assert.ok(j >= 0 && j <= i, 'The selected permutation must stay inside its original array.');
+ rolls.push(Math.floor((j + 0.5) / (i + 1) * 0x100000000));
+ [current[i], current[j]] = [current[j], current[i]];
+ }
+ assert.deepEqual(current, desired);
+ return rolls;
+}
+function lottery(rolls: readonly number[], operation: () => T): T {
+ const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
+ const native = crypto.getRandomValues.bind(crypto);
+ let index = 0;
+ crypto.getRandomValues = (array: V): V => {
+ if (array instanceof Uint32Array && array.length === 1 && index < rolls.length)
+ array[0] = rolls[index++];
+ else Reflect.apply(native, crypto, [array]);
+ return array;
+ };
+ try { return operation(); }
+ finally {
+ if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
+ else Reflect.deleteProperty(crypto, 'getRandomValues');
+ }
+}
+/** Exact Fisher-Yates entropy that makes the engine's own `shuffle` emit `first`
+ * in position zero of the supplied physical candidate array. */
+function shuffleRolls(source: readonly T[], first: string): number[] {
+ const found = source.find((candidate) => candidate.id === first);
+ assert.ok(found, 'The shared Duke disc must be in the emitted candidate pool.');
+ return permutation(source, [found, ...source.filter((candidate) => candidate.id !== first)]);
+}
+
+/** Reorder only the original undealt Spice Cards for the opening land blows. */
+export function primeNexusDukeSpice(game: Game): void {
+ orderE3NexusEcazSpice(game);
+}
+
+export type NexusDukeSetup = {
+ initial: Game; offered: Game; afterSetup: Game; game: Game;
+ owner: string; captor: string; entrant: string; others: string[];
+ token: string; destination: string;
+ actions: NexusDukeStep[];
+};
+export type NexusDukeSetupOptions = {
+ roster?: readonly FactionId[];
+ deal?: NexusDukeDeal;
+};
+
+function dealt(game: Game, deal: NexusDukeDeal): Game {
+ const cards = treacheryDeck(['ecaz']), remaining = [...cards], front: string[] = [];
+ for (const player of game.players)
+ for (let n = 0; n < (player.faction === 'harkonnen' ? 2 : 1); n++) {
+ const matcher = slot([...deal(player.faction)][n]);
+ const index = remaining.findIndex((card) => matcher(card));
+ assert.ok(index >= 0, `The original ${player.faction} deal needs an undealt physical Treachery card.`);
+ front.push(remaining.splice(index, 1)[0].id);
+ }
+ // Auction order keeps the original weapon cards first; every card is conserved.
+ const rest = [...remaining.filter(isWeapon), ...remaining.filter((card) => !isWeapon(card))];
+ const rolls = [
+ ...permutation(NEXUS_FACTIONS, ['ecaz', ...NEXUS_FACTIONS.filter((faction) => faction !== 'ecaz')]),
+ ...permutation(cards.map((card) => card.id), [...front, ...rest.map((card) => card.id)]),
+ ];
+ return lottery(rolls, () => initializeNexusGameForAudit(game));
+}
+
+/** Genuine completed standalone original setup. Stable seam for other suites. */
+export function createNexusDukeCaptureSetup(options: NexusDukeSetupOptions = {}): NexusDukeSetup {
+ const roster = options.roster ?? (['ecaz', 'harkonnen', 'guild', 'fremen'] as FactionId[]);
+ let game = createGame('NEXUSDUKECAPTURE', newPlayer(roster[0], roster[0], roster[0]), true, ['ecaz']);
+ for (const faction of roster.slice(1)) joinGame(game, newPlayer(faction, faction, faction));
+ assert.equal(game.status, 'lobby');
+ assert.equal(game.advanced, true);
+ assert.deepEqual(game.expansions, ['ecaz']);
+ assert.ok(!game.leaderSkills && !game.ecazTreachery && !game.homeworlds);
+ assert.ok(roster.includes('ecaz') && roster.includes('harkonnen') && roster.length >= 3);
+ const actions: NexusDukeStep[] = [], blank = emptyNexusDukePlan();
+ const host = game.host;
+ for (const [type, enabled] of [['techTokens', false], ['strongholdCards', false], ['homeworlds', false]] as const)
+ if (!!game[type] !== enabled)
+ game = stepNexusDuke(game, blank, { actor: host, action: { type, enabled } }, actions);
+ game.discoveryEnabled = false;
+ game.nexusCards ??= { cards: null, phase: null };
+ const initial = nexusDukeReload(game);
+ for (const player of game.players)
+ if (!player.ready)
+ game = stepNexusDuke(game, blank, { actor: player.id, action: { type: 'ready' } }, actions);
+ game = dealt(game, options.deal ?? defaultDeal);
+ const offered = nexusDukeReload(game);
+ for (let n = 0; game.status === 'setup' && n < 240; n++) {
+ const next = nextE3NexusEcazStep(game);
+ assert.ok(next, 'The original setup needs its actual current owner.');
+ game = stepNexusDuke(game, blank, next, actions);
+ }
+ assert.equal(game.status, 'playing');
+ const owner = nexusDukeSeat(game, 'ecaz').id, captor = nexusDukeSeat(game, 'harkonnen').id;
+ const others = game.players.filter((player) => player.id !== owner).map((player) => player.id);
+ const guild = game.players.find((player) => player.faction === 'guild');
+ const entrant = (guild && guild.id !== owner ? guild.id : others.find((id) => id !== owner))!;
+ assert.notEqual(entrant, owner);
+ return { initial, offered, afterSetup: nexusDukeReload(game), game: nexusDukeReload(game),
+ owner, captor, entrant, others, token: reusableToken(game), destination: ambientStronghold(game), actions };
+}
+function reusableToken(game: Game): string {
+ const token = game.ecazAmbassadors?.tokens.find((candidate) => candidate.effect === 'ecaz');
+ assert.ok(token, 'The reusable Ecaz Ambassador token must be in the original supply.');
+ return token.id;
+}
+/** An empty, storm-free stronghold that no faction occupies yet. */
+function ambientStronghold(game: Game): string {
+ const occupied = new Set();
+ for (const player of game.players)
+ for (const key of Object.keys(player.forces)) occupied.add(key.split(':')[0]);
+ const found = TERRITORIES.find((candidate) => candidate.type === 'stronghold' &&
+ !candidate.sectors.includes(game.storm) && !occupied.has(candidate.id));
+ assert.ok(found, 'The original strongholds must leave one empty placement stronghold.');
+ return found.id;
+}
+function shipmentSector(game: Game, id: string): number {
+ const sectors = territory(id).sectors;
+ return sectors.find((sector) => sector !== game.storm) ?? sectors[0];
+}
+
+/** A plan for a settled table: no optional action unless the caller asks. */
+export function quietPlan(game: Game, overrides: Partial = {}): NexusDukePlan {
+ const owner = nexusDukeSeat(game, 'ecaz').id;
+ const captor = game.players.find((player) => player.faction === 'harkonnen')?.id ??
+ game.players.find((player) => player.id !== owner)!.id;
+ const others = game.players.filter((player) => player.id !== owner).map((player) => player.id);
+ return {
+ owner, captor, entrant: others.find((id) => id !== captor)!, others,
+ token: reusableToken(game), destination: ambientStronghold(game),
+ place: false, capture: 'accept', mode: 'keep', ship: {}, shipped: {}, bid: 0, lastBid: '',
+ ...overrides,
+ };
+}
+
+/** The exact next action of the original chronology. A null result means a real
+ * owner must still make a private choice (or seal a private Battle Plan). */
+export function nextNexusDukeStep(game: Game, plan: NexusDukePlan): NexusDukeStep | null {
+ if (!game.response && !game.phaseOpening && !game.pendingTreacheryDiscard) {
+ const decision = game.decision;
+ if (decision?.kind === 'captureOffer')
+ return { actor: decision.player, action: { type: 'decision', accept: plan.capture === 'accept' } };
+ if (decision?.kind === 'capturedLeader')
+ return { actor: decision.player, action: { type: 'decision', mode: plan.mode } };
+ if (decision?.kind === 'battleLosses')
+ return { actor: decision.player, action: { type: 'decision', choice: 0 } };
+ if (decision?.kind === 'battleCards')
+ return { actor: decision.player, action: { type: 'decision', discard: [] } };
+ if (decision?.kind === 'techToken')
+ return { actor: decision.player, action: { type: 'decision', token: decision.choices[0] } };
+ if (decision?.kind === 'ecazPlacement') {
+ const token = game.ecazAmbassadors?.tokens.find((candidate) => candidate.id === plan.token);
+ return plan.place && token?.zone === 'supply'
+ ? { actor: decision.player, action: { type: 'decision', token: plan.token, territory: plan.destination } }
+ : { actor: decision.player, action: { type: 'decision', decline: true } };
+ }
+ if (decision?.kind === 'ecazAmbassador') {
+ const entry = game.pendingAmbassador;
+ if (entry?.token === plan.token && entry.stage === 'offer' && entry.owner === plan.owner)
+ return { actor: decision.player, action: { type: 'decision', event: entry.event,
+ trigger: true, beneficiary: plan.owner, choice: 'duke' } };
+ }
+ if (game.phase === 3 && game.auction && plan.bid > 0 && game.auction.active === plan.owner &&
+ plan.lastBid !== String(game.turn) && !game.auction.bidder &&
+ isWeapon(game.auction.cards[game.auction.index]) &&
+ nexusDukePlayer(game, plan.owner).hand.length < handLimit(nexusDukePlayer(game, plan.owner))) {
+ plan.lastBid = String(game.turn);
+ return { actor: plan.owner, action: { type: 'bid', amount: 1 } };
+ }
+ const order = game.phase === 5 && game.active ? plan.ship[game.active] : undefined;
+ if (order && nexusDukeClean(game) && plan.shipped[game.active!] !== String(game.turn))
+ return { actor: game.active!, action: { type: 'ship', territory: order.territory,
+ sector: shipmentSector(game, order.territory), amount: order.amount, elite: 0, allyPayment: 0 } };
+ }
+ return nextE3NexusEcazStep(game);
+}
+
+export function stepNexusDuke(game: Game, plan: NexusDukePlan, step: NexusDukeStep,
+ actions?: NexusDukeStep[]): Game {
+ actions?.push(structuredClone(step));
+ if (step.action.type === 'ship') plan.shipped[step.actor] = String(game.turn);
+ const state = nexusDukeReload(game);
+ if (state.pendingCapture && (state.response?.kind === 'capture' ||
+ state.decision?.kind === 'captureOffer' && step.action.type === 'decision' && step.action.accept === true)) {
+ const rolls = shuffleRolls(emittedCandidates(state), DUKE_VIDAL_ID);
+ return lottery(rolls, () => applyAction(state, step.actor, step.action));
+ }
+ return withClassicDiscoveryNexusLottery(() => applyAction(state, step.actor, step.action));
+}
+
+/** The case's own candidate pool, read from the engine's original admission.
+ * The released-battle witness the capture itself published is validated here. */
+function emittedCandidates(game: Game): Leader[] {
+ const pending = game.pendingCapture;
+ assert.ok(pending, 'The original capture declaration must still be current.');
+ return captureCandidates(game, pending.loser, pending.territory, releasedWitness(game));
+}
+function releasedWitness(game: Game): DukeReleasedCaptureWitness | undefined {
+ const pending = game.pendingCapture;
+ if (!pending || !('duke' in pending)) return undefined;
+ const candidate: unknown = pending.duke;
+ if (candidate === undefined) return undefined;
+ if (typeof candidate !== 'object' || candidate === null || Array.isArray(candidate))
+ throw new Error('The original capture lost its physical released-battle witness.');
+ const deaths = 'deaths' in candidate ? candidate.deaths : undefined;
+ const usedAt = 'usedAt' in candidate ? candidate.usedAt : undefined;
+ if (typeof deaths !== 'number' || !Number.isSafeInteger(deaths) ||
+ typeof usedAt !== 'string' || !usedAt)
+ throw new Error('The original capture lost its physical released-battle witness.');
+ return { deaths, usedAt };
+}
+
+export function advanceNexusDuke(state: Game, until: (game: Game) => boolean, plan: NexusDukePlan,
+ actions?: NexusDukeStep[]): Game {
+ let game = state;
+ for (let n = 0; n < 2600; n++) {
+ if (until(game)) return game;
+ assert.equal(game.status, 'playing');
+ const next = nextNexusDukeStep(game, plan);
+ assert.ok(next, `The original chronology needs its real owner at ${game.turn}/${game.phase}/${game.decision?.kind ?? 'open'}.`);
+ game = stepNexusDuke(game, plan, next, actions);
+ }
+ throw Error('The original Nexus Duke chronology did not reach its requested window.');
+}
+
+function living(game: Game, id: string): Leader[] {
+ return nexusDukePlayer(game, id).leaders.filter((leader) => !leader.dead);
+}
+function strongest(list: readonly Leader[]): Leader {
+ const sorted = [...list].sort((a, b) => b.strength - a.strength)[0];
+ assert.ok(sorted, 'The original roster must still own a living leader.');
+ return sorted;
+}
+function weakest(list: readonly Leader[]): Leader {
+ const sorted = [...list].sort((a, b) => a.strength - b.strength)[0];
+ assert.ok(sorted, 'The original roster must still own a living leader.');
+ return sorted;
+}
+
+/** The real paid Ecaz Ambassador placement, then another seat's real arrival. */
+function ambassadorAcquisition(state: Game, plan: NexusDukePlan, actions: NexusDukeStep[]): {
+ placement: NexusDukeTransition; arrival: NexusDukeTransition & { duke: DukeState }; game: Game;
+} {
+ let game = advanceNexusDuke(state, (candidate) => candidate.decision?.kind === 'ecazPlacement', plan, actions);
+ const placementBefore = nexusDukeReload(game);
+ const placementStep: NexusDukeStep = { actor: plan.owner, action: {
+ type: 'decision', token: plan.token, territory: plan.destination } };
+ game = stepNexusDuke(game, plan, placementStep, actions);
+ game = advanceNexusDuke(game, (candidate) =>
+ candidate.ecazAmbassadors!.tokens.find((token) => token.id === plan.token)!.zone === 'placed', plan, actions);
+ const placement: NexusDukeTransition = { before: placementBefore, step: placementStep, after: nexusDukeReload(game) };
+ plan.place = false;
+ plan.ship = {
+ [plan.entrant]: { territory: plan.destination, amount: 1 },
+ [plan.owner]: { territory: 'carthag', amount: 1 },
+ };
+ game = advanceNexusDuke(game, (candidate) => candidate.decision?.kind === 'ecazAmbassador' &&
+ candidate.pendingAmbassador?.token === plan.token &&
+ candidate.pendingAmbassador.stage === 'offer', plan, actions);
+ const arrivalBefore = nexusDukeReload(game);
+ const arrivalStep: NexusDukeStep = { actor: plan.owner, action: { type: 'decision',
+ event: game.pendingAmbassador!.event, trigger: true, beneficiary: plan.owner, choice: 'duke' } };
+ game = stepNexusDuke(game, plan, arrivalStep, actions);
+ assert.equal(game.dukeVidal?.controller, plan.owner, 'The real Ecaz Ambassador must acquire the shared disc.');
+ assert.equal(game.dukeVidal?.source, 'ecaz');
+ const arrival = { before: arrivalBefore, step: arrivalStep, after: nexusDukeReload(game),
+ duke: structuredClone(game.dukeVidal!) };
+ plan.ship = {};
+ return { placement, arrival, game };
+}
+
+/** Real Battle declaration; both private Battle Plans still need their owners. */
+export function openNexusDukeBattle(state: Game, plan: NexusDukePlan, actions: NexusDukeStep[],
+ where: { territory: string; first: string; second: string }): NexusDukeTransition & { game: Game } {
+ let game = advanceNexusDuke(state, (candidate) =>
+ candidate.phase === 6 && nexusDukeClean(candidate) && !!candidate.active, plan, actions);
+ const choice = viewGame(game, game.active!).battleChoices.find((candidate) =>
+ candidate.territory === where.territory &&
+ [where.first, where.second].includes(candidate.attacker) &&
+ [where.first, where.second].includes(candidate.defender));
+ assert.ok(choice, `The real ${where.territory} Battle between the original seats must be offered.`);
+ const step: NexusDukeStep = { actor: choice.chooser, action: { type: 'chooseBattle',
+ territory: where.territory,
+ target: choice.attacker === choice.chooser ? choice.defender : choice.attacker } };
+ const before = nexusDukeReload(game);
+ game = stepNexusDuke(game, plan, step, actions);
+ game = advanceNexusDuke(game, (candidate) => !!candidate.battle && nexusDukeClean(candidate) &&
+ !candidate.battle.preparation && candidate.battle.preLeader?.closed !== false, plan, actions);
+ return { before, step, after: nexusDukeReload(game), game };
+}
+export type NexusDukeSeatPlan = {
+ actor: string; leader: string | null; dial: number; support?: number;
+ weapon?: string; defense?: string;
+};
+export function sealNexusDukeBattle(state: Game, plan: NexusDukePlan, actions: NexusDukeStep[],
+ plans: readonly NexusDukeSeatPlan[]): Game {
+ let game = state;
+ for (const entry of plans) {
+ const step: NexusDukeStep = { actor: entry.actor, action: { type: 'battlePlan',
+ leader: entry.leader, dial: entry.dial, support: entry.support ?? 0,
+ ...(entry.weapon ? { weapon: entry.weapon } : {}),
+ ...(entry.defense ? { defense: entry.defense } : {}) } };
+ game = stepNexusDuke(game, plan, step, actions);
+ if (!game.battle?.revealed)
+ game = advanceNexusDuke(game, (candidate) => !!candidate.battle && nexusDukeClean(candidate) &&
+ !candidate.battle.preparation && candidate.battle.preLeader?.closed !== false, plan, actions);
+ }
+ assert.ok(game.battle?.revealed, 'The original sealed plans must reveal this Battle.');
+ return game;
+}
+
+export type NexusDukeClosing = {
+ alliance: Game; before: Game; step: NexusDukeStep; after: Game; game: Game;
+};
+/** The genuine closing Nexus: two other seats pact, the real Spice Blow/Nexus
+ * runs, and unallied Ecaz spends that phase's real Nexus choice on its own card. */
+export function closeNexusDukeDraw(state: Game, plan: NexusDukePlan, actions: NexusDukeStep[]): NexusDukeClosing {
+ assert.ok(plan.others.length >= 2, 'The original pact needs two other real seats.');
+ let game = advanceNexusDuke(state, (candidate) =>
+ candidate.turn === 2 && candidate.phase === 1 && nexusDukeClean(candidate), plan, actions);
+ orderE3NexusEcazSpice(game, true);
+ game = advanceNexusDuke(game, (candidate) =>
+ candidate.nexus && !candidate.spiceWindow && !candidate.spiceResolution && nexusDukeClean(candidate), plan, actions);
+ for (const [actor, target] of [[plan.others[0], plan.others[1]], [plan.others[1], plan.others[0]]])
+ game = stepNexusDuke(game, plan, { actor, action: { type: 'alliance', target } }, actions);
+ const alliance = nexusDukeReload(game);
+ assert.equal(nexusDukePlayer(game, plan.owner).ally, null, 'Ecaz must stay unallied for the closing draw.');
+ game = advanceNexusDuke(game, (candidate) =>
+ candidate.nexusCards?.phase?.stage === 'drawing' && nexusDukeClean(candidate), plan, actions);
+ assert.ok(game.nexusCards!.phase!.eligible.includes(plan.owner));
+ const before = nexusDukeReload(game);
+ const step: NexusDukeStep = { actor: plan.owner, action: { type: 'nexusCardChoice',
+ turn: game.turn, card: game.nexusCards!.cards!.hands[plan.owner], choice: 'draw', ownRedraws: 0 } };
+ game = stepNexusDuke(game, plan, step, actions);
+ game = advanceNexusDuke(game, (candidate) => candidate.nexusCards?.phase?.stage === 'complete', plan, actions);
+ assert.equal(game.nexusCards!.cards!.hands[plan.owner], 'ecaz',
+ 'The real closing draw must leave Ecaz holding the physical Ecaz Nexus card.');
+ return { alliance, before, step, after: nexusDukeReload(game), game };
+}
+
+export type NexusDukeWindow = { game: Game; action: Action; event: string; plan: NexusDukePlan };
+/** The first quiet Battle boundary where native Ecaz physically holds its own
+ * Nexus card and stays unallied. Real owners keep every other choice. */
+export function quietNexusDukeWindow(state: Game, options: Partial = {},
+ actions: NexusDukeStep[] = []): NexusDukeWindow {
+ const plan = quietPlan(state, options);
+ // An empty Battle phase closes automatically. Keep a real pending battle
+ // through a legal native shipment rather than installing an artificial pause.
+ if (!Object.keys(plan.ship).length)
+ plan.ship = { [plan.owner]: { territory: 'carthag', amount: 1 } };
+ let game = state;
+ if (game.nexusCards?.cards?.hands[plan.owner] !== 'ecaz')
+ game = closeNexusDukeDraw(game, plan, actions).game;
+ game = advanceNexusDuke(game, (candidate) => candidate.phase === 6 && nexusDukeClean(candidate) &&
+ !candidate.battle && candidate.nexusCards?.cards?.hands[plan.owner] === 'ecaz', plan, actions);
+ const action = nexusEcazDukeAction(viewGame(game, plan.owner));
+ assert.ok(action, 'The quiet Ecaz Nexus Cunning window must be available.');
+ assert.ok(typeof action.event === 'string');
+ return { game, action, event: action.event, plan };
+}
+
+export type NexusDukeCaptureVariant = 'unused' | 'used';
+export type NexusDukeCaptureOptions = {
+ variant?: NexusDukeCaptureVariant;
+ mode?: 'keep' | 'execute';
+ roster?: readonly FactionId[];
+ deal?: NexusDukeDeal;
+};
+
+export type NexusDukeCaptureFixture = {
+ initial: Game; offered: Game; afterSetup: Game;
+ owner: string; captor: string; entrant: string; others: string[];
+ token: string; destination: string;
+ placement: NexusDukeTransition;
+ arrival: NexusDukeTransition & { duke: DukeState };
+ battle: NexusDukeTransition & { territory: string };
+ event: string;
+ sealed: Game;
+ declared: NexusDukeCaptureDeclaration | null;
+ offer: NexusDukeTransition | null;
+ candidates: Leader[];
+ resolved: NexusDukeResolution | null;
+ decision: NexusDukeTransition | null;
+ duke: DukeState;
+ plan: NexusDukePlan;
+ game: Game;
+ actions: NexusDukeStep[];
+};
+
+type InternalCaptureOptions = NexusDukeCaptureOptions & {
+ leadDuke: boolean; poison: boolean; capture: 'accept' | 'decline';
+};
+
+function captureChronology(options: InternalCaptureOptions): NexusDukeCaptureFixture {
+ const setup = createNexusDukeCaptureSetup({ roster: options.roster,
+ deal: options.deal ?? (options.poison ? poisonDeal : defaultDeal) });
+ const actions = setup.actions;
+ const plan: NexusDukePlan = {
+ ...quietPlan(setup.afterSetup), place: true,
+ capture: options.capture, mode: options.mode ?? 'keep',
+ };
+ primeNexusDukeSpice(setup.afterSetup);
+ let game = nexusDukeReload(setup.afterSetup);
+ const acquisition = ambassadorAcquisition(game, plan, actions);
+ game = acquisition.game;
+ plan.ship = { [plan.owner]: { territory: 'carthag', amount: 1 } };
+ game = advanceNexusDuke(game, (candidate) =>
+ candidate.phase === 6 && nexusDukeClean(candidate) && !!candidate.active, plan, actions);
+ plan.ship = {};
+ const battle = openNexusDukeBattle(game, plan, actions,
+ { territory: 'carthag', first: plan.owner, second: plan.captor });
+ game = battle.game;
+ const weapon = options.poison
+ ? nexusDukePlayer(game, plan.captor).hand.find((card) => card.kind === 'poison')
+ : undefined;
+ if (options.poison) assert.ok(weapon, 'The real setup deal must leave Harkonnen holding the physical poison.');
+ game = sealNexusDukeBattle(game, plan, actions, [
+ { actor: plan.owner, leader: options.leadDuke ? DUKE_VIDAL_ID : weakest(living(game, plan.owner)).id,
+ dial: 0, support: 0 },
+ { actor: plan.captor, leader: strongest(living(game, plan.captor)).id, dial: 2, support: 0,
+ ...(weapon ? { weapon: weapon.id } : {}) },
+ ]);
+ const sealed = nexusDukeReload(game);
+ const battleEvent = sealed.battle?.event ?? '';
+ game = advanceNexusDuke(game, (candidate) =>
+ candidate.decision?.kind === 'captureOffer' || (!candidate.battle && nexusDukeClean(candidate)), plan, actions);
+ let declared: NexusDukeCaptureDeclaration | null = null, offer: NexusDukeTransition | null = null;
+ let resolved: NexusDukeResolution | null = null, decision: NexusDukeTransition | null = null;
+ let candidates: Leader[] = [];
+ if (game.decision?.kind === 'captureOffer' && options.capture === 'decline') {
+ game = stepNexusDuke(game, plan,
+ { actor: game.decision.player, action: { type: 'decision', accept: false } }, actions);
+ } else if (game.decision?.kind === 'captureOffer') {
+ const pending = game.pendingCapture!;
+ declared = { player: pending.player, loser: pending.loser, territory: pending.territory };
+ candidates = emittedCandidates(game);
+ const acceptStep: NexusDukeStep = { actor: game.decision.player, action: { type: 'decision', accept: true } };
+ const offerBefore = nexusDukeReload(game);
+ game = stepNexusDuke(game, plan, acceptStep, actions);
+ offer = { before: offerBefore, step: acceptStep, after: nexusDukeReload(game) };
+ const resolveBefore = nexusDukeReload(game);
+ game = advanceNexusDuke(game, (candidate) =>
+ candidate.decision?.kind === 'capturedLeader' || (!candidate.battle && nexusDukeClean(candidate)), plan, actions);
+ assert.ok(game.decision?.kind === 'capturedLeader', 'The original capture response must select the shared disc.');
+ assert.equal(game.decision.leader, DUKE_VIDAL_ID);
+ resolved = { before: resolveBefore, after: nexusDukeReload(game) };
+ const modeStep: NexusDukeStep = { actor: game.decision!.player, action: { type: 'decision', mode: plan.mode } };
+ const modeBefore = nexusDukeReload(game);
+ game = stepNexusDuke(game, plan, modeStep, actions);
+ decision = { before: modeBefore, step: modeStep, after: nexusDukeReload(game) };
+ }
+ game = advanceNexusDuke(game, (candidate) =>
+ !candidate.battle && !candidate.pendingCapture && nexusDukeClean(candidate), plan, actions);
+ return {
+ initial: setup.initial, offered: setup.offered, afterSetup: setup.afterSetup,
+ owner: plan.owner, captor: plan.captor, entrant: plan.entrant, others: plan.others,
+ token: plan.token, destination: plan.destination,
+ placement: acquisition.placement, arrival: acquisition.arrival,
+ battle: { ...battle, territory: 'carthag' }, event: battleEvent, sealed,
+ declared, offer, candidates, resolved, decision,
+ duke: structuredClone(game.dukeVidal!), plan, game: nexusDukeReload(game), actions,
+ };
+}
+
+/** Real Harkonnen capture of the shared disc: unused, or just used in the very
+ * Battle that captures it. */
+export function createNexusDukeCaptureFixture(options: NexusDukeCaptureOptions = {}): NexusDukeCaptureFixture {
+ const variant = options.variant ?? 'unused';
+ return captureChronology({ ...options, leadDuke: variant === 'used', poison: false, capture: 'accept' });
+}
+
+export type NexusDukeDeathFixture = {
+ fixture: NexusDukeCaptureFixture;
+ /** Settled turn-1 aftermath with the real death applied. */
+ game: Game;
+ /** The real closing draw, then the quiet Battle boundary where Ecaz holds its card. */
+ window: Game;
+ action: Action;
+ owner: string; captor: string; entrant: string;
+ deaths: number; duke: DukeState; plan: NexusDukePlan; actions: NexusDukeStep[];
+};
+
+/** Real Battle death of the shared disc: Ecaz commits the Duke, loses, and the
+ * winning Harkonnen plan's real poison card kills it into the Tanks. */
+export function createNexusDukeDeathFixture(options: NexusDukeCaptureOptions = {}): NexusDukeDeathFixture {
+ const fixture = captureChronology({ ...options, leadDuke: true, poison: true, capture: 'decline' });
+ assert.equal(fixture.game.dukeVidal!.leader.dead, true, 'The real poison must kill the shared disc.');
+ const quiet = quietNexusDukeWindow(fixture.game, fixture.plan, fixture.actions);
+ return { fixture, game: fixture.game, window: quiet.game, action: quiet.action,
+ owner: fixture.owner, captor: fixture.captor, entrant: fixture.entrant,
+ deaths: fixture.game.dukeVidal!.leader.deaths, duke: structuredClone(fixture.game.dukeVidal!),
+ plan: fixture.plan, actions: fixture.actions };
+}
+
+export type NexusDukeReclaim = {
+ closing: NexusDukeClosing; window: Game; before: Game; step: NexusDukeStep; after: Game; duke: DukeState;
+};
+/** Genuine Ecaz Nexus Cunning liberation of the actual captured shared disc,
+ * reached through the real closing draw. */
+export function reclaimCapturedNexusDuke(state: Game, plan: NexusDukePlan,
+ actions: NexusDukeStep[] = [], options: { battle?: boolean } = {}): NexusDukeReclaim {
+ assert.ok(state.dukeVidal?.leader.capturedBy, 'Ecaz Nexus Cunning must liberate a real captive.');
+ assert.equal(dukeController(state.dukeVidal!), plan.captor);
+ const closing = closeNexusDukeDraw(state, plan, actions);
+ if (options.battle) plan.ship = { [plan.owner]: { territory: 'carthag', amount: 1 } };
+ const quiet = quietNexusDukeWindow(closing.game, plan, actions);
+ plan.ship = {};
+ plan.place = false;
+ const step: NexusDukeStep = { actor: plan.owner, action: quiet.action };
+ const before = nexusDukeReload(quiet.game);
+ const game = stepNexusDuke(quiet.game, plan, step, actions);
+ assert.equal(dukeController(game.dukeVidal!), plan.owner);
+ assert.equal(game.dukeVidal!.leader.capturedBy, undefined);
+ return { closing, window: quiet.game, before, step, after: nexusDukeReload(game),
+ duke: structuredClone(game.dukeVidal!) };
+}
+
+export type NexusDukeUseRelease = {
+ closing: Game; battle: NexusDukeTransition; game: Game; duke: DukeState;
+};
+/** The captor really uses the retained captive in one later Battle; the original
+ * aftermath then releases and sets the same disc aside. */
+export function releaseCapturedNexusDukeByUse(state: Game, plan: NexusDukePlan,
+ options: { territory?: string; captorForces?: number } = {},
+ actions: NexusDukeStep[] = []): NexusDukeUseRelease {
+ const territoryId = options.territory ?? 'imperial_basin', captorForces = options.captorForces ?? 5;
+ assert.equal(dukeController(state.dukeVidal!), plan.captor);
+ plan.capture = 'decline';
+ const closing = closeNexusDukeDraw(state, plan, actions).game;
+ plan.ship = { [plan.captor]: { territory: territoryId, amount: captorForces } };
+ let game = advanceNexusDuke(closing, (candidate) =>
+ candidate.phase === 6 && nexusDukeClean(candidate) && !!candidate.active, plan, actions);
+ plan.ship = {};
+ const battle = openNexusDukeBattle(game, plan, actions,
+ { territory: territoryId, first: plan.captor, second: plan.owner });
+ game = sealNexusDukeBattle(battle.game, plan, actions, [
+ { actor: plan.captor, leader: DUKE_VIDAL_ID, dial: 0, support: 0 },
+ { actor: plan.owner, leader: weakest(living(game, plan.owner)).id, dial: 0, support: 0 },
+ ]);
+ game = advanceNexusDuke(game, (candidate) =>
+ !candidate.battle && !candidate.decision && nexusDukeClean(candidate), plan, actions);
+ assert.equal(game.dukeVidal!.leader.capturedBy, undefined);
+ return { closing, battle, game, duke: structuredClone(game.dukeVidal!) };
+}
+
+export type NexusDukeLeaderDeath = { leader: string; weapon: string; turn: number };
+export type NexusDukeReturn = {
+ game: Game; deaths: NexusDukeLeaderDeath[]; returned: Game; duke: DukeState;
+};
+function carthagForces(game: Game, id: string): number {
+ return Object.entries(nexusDukePlayer(game, id).forces)
+ .filter(([key]) => key.startsWith('carthag:'))
+ .reduce((sum, [, count]) => sum + count, 0);
+}
+/** Real route to the all-native-Harkonnen-dead return: every living Harkonnen
+ * leader dies in a real Battle Ecaz wins with a real auctioned weapon, and the
+ * aftermath that kills the last one returns the captive shared disc. */
+export function releaseCapturedNexusDukeByAllHarkDead(state: Game, plan: NexusDukePlan,
+ actions: NexusDukeStep[] = []): NexusDukeReturn {
+ assert.equal(dukeController(state.dukeVidal!), plan.captor);
+ let game = closeNexusDukeDraw(state, plan, actions).game;
+ const deaths: NexusDukeLeaderDeath[] = [];
+ for (const target of [...nexusDukePlayer(game, plan.captor).leaders]
+ .sort((a, b) => a.strength - b.strength)) {
+ const turn = game.turn;
+ plan.bid = nexusDukePlayer(game, plan.owner).hand.some(isWeapon) ? 0 : 1;
+ plan.capture = 'decline';
+ plan.ship = {
+ ...(carthagForces(game, plan.owner) ? {} : { [plan.owner]: { territory: 'carthag', amount: 1 } }),
+ ...(carthagForces(game, plan.captor) ? {} : { [plan.captor]: { territory: 'carthag', amount: 1 } }),
+ };
+ game = advanceNexusDuke(game, (candidate) =>
+ candidate.phase === 6 && nexusDukeClean(candidate) && !!candidate.active, plan, actions);
+ plan.ship = {};
+ plan.bid = 0;
+ const weapon = nexusDukePlayer(game, plan.owner).hand.find(isWeapon);
+ assert.ok(weapon, 'The real Bidding win must leave Ecaz holding a physical weapon.');
+ assert.ok(carthagForces(game, plan.owner) > 0 && carthagForces(game, plan.captor) > 0,
+ 'Both original armies must really be present in the Battle territory.');
+ const battle = openNexusDukeBattle(game, plan, actions,
+ { territory: 'carthag', first: plan.owner, second: plan.captor });
+ game = sealNexusDukeBattle(battle.game, plan, actions, [
+ { actor: plan.owner, leader: strongest(living(game, plan.owner)).id, dial: 0, support: 0,
+ weapon: weapon.id },
+ { actor: plan.captor, leader: target.id, dial: 0, support: 0 },
+ ]);
+ game = advanceNexusDuke(game, (candidate) =>
+ !candidate.battle && !candidate.decision && nexusDukeClean(candidate), plan, actions);
+ assert.equal(nexusDukePlayer(game, plan.captor).leaders
+ .find((leader) => leader.id === target.id)!.dead, true,
+ `The real ${weapon.name} must kill the committed Harkonnen leader.`);
+ deaths.push({ leader: target.id, weapon: weapon.id, turn });
+ if (deaths.length < nexusDukePlayer(game, plan.captor).leaders.length) {
+ game = advanceNexusDuke(game, (candidate) => candidate.turn > turn && candidate.phase === 1 &&
+ nexusDukeClean(candidate), plan, actions);
+ orderE3NexusEcazSpice(game);
+ }
+ }
+ assert.deepEqual(living(game, plan.captor), [], 'Every native Harkonnen leader must really be dead.');
+ assert.equal(game.dukeVidal!.leader.capturedBy, undefined,
+ 'The aftermath that killed the last native leader must return the captive.');
+ return { game, deaths, returned: game, duke: structuredClone(game.dukeVidal!) };
+}
+
+/** Shared custody invariants: one shared disc, no fabricated roster/traitor
+ * membership, conserved physical cards and forces. */
+export function assertNexusDukeCustody(game: Game): void {
+ for (const player of game.players) {
+ assert.deepEqual(player.leaders.map((leader) => leader.id).sort(),
+ leaders(player.faction).map((leader) => leader.id).sort(), `${player.faction} roster`);
+ assert.ok(!player.leaders.some((leader) => leader.id === DUKE_VIDAL_ID),
+ 'The shared disc never joins a native roster.');
+ assert.equal(player.reserves + player.tanks +
+ Object.values(player.forces).reduce((sum, count) => sum + count, 0), 20, player.faction);
+ }
+ const traitors = [...(game.traitorReserve ?? []),
+ ...game.players.flatMap((player) => [...player.traitors, ...player.traitorChoices]),
+ ...(game.ecazLoyalty?.card ? [game.ecazLoyalty.card] : [])];
+ assert.deepEqual(traitors.sort(), traitorDeck(game.players).sort());
+ assert.ok(!traitors.includes(DUKE_VIDAL_ID), 'The shared disc has no Traitor identity.');
+ const physical = [...game.deck, ...game.discard, ...game.players.flatMap((player) => player.hand),
+ ...(game.auction?.cards.slice(game.auction.index + (game.currentAuctionSale ? 1 : 0)) ?? [])];
+ assert.deepEqual(physical.map((card) => card.id).sort(), treacheryDeck(['ecaz']).map((card) => card.id).sort());
+ assert.equal(game.dukeVidal!.leader.id, DUKE_VIDAL_ID);
+ assert.equal(game.dukeVidal!.leader.strength, 6);
+ assert.equal(game.dukeVidal!.leader.faction, 'ecaz');
+ assert.ok(!game.players.some((player) => player.leaders.some((leader) => leader.id === DUKE_VIDAL_ID)));
+ if (game.ecazAmbassadors) validateAmbassadors(game.ecazAmbassadors);
+}
diff --git a/tests/nexus-duke-capture-runtime.test.ts b/tests/nexus-duke-capture-runtime.test.ts
new file mode 100644
index 00000000..05adf784
--- /dev/null
+++ b/tests/nexus-duke-capture-runtime.test.ts
@@ -0,0 +1,234 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { viewGame } from '../game/engine';
+import { DUKE_VIDAL_ID, dukeController } from '../game/duke-vidal';
+import {
+ advanceNexusDuke, assertNexusDukeCustody, createNexusDukeCaptureFixture,
+ createNexusDukeDeathFixture, nexusDukeClean, nexusDukePlayer, quietNexusDukeWindow,
+ reclaimCapturedNexusDuke, releaseCapturedNexusDukeByAllHarkDead,
+ releaseCapturedNexusDukeByUse, openNexusDukeBattle, sealNexusDukeBattle, stepNexusDuke,
+} from './fixture-nexus-duke-capture';
+
+void test('a real Harkonnen Battle win captures the unused shared Duke', () => {
+ const fixture = createNexusDukeCaptureFixture({ variant: 'unused' });
+ const { owner, captor, game } = fixture;
+ assert.ok(fixture.candidates.some((leader) => leader.id === DUKE_VIDAL_ID),
+ 'The shared disc must be in the actual original capture pool.');
+ const duke = game.dukeVidal!;
+ assert.equal(dukeController(duke), captor);
+ assert.equal(duke.controller, owner, 'An unused disc keeps its underlying Ecaz tenure.');
+ assert.equal(duke.source, 'ecaz');
+ assert.equal(duke.leader.capturedBy, captor);
+ assert.equal(duke.leader.dead, false);
+ assert.equal(duke.leader.deaths, 0);
+ assert.equal(duke.leader.usedAt, undefined);
+ assert.equal(duke.leader.gholaBy, undefined);
+ // The just-used native losing leader keeps its own one-battle release.
+ assert.equal(nexusDukePlayer(fixture.game, owner).leaders
+ .filter((leader) => leader.usedAt === 'carthag').length, 1);
+ assertNexusDukeCustody(game);
+});
+
+void test('a just-used Duke is captured from its original battle release through the witness', () => {
+ const fixture = createNexusDukeCaptureFixture({ variant: 'used' });
+ const { captor, game } = fixture;
+ const duke = game.dukeVidal!;
+ assert.equal(dukeController(duke), captor);
+ assert.equal(duke.controller, null, 'A released battle disc keeps no underlying control.');
+ assert.equal(duke.acquiredTurn, null);
+ assert.equal(duke.source, null);
+ assert.equal(duke.leader.capturedBy, captor);
+ assert.equal(duke.leader.usedAt, 'carthag');
+ assert.equal(duke.leader.dead, false);
+ assert.equal(duke.leader.deaths, 0);
+ assertNexusDukeCustody(game);
+});
+
+void test('executing the captive disc pays the original bounty and hides the death from unrelated seats', () => {
+ const fixture = createNexusDukeCaptureFixture({ variant: 'used', mode: 'execute' });
+ const { owner, captor } = fixture;
+ const before = fixture.decision!.before, after = fixture.decision!.after;
+ const prior = before.dukeVidal!;
+ const duke = after.dukeVidal!;
+ assert.equal(prior.leader.deaths, 0);
+ assert.equal(duke.leader.dead, true);
+ assert.equal(duke.leader.deaths, Math.max(2, prior.leader.deaths + 1));
+ assert.equal(duke.leader.capturedBy, undefined);
+ assert.equal(dukeController(duke), null, 'An executed disc is set aside in the Tanks.');
+ assert.equal(duke.controller, null);
+ assert.equal(duke.source, null);
+ assert.equal(duke.acquiredTurn, null);
+ assert.equal(duke.leader.faction, 'ecaz');
+ assert.equal(duke.leader.strength, 6);
+ assert.equal(nexusDukePlayer(after, captor).spice,
+ nexusDukePlayer(before, captor).spice + 2, 'The captor is paid the printed 2 spice.');
+ assert.equal(nexusDukePlayer(after, owner).spice, nexusDukePlayer(before, owner).spice);
+ assert.equal(nexusDukePlayer(after, owner).leaders.length, 5);
+ const unrelated = fixture.others.find((id) => id !== owner && id !== captor)!;
+ assert.deepEqual(viewGame(after, unrelated).dukeVidal!.leader,
+ viewGame(before, unrelated).dukeVidal!.leader,
+ 'A private execution never discloses the secret selection or death to unrelated seats.');
+ assert.equal(viewGame(after, owner).dukeVidal!.leader.dead, true);
+ assert.equal(viewGame(after, captor).dukeVidal!.leader.dead, true);
+ assertNexusDukeCustody(after);
+});
+
+void test('genuine Ecaz Nexus Cunning liberates the actual captured disc through the real closing draw', () => {
+ const fixture = createNexusDukeCaptureFixture({ variant: 'unused' });
+ const { owner, captor, plan } = fixture;
+ assert.equal(dukeController(fixture.game.dukeVidal!), captor);
+ const reclaim = reclaimCapturedNexusDuke(fixture.game, plan);
+ const holder = nexusDukePlayer(reclaim.before, owner);
+ const spice = holder.spice, forces = structuredClone(holder.forces), tanks = holder.tanks;
+ const revived = holder.leaderRevived;
+ const duke = reclaim.after.dukeVidal!;
+ assert.equal(dukeController(duke), owner);
+ assert.equal(duke.controller, owner);
+ assert.equal(duke.source, 'ecazNexus');
+ assert.equal(duke.leader.capturedBy, undefined);
+ assert.equal(duke.leader.concealed, undefined);
+ assert.equal(duke.leader.dead, false);
+ assert.equal(duke.leader.deaths, fixture.duke.leader.deaths, 'Liberation preserves death history.');
+ assert.equal(duke.leader.gholaBy, undefined);
+ assert.ok(reclaim.after.nexusCards!.cards!.discard.includes('ecaz'),
+ 'The physical Ecaz Nexus card is spent by the play.');
+ const liberator = nexusDukePlayer(reclaim.after, owner);
+ assert.equal(liberator.spice, spice, 'Liberation costs no spice.');
+ assert.deepEqual(liberator.forces, forces);
+ assert.equal(liberator.tanks, tanks);
+ assert.equal(liberator.leaderRevived, revived, 'Liberation spends no ordinary leader slot.');
+ assert.equal(liberator.leaders.length, 5);
+ assert.equal(nexusDukePlayer(reclaim.after, captor).spice, nexusDukePlayer(reclaim.before, captor).spice);
+ assertNexusDukeCustody(reclaim.after);
+});
+
+void test('a liberated Duke fights once through the native battle path', () => {
+ const fixture = createNexusDukeCaptureFixture({ variant: 'unused' });
+ const { owner, captor, plan, actions } = fixture;
+ const reclaim = reclaimCapturedNexusDuke(fixture.game, plan, actions, { battle: true });
+ const battle = openNexusDukeBattle(reclaim.after, plan, actions,
+ { territory: 'carthag', first: owner, second: captor });
+ const enemy = nexusDukePlayer(battle.game, captor).leaders
+ .filter((leader) => !leader.dead).sort((a, b) => a.strength - b.strength)[0];
+ assert.ok(enemy);
+ const revealed = sealNexusDukeBattle(battle.game, plan, actions, [
+ { actor: owner, leader: DUKE_VIDAL_ID, dial: 0, support: 0 },
+ { actor: captor, leader: enemy.id, dial: 0, support: 0 },
+ ]);
+ const after = advanceNexusDuke(revealed, (game) => !game.battle && nexusDukeClean(game), plan, actions);
+ assert.equal(after.dukeVidal!.leader.dead, false);
+ assert.equal(after.dukeVidal!.leader.deaths, fixture.duke.leader.deaths);
+ assert.equal(after.dukeVidal!.leader.usedAt, 'carthag');
+ assert.equal(dukeController(after.dukeVidal!), null);
+ assert.equal(after.dukeVidal!.leader.capturedBy, undefined);
+ assert.equal(after.dukeVidal!.leader.concealed, undefined);
+ assertNexusDukeCustody(after);
+});
+
+void test('unused liberated Nexus tenure expires at the original turn end without restoring captivity', () => {
+ const fixture = createNexusDukeCaptureFixture({ variant: 'unused' });
+ const { plan, actions } = fixture;
+ const reclaim = reclaimCapturedNexusDuke(fixture.game, plan, actions);
+ const battle = openNexusDukeBattle(reclaim.after, plan, actions,
+ { territory: 'carthag', first: fixture.owner, second: fixture.captor });
+ const own = nexusDukePlayer(battle.game, fixture.owner).leaders
+ .filter(leader => !leader.dead).sort((a, b) => b.strength - a.strength)[0];
+ const enemy = nexusDukePlayer(battle.game, fixture.captor).leaders
+ .filter(leader => !leader.dead).sort((a, b) => a.strength - b.strength)[0];
+ assert.ok(own && enemy);
+ const revealed = sealNexusDukeBattle(battle.game, plan, actions, [
+ { actor: fixture.owner, leader: own.id, dial: 0, support: 0 },
+ { actor: fixture.captor, leader: enemy.id, dial: 0, support: 0 },
+ ]);
+ const after = advanceNexusDuke(revealed, (game) => game.turn > reclaim.after.turn, plan, actions);
+ assert.equal(dukeController(after.dukeVidal!), null);
+ assert.equal(after.dukeVidal!.controller, null);
+ assert.equal(after.dukeVidal!.source, null);
+ assert.equal(after.dukeVidal!.acquiredTurn, null);
+ assert.equal(after.dukeVidal!.leader.capturedBy, undefined);
+ assert.equal(after.dukeVidal!.leader.concealed, undefined);
+ assert.equal(after.dukeVidal!.leader.dead, false);
+ assert.equal(after.dukeVidal!.leader.deaths, fixture.duke.leader.deaths);
+ assertNexusDukeCustody(after);
+});
+
+void test('a later real Battle use releases the retained captive disc', () => {
+ const fixture = createNexusDukeCaptureFixture({ variant: 'unused' });
+ const { owner, plan } = fixture;
+ const release = releaseCapturedNexusDukeByUse(fixture.game, plan);
+ const duke = release.game.dukeVidal!;
+ assert.equal(duke.leader.capturedBy, undefined);
+ assert.equal(duke.leader.concealed, undefined);
+ assert.equal(dukeController(duke), null);
+ assert.equal(duke.controller, null);
+ assert.equal(duke.acquiredTurn, null);
+ assert.equal(duke.source, null);
+ assert.equal(duke.leader.dead, false);
+ assert.equal(duke.leader.deaths, fixture.duke.leader.deaths);
+ assert.equal(duke.leader.usedAt, 'imperial_basin',
+ 'The one-battle use stamps the shared disc at the real Battle territory.');
+ assert.equal(nexusDukePlayer(release.game, owner).leaders.every((leader) => !leader.dead), true);
+ assertNexusDukeCustody(release.game);
+});
+
+void test('all native Harkonnen leaders dead returns the captive disc to its underlying Ecaz custody', () => {
+ const fixture = createNexusDukeCaptureFixture({ variant: 'unused' });
+ const { owner, captor, plan } = fixture;
+ const returned = releaseCapturedNexusDukeByAllHarkDead(fixture.game, plan);
+ assert.equal(returned.deaths.length, 5);
+ assert.deepEqual(returned.deaths.map((entry) => entry.leader).sort(),
+ nexusDukePlayer(fixture.game, captor).leaders.map((leader) => leader.id).sort());
+ assert.deepEqual(nexusDukePlayer(returned.game, captor).leaders.filter((leader) => !leader.dead), []);
+ const duke = returned.game.dukeVidal!;
+ assert.equal(duke.leader.capturedBy, undefined);
+ assert.equal(duke.leader.concealed, undefined);
+ assert.equal(dukeController(duke), owner,
+ 'The early return restores the still-valid original Ecaz tenure.');
+ assert.equal(duke.controller, owner);
+ assert.equal(duke.source, 'ecaz');
+ assert.equal(duke.acquiredTurn, 1);
+ assert.equal(duke.leader.dead, false);
+ assert.equal(duke.leader.deaths, fixture.duke.leader.deaths);
+ assertNexusDukeCustody(returned.game);
+});
+
+void test('a genuine dead Duke is dealt the printed Ecaz Ghola and then taken by quiet Cunning', () => {
+ const death = createNexusDukeDeathFixture({
+ deal: (faction) => faction === 'ecaz' ? ['ghola']
+ : faction === 'harkonnen' ? ['worthless', 'poison'] : ['worthless'],
+ });
+ const { owner, plan } = death;
+ assert.equal(death.game.dukeVidal!.leader.dead, true);
+ assert.ok(death.deaths >= 1);
+ assert.equal(death.game.dukeVidal!.leader.capturedBy, undefined);
+ assert.equal(death.game.dukeVidal!.leader.gholaBy, undefined);
+ assert.equal(death.window.nexusCards!.cards!.hands[owner], 'ecaz');
+ const ghola = nexusDukePlayer(death.window, owner).hand.find((card) => card.effect === 'ghola');
+ assert.ok(ghola, 'The real setup deal must leave Ecaz holding the printed Ghola card.');
+ const slot = nexusDukePlayer(death.window, owner).leaderRevived;
+ const revived = stepNexusDuke(death.window, plan,
+ { actor: owner, action: { type: 'card', card: ghola.id, leader: DUKE_VIDAL_ID } }, death.actions);
+ const disc = revived.dukeVidal!;
+ assert.equal(disc.leader.dead, false);
+ assert.equal(disc.leader.deaths, death.deaths, 'The Ghola return preserves death history.');
+ assert.equal(disc.leader.usedAt, undefined);
+ assert.equal(disc.leader.gholaBy, undefined, 'No foreign-Ghola custody is ever produced.');
+ assert.equal(disc.controller, null);
+ assert.equal(disc.source, null);
+ assert.equal(dukeController(disc), null);
+ assert.equal(nexusDukePlayer(revived, owner).leaderRevived, slot,
+ 'The printed Ghola spends no ordinary leader-revival allowance.');
+ assert.equal(nexusDukePlayer(revived, owner).leaders.length, 5);
+ assertNexusDukeCustody(revived);
+ const window = quietNexusDukeWindow(revived, plan);
+ const taken = stepNexusDuke(window.game, plan, { actor: owner, action: window.action }, death.actions);
+ assert.equal(dukeController(taken.dukeVidal!), owner);
+ assert.equal(taken.dukeVidal!.source, 'ecazNexus');
+ assert.equal(taken.dukeVidal!.leader.capturedBy, undefined);
+ assert.equal(taken.dukeVidal!.leader.gholaBy, undefined);
+ assert.equal(taken.dukeVidal!.leader.deaths, death.deaths);
+ assert.equal(nexusDukePlayer(taken, owner).leaders.length, 5);
+ assert.ok(!taken.players.some((player) => player.leaders.some((leader) => leader.id === DUKE_VIDAL_ID)));
+ assert.equal(taken.players.flatMap((player) => player.traitors).includes(DUKE_VIDAL_ID), false);
+ assertNexusDukeCustody(taken);
+});
diff --git a/tests/nexus-ecaz-duke-captured.test.ts b/tests/nexus-ecaz-duke-captured.test.ts
new file mode 100644
index 00000000..d23c37b8
--- /dev/null
+++ b/tests/nexus-ecaz-duke-captured.test.ts
@@ -0,0 +1,233 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { applyAction, viewGame, type Game } from '../game/engine';
+import {
+ acquireDuke, captureDuke, consumeDuke, createDukeVidal, dukeController,
+ DUKE_VIDAL_ID, executeCapturedDuke, expireDuke,
+} from '../game/duke-vidal';
+import { createNexusCards, drawNexusCard } from '../game/nexus-cards';
+import { quoteEcazDukeAcquisition } from '../game/ecaz-duke-acquisition';
+import { resolveEcazDukeRevival } from '../game/ecaz-duke-revival';
+import {
+ createNexusEcazDukeReceipt, nexusEcazDukeEvent, nexusEcazDukeModeSupported,
+ nexusEcazDukeSignature, quoteNexusEcazDuke, validateNexusEcazDuke,
+ type NexusEcazDukeCapturedReceipt,
+} from '../game/nexus-ecaz-duke';
+import { createNexusDukeDeathFixture } from './fixture-nexus-duke-capture';
+
+/** A small pure-quote fixture; the separate Ghola test uses the genuine setup seam. */
+function position(): Game {
+ const players = [
+ { id: 'e', faction: 'ecaz' as const, ally: null, leaders: [] },
+ { id: 'h', faction: 'harkonnen' as const, ally: null, leaders: [] },
+ { id: 'g', faction: 'guild' as const, ally: null, leaders: [] },
+ ];
+ const cards = createNexusCards(players, () => 0.5);
+ const index = cards.deck.indexOf('ecaz');
+ assert.ok(index >= 0);
+ cards.deck.unshift(...cards.deck.splice(index, 1));
+ return {
+ players, advanced: true, expansions: ['ecaz'],
+ nexusCards: { cards: drawNexusCard(cards, 'e', players, () => 0), phase: null },
+ status: 'playing', phase: 6, turn: 3, dukeVidal: createDukeVidal(),
+ } as unknown as Game;
+}
+
+void test('Cunning sees actual capture over its own underlying Ecaz tenure and liberates it for this turn only', () => {
+ const g = position();
+ const owned = acquireDuke(g.dukeVidal!, 'e', 1, 'ecaz');
+ owned.leader.deaths = 2;
+ g.dukeVidal = captureDuke(owned, 'h', 'e', 3, 'arrakeen');
+ assert.throws(() => quoteEcazDukeAcquisition(g, 'e'));
+ const offer = quoteNexusEcazDuke(g, 'e')!;
+ assert.equal(offer.blocked, null, 'underlying owner is not the captive effective controller');
+ assert.equal(offer.dukeController, 'h', 'original losing controller is entitled to its own capture');
+ const receipt = createNexusEcazDukeReceipt(g, 'e');
+ validateNexusEcazDuke(g, receipt, 0);
+ g.dukeVidal = acquireDuke(g.dukeVidal!, 'e', 3, 'ecazNexus');
+ assert.equal(dukeController(g.dukeVidal), 'e');
+ assert.equal(g.dukeVidal.source, 'ecazNexus');
+ assert.equal(g.dukeVidal.leader.capturedBy, undefined);
+ assert.equal(g.dukeVidal.leader.concealed, undefined);
+ g.dukeVidal = expireDuke(g.dukeVidal, 3);
+ assert.equal(dukeController(g.dukeVidal), null);
+ assert.equal(g.dukeVidal.leader.deaths, 2);
+ validateNexusEcazDuke(g, receipt, 0);
+ // Later death and another turn are not mutations of the historical card play.
+ g.dukeVidal.leader.dead = true;
+ g.dukeVidal.leader.deaths++;
+ g.turn = 4;
+ g.phase = 0;
+ validateNexusEcazDuke(g, JSON.parse(JSON.stringify(receipt)), 0);
+});
+
+void test('the owner-private offer masks a concealed captor unless Ecaz was the actual losing controller', () => {
+ const g = position();
+ g.dukeVidal = captureDuke(acquireDuke(g.dukeVidal!, 'g', 3, 'ally'), 'h', 'g', 3, 'carthag');
+ const offer = quoteNexusEcazDuke(g, 'e')!;
+ assert.equal(offer.blocked, null);
+ assert.equal(offer.dukeController, 'g');
+ assert.equal(offer.event.includes('"h"'), false);
+ assert.equal(quoteNexusEcazDuke(g, 'h'), null);
+ const record = createNexusEcazDukeReceipt(g, 'e');
+ validateNexusEcazDuke(g, record, 0);
+ delete g.dukeVidal.leader.concealed;
+ assert.equal(quoteNexusEcazDuke(g, 'e')!.dukeController, 'h', 'revealed custody is public');
+});
+
+void test('a released witness captive stays unavailable while used but can be liberated after the original turn stamp clears', () => {
+ const g = position();
+ const used = acquireDuke(g.dukeVidal!, 'g', 3, 'ally');
+ used.leader.usedAt = 'arrakeen';
+ used.leader.deaths = 3;
+ g.dukeVidal = captureDuke(consumeDuke(used), 'h', 'g', 3, 'arrakeen', {
+ deaths: 3, usedAt: 'arrakeen',
+ });
+ assert.notEqual(quoteNexusEcazDuke(g, 'e')!.blocked, null);
+ assert.throws(() => createNexusEcazDukeReceipt(g, 'e'));
+ g.turn = 4;
+ delete g.dukeVidal.leader.usedAt;
+ const receipt = createNexusEcazDukeReceipt(g, 'e');
+ validateNexusEcazDuke(g, receipt, 0);
+ const liberated = acquireDuke(g.dukeVidal, 'e', 4, 'ecazNexus');
+ assert.equal(dukeController(liberated), 'e');
+ assert.equal(liberated.leader.deaths, 3);
+ assert.equal(liberated.leader.usedAt, undefined);
+ assert.equal(dukeController(expireDuke(liberated, 4)), null);
+});
+
+void test('captured v2 receipts reject impossible custody and return transitions even after a fresh signature', () => {
+ const g = position();
+ g.dukeVidal = captureDuke(acquireDuke(g.dukeVidal!, 'g', 3, 'ally'), 'h', 'g', 3, 'carthag');
+ const receipt = createNexusEcazDukeReceipt(g, 'e');
+ assert.ok('version' in receipt);
+ const mutations: ((saved: NexusEcazDukeCapturedReceipt) => void)[] = [
+ saved => { saved.before.leader.capturedBy = 'g'; },
+ saved => { saved.before.leader.concealed!.controller = 'h'; },
+ saved => { saved.before.leader.concealed!.deaths++; },
+ saved => { saved.before.source = 'ecazNexus'; },
+ saved => { saved.before.acquiredTurn = 4; },
+ saved => { saved.after.leader.deaths++; },
+ saved => { saved.after.controller = 'h'; },
+ saved => { Object.assign(saved.after.leader, { capturedBy: 'h' }); },
+ saved => { Object.assign(saved.before.leader, { gholaBy: 'g' }); },
+ saved => { Object.assign(saved.before.leader.concealed!, { extra: 1 }); },
+ saved => { Object.assign(saved, { version: 3 }); },
+ ];
+ for (const mutate of mutations) {
+ const saved = structuredClone(receipt);
+ mutate(saved);
+ saved.signature = nexusEcazDukeSignature(saved);
+ assert.throws(() => validateNexusEcazDuke(g, saved, 0));
+ }
+ const downgraded = structuredClone(receipt) as unknown as Record;
+ delete downgraded.version;
+ assert.throws(() => validateNexusEcazDuke(g, downgraded as never, 0));
+ g.dukeVidal.leader.gholaBy = 'g';
+ assert.notEqual(quoteNexusEcazDuke(g, 'e')!.blocked, null);
+ assert.throws(() => createNexusEcazDukeReceipt(g, 'e'));
+});
+
+
+void test('a privately executed return remains a Tanks resurrection with the unchanged old receipt schema', () => {
+ const g = position();
+ const captured = captureDuke(acquireDuke(g.dukeVidal!, 'g', 3, 'ally'), 'h', 'g', 3, 'arrakeen');
+ g.dukeVidal = captured;
+ const priorOffer = quoteNexusEcazDuke(g, 'e')!;
+ g.dukeVidal = executeCapturedDuke(captured, 'h', 3);
+ const offer = quoteNexusEcazDuke(g, 'e')!;
+ assert.equal(offer.blocked, null);
+ assert.equal(offer.dukeController, 'g', 'private execution still masks the underlying public snapshot');
+ assert.equal(offer.event, priorOffer.event, 'private execution does not change an unrelated owner action token');
+ const receipt = createNexusEcazDukeReceipt(g, 'e');
+ assert.equal(receipt.event, nexusEcazDukeEvent(3, 6, 'e', 0, null, null),
+ 'private history still binds actual Tanks custody rather than projected prior tenure');
+ validateNexusEcazDuke(g, receipt, 0);
+ g.dukeVidal = acquireDuke(g.dukeVidal, 'e', 3, 'ecazNexus');
+ assert.equal(g.dukeVidal.leader.dead, false);
+ assert.equal(g.dukeVidal.leader.deaths, 2);
+ assert.equal(g.dukeVidal.leader.concealed, undefined);
+ assert.equal(g.dukeVidal.captureVisibility, undefined);
+ validateNexusEcazDuke(g, receipt, 0);
+});
+
+void test('Advanced Harkonnen admission is only the existing standalone no-Skills profile, not a broader Duke exception', () => {
+ const g = position();
+ assert.equal(nexusEcazDukeModeSupported(g), true);
+ assert.equal(quoteEcazDukeAcquisition(g, 'e').source, 'ecaz');
+ g.dukeVidal!.leader.dead = true;
+ g.dukeVidal!.leader.deaths = 1;
+ assert.equal(resolveEcazDukeRevival(g, 'e').player, 'e');
+ assert.throws(() => resolveEcazDukeRevival(g, 'h'));
+ for (const change of [
+ (saved: Game) => { saved.leaderSkills = {} as Game['leaderSkills']; },
+ (saved: Game) => { saved.players[2].faction = 'moritani'; },
+ ]) {
+ const excluded = structuredClone(g);
+ change(excluded);
+ assert.equal(nexusEcazDukeModeSupported(excluded), false);
+ assert.notEqual(quoteNexusEcazDuke(excluded, 'e')!.blocked, null);
+ assert.throws(() => resolveEcazDukeRevival(excluded, 'e'));
+ excluded.dukeVidal!.leader.dead = false;
+ assert.throws(() => quoteEcazDukeAcquisition(excluded, 'e'));
+ }
+ for (const phase of [4, 5, 7]) {
+ const outside = position();
+ outside.phase = phase;
+ assert.notEqual(quoteNexusEcazDuke(outside, 'e')!.blocked, null);
+ }
+});
+
+void test('the actual Ecaz Ghola Treachery return sets the shared disc aside before a separate Cunning acquisition', () => {
+ const death = createNexusDukeDeathFixture({
+ deal: faction => faction === 'ecaz' ? ['ghola']
+ : faction === 'harkonnen' ? ['worthless', 'poison'] : ['worthless'],
+ });
+ let g = death.window;
+ const owner = death.owner;
+ assert.equal(g.dukeVidal!.leader.dead, true);
+ const deaths = g.dukeVidal!.leader.deaths;
+ const ghola = g.players.find(player => player.id === owner)!.hand
+ .find(card => card.effect === 'ghola')!;
+ assert.ok(ghola, 'Ecaz retains the original setup-dealt physical Ghola through the real battle death');
+ const beforeSpice = g.players.find(player => player.id === owner)!.spice;
+ const beforeSlot = g.players.find(player => player.id === owner)!.leaderRevived;
+ const rosters = g.players.map(player => player.leaders.map(leader => leader.id));
+ const forces = structuredClone(g.players.map(player => ({
+ reserves: player.reserves, tanks: player.tanks, forces: player.forces, elites: player.elites,
+ })));
+ g = applyAction(g, owner, { type: 'card', card: ghola.id, leader: DUKE_VIDAL_ID });
+ for (let step = 0; g.response && step < g.players.length; step++) {
+ const responder = g.players.find(player => !g.response!.passed.includes(player.id))!;
+ g = applyAction(g, responder.id, { type: 'passResponse' });
+ }
+ assert.equal(g.response, null);
+ assert.equal(g.dukeVidal!.leader.dead, false);
+ assert.equal(g.dukeVidal!.leader.deaths, deaths);
+ assert.equal(g.dukeVidal!.leader.gholaBy, undefined);
+ assert.equal(g.dukeVidal!.leader.usedAt, undefined, 'the real Ghola effect clears prior battle use');
+ assert.equal(dukeController(g.dukeVidal!), null, 'Ghola grants no fabricated active native Ecaz control');
+ assert.equal(g.discard.filter(card => card.id === ghola.id).length, 1);
+ const offer = viewGame(g, owner).nexusEcazDuke!;
+ assert.equal(offer.blocked, null);
+ const revivedSlot = g.players.find(player => player.id === owner)!.leaderRevived;
+ assert.equal(revivedSlot, beforeSlot, 'Ghola does not spend the normal leader revival allowance');
+ const returned = g;
+ g = applyAction(g, owner, { type: 'nexusEcazDuke', event: offer.event });
+ assert.equal(dukeController(g.dukeVidal!), owner);
+ assert.equal(g.dukeVidal!.source, 'ecazNexus');
+ assert.equal(g.dukeVidal!.leader.deaths, deaths);
+ assert.equal(g.dukeVidal!.leader.gholaBy, undefined);
+ assert.equal(g.players.find(player => player.id === owner)!.leaderRevived, revivedSlot);
+ assert.equal(g.players.find(player => player.id === owner)!.spice, beforeSpice);
+ assert.deepEqual(g.players.map(player => player.leaders.map(leader => leader.id)), rosters);
+ assert.deepEqual(g.players.map(player => ({
+ reserves: player.reserves, tanks: player.tanks, forces: player.forces, elites: player.elites,
+ })), forces);
+ assert.equal(returned.nexusCards!.cards!.hands[owner], 'ecaz');
+ assert.equal(g.nexusCards!.cards!.hands[owner], null);
+ assert.equal(g.nexusCards!.cards!.discard.filter(card => card === 'ecaz').length, 1);
+ const receipt = g.nexusEcazDukeHistory!.at(-1)!;
+ assert.equal('version' in receipt, false, 'ordinary Ecaz Ghola return is not a foreign-Ghola or captive producer');
+ validateNexusEcazDuke(g, receipt, g.nexusEcazDukeHistory!.length - 1);
+});
diff --git a/tests/nexus-ecaz-duke-controls.test.ts b/tests/nexus-ecaz-duke-controls.test.ts
deleted file mode 100644
index 76d8f51c..00000000
--- a/tests/nexus-ecaz-duke-controls.test.ts
+++ /dev/null
@@ -1,97 +0,0 @@
-import test from 'node:test';
-import assert from 'node:assert/strict';
-import { createElement } from 'react';
-import { renderToStaticMarkup } from 'react-dom/server';
-import { viewGame, type GameView } from '../game/engine';
-import { NexusEcazDuke } from '../components/nexus-ecaz-duke';
-import { nexusEcazDukeAction } from '../game/nexus-ecaz-duke-options';
-import { consumeDuke } from '../game/duke-vidal';
-import { nexusEcazDukePosition } from './fixture-nexus-ecaz-duke';
-
-const event = '["nexusEcazDuke",2,6,"ecaz","moritani",0]';
-
-function projected(overrides: Record = {}): GameView {
- return {
- status: 'playing', phase: 6, turn: 2, me: 'ecaz', active: 'moritani',
- advanced: false, battle: null, phaseOpening: null, response: null, decision: null,
- truthtrance: null, automaticContinuationPending: false,
- players: [
- { id: 'ecaz', name: 'House Ecaz', faction: 'ecaz', ally: null, bot: 'Easy' },
- { id: 'moritani', name: 'House Moritani', faction: 'moritani', ally: null },
- ],
- dukeVidal: { controller: 'moritani' },
- nexusCards: { card: 'ecaz', waiting: [] },
- nexusEcazInquiry: { offer: null, history: [] },
- nexusEcazDuke: { event, blocked: null, dukeController: 'moritani' },
- ...overrides,
- } as unknown as GameView;
-}
-
-function markup(view: GameView, busy = false) {
- return renderToStaticMarkup(createElement(NexusEcazDuke, { game: view, busy, act() {} }));
-}
-
-
-void test('private projection and physical card limit the offer without probing a rival hand', () => {
- const game = projected();
- Object.defineProperty(game.players[1], 'hand', {
- get() { throw new Error('Rival private cards must remain unread'); },
- });
- const html = markup(game);
- assert.match(html, /]*disabled=""/);
-
- const rival = projected({ me: 'moritani', nexusCards: { card: null }, nexusEcazDuke: null });
- assert.equal(markup(rival), '');
- assert.equal(nexusEcazDukeAction(rival), null);
- assert.equal(markup(projected({ nexusCards: { card: null } })), '');
- assert.equal(markup(projected({ nexusEcazDuke: null })), '');
-});
-
-void test('actual Tanked Duke gives the native owner an enabled event-bound control, never a rival offer', () => {
- for (const advanced of [false, true]) {
- const { game } = nexusEcazDukePosition(advanced);
- // Explicit conserved Tanks position, not fabricated Nexus custody or a public offer.
- game.dukeVidal = consumeDuke(game.dukeVidal!);
- game.dukeVidal.leader.dead = true;
- game.dukeVidal.leader.deaths = 2;
- game.dukeVidal.leader.usedAt = 'carthag';
- const owner = viewGame(game, 'p');
- assert.match(markup(owner), /]*disabled=""/);
- for (const seat of ['q', 'r']) {
- const rival = viewGame(game, seat);
- assert.equal(rival.nexusEcazDuke, null);
- assert.equal(nexusEcazDukeAction(rival), null);
- assert.equal(markup(rival), '');
- }
- }
-});
-
-void test('blocked offer remains explanatory but no interrupt, alliance, or started battle can spend it', () => {
- const blocked = projected({ nexusEcazDuke: {
- event, blocked: 'Duke Vidal is captured and cannot be taken.', dukeController: 'moritani',
- } });
- assert.equal(nexusEcazDukeAction(blocked), null);
- assert.match(markup(blocked), /]*disabled=""/);
-
- for (const changes of [
- { status: 'finished' }, { phase: 5 }, { phaseOpening: { passed: [] } },
- { battle: { event: 'battle-already-chosen' } },
- { automaticContinuationPending: true }, { truthtrance: { player: 'ecaz' } },
- { response: { kind: 'karama' } }, { decision: { kind: 'battle' } },
- { nexusCards: { card: 'ecaz', waiting: ['moritani'] } },
- { nexusCards: { card: null, waiting: [] } },
- { roomControl: { paused: true } },
- { players: [
- { id: 'ecaz', faction: 'ecaz', ally: 'moritani' },
- { id: 'moritani', faction: 'moritani', ally: 'ecaz' },
- ] },
- ]) {
- const view = projected(changes);
- assert.equal(nexusEcazDukeAction(view), null);
- assert.match(markup(view), /]*disabled=""|^$/);
- }
-});
diff --git a/tests/nexus-ecaz-duke-quote.test.ts b/tests/nexus-ecaz-duke-quote.test.ts
index 3b1e53c8..db2ffdcd 100644
--- a/tests/nexus-ecaz-duke-quote.test.ts
+++ b/tests/nexus-ecaz-duke-quote.test.ts
@@ -160,9 +160,8 @@ void test('living already-used Duke cannot gain a new battle use from Ecaz Cunni
g.dukeVidal!.leader.usedAt = 'carthag';
g.dukeVidal!.leader.deaths = 2;
const before = structuredClone(g.dukeVidal);
- assert.match(quoteNexusEcazDuke(g, 'ecaz-seat')!.blocked!, /already been used/);
- assert.throws(() => createNexusEcazDukeReceipt(g, 'ecaz-seat'), /already been used/);
- assert.throws(() => acquireDuke(g.dukeVidal!, 'ecaz-seat', 3, 'ecazNexus'), /already been used/);
+ assert.throws(() => createNexusEcazDukeReceipt(g, 'ecaz-seat'));
+ assert.throws(() => acquireDuke(g.dukeVidal!, 'ecaz-seat', 3, 'ecazNexus'));
assert.deepEqual(g.dukeVidal, before);
const ordinary = acquireDuke(g.dukeVidal!, 'moritani-seat', 3, 'moritani');
assert.deepEqual(ordinary.leader, before!.leader);
@@ -179,8 +178,7 @@ void test('Tanked acquisition requires set-aside custody and never bypasses capt
]) {
const duke = { ...g.dukeVidal!, ...change };
const before = structuredClone(duke);
- assert.match(quoteNexusEcazDuke({ ...g, dukeVidal: duke }, 'ecaz-seat')!.blocked!, /set aside/);
- assert.throws(() => acquireDuke(duke, 'ecaz-seat', 3, 'ecazNexus'), /set aside/);
+ assert.throws(() => acquireDuke(duke, 'ecaz-seat', 3, 'ecazNexus'));
assert.deepEqual(duke, before);
}
for (const dead of [false, true]) {
@@ -189,8 +187,6 @@ void test('Tanked acquisition requires set-aside custody and never bypasses capt
duke.leader.dead = dead;
duke.leader[custody] = 'moritani-seat';
const before = structuredClone(duke);
- assert.match(quoteNexusEcazDuke({ ...g, dukeVidal: duke }, 'ecaz-seat')!.blocked!,
- /uncaptured, non-Ghola/);
for (const source of ['moritani', 'ecaz', 'ecazNexus', 'ally'] as const)
assert.throws(() => acquireDuke(duke, 'ecaz-seat', 3, source));
assert.deepEqual(duke, before);
@@ -198,13 +194,12 @@ void test('Tanked acquisition requires set-aside custody and never bypasses capt
}
});
-void test('dead receipts retain the old signature shape and reject non-set-aside Tanks history', () => {
+void test('dead receipts reject non-set-aside Tanks history', () => {
const g = game();
g.dukeVidal!.leader.dead = true;
g.dukeVidal!.leader.deaths = 2;
const original = createNexusEcazDukeReceipt(g, 'ecaz-seat');
validateNexusEcazDuke(g, original, 0);
- assert.equal(original.signature, nexusEcazDukeSignature(JSON.parse(JSON.stringify(original))));
for (const change of [
{ controller: 'moritani-seat', acquiredTurn: 3, source: 'moritani' as const },
{ acquiredTurn: 2 },
@@ -228,8 +223,6 @@ void test('ordinary Ecaz Ambassador custody survives end turn; Nexus custody doe
const ordinary = acquireDuke(g.dukeVidal!, 'ecaz-seat', 3, 'ecaz');
assert.equal(expireDuke(ordinary, 3).controller, 'ecaz-seat');
assert.equal(expireDuke(ordinary, 4).source, 'ecaz');
- assert.equal(quoteNexusEcazDuke({ ...g, dukeVidal: ordinary }, 'ecaz-seat')?.blocked,
- 'Ecaz already controls Duke Vidal.');
const moritani = acquireDuke(ordinary, 'moritani-seat', 3, 'moritani');
const nexus = acquireDuke(moritani, 'ecaz-seat', 3, 'ecazNexus');
assert.equal(expireDuke(nexus, 2).source, 'ecazNexus');
@@ -238,8 +231,6 @@ void test('ordinary Ecaz Ambassador custody survives end turn; Nexus custody doe
const unavailable = structuredClone(nexus);
unavailable.leader[property] = 'moritani-seat';
assert.throws(() => acquireDuke(unavailable, 'ecaz-seat', 3, 'ecazNexus'));
- assert.match(quoteNexusEcazDuke({ ...g, dukeVidal: unavailable }, 'ecaz-seat')!.blocked!,
- /uncaptured, non-Ghola/);
}
});
@@ -299,7 +290,7 @@ void test('damaged and re-signed impossible history cannot forge the original Du
invalid(record => { record.sequence = 1; });
invalid(record => { record.event = nexusEcazDukeEvent(3, 6, 'ecaz-seat', 0, null, null); });
invalid(record => { (record as NexusEcazDukeReceipt & { extra: boolean }).extra = true; });
- invalid(record => { record.before = { ...record.before, leader: { ...record.before.leader, id: 'other' as never } }; });
+ invalid(record => { record.before.leader.id = 'other' as never; });
assert.throws(() => validateNexusEcazDuke(g, null as never, 0));
assert.throws(() => validateNexusEcazDuke({ ...g, phase: 5 }, original, 0));
assert.throws(() => validateNexusEcazDuke({ ...g, nexusCards: null }, original, 0));