From be8703dc5139e4754535b3275eab74c0f28eb119 Mon Sep 17 00:00:00 2001
From: Quintine <15965533+Quintine@users.noreply.github.com>
Date: Thu, 8 Oct 2026 19:05:08 +1100
Subject: [PATCH] Connect printed Diplomat defenses and Ecaz Tanked Duke
Cunning
---
components/diplomat-defense.tsx | 21 +-
components/nexus-ecaz-duke.tsx | 4 +-
docs/CURRENT_STATUS.md | 16 ++
docs/DIPLOMAT_DEFENSE.md | 84 ++++++--
docs/NEXUS_ECAZ_RULES.md | 76 +++++--
docs/RULE_DECISIONS.md | 6 +-
game/battle-resolution-quote.ts | 5 +-
game/diplomat-defense.ts | 74 +++++--
game/duke-vidal.ts | 27 ++-
game/engine.ts | 16 +-
game/nexus-ecaz-duke.ts | 17 +-
game/reference.ts | 6 +-
tests/diplomat-defense-controls.test.tsx | 163 +++++++++------
tests/diplomat-defense.test.ts | 256 ++++++++++++++++++++++-
tests/nexus-ecaz-duke-controls.test.ts | 52 ++---
tests/nexus-ecaz-duke-engine.test.ts | 2 +-
tests/nexus-ecaz-duke-quote.test.ts | 168 ++++++++++++++-
17 files changed, 811 insertions(+), 182 deletions(-)
diff --git a/components/diplomat-defense.tsx b/components/diplomat-defense.tsx
index b67d9f54..0331186f 100644
--- a/components/diplomat-defense.tsx
+++ b/components/diplomat-defense.tsx
@@ -1,7 +1,8 @@
'use client';
import type { Action, GameView } from '@/game/engine';
-import { battleCardLabel } from '@/game/battle-cards';
+import { isPortableSnooper } from '@/game/battle-cards';
+import { copiedDefenseKind, diplomatDefenseLabel } from '@/game/diplomat-defense';
import { Button } from './ui/button';
export function DiplomatDefense({
@@ -20,7 +21,16 @@ export function DiplomatDefense({
const visibleCards = [...(game.battle?.cards ?? []), ...(me?.hand ?? [])];
const card = (id: string) => visibleCards.find((entry) => entry.id === id);
const source = card(decision.source);
- const copiedDefense = source ? battleCardLabel(source.kind) : 'defense';
+ const copiedKind =
+ game.battle?.diplomatDefense?.kind ?? copiedDefenseKind(source);
+ const copiedDefense = copiedKind
+ ? diplomatDefenseLabel(copiedKind)
+ : 'defense';
+ const interpretedRole =
+ copiedKind === 'shieldSnooper' ||
+ copiedKind === 'weirdingWay' ||
+ copiedKind === 'chemistry' ||
+ isPortableSnooper(source);
return (
@@ -34,6 +44,13 @@ export function DiplomatDefense({
must be discarded after this battle. Decline to proceed without a copied
defense.
+ {interpretedRole && (
+
+ First-version interpretation: copy the defense role already used by
+ the opponent, not its activation, late-play opportunity or requirement
+ for another card in your own plan.
+
+ )}
{decision.cards.map((id) => (
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 living, uncaptured, non-Ghola Duke for this turn, even from Moritani. If unused, this temporary control expires at turn end; using him in battle consumes the shared Duke.
- The printed Tanks revival, capture, and Ghola effects are not supported by this Cunning action.
+ 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.
{offer.blocked && {offer.blocked}
}
{ if (!busy && action) act(action); }}>
diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md
index d1e1fc80..bdf13a18 100644
--- a/docs/CURRENT_STATUS.md
+++ b/docs/CURRENT_STATUS.md
@@ -142,6 +142,22 @@ spice/counters and completed events. No broad suite, recovery campaign or
deployed acceptance was run.
Compiler/lint and two affected Ambassador/worm contract cases pass.
+**Printed defense / Tanked Duke wave integrated.** Disjoint helper/control
+slices are integrated by the parent. Native Ix/Richese skill battles prove
+hybrid protection, Weirding's non-Shield behavior, Chemistry against Tooth,
+additional Ix Snooper and sealed Portable, with one physical Worthless discard.
+Existing capability1 battles finish without a retroactive hybrid choice;
+new battles use capability2. Basic/Advanced Ecaz spends one actual Nexus,
+revives the same Tanked Duke, preserves deaths2, uses him in a native battle
+and releases once. The independent resurrection/use reset remains provisional;
+captured/Ghola and wider timing remain guarded.
+Live isolated React controls in Chromium reach hybrid survival/disposal and
+Tanked acquisition with exact native outcomes. No broad suite, recovery
+campaign, HTTP or deployed acceptance was run.
+Types/lint and the affected five-file union pass **33/33**. The sole initial
+failure was an erroneous unsupported-Shield fixture row, removed before
+the focused repair and union rerun; runtime code was not weakened.
+
**7 October 2026 — five pending rulings resolved by the user.** The user
answered five recorded questions; each is now recorded in
[the decision index](RULE_DECISIONS.md#user-rulings--7-october-2026) and
diff --git a/docs/DIPLOMAT_DEFENSE.md b/docs/DIPLOMAT_DEFENSE.md
index fd07484d..0b885fac 100644
--- a/docs/DIPLOMAT_DEFENSE.md
+++ b/docs/DIPLOMAT_DEFENSE.md
@@ -1,9 +1,11 @@
# Diplomat copied-defense prototype
13 September 2026. **Prototyped**, with **Partial** rules coverage. This connects
-the Diplomat's normal band for canonical base Shield and Snooper defenses only.
-The lower [retreat prototype](LEADER_SKILLS_RULES.md#bounded-diplomat-retreat-interpretation)
-is now connected separately; combined modules and other defense cards remain
+the Diplomat's normal band for the canonical printed defenses that the opponent
+actually used: Shield, Snooper, Shield Snooper, defensive Weirding Way,
+defensive Chemistry and Portable Snooper sealed in the defense slot. The lower
+[retreat prototype](LEADER_SKILLS_RULES.md#bounded-diplomat-retreat-interpretation)
+is now connected separately; combined modules and other defense sources remain
unfinished. Public Leader Skills starts and publication remain gated.
## Source and interpretation
@@ -19,15 +21,36 @@ selected for battle, if applicable. Page 12 limits a captured skill to its
lower band. The archived component photograph and transcript are retained in
the local source archive described by [the common source contract](LEADER_SKILLS_RULES.md).
-The connected base interpretation therefore accepts one actual Worthless card
+The connected interpretation therefore accepts one actual Worthless card
from either plan slot, or offers a choice when both slots contain Worthless
cards. An actual defense makes the skill unavailable. The opponent must have
-played a canonical base Shield or Snooper; an empty defense supplies nothing to
-copy. The copied role protects exactly like that base defense without changing
-the Worthless card's identity or either revealed plan. This lets the selected
-native Diplomat protect itself: copied protection is resolved first, then the
-common surviving-trainer rule is evaluated. If the copied type does not stop
-the weapon, removing the dead trainer's skill does not change that outcome.
+played one of the canonical printed defenses; an empty, unsupported or forged
+face supplies nothing to copy. Canonical identity is matched by physical ID with
+printed kind, name and effect, so a tampered card cannot inherit a role by
+claiming a printed name.
+
+The copied role is the role the opponent actually used in the defense slot, and
+each printed distinction survives: a Shield Snooper protects against both
+projectile and poison weapons and still counts as a Shield for the lasgun
+explosion; a defensive Weirding Way stops a projectile weapon but is **not** a
+Shield and triggers no explosion; Chemistry stops Poison Tooth, which a Snooper
+copy does not; a Portable Snooper sealed in the defense slot copies only its
+poison-defense role. The shared battle-card role helpers supply
+these distinctions rather than a binary Shield/Snooper substitution.
+
+**First-version interpretation.** Copying the opponent's already-used defensive
+role does not copy its activation, its late-play opportunity or its own-slot
+prerequisite. Two consequences are explicit implementation inferences. A
+defensive Weirding Way keeps its printed requirement that another weapon or
+Worthless card occupy the weapon slot of the plan it was played in, so the
+opponent's real plan weapon is validated before any copy is offered; our own
+plan needs no weapon to receive that copied role. A copied Portable Snooper does
+not gain the original card's after-reveal play, which the prototype already
+excludes for every late defense. The copied role protects exactly like that
+defense without changing the Worthless card's identity or either revealed plan,
+and the copied defense is resolved first so that the common surviving-trainer
+rule is evaluated afterwards. If the copied type does not stop the weapon,
+removing the dead trainer's skill does not change that outcome.
Two timing and cleanup rules are explicit implementation inferences. The
optional public choice occurs after both plans reveal and before traitor calls,
@@ -43,15 +66,21 @@ specific discard instruction overrides in this prototype.
## Connected behavior
New supported battles carry a version marker and an independent event-bound
-receipt. After reveal, the server derives the eligible physical Worthless IDs,
+receipt. The marker also records which copied sources that battle admits: a
+newly stamped battle admits every canonical printed defense above, while an
+already-open older battle keeps its original canonical base Shield and Snooper
+sources only, so widening the quote cannot invent a retroactive choice for a
+pending plan that never had one. After reveal, the server derives the eligible
+physical Worthless IDs,
opposing defense and live native assignment from current custody. Choosing one
records its copied kind; declining records that choice. Either result preserves
the original plans and leaves every card in its hand until battle resolution.
Legacy battles without the new marker keep their existing continuation and do
not gain a retroactive Diplomat opportunity.
-The copied base role feeds ordinary weapon survival and existing Poison or
-Projectile Defense skill checks. The physical Worthless identity remains
+The copied printed role feeds ordinary weapon survival and existing Poison or
+Projectile Defense skill checks through the shared battle-card role helpers.
+The physical Worthless identity remains
available to checks such as Warmaster. Only the chosen Worthless receives the
mandatory skill discard; a second played Worthless follows ordinary winner or
loser cleanup. The receipt is revalidated before views, normalization and
@@ -60,13 +89,24 @@ card faces and unique physical custody.
The decision and its already-revealed card IDs are public. Internal frame,
signature and binding fields are not projected. Before joint reveal, ordinary
-sealed-plan and hand privacy remain unchanged. Human controls name the copied
-defense and each eligible Worthless card, and allow an explicit decline. All
+sealed-plan and hand privacy remain unchanged. Human controls name the opposing
+card, the actual copied role and each eligible Worthless card, allow an explicit
+decline, and state the first-version interpretation above when the copied role
+is a hybrid or otherwise not a plain Shield or Snooper. All
four AI profiles use the public revealed cards and shared legality; no opposing
hand or pre-reveal plan is inspected.
## Evidence and remaining scope
+8 October native Ix/Richese skill-battle smokes prove hybrid protection
+against Poison Blade, Weirding against projectile and non-exploding lasgun,
+Chemistry against activated Tooth, additional Ix Snooper and sealed Portable.
+Each selected physical Worthless is discarded once. A capability1 battle
+finishes without a retroactive hybrid offer or missing-receipt rejection;
+new battles receive capability2. In Chromium, the live owned hybrid control
+reaches native leader survival and one disposal after actual traitor actions.
+This is isolated native engine/control proof, not deployed acceptance.
+
Sixteen focused checks were observed before the broad checkpoint run. They
cover the pure base quote, Basic and Advanced battles, either Worthless slot,
two-card choice, same-disc survival, Shield explosion composition, captured
@@ -89,9 +129,11 @@ checkpoint commit and private source-bound report.
The bounded [lower retreat](LEADER_SKILLS_RULES.md#bounded-diplomat-retreat-interpretation)
now follows a documented conservative strength and outcome order; this does not
-settle a publisher priority ruling. Shield Snooper, Chemistry, defensive
-Weirding Way, Portable Snooper, Carthag-added protection and any future copied
-or modified defense still need a separate contract for whether generic roles or
-named exceptions are inherited. Homeworlds, Nexus, Discoveries, Stronghold
-Cards, Tech Tokens and other combined modules remain guarded. These limits
-preserve the existing public gates.
+settle a publisher priority ruling. Carthag-added protection, mirror and
+numeric or Stronghold strength modifiers, late Portable Snooper play, and any
+future copied or modified defense still need a separate contract for whether
+generic roles or named exceptions are inherited; the copied role never imports
+a modifier, an activation or a late-play window. Canonical additional Ix Shield
+and Snooper faces use those same printed roles in new battles. Homeworlds, Nexus,
+Discoveries, Stronghold Cards, Tech Tokens and other combined modules remain
+guarded. These limits preserve the existing public gates.
diff --git a/docs/NEXUS_ECAZ_RULES.md b/docs/NEXUS_ECAZ_RULES.md
index 04a05173..6b1e8293 100644
--- a/docs/NEXUS_ECAZ_RULES.md
+++ b/docs/NEXUS_ECAZ_RULES.md
@@ -87,25 +87,31 @@ the bounded runtime below.
When Ecaz and Moritani are both seated in a three-to-six-player Basic or
Advanced game using only their expansion and Nexus cards (other seats may be
classic factions), an unallied Ecaz holder may spend the physical Ecaz Nexus
-card at a quiet Battle phase boundary to take the **existing** living,
-uncaptured, non-Ghola Duke Vidal. This transfers the one shared disc even if
-Moritani controls him; it does not add a leader or Traitor Card to a native
-roster. The owner's private offer reports the current public controller and
-availability. Other seats see no held-card-dependent offer or pause.
+card at a quiet Battle phase boundary to take the **existing** uncaptured,
+non-Ghola Duke Vidal, living or set aside in the Tanks. This transfers the one
+shared disc even if Moritani controls the living Duke; a Tanked acquisition
+revives that same disc and preserves its death count. It does not add a leader
+or Traitor Card to a native roster. The owner's private offer reports the
+current public controller and availability. Other seats see no
+held-card-dependent offer or pause.
-Acceptance binds the original controller, source, turn, seated factions and
-event to a signed saved receipt, discards the single card, changes Duke custody
-and retains independent replay markers. The ordinary battle leader path may
-then select Duke, and battle use sets him aside through its existing lifecycle.
+Acceptance binds the original controller, source, alive/dead status, turn,
+seated factions and event to a signed saved receipt, discards the single card,
+changes Duke custody and retains independent replay markers. Receipt keys and
+signature field order are unchanged: older completed living receipts remain
+valid, with no invented historical death count. The actual shared disc retains
+its death history. The ordinary battle leader path may then select Duke, and
+battle use sets him aside through its existing lifecycle.
Unused **Nexus** custody ends at turn end. This distinct temporary source does
not shorten the ordinary Ecaz Ambassador tenure, which lasts until battle use
or a later Moritani acquisition. JSON restoration and saved-room replay retain
the completed play without recomputing historical custody from a later turn.
-The printed Cunning explicitly reaches Duke in capture, Tanks or Ghola custody;
-this **bounded prototype does not**. Those return destinations and exceptional
-priority interactions need a coherent physical-custody contract before they
-are enabled. The face does not print a Battle-only timing restriction; the
+The printed Cunning explicitly reaches Duke in capture, Tanks or Ghola custody.
+The **bounded prototype now includes set-aside Tanks custody**, but not capture
+or Ghola acquisition. Those return destinations and exceptional priority
+interactions still need a coherent physical-custody contract before they are
+enabled. The face does not print a Battle-only timing restriction; the
quiet phase-six window is a product implementation boundary, **not** a GF9
ruling. Advanced Harkonnen tables are also excluded: the existing shared
leader battle path cannot select Duke there, so the card must not be spent
@@ -113,6 +119,41 @@ for an unusable disc. At that initial checkpoint other expansion factions,
Homeworlds, Discoveries, Leader Skills and combined optional modules remained
gated. This was not full Ecaz, Moritani or Nexus module acceptance.
+### Tanks Cunning composition — 8 October 2026
+
+The printed Ecaz Cunning supplies the Tanks allowance. Only its native,
+unallied Ecaz holder may spend the actual Ecaz Nexus card to perform this
+acquisition at the existing quiet Battle boundary. The pure custody transition
+permits a dead disc only for `ecazNexus`, with controller, acquisition turn and
+source all null before acquisition. It revives the existing disc and assigns
+Ecaz's current-turn Nexus custody. Moritani, ordinary Ecaz Ambassador and ally
+acquisitions still reject a dead Duke; capture/Ghola and Advanced Harkonnen
+gates are unchanged.
+
+Clearing the old battle-use stamp on this independent resurrection permits
+one new use under the normal shared-Duke lifecycle. That allowance/use
+composition is **provisional**, not an inspected GF9 FAQ ruling. A living,
+already-used Duke remains unavailable; taking a living disc does not clear
+its stamp. There is no separate paid revival charge or native revival slot
+for this Nexus action. This does not broaden the exact user ruling that only
+Ecaz revives Duke, including when using the Ghola Treachery Card, into support
+for Cunning's exceptional Ghola custody or return destinations.
+
+Focused cases are authored in [the quote/source tests](../tests/nexus-ecaz-duke-quote.test.ts):
+source-specific dead-disc rejection, same-disc revival with preserved death
+history and cleared prior use, real Basic/Advanced setup and physical Nexus
+spending by all four bot profiles, living-used rejection, set-aside custody,
+and the unchanged historical receipt shape. These additions are not a claim
+of executed checks or live-control proof.
+
+Parent native Basic/Advanced smokes now spend one original Nexus, acquire
+the same Tanked Duke with deaths2 retained and old use cleared, then seal
+him in a real new battle and release once after survival. No spice is
+charged for the Nexus action. The live owned control in Chromium reaches
+the same acquisition, death history and card disposal against the native
+engine. This is isolated rule/control proof, not HTTP, recovery or deployed
+acceptance; no broad suite was run.
+
### Standalone skill/Nexus follow-up — 5 October 2026
The fresh standalone Ecaz/classic ecaz33/all14/all12 entry now composes the
@@ -121,11 +162,16 @@ and selected original Tech/Advanced Strongholds. Moritani need not be seated.
Five ordinary training discs remain; the shared Duke receives no assignment.
Human YZLNTNEZ uses Duke6 with face-up normal Warmaster/Worthless and a held
Shield against actual poison, wins7–5, releases Duke before cleanup and keeps
-the original winner reward/city income. Advanced Harkonnen, dead/captured/
-Ghola recovery, shared-Duke training and unrelated overlays remain guarded.
+the original winner reward/city income. At that checkpoint Advanced Harkonnen,
+dead/captured/Ghola recovery, shared-Duke training and unrelated overlays
+remained guarded; the Tanks-only Cunning change above supersedes its dead-disc
+gate, not its capture/Ghola boundaries.
See [the exact standalone profile and evidence](NEXUS_CARD_RULES.md#standalone-e3-nexus-skills-and-original-components--5-october-2026).
+The following describes the initial living-only checkpoint, not verification
+of the Tanks follow-up:
+
[The focused Duke tests](../tests/nexus-ecaz-duke-engine.test.ts) exercise
genuine Ecaz/Moritani setup, physical draw/spend, Basic and Advanced transfer,
ordinary leader projection, actual end-turn expiry, blocked custody,
diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md
index 07bf5206..08040abb 100644
--- a/docs/RULE_DECISIONS.md
+++ b/docs/RULE_DECISIONS.md
@@ -376,7 +376,7 @@ dedicated retrieved worked FAQs. [Contract and remaining limits](HOMEWORLD_OCCUP
| Spice Banker battle spending | Secret own-spice commitment is separate from support; only the surviving selected disc gains strength. Core Battle Plan payment rules spend it on death/loss and waive it for a sole traitor-call winner. This payment composition is explicit; upper deferred-income custody remains pending. | [Source contract and prototype](SPICE_BANKER_RUNTIME.md) |
| Smuggler shipping | Optional free companion is included in the actual total; apply Guild rounding to the reduced priced count. Ordinary Fremen reserves are on planet. The officially permitted owned No-Field plus a separate free force is now prototyped at the unchanged one-marker price. Legacy unstamped ordinary declarations keep their quoted full price. | [Ordinary contract](SMUGGLER_SHIPMENT.md), [No-Field combination](SMUGGLER_NO_FIELD.md) |
| Sandmaster movement | One optional board spice per distinct entered territory; source-only repositioning earns none. Per-movement Hajr reset is a documented grammatical inference. Canceled moves earn nothing. Native Fremen worm rides and physical Fremen Ambassador relocations collect optionally at the destination before arrival reactions, without an invented intermediate route. Multiple-pile and concealed-relocation collection remain guarded. | [Ground/direct relocation](SANDMASTER_MOVEMENT.md), [worm rides](SANDMASTER_WORM.md) |
-| Diplomat defense | With no actual defense, one physical Worthless card in either Battle Plan slot may copy the opponent's canonical base Shield or Snooper. The same native trained disc may protect itself. The public after-reveal/before-traitor choice and mandatory discard after traitor preemption are documented implementation inferences. Lower retreat and modified or multirole defenses remain guarded. | [Source and connected prototype](DIPLOMAT_DEFENSE.md) |
+| Diplomat defense | A committed Worthless copies the opponent's canonical printed defensive role: base/Ix Shield/Snooper, hybrid, defensive Weirding/Chemistry or sealed Portable. Named combat distinctions remain. Copying the used role, not activation or own-slot prerequisites, is a visibly qualified first version. Reveal-before-traitor timing and mandatory disposal remain implementation interpretations; existing base-only battle windows are not reopened. Late/mirror/numeric modifications remain separate. | [Source and connected prototype](DIPLOMAT_DEFENSE.md) |
| Bureaucrat payments | Once per phase, optionally divert two from an actual third-party payment of at least five; payer cost is unchanged. Bribes trigger at transfer to the recipient's shield, not later collection. Native income is offered after Karama allowance as an explicit outcome-based inference; split contributions, split recipients and remaining payment families need further integration. | [Source and connected boundary](BUREAUCRAT_PAYMENTS.md) |
| Mentat question preview | The physical card says to show the card to the questioner, privately; it stays in hand and does not bind a plan. Ask before posture/faction powers. A uniform target response protects held/absent privacy but awaits approval as an exception to skipping forced confirmations; standard activation stays disabled. Empty hands skip with no fabricated answer. | [Source and preview](MENTAT_QUESTION.md) |
| Leader battle effects | Rihani normal peek and reshuffle precede a separate committed optional draw; ordering is an explicit printed-order inference. Unused excludes actual calls, not mere disclosure. Mentat/Bureaucrat change scores; Sandmaster adds board spice. Multiple piles and Face Dancer composition stay guarded. | [Source contract and prototype](LEADER_BATTLE_EFFECTS.md) |
@@ -467,7 +467,7 @@ These are unresolved in the cited records. Do not infer an answer from elapsed t
| Recruits late accounting and rates | A user question has been sent for two free plus one paid Atreides return before Recruits: keep the disputed case gated, or one/two further free returns without refund. No answer is assumed. Late-changing free rates, pending repricing and same-turn recovered-card replay also stay gated. The connected clean-case preview retains the independent seven-force cap after faction-advantage cancellation as an explicit inference, not a direct combined FAQ. | [Source audit](RECRUITS_RULES.md), [runtime scope](RECRUITS_RUNTIME.md) |
| Sapho remaining timing | First versus unspent Advanced Guild priority, partly completed combined turns, cyclic auction duration and later battle timing stay guarded. The existing pre-plan opportunity supports [early aggressor](SAPHO_AGGRESSOR.md). Prior questions remain unanswered. | [Source audit](JUICE_OF_SAPHO_SOURCE_UPDATE.md), [battle review](JUICE_OF_SAPHO_BATTLE_REVIEW.md) |
| Semuta Drug | Full-hand exchange is user-approved; neutral all-seat acknowledgements remain the explicit application policy. Other discard producers, nested continuation coverage and full-game acceptance remain unfinished. | [Source update and selected exchange](SEMUTA_SOURCE_UPDATE_20260907.md), [privacy review](SEMUTA_PRIVACY_REVIEW.md) |
-| Leader Skills edge cases | Capture/replacement eligibility, Banker escrow custody, Smuggler modifier/allocation boundaries, Mentat empty-hand fallback, modified Diplomat defenses and simultaneous optional timing remain source gaps. After an online publisher/designer/tournament check found no explicit priority ruling, the bounded standalone Diplomat retreat prototype explicitly chooses printed/copied disc strength and a surviving-loser retreat before ordinary casualties; other combinations remain guarded. This is an implementation interpretation, not an official clarification. | [Material boundaries and selected retreat interpretation](LEADER_SKILLS_RULES.md#bounded-diplomat-retreat-interpretation), [Diplomat defense boundary](DIPLOMAT_DEFENSE.md#evidence-and-remaining-scope) |
+| Leader Skills edge cases | Capture/replacement eligibility, Banker escrow custody, Smuggler modifier/allocation boundaries, Mentat empty-hand fallback, late/mirror/numeric Diplomat defenses and simultaneous optional timing remain source gaps. Printed alternate/hybrid defense roles now have a connected, explicitly interpreted copy path. The bounded Diplomat retreat prototype chooses printed/copied strength and surviving-loser retreat before casualties; this is not an official priority clarification. | [Material boundaries and retreat](LEADER_SKILLS_RULES.md#bounded-diplomat-retreat-interpretation), [Defense limits](DIPLOMAT_DEFENSE.md#evidence-and-remaining-scope) |
| Smuggler collection strength | A fresh primary-source check found no modifier ruling. The user was asked whether to keep affected combinations gated, use printed/copied disc value, or include leader bonuses with Stunner zero. No answer is assumed. The reveal-bound deferred receipt is supported as an explicit timing inference; unmodified lower collection is now [prototyped](SMUGGLER_BATTLE.md); modified strength, multiple positive piles and combined modules remain gated. | [Smuggler follow-up](LEADER_SKILLS_RULES.md#20-september-smuggler-follow-up) |
| Ordinary Guild cancellation | Unaffordable repricing, replacement/retry/decline, consumed opportunity, contributor adjustment and ordering with special shipment-stop. The affordability question was already sent. | [Material timing](GUILD_RATE_KARAMA_RULES.md#material-unresolved-timing) |
| Base special Karama | Unfunded auction recovery, stopped-shipment settlement and Atreides full-plan commitment order remain provisional. Native full-plan/Voice/Prescience/Truthtrance/Ghola interactions now have genuine-setup focused evidence, but the source audit does not settle the special offer/deadline or target-first interpretation. | [Explicit base interpretations](BASE_ADVANCED_READINESS_20260907.md#explicit-unresolved-base-interpretations), [full-plan interaction audit](ATREIDES_FULL_PLAN_TIMING.md) |
@@ -481,7 +481,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 Vidal for this turn even from capture, Tanks or Ghola custody, but names no phase. The connected first version chooses the quiet Battle phase boundary and a living, uncaptured, non-Ghola shared disc, including a Moritani-controlled Duke. This is a bounded product timing and custody decision, **not** a publisher restriction. Advanced Harkonnen is gated because its existing battle path cannot select Duke. Exceptional return destinations, response priority and combined modules remain gated; ordinary Ecaz Ambassador custody is unaffected. | [Source and bounded runtime](NEXUS_ECAZ_RULES.md#bounded-native-cunning-duke-vidal) |
+| 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) |
| Emperor Secret Ally purchase timing | The bounded normal bank-auction prototype chooses a uniform winner payment confirmation in classic-faction Nexus-only games with Emperor absent, including otherwise sole spice payments. This prevents a holder-only pause from exposing hidden card eligibility. It is a provisional product privacy choice, **not** a user or publisher ruling; the previously asked confirmation-versus-gate question remains open. Seller-paid/allied purchases and other sources stay gated. | [Source contract and bounded prototype](NEXUS_EMPEROR_RULES.md#secret-ally-retain-the-actual-purchase-price), [runtime](NEXUS_EMPEROR_SECRET_ALLY_RUNTIME.md#bank-auction-purchase-alternative) |
| 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-resolution-quote.ts b/game/battle-resolution-quote.ts
index c5a90e02..b5eda970 100644
--- a/game/battle-resolution-quote.ts
+++ b/game/battle-resolution-quote.ts
@@ -378,11 +378,12 @@ function calculate(input: BattleResolutionInput): BattleResolutionQuote {
if (copy === undefined) return defense;
const quote = quoteDiplomatDefense({ assignments: side.leaderSkills ?? [], selectedLeader: side.plan.leader,
weapon: card(side, side.plan.weapon), defense: card(side, side.plan.defense),
- opposingDefense: card(other, other.plan.defense) });
+ opposingDefense: card(other, other.plan.defense), opposingWeapon: card(other, other.plan.weapon),
+ expandedDefenses: true });
requireQuote(copy && quote && !side.lateDefense && !other.lateDefense &&
!side.stronghold && !other.stronghold && !input.homeworld &&
copy.leader === quote.leader && copy.source === quote.source && copy.kind === quote.kind && quote.cards.includes(copy.card),
- 'The copied Diplomat defense lost its native trainer, committed Worthless or opposing base defense.');
+ 'The copied Diplomat defense lost its native trainer, committed Worthless or opposing printed defense.');
return copiedDiplomatDefense(quote, copy.card);
};
const effectiveAd = effectiveDefense(a, d, ad), effectiveDd = effectiveDefense(d, a, dd);
diff --git a/game/diplomat-defense.ts b/game/diplomat-defense.ts
index 5cd011aa..26096515 100644
--- a/game/diplomat-defense.ts
+++ b/game/diplomat-defense.ts
@@ -1,12 +1,13 @@
import { ordinaryLeaderSkillModeSupported, type LeaderSkillProfile } from './leader-skill-profile';
-import { baseDeck, type Card } from './cards';
+import { baseDeck, ixDeck, type Card } from './cards';
import type { BattleLeaderSkill } from './leader-skill-combat';
+import { isPortableSnooper, validBattleCardPair } from './battle-cards';
export type DiplomatDefenseQuote = {
leader: string;
cards: string[];
source: string;
- kind: 'shield' | 'snooper';
+ kind: 'shield' | 'snooper' | 'shieldSnooper' | 'weirdingWay' | 'chemistry';
};
/** Planning eligibility does not inspect the opponent's unsubmitted cards. */
@@ -22,45 +23,86 @@ export function diplomatTrainer(
);
}
-const baseDefenses = new Map(
- baseDeck()
+/** Consumer-facing role label; hybrid and non-Shield printed roles stay distinct. */
+const DEFENSE_LABELS: Record = {
+ shield: 'Projectile defense',
+ snooper: 'Poison defense',
+ shieldSnooper: 'Projectile and poison defense (Shield)',
+ weirdingWay: 'Projectile defense (not a Shield)',
+ chemistry: 'Poison defense (stops Poison Tooth)',
+};
+
+export function diplomatDefenseLabel(kind: DiplomatDefenseQuote['kind']): string {
+ return DEFENSE_LABELS[kind];
+}
+
+/** Classify the used defensive role; the quote below checks the canonical face.
+ * A sealed Portable Snooper has the ordinary poison-defense role. */
+export function copiedDefenseKind(
+ card: Card | undefined,
+): DiplomatDefenseQuote['kind'] | null {
+ if (!card) return null;
+ if (isPortableSnooper(card)) return 'snooper';
+ return Object.hasOwn(DEFENSE_LABELS, card.kind)
+ ? (card.kind as DiplomatDefenseQuote['kind'])
+ : null;
+}
+
+const baseCards = baseDeck();
+const baseDefenses: Partial> = Object.fromEntries(
+ baseCards
.filter((card) => card.kind === 'shield' || card.kind === 'snooper')
.map((card) => [card.id, card]),
);
-/** A copied role does not change either physical card or either sealed plan. */
+const printedDefenses: Partial> = Object.fromEntries(
+ [...baseCards, ...ixDeck()]
+ .filter((card) => copiedDefenseKind(card))
+ .map((card) => [card.id, card]),
+);
+
+/** A copied role does not change either physical card or either sealed plan.
+ * `expandedDefenses` defaults to true for new battles and direct callers; an
+ * already-open version-1 battle passes false so it keeps exactly its original
+ * canonical base Shield/Snooper sources, with no retroactive choice or pair
+ * validation. */
export function quoteDiplomatDefense(input: {
assignments: readonly BattleLeaderSkill[];
selectedLeader: string | null | undefined;
weapon?: Card;
defense?: Card;
opposingDefense?: Card;
+ opposingWeapon?: Card;
+ expandedDefenses?: boolean;
}): DiplomatDefenseQuote | null {
const assignment = diplomatTrainer(input.assignments, input.selectedLeader);
if (!assignment || (input.defense && input.defense.kind !== 'worthless'))
return null;
+ const expanded = input.expandedDefenses !== false;
const source = input.opposingDefense;
- const printed = source && baseDefenses.get(source.id);
+ const kind = copiedDefenseKind(source);
+ const printed =
+ source && (expanded ? printedDefenses[source.id] : baseDefenses[source.id]);
if (
!source ||
- !printed ||
- printed.kind !== source.kind ||
- printed.name !== source.name
+ !kind ||
+ (!(expanded && isPortableSnooper(source)) &&
+ (!printed ||
+ printed.kind !== source.kind ||
+ printed.name !== source.name ||
+ printed.effect !== source.effect)) ||
+ (expanded && !validBattleCardPair(input.opposingWeapon, source))
)
return null;
const cards = [input.weapon, input.defense]
.filter((card): card is Card => card?.kind === 'worthless')
.map((card) => card.id);
if (!cards.length || new Set(cards).size !== cards.length) return null;
- return {
- leader: assignment.leader,
- cards,
- source: source.id,
- kind: source.kind as 'shield' | 'snooper',
- };
+ return { leader: assignment.leader, cards, source: source.id, kind };
}
-/** Native skill inputs must already exclude dead or foreign-ghola trainers. */
+/** Copy the already-used role, not its original own-slot prerequisite or activation.
+ * Native skill inputs must already exclude dead or foreign-ghola trainers. */
export function copiedDiplomatDefense(
quote: DiplomatDefenseQuote,
card: string,
diff --git a/game/duke-vidal.ts b/game/duke-vidal.ts
index 299adbea..c398bd4e 100644
--- a/game/duke-vidal.ts
+++ b/game/duke-vidal.ts
@@ -40,9 +40,10 @@ function validTurn(turn: number) {
}
/**
- * Custody only: the caller authorizes the faction power and resolves Karama first.
- * Blocking captured/ghola acquisition is an implementation boundary for unresolved
- * Moritani interactions, not a verified blanket restriction on the printed power.
+ * 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.
*/
export function acquireDuke(
state: DukeState,
@@ -55,13 +56,27 @@ export function acquireDuke(
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)
- throw new Error('Duke Vidal is in the Tanks and cannot be acquired.');
+ 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.',
);
- return { ...cloneDuke(state), controller, acquiredTurn: turn, source };
+ 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 (state.leader.dead) {
+ acquired.leader.dead = false;
+ // An independent resurrection permits a new battle use without erasing deaths.
+ delete acquired.leader.usedAt;
+ }
+ acquired.controller = controller;
+ acquired.acquiredTurn = turn;
+ acquired.source = source;
+ return acquired;
}
/** The battle caller resolves leader death and bounty before releasing temporary control. */
diff --git a/game/engine.ts b/game/engine.ts
index 57c79d5f..9c78639c 100644
--- a/game/engine.ts
+++ b/game/engine.ts
@@ -25,7 +25,7 @@ import { quoteSandmasterMovement, validateSandmasterMovement, sandmasterCollecti
import { sandmasterWormCollection } from './sandmaster-worm';
import { spiceBankerModeSupported, validateSpiceBankerSpend } from './spice-banker';
import { BankerIncomeError, createBankerIncomeState, validateBankerIncomeState, quoteBankerIncome, commitBankerIncome, quoteBankerIncomeCollection, commitBankerIncomeCollection, projectBankerIncome, type BankerIncomeState, type BankerIncomeAuthority, type BankerIncomeContext } from './spice-banker-income';
-import { quoteDiplomatDefense, diplomatDefenseModeSupported, type DiplomatDefenseQuote } from './diplomat-defense';
+import { quoteDiplomatDefense, diplomatDefenseModeSupported, diplomatDefenseLabel, type DiplomatDefenseQuote } from './diplomat-defense';
import { diplomatRetreatChoices, type DiplomatRetreatSelection, type DiplomatRetreatDestination } from './diplomat-retreat';
import { ECAZ_START_FORCES, quoteEcazStartingForces } from './ecaz-setup';
import { battleCardSlotEligible, battleCategoryInspectionValue, fixedBattleInspectionMatches, validBattleSlotPair } from './battle-card-slots';
@@ -707,7 +707,7 @@ export type Battle = {
mentatQuestionEvent?: string;
mentatQuestion?: MentatQuestionReceipt;
/** New battles opt into the revealed Diplomat step; legacy battles are not reopened. */
- diplomatDefenseVersion?: 1;
+ diplomatDefenseVersion?: 1 | 2;
diplomatDefenseEvent?: string;
diplomatDefense?: DiplomatDefenseReceipt;
/** New battles may pause after the outcome is known, before loser losses. */
@@ -21021,6 +21021,8 @@ function diplomatDefenseOffer(g: Game): (DiplomatDefenseQuote & { player: string
selectedLeader: plan.leader,
weapon: cardOf(p, plan.weapon), defense: cardOf(p, plan.defense),
opposingDefense: cardOf(other, opposingPlan.defense),
+ opposingWeapon: cardOf(other, opposingPlan.weapon),
+ expandedDefenses: b.diplomatDefenseVersion === 2,
});
if (quote) return { ...quote, player };
}
@@ -21043,7 +21045,7 @@ function diplomatDefenseIntegrity(g: Game) {
const decisions = homeworldSavedDecisions(g).filter(d => d.kind === 'diplomatDefense');
if (b?.diplomatDefenseVersion === undefined && b?.diplomatDefense === undefined &&
b?.diplomatDefenseEvent === undefined && !decisions.length) return;
- requireRule(b && b.diplomatDefenseVersion === 1,
+ requireRule(b && (b.diplomatDefenseVersion === 1 || b.diplomatDefenseVersion === 2),
'The saved Diplomat choice lost its original battle version.');
const receipt = b.diplomatDefense;
const offer = diplomatDefenseOffer(g);
@@ -21075,7 +21077,7 @@ function diplomatDefenseIntegrity(g: Game) {
}
function offerDiplomatDefense(g: Game): boolean {
const b = g.battle!;
- if (b.diplomatDefenseVersion !== 1 || b.diplomatDefense) return false;
+ if ((b.diplomatDefenseVersion !== 1 && b.diplomatDefenseVersion !== 2) || b.diplomatDefense) return false;
const offer = diplomatDefenseOffer(g);
if (!offer) return false;
requireRule(b.event && !Object.keys(b.traitorCalls).length,
@@ -21087,7 +21089,7 @@ function offerDiplomatDefense(g: Game): boolean {
b.diplomatDefense = receipt;
b.diplomatDefenseEvent = event;
g.decision = { kind: 'diplomatDefense', player: offer.player, event, cards: [...offer.cards], source: offer.source };
- log(g, `${getPlayer(g, offer.player).name} may use Diplomat to make one committed Worthless card copy the opposing ${offer.kind === 'shield' ? 'Shield' : 'Snooper'} before traitor decisions.`,
+ log(g, `${getPlayer(g, offer.player).name} may use Diplomat to make one committed Worthless card copy the opposing ${diplomatDefenseLabel(offer.kind)} before traitor decisions.`,
{ faction: getPlayer(g, offer.player).faction, name: 'Diplomat defense' });
return true;
}
@@ -29273,7 +29275,7 @@ function applyActionInner(
receipt.stage = receipt.card ? 'copied' : 'declined';
receipt.signature = diplomatDefenseSignature(receipt);
log(g, receipt.card
- ? `${p.name} used Diplomat: ${cardOf(p, receipt.card)!.name} copies the opposing ${receipt.kind === 'shield' ? 'Shield' : 'Snooper'} for this battle and must be discarded afterward. The original plans and physical card identities remain unchanged.`
+ ? `${p.name} used Diplomat: ${cardOf(p, receipt.card)!.name} copies the opposing ${diplomatDefenseLabel(receipt.kind)} for this battle and must be discarded afterward. The original plans and physical card identities remain unchanged.`
: `${p.name} declined to copy the opposing defense with Diplomat.`,
{ faction: p.faction, name: 'Diplomat defense' });
nextRevealedDecision(g);
@@ -31477,7 +31479,7 @@ function applyActionInner(
event: battleEvent,
...(g.leaderSkills ? { smugglerCollectionVersion: 1 as const } : {}),
...(g.mentatQuestionPreview === true && g.leaderSkills && mentatQuestionModeSupported(g) ? { mentatQuestionVersion: 1 as const } : {}),
- ...(g.leaderSkills && diplomatDefenseModeSupported(g) ? { diplomatDefenseVersion: 1 as const } : {}),
+ ...(g.leaderSkills && diplomatDefenseModeSupported(g) ? { diplomatDefenseVersion: 2 as const } : {}),
...(g.leaderSkills && ordinaryLeaderSkillModeSupported(g) ? { diplomatRetreatVersion: 1 as const } : {}),
...(g.ecazTreachery ? { harassAllocationVersion: 1 as const } : {}),
...(byFaction(g, 'richese')
diff --git a/game/nexus-ecaz-duke.ts b/game/nexus-ecaz-duke.ts
index 9469d6a3..d5887aa6 100644
--- a/game/nexus-ecaz-duke.ts
+++ b/game/nexus-ecaz-duke.ts
@@ -15,7 +15,7 @@ type DukeBefore = {
controller: string | null;
acquiredTurn: number | null;
source: DukeSource | null;
- leader: { id: typeof DUKE_VIDAL_ID; dead: false; capturedBy: null; gholaBy: null };
+ leader: { id: typeof DUKE_VIDAL_ID; dead: boolean; capturedBy: null; gholaBy: null };
};
/** A completed physical card play, not a second copy of the Duke's current custody. */
@@ -72,10 +72,13 @@ 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.dead ||
+ else if (!duke || duke.leader.id !== DUKE_VIDAL_ID ||
duke.leader.capturedBy || duke.leader.gholaBy)
- blocked = 'Only a living, uncaptured, non-Ghola Duke Vidal can be acquired.';
- else if (duke.leader.usedAt)
+ 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.';
@@ -122,7 +125,7 @@ export function createNexusEcazDukeReceipt(
turn: g.turn, phase: 6, sequence,
before: {
controller: duke.controller, acquiredTurn: duke.acquiredTurn, source: duke.source,
- leader: { id: DUKE_VIDAL_ID, dead: false, capturedBy: null, gholaBy: null },
+ leader: { id: DUKE_VIDAL_ID, dead: duke.leader.dead, capturedBy: null, gholaBy: null },
},
after: { controller: after.controller!, acquiredTurn: after.acquiredTurn!, source: 'ecazNexus' },
signature: '',
@@ -161,8 +164,10 @@ export function validateNexusEcazDuke(
!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 ||
+ leader.id !== DUKE_VIDAL_ID || typeof leader.dead !== 'boolean' ||
leader.capturedBy !== null || leader.gholaBy !== null ||
+ (leader.dead &&
+ (before.controller !== null || before.acquiredTurn !== null || before.source !== null)) ||
!(before.controller === null ||
g.players.some(player => player.id === before.controller)) ||
(before.controller === null
diff --git a/game/reference.ts b/game/reference.ts
index 3b6334e3..641b492a 100644
--- a/game/reference.ts
+++ b/game/reference.ts
@@ -4508,7 +4508,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 living, unclaimed Duke for this turn, even from Moritani. Original standalone OR paired Ecaz/Moritani/classic Nexus profiles admit this bounded effect with or without Skills and optional Homeworlds/Discovery/Tech/Advanced Strongholds. The one shared Duke remains separate from ordinary training and is set aside after use or at turn end. Advanced Harkonnen is independently excluded even where standalone no-Skills roster admission otherwise permits it. Captured, dead or foreign-Ghola Duke custody and unqualified reaction timing remain guarded; this Battle boundary is the existing product interpretation, not a printed phase restriction.',
+ '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.',
'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.',
@@ -4734,7 +4734,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'Rihani normally inspects two random Traitor Deck cards privately, then reshuffles them. A surviving selected native Rihani also offers a separate optional draw of two. Decide before seeing them: once drawn, keep one new card and publicly reveal and return one unused old card. The other new card returns secretly. A captured Rihani grants only the optional exchange. Your private history records both samples; a card just called to win is already used.',
'Smuggler can include one free accompanying force in an off-planet shipment into an empty territory. Select the entire physical total; three forces cost the ordinary price of two. The checkbox can decline this optional benefit. Guild rounding follows the free force. Richese may instead ship one separate free reserve force beside its owned No-Field into an empty territory; the marker retains its ordinary price and hidden value. Existing forces, including advisors, prevent the bonus; ordinary Fremen on-planet reinforcement has no bonus.',
'Spice Banker can commit one through three own spice with its Battle Plan, separate from force support. Only the surviving selected skilled disc gains that strength. The amount stays private until authorized plan inspection or public reveal. It is paid on win or loss, including leader death, except a sole traitor-call winner spends nothing. Normal deferred income connects only in the separate fresh classic or supported native banker-income profiles, including the bounded Advanced Moritani roster; ordinary/default starts are unchanged.',
- 'Diplomat may use one committed Worthless card as the opponent’s base Shield or Snooper if you played no defense. After both plans reveal, choose the card or decline before traitor decisions. A native Diplomat may protect its own selected trained leader this way; a captive grants no upper effect. The chosen Worthless must be discarded afterward, even if you win. A surviving trained leader that loses a supported battle may retreat selected undialed physical counters up to its printed/copied disc strength into one empty adjacent non-stronghold territory before ordinary loser losses; modified defenses remain unfinished.',
+ 'Diplomat may use one committed Worthless as the opponent’s canonical printed defensive role when you played no actual defense: Shield, Snooper, Shield Snooper, defensive Weirding Way or Chemistry, or sealed Portable Snooper. Hybrid protection, Weirding’s non-Shield behavior and Chemistry’s Poison Tooth exception remain distinct. Copying the used role without its activation or own-slot prerequisite is the explicit first-version interpretation. Choose or decline after reveal, before traitors; the Worthless must be discarded even if you win. Already-open base-only battles keep that source window. A native Diplomat can protect its own selected disc; a captive grants no upper band. A surviving trained loser may retreat selected undialed physical counters up to printed/copied disc strength to one empty adjacent non-stronghold before losses. Late/mirror/numeric modifications remain separate.',
'Mentat has an explicit private developer preview for naming a specific weapon before posture and faction powers in supported classic and native skill profiles, including bounded Advanced Moritani. The target privately shows the named held card or chooses another held card. Weirding Way is a nameable default weapon; Chemistry is not. The card stays held and need not enter a Battle Plan. Every answer uses the same private response step; ordinary activation awaits the user’s timing-design decision. Empty hands skip without an answer. Private history is an observation, not a later hand guarantee.',
'Bureaucrat may redirect two spice from a payment of at least five between two other players, once per phase. The payer still pays in full; the recipient receives two less. Declining keeps the use available for a later payment. Paid bribes qualify when transferred, with the remaining share still unavailable until Mentat Pause. Ordinary auctions, Guild income, Richese sales and actual paid Tleilaxu revival income in supported Basic or bounded Advanced native skill profiles have connected payment paths. A separate free-revival award is not part of the paid price and stays with Tleilaxu. Split funding, gifts, other payment families and combined modules remain unfinished.',
'Smuggler records the existing battle-territory spice when plans reveal, capped by the selected leader’s unmodified strength. If that leader survives, it collects automatically even when losing or no forces remain. A captive pays its controller. Pending spice cannot fund the plan. Collection precedes Sandmaster’s addition; a drained pile receives none. Modified strength, multiple positive piles and combined modules remain guarded. Older saved battles keep their original behavior.',
@@ -4744,7 +4744,7 @@ export const RULE_TOPICS: RuleTopic[] = [
],
related: ['spice-banker-income', 'choam-leader-skills', 'ixian-leader-skills', 'tleilaxu-leader-skills', 'richese-leader-skills', 'ecaz-leader-skills', 'moritani-leader-skills', 'setup','choam-modules','implementation-checklist'],
checklist: [
- { area:'Implementation',status:'Partial',detail:'Exact fourteen-card custody, source-ordered setup, public capture, concealment, death and own revival connect five role bonuses, Planetologist movement/Special substitution and Suk Graduate casualty rescue. Rihani inspection/exchange and lower Mentat/Bureaucrat/Sandmaster effects are connected. Smuggler normal reserve shipping, the owned Richese No-Field companion, unmodified battle collection and optional Sandmaster ground-route, native Fremen worm-ride and physical Fremen Ambassador destination collection are connected. A separately opted-in Mentat preview connects private pre-plan questions and historical observations while ordinary activation awaits the uniform-response decision. Spice Banker lower spending and native Diplomat base Shield/Snooper copying are connected; skilled Diplomat retreat now transfers undialed normal/elite forces before losing casualties in supported battles. Normal Banker income connects only in the separate fresh classic or supported Basic native banker-income profile; other remaining bands and combined modules remain missing. Bureaucrat redirects supported third-party auction, shipment and bribe payments with exact saved once-per-phase use.',evidence:['game/leader-skill-cards.ts','game/leader-skills.ts','game/leader-skill-combat.ts','game/suk-graduate.ts','game/rihani-decipherer.ts','game/mentat-question.ts','game/bureaucrat-payment.ts','game/leader-skill-battle-board.ts','game/smuggler-shipment.ts','game/smuggler-no-field.ts','game/smuggler-battle.ts','game/spice-banker.ts','game/spice-banker-income.ts','game/sandmaster-movement.ts','game/sandmaster-worm.ts','game/fremen-ambassador.ts','game/diplomat-defense.ts','game/diplomat-retreat.ts','game/engine.ts'] },
+ { area:'Implementation',status:'Partial',detail:'Exact fourteen-card custody, source-ordered setup, public capture, concealment, death and own revival connect five role bonuses, Planetologist movement/Special substitution and Suk Graduate casualty rescue. Rihani inspection/exchange and lower Mentat/Bureaucrat/Sandmaster effects are connected. Smuggler normal reserve shipping, the owned Richese No-Field companion, unmodified battle collection and optional Sandmaster ground-route, native Fremen worm-ride and physical Fremen Ambassador destination collection are connected. A separately opted-in Mentat preview connects private pre-plan questions and historical observations while ordinary activation awaits the uniform-response decision. Spice Banker lower spending and native Diplomat canonical printed-defense copying are connected; skilled Diplomat retreat transfers undialed normal/elite forces before losing casualties. Normal Banker income connects only in its separate fresh profiles; remaining bands and combined modules stay open. Bureaucrat redirects supported third-party auction, shipment and bribe payments with saved once-per-phase use.',evidence:['game/leader-skill-cards.ts','game/leader-skills.ts','game/leader-skill-combat.ts','game/suk-graduate.ts','game/rihani-decipherer.ts','game/mentat-question.ts','game/bureaucrat-payment.ts','game/leader-skill-battle-board.ts','game/smuggler-shipment.ts','game/smuggler-no-field.ts','game/smuggler-battle.ts','game/spice-banker.ts','game/spice-banker-income.ts','game/sandmaster-movement.ts','game/sandmaster-worm.ts','game/fremen-ambassador.ts','game/diplomat-defense.ts','game/diplomat-retreat.ts','game/engine.ts'] },
{ area:'Player controls',status:'Partial',detail:'Private card/leader selection, readable inspectors, public battle posture and revival draw/decline, Planetologist range/gather controls, Special weapon selection and Suk Graduate physical rescue choices are connected. Rihani has private history and separate keep-new/reveal-old controls; automatic battle effects need no confirmation. Smuggler has explicit ordinary shipping opt-out and No-Field companion opt-in with physical-versus-priced force counts, plus automatic surviving-leader battle collection with reveal-time guidance. Banker exposes separate sealed spending and authorized inspection. Sandmaster exposes explicit legal routes, per-territory collection choices and optional worm/physical Ambassador destination checkboxes. Diplomat offers a named committed Worthless choice or decline after public reveal, and a separate surviving-loser destination/normal/elite retreat or decline. Bureaucrat offers redirect or full payment with explicit recipient amounts and deferred bribe custody. Remaining effect controls are missing.',evidence:['components/leader-skills.tsx','components/planetologist-movement.tsx','components/suk-graduate.tsx','components/rihani-decipherer.tsx','components/mentat-question.tsx','components/bureaucrat-payment.tsx','components/leader-skill-battle-guide.tsx','components/smuggler-shipment.tsx','components/spice-banker.tsx','components/sandmaster-movement.tsx','components/sandmaster-worm.tsx','components/ambassador-movement.tsx','components/diplomat-defense.tsx','components/diplomat-retreat.tsx','components/game-table.tsx'] },
{ area:'AI',status:'Partial',detail:'All four profiles use private offers, legal visibility/revival actions and Planetologist movement/battle candidates and legal Suk Graduate rescues, Rihani exchanges, shared battle scoring, Smuggler reserve/owned No-Field shipments and unmodified collection plans, Sandmaster route, worm and physical Ambassador destination collection, funded Banker commitments and optional Diplomat copying from public revealed defenses or choosing legal losing-battle retreat. Bureaucrat uses the public payee alliance to redirect enemy income or preserve allied income. These are legal prototype paths; complete skill strategy and difficulty calibration remain unfinished.',evidence:['game/bots.ts'] },
{ area:'Documentation',status:'Partial',detail:'All fourteen faces and common rules are recorded. Skilled capture follows public physical-card custody; replacement entitlement and other material timing interpretations remain explicit.',evidence:['docs/LEADER_SKILLS_RULES.md','docs/LEADER_SKILLS_CAPTURE.md','docs/LEADER_SKILLS_RUNTIME.md','docs/PLANETOLOGIST_RULES.md','docs/SUK_GRADUATE_RULES.md','docs/LEADER_BATTLE_EFFECTS.md','docs/MENTAT_QUESTION.md','docs/BUREAUCRAT_PAYMENTS.md','docs/SMUGGLER_SHIPMENT.md','docs/SPICE_BANKER_RUNTIME.md','docs/SANDMASTER_WORM.md','docs/DIPLOMAT_DEFENSE.md'] },
diff --git a/tests/diplomat-defense-controls.test.tsx b/tests/diplomat-defense-controls.test.tsx
index 5b8798c5..001429d3 100644
--- a/tests/diplomat-defense-controls.test.tsx
+++ b/tests/diplomat-defense-controls.test.tsx
@@ -1,28 +1,20 @@
import test from 'node:test';
import assert from 'node:assert/strict';
-import { registerHooks } from 'node:module';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { DiplomatDefense } from '../components/diplomat-defense';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
-import { applyAction, viewGame, type Game } from '../game/engine';
+import { applyAction, viewGame, type Game, type GameView } from '../game/engine';
+import { baseDeck, ixBattleCards, type Card } from '../game/cards';
+import { richeseCards } from '../game/richese-cards';
+import type { DiplomatDefenseQuote } from '../game/diplomat-defense';
import {
diplomatDefenseGame,
revealDiplomatPlans,
takeBattleCard,
} from './diplomat-defense-fixture';
-const aliases = registerHooks({
- resolve(specifier, context, next) {
- return next(
- specifier === 'next/image' ? 'vinext/shims/image' : specifier === 'next/link' ? 'vinext/shims/link' : specifier,
- context,
- );
- },
-});
-const { RevealedBattle } = await import('../components/revealed-battle');
-aliases.deregister();
function markup(game: Game, viewer = 'a', busy = false) {
return renderToStaticMarkup(
@@ -34,6 +26,37 @@ function markup(game: Game, viewer = 'a', busy = false) {
);
}
+/** Project the public revealed receipt with a chosen copied role. Native
+ * integration is proven separately once the server passes the widened kind. */
+function markupCopy(source: Card, kind: DiplomatDefenseQuote['kind']): string {
+ const base = viewGame(revealDiplomatPlans(diplomatDefenseGame()), 'a');
+ const battle = base.battle!;
+ const view: GameView = {
+ ...base,
+ battle: {
+ ...battle,
+ cards: [source],
+ diplomatDefense: { ...battle.diplomatDefense!, source: source.id, kind },
+ },
+ decision: {
+ kind: 'diplomatDefense',
+ player: 'a',
+ event: 'projected',
+ cards: ['worthless-projected'],
+ source: source.id,
+ },
+ };
+ return renderToStaticMarkup(
+ createElement(DiplomatDefense, { game: view, act() {}, busy: false }),
+ );
+}
+
+function describedProtection(html: string) {
+ const match = /provides (.*?)<\/strong> protection/.exec(html);
+ assert.ok(match, html);
+ return match[1];
+}
+
function revealAgainstProjectile(game: Game) {
const own = game.players.find((player) => player.id === 'a')!;
const other = game.players.find((player) => player.id === 'd')!;
@@ -54,26 +77,16 @@ function revealAgainstProjectile(game: Game) {
return next;
}
-void test('the deciding owner sees the copied protection, named Worthless choice, mandatory discard and decline', () => {
+void test('only the deciding owner can access eligible committed cards, with busy controls disabled', () => {
const game = diplomatDefenseGame();
const uncommitted = takeBattleCard(game, 'd', 'worthless');
const revealed = revealDiplomatPlans(game);
const html = markup(revealed);
- assert.match(html, /aria-label="Diplomat defense choice"/);
- assert.match(html, /opposing committed defense, Snooper/);
- assert.match(html, /Poison defense<\/strong> protection/);
- assert.match(html, /Copy Snooper with Baliset · mandatory discard/);
- assert.match(html, /must be discarded after this battle/);
- assert.match(html, /Decline Diplomat defense/);
assert.equal(html.includes(uncommitted.name), false);
assert.equal(markup(revealed, 'd'), '');
assert.ok(
(markup(revealed, 'a', true).match(/disabled=""/g) ?? []).length >= 2,
);
- assert.match(
- markup(revealDiplomatPlans(diplomatDefenseGame(false, true))),
- /Diplomat defense choice/,
- );
});
void test('all profiles copy useful revealed protection and decline a mismatched defense', () => {
@@ -113,45 +126,6 @@ void test('all profiles copy useful revealed protection and decline a mismatched
}
});
-void test('the revealed plan preserves the public copied or declined Diplomat status across views', () => {
- const unsealed = diplomatDefenseGame();
- assert.equal(
- renderToStaticMarkup(
- createElement(RevealedBattle, { game: viewGame(unsealed, 'a') }),
- ),
- '',
- );
-
- let copied = revealDiplomatPlans(unsealed);
- assert.equal(copied.decision?.kind, 'diplomatDefense');
- copied = applyAction(copied, 'a', {
- type: 'decision',
- event: copied.decision.event,
- card: copied.decision.cards[0],
- });
- for (const viewer of ['a', 'd']) {
- const html = renderToStaticMarkup(
- createElement(RevealedBattle, { game: viewGame(copied, viewer) }),
- );
- assert.match(
- html,
- /Diplomat: Baliset copies Snooper · discard after battle/,
- );
- }
-
- let declined = revealDiplomatPlans(diplomatDefenseGame());
- assert.equal(declined.decision?.kind, 'diplomatDefense');
- declined = applyAction(declined, 'a', {
- type: 'decision',
- event: declined.decision.event,
- card: null,
- });
- const declinedHtml = renderToStaticMarkup(
- createElement(RevealedBattle, { game: viewGame(declined, 'd') }),
- );
- assert.match(declinedHtml, /Diplomat: defense copy declined/);
- assert.doesNotMatch(declinedHtml, /discard after battle/);
-});
void test('all profiles retain legal Worthless plans for the native trainer and the selected hidden trained disc', () => {
for (const difficulty of DIFFICULTIES) {
@@ -177,7 +151,70 @@ void test('all profiles retain legal Worthless plans for the native trainer and
candidates.length,
`${difficulty} omitted ${hidden ? 'trained-disc' : 'native'} Diplomat planning`,
);
- assert.doesNotThrow(() => applyAction(game, 'a', candidates[0]));
+ let sealed = applyAction(game, 'a', candidates[0]);
+ const opponent = sealed.players.find(player => player.id === 'd')!;
+ sealed = applyAction(sealed, 'd', {
+ type: 'battlePlan', dial: 0, leader: 'guild-1',
+ weapon: opponent.hand.find(card => card.kind === 'poison')!.id,
+ defense: opponent.hand.find(card => card.kind === 'snooper')!.id,
+ });
+ assert.ok(sealed.decision?.kind === 'diplomatDefense');
+ const selected = sealed.decision!;
+ sealed = applyAction(sealed, 'a', { type: 'decision', event: selected.event, card: selected.cards[0] });
+ sealed = applyAction(sealed, 'a', { type: 'traitorCall', call: false });
+ sealed = applyAction(sealed, 'd', { type: 'traitorCall', call: false });
+ assert.equal(sealed.players[0].leaders.find(disc => disc.id === leader)!.dead, false);
+ assert.equal(sealed.discard.filter(card => card.id === selected.cards[0]).length, 1);
}
}
});
+
+void test('the choice describes the actual copied role instead of a binary Shield or Snooper', () => {
+ const ix = ixBattleCards(),
+ deck = baseDeck();
+ const snooper = deck.find((c) => c.kind === 'snooper')!;
+ const cases = [
+ { source: snooper, kind: 'snooper' as const },
+ { source: deck.find((c) => c.kind === 'shield')!, kind: 'shield' as const },
+ {
+ source: ix.find((c) => c.kind === 'shieldSnooper')!,
+ kind: 'shieldSnooper' as const,
+ },
+ {
+ source: ix.find((c) => c.kind === 'weirdingWay')!,
+ kind: 'weirdingWay' as const,
+ },
+ {
+ source: ix.find((c) => c.kind === 'chemistry')!,
+ kind: 'chemistry' as const,
+ },
+ {
+ source: richeseCards().find((c) => c.effect === 'portableSnooper')!,
+ kind: 'snooper' as const,
+ },
+ ];
+ const described = cases.map(({ source, kind }) => {
+ const html = markupCopy(source, kind);
+ assert.ok(html.includes(source.name), source.name);
+ assert.ok(
+ html.includes(`Copy ${source.name} with worthless-projected`),
+ source.name,
+ );
+ return describedProtection(html);
+ });
+ assert.equal(new Set(described.slice(0, 2)).size, 2);
+ assert.equal(described[0], described[5]);
+ for (const index of [2, 3, 4])
+ assert.ok(
+ !described.slice(0, 2).includes(described[index]),
+ cases[index].source.name,
+ );
+ for (const [index, { source, kind }] of cases.entries()) {
+ const html = markupCopy(source, kind);
+ assert.equal(
+ html.includes('data-copied-role-interpretation="true"'),
+ index >= 2,
+ source.name,
+ );
+ }
+});
diff --git a/tests/diplomat-defense.test.ts b/tests/diplomat-defense.test.ts
index c8fafa41..e63b28a3 100644
--- a/tests/diplomat-defense.test.ts
+++ b/tests/diplomat-defense.test.ts
@@ -1,7 +1,14 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame, type Game, type Action } from '../game/engine';
-import { baseDeck } from '../game/cards';
+import { baseDeck, ixBattleCards, ixStandardCards, type Card } from '../game/cards';
+import { richeseCards } from '../game/richese-cards';
+import {
+ battleWeaponsExplode,
+ defenseTypes,
+ isShield,
+ weaponKills,
+} from '../game/battle-cards';
import {
quoteDiplomatDefense,
copiedDiplomatDefense,
@@ -12,6 +19,11 @@ import {
revealDiplomatPlans,
takeBattleCard,
} from './diplomat-defense-fixture';
+import {
+ completedAdvancedNativeSkillsGame,
+ stageAdvancedNativeSkillBattle,
+ openAdvancedNativeSkillBattle,
+} from './fixture-advanced-native-skills';
function choose(game: Game, card: string | null): Game {
assert.equal(game.decision?.kind, 'diplomatDefense');
@@ -306,3 +318,245 @@ void test('old unmarked revealed battles retain their original continuation with
true,
);
});
+
+void test('a copied printed role keeps Shield Snooper hybrid, defensive Weirding Way and Chemistry distinct', () => {
+ const worthless = baseDeck().filter((c) => c.kind === 'worthless');
+ const ix = ixBattleCards();
+ const hybrid = ix.find((c) => c.kind === 'shieldSnooper')!,
+ weirding = ix.find((c) => c.kind === 'weirdingWay')!,
+ chemistry = ix.find((c) => c.kind === 'chemistry')!,
+ poisonTooth = ix.find((c) => c.kind === 'poisonTooth')!;
+ const deck = baseDeck();
+ const projectile = deck.find((c) => c.kind === 'projectile')!,
+ lasgun = deck.find((c) => c.kind === 'lasgun')!,
+ poison = deck.find((c) => c.kind === 'poison')!,
+ snooper = deck.find((c) => c.kind === 'snooper')!;
+ const assignments = [
+ {
+ skill: 'diplomat' as const,
+ leader: 'trainer',
+ faceUp: true,
+ captured: false,
+ },
+ ];
+ const copy = (opposingDefense: Card, opposingWeapon?: Card) => {
+ const quote = quoteDiplomatDefense({
+ assignments,
+ selectedLeader: 'other',
+ weapon: worthless[0],
+ opposingDefense,
+ opposingWeapon,
+ });
+ assert.ok(quote, `${opposingDefense.name} should supply a copied role`);
+ const copied = copiedDiplomatDefense(quote, worthless[0].id);
+ assert.equal(copied.id, worthless[0].id);
+ return copied;
+ };
+ const hybridCopy = copy(hybrid, lasgun);
+ assert.equal(hybridCopy.kind, 'shieldSnooper');
+ assert.deepEqual(defenseTypes(hybridCopy), ['shield', 'snooper']);
+ assert.ok(isShield(hybridCopy));
+ assert.ok(battleWeaponsExplode(lasgun, undefined, undefined, hybridCopy));
+
+ // Defensive Weirding Way keeps its original paired-weapon prerequisite.
+ assert.equal(
+ quoteDiplomatDefense({
+ assignments,
+ selectedLeader: 'other',
+ weapon: worthless[0],
+ opposingDefense: weirding,
+ }),
+ null,
+ );
+ const weirdingCopy = copy(weirding, projectile);
+ assert.equal(weirdingCopy.kind, 'weirdingWay');
+ assert.equal(isShield(weirdingCopy), false);
+ assert.equal(weaponKills(projectile, weirdingCopy), false);
+ assert.equal(battleWeaponsExplode(lasgun, undefined, undefined, weirdingCopy), false);
+ // Copying the used role does not copy its own-slot prerequisite onto this plan.
+ assert.ok(
+ quoteDiplomatDefense({
+ assignments,
+ selectedLeader: 'other',
+ weapon: worthless[0],
+ opposingDefense: weirding,
+ opposingWeapon: worthless[1],
+ }),
+ );
+
+ const chemistryCopy = copy(chemistry, poisonTooth);
+ assert.equal(chemistryCopy.kind, 'chemistry');
+ assert.equal(weaponKills(poisonTooth, chemistryCopy), false);
+ assert.equal(weaponKills(poisonTooth, hybridCopy), true);
+ assert.equal(weaponKills(poison, chemistryCopy), false);
+ assert.equal(weaponKills(projectile, chemistryCopy), true);
+
+ const snooperCopy = copy(snooper, poison);
+ assert.equal(snooperCopy.kind, 'snooper');
+ assert.equal(weaponKills(poison, snooperCopy), false);
+ assert.equal(weaponKills(poisonTooth, snooperCopy), true);
+});
+
+void test('a sealed Portable Snooper supplies only its original poison-defense role', () => {
+ const portable = richeseCards().find((c) => c.effect === 'portableSnooper')!;
+ const worthless = baseDeck().filter((c) => c.kind === 'worthless');
+ const deck = baseDeck();
+ const poison = deck.find((c) => c.kind === 'poison')!,
+ projectile = deck.find((c) => c.kind === 'projectile')!,
+ shield = deck.find((c) => c.kind === 'shield')!;
+ const assignments = [
+ {
+ skill: 'diplomat' as const,
+ leader: 'trainer',
+ faceUp: true,
+ captured: false,
+ },
+ ];
+ const quote = quoteDiplomatDefense({
+ assignments,
+ selectedLeader: 'other',
+ weapon: worthless[0],
+ defense: worthless[1],
+ opposingDefense: portable,
+ })!;
+ assert.equal(quote.kind, 'snooper');
+ const copied = copiedDiplomatDefense(quote, worthless[1].id);
+ assert.equal(copied.kind, 'snooper');
+ assert.equal(copied.id, worthless[1].id);
+ assert.equal(isShield(copied), false);
+ assert.equal(weaponKills(poison, copied), false);
+ assert.equal(weaponKills(projectile, copied), true);
+ for (const forged of [
+ { ...portable, name: 'Snooper' },
+ { ...portable, kind: 'snooper' as const },
+ { ...portable, id: 'richese-portable-snooper-copy' },
+ { ...portable, effect: undefined },
+ ])
+ assert.equal(
+ quoteDiplomatDefense({
+ assignments,
+ selectedLeader: 'other',
+ weapon: worthless[0],
+ opposingDefense: forged,
+ }),
+ null,
+ forged.id,
+ );
+ const ix = ixBattleCards();
+ for (const unsupported of [
+ poison,
+ projectile,
+ worthless[0],
+ ix.find((c) => c.kind === 'poisonTooth')!,
+ ix.find((c) => c.kind === 'artillery')!,
+ ix.find((c) => c.kind === 'poisonBlade')!,
+ ])
+ assert.equal(
+ quoteDiplomatDefense({
+ assignments,
+ selectedLeader: 'other',
+ weapon: worthless[0],
+ opposingDefense: unsupported,
+ }),
+ null,
+ unsupported.id,
+ );
+ // A forged face cannot inherit the role by claiming a printed name alone.
+ for (const forged of [
+ { ...ix.find((c) => c.kind === 'weirdingWay')!, kind: 'shield' as const },
+ { ...ix.find((c) => c.kind === 'chemistry')!, kind: 'snooper' as const },
+ { ...shield, kind: 'shieldSnooper' as const },
+ { ...shield, id: 'ix-shield', kind: 'shieldSnooper' as const },
+ ])
+ assert.equal(
+ quoteDiplomatDefense({
+ assignments,
+ selectedLeader: 'other',
+ weapon: worthless[0],
+ opposingDefense: forged,
+ opposingWeapon: projectile,
+ }),
+ null,
+ forged.id,
+ );
+});
+
+void test('an older battle admits only its original base Shield and Snooper sources', () => {
+ const worthless = baseDeck().filter((c) => c.kind === 'worthless');
+ const deck = baseDeck();
+ const shield = deck.find((c) => c.kind === 'shield')!,
+ snooper = deck.find((c) => c.kind === 'snooper')!,
+ projectile = deck.find((c) => c.kind === 'projectile')!;
+ const ix = ixBattleCards();
+ const ixStandard = ixStandardCards();
+ const portable = richeseCards().find((c) => c.effect === 'portableSnooper')!;
+ const assignments = [
+ {
+ skill: 'diplomat' as const,
+ leader: 'trainer',
+ faceUp: true,
+ captured: false,
+ },
+ ];
+ const legacy = (opposingDefense: Card, opposingWeapon?: Card) =>
+ quoteDiplomatDefense({
+ assignments,
+ selectedLeader: 'other',
+ weapon: worthless[0],
+ opposingDefense,
+ opposingWeapon,
+ expandedDefenses: false,
+ });
+ assert.equal(legacy(shield)?.kind, 'shield');
+ assert.equal(legacy(snooper, projectile)?.kind, 'snooper');
+ for (const unavailable of [
+ ix.find((c) => c.kind === 'shieldSnooper')!,
+ ix.find((c) => c.kind === 'weirdingWay')!,
+ ix.find((c) => c.kind === 'chemistry')!,
+ ixStandard.find((c) => c.id === 'ix-shield')!,
+ ixStandard.find((c) => c.id === 'ix-snooper')!,
+ portable,
+ ])
+ assert.equal(
+ legacy(unavailable, unavailable.kind === 'weirdingWay' ? projectile : undefined),
+ null,
+ unavailable.id,
+ );
+ for (const unavailable of [
+ ix.find((c) => c.kind === 'shieldSnooper')!,
+ ix.find((c) => c.kind === 'weirdingWay')!,
+ ix.find((c) => c.kind === 'chemistry')!,
+ portable,
+ ])
+ assert.ok(
+ quoteDiplomatDefense({
+ assignments,
+ selectedLeader: 'other',
+ weapon: worthless[0],
+ opposingDefense: unavailable,
+ opposingWeapon:
+ unavailable.kind === 'weirdingWay' ? projectile : undefined,
+ }),
+ unavailable.id,
+ );
+});
+
+void test('an already-open base-only battle finishes without a retroactive hybrid copy or missing-receipt deadlock', () => {
+ const initialized = completedAdvancedNativeSkillsGame({
+ family: 'ixians', requestedSkill: 'diplomat', skillOwner: 'emperor', opponents: ['guild'],
+ });
+ let game = stageAdvancedNativeSkillBattle(initialized, 'emperor', 'guild');
+ const worthless = takeBattleCard(game, 'emperor', 'worthless');
+ const weapon = takeBattleCard(game, 'guild', 'poisonBlade');
+ const defense = takeBattleCard(game, 'guild', 'shieldSnooper');
+ game = openAdvancedNativeSkillBattle(game, 'emperor', 'guild', true);
+ // An in-flight battle created before printed-defense capability was enabled.
+ game.battle!.diplomatDefenseVersion = 1;
+ game = applyAction(game, 'emperor', { type: 'battlePlan', dial: 0, leader: 'emperor-0', defense: worthless.id });
+ game = applyAction(game, 'guild', { type: 'battlePlan', dial: 0, leader: 'guild-1', weapon: weapon.id, defense: defense.id });
+ assert.equal(game.decision, null);
+ assert.equal(game.battle!.diplomatDefense, undefined);
+ game = applyAction(game, 'emperor', { type: 'traitorCall', call: false });
+ game = applyAction(game, 'guild', { type: 'traitorCall', call: false });
+ assert.equal(game.players.find(player => player.id === 'emperor')!.leaders.find(leader => leader.id === 'emperor-0')!.dead, true);
+});
diff --git a/tests/nexus-ecaz-duke-controls.test.ts b/tests/nexus-ecaz-duke-controls.test.ts
index c9ef9d6f..76d8f51c 100644
--- a/tests/nexus-ecaz-duke-controls.test.ts
+++ b/tests/nexus-ecaz-duke-controls.test.ts
@@ -2,10 +2,11 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
-import type { GameView } from '../game/engine';
-import { botActions } from '../game/bots';
+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]';
@@ -30,17 +31,6 @@ function markup(view: GameView, busy = false) {
return renderToStaticMarkup(createElement(NexusEcazDuke, { game: view, busy, act() {} }));
}
-void test('Ecaz chooses the private, event-bound custody action before the ordinary battle decision in Basic and Advanced play', () => {
- for (const advanced of [false, true]) {
- for (const profile of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
- const game = projected({ advanced });
- game.players[0].bot = profile;
- const action = { type: 'nexusEcazDuke', event };
- assert.deepEqual(nexusEcazDukeAction(game), action);
- assert.deepEqual(botActions(game)[0], action);
- }
- }
-});
void test('private projection and physical card limit the offer without probing a rival hand', () => {
const game = projected();
@@ -48,15 +38,9 @@ void test('private projection and physical card limit the offer without probing
get() { throw new Error('Rival private cards must remain unread'); },
});
const html = markup(game);
- assert.match(html, /Current public controller: House Moritani/);
- assert.match(html, /living, uncaptured, non-Ghola Duke/);
- assert.match(html, /physical Ecaz Nexus card/);
- assert.match(html, /temporary control expires at turn end/);
- assert.match(html, /using him in battle consumes the shared Duke/);
- assert.match(html, /printed Tanks revival, capture, and Ghola effects are not supported/);
- assert.match(html, />Discard Ecaz Nexus · take Duke this turn/);
+ assert.match(html, /]*>Discard Ecaz Nexus/);
+ assert.match(markup(game, true), /]*disabled=""/);
const rival = projected({ me: 'moritani', nexusCards: { card: null }, nexusEcazDuke: null });
assert.equal(markup(rival), '');
@@ -65,13 +49,33 @@ void test('private projection and physical card limit the offer without probing
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), /Duke Vidal is captured and cannot be taken/);
- assert.match(markup(blocked), /disabled=""[^>]*>Discard Ecaz Nexus/);
+ assert.match(markup(blocked), /]*disabled=""/);
for (const changes of [
{ status: 'finished' }, { phase: 5 }, { phaseOpening: { passed: [] } },
@@ -88,6 +92,6 @@ void test('blocked offer remains explanatory but no interrupt, alliance, or star
]) {
const view = projected(changes);
assert.equal(nexusEcazDukeAction(view), null);
- assert.match(markup(view), /disabled=""[^>]*>Discard Ecaz Nexus|^$/);
+ assert.match(markup(view), /]*disabled=""|^$/);
}
});
diff --git a/tests/nexus-ecaz-duke-engine.test.ts b/tests/nexus-ecaz-duke-engine.test.ts
index d1419baf..321cca4b 100644
--- a/tests/nexus-ecaz-duke-engine.test.ts
+++ b/tests/nexus-ecaz-duke-engine.test.ts
@@ -47,7 +47,7 @@ void test('Ecaz Cunning physically overrides Moritani Duke custody and expires w
}
});
-void test('a stale offer, busy table, dead, captured or Ghola Duke cannot spend Ecaz Cunning', () => {
+void test('a stale offer, busy table, invalid retained Tanks custody, captured or Ghola Duke cannot spend Ecaz Cunning', () => {
const { game, action } = position();
const changed = reload(game);
changed.dukeVidal = acquireDuke(changed.dukeVidal!, 'r', changed.turn, 'ecaz');
diff --git a/tests/nexus-ecaz-duke-quote.test.ts b/tests/nexus-ecaz-duke-quote.test.ts
index c5c8ef7e..3b1e53c8 100644
--- a/tests/nexus-ecaz-duke-quote.test.ts
+++ b/tests/nexus-ecaz-duke-quote.test.ts
@@ -1,7 +1,8 @@
import assert from 'node:assert/strict';
import test from 'node:test';
-import type { Game } from '../game/engine';
-import { acquireDuke, createDukeVidal, expireDuke } from '../game/duke-vidal';
+import { applyAction, viewGame, type Game } from '../game/engine';
+import { botActions } from '../game/bots';
+import { acquireDuke, consumeDuke, createDukeVidal, expireDuke } from '../game/duke-vidal';
import { createNexusCards, discardNexusCard } from '../game/nexus-cards';
import {
createNexusEcazDukeReceipt,
@@ -11,6 +12,8 @@ import {
validateNexusEcazDuke,
type NexusEcazDukeReceipt,
} from '../game/nexus-ecaz-duke';
+import { nexusEcazDukePosition } from './fixture-nexus-ecaz-duke';
+import { nexusTraitorInventory } from './fixture-nexus-traitors';
function game(advanced = false): Game {
const players = [
@@ -48,14 +51,12 @@ void test('native physical Ecaz Cunning overrides Moritani at a quiet Battle bou
assert.equal(g.nexusCards!.cards!.hands['ecaz-seat'], null);
assert.deepEqual(JSON.parse(JSON.stringify(record)), record);
assert.equal(nexusEcazDukeSignature(JSON.parse(JSON.stringify(record))), record.signature);
- assert.doesNotThrow(() => validateNexusEcazDuke(g, JSON.parse(JSON.stringify(record)), 0));
// Completed history does not need the original custody or the spent card to remain current.
g.dukeVidal = expireDuke(g.dukeVidal!, 3);
assert.equal(g.dukeVidal.controller, null);
assert.equal(g.dukeVidal.source, null);
g.turn = 4;
g.phase = 1;
- assert.doesNotThrow(() => validateNexusEcazDuke(g, record, 0));
});
void test('unclaimed Duke creates a null-safe public event and historical receipt', () => {
@@ -70,10 +71,158 @@ void test('unclaimed Duke creates a null-safe public event and historical receip
controller: null, acquiredTurn: null, source: null,
leader: { id: 'duke-vidal', dead: false, capturedBy: null, gholaBy: null },
});
- assert.doesNotThrow(() => validateNexusEcazDuke(g, receipt, 0));
assert.equal(receipt.signature, nexusEcazDukeSignature(JSON.parse(JSON.stringify(receipt))));
});
+void test('Ecaz Nexus alone revives the set-aside shared Duke and clears only his battle-use stamp', () => {
+ for (const advanced of [false, true]) {
+ const g = game(advanced);
+ const tanked = g.dukeVidal!;
+ tanked.leader.dead = true;
+ tanked.leader.deaths = 3;
+ tanked.leader.usedAt = 'carthag';
+ const before = structuredClone(tanked);
+ assert.deepEqual(quoteNexusEcazDuke(g, 'ecaz-seat'), {
+ event: nexusEcazDukeEvent(3, 6, 'ecaz-seat', 0, null, null),
+ blocked: null, dukeController: null,
+ });
+ for (const source of ['moritani', 'ecaz', 'ally'] as const)
+ assert.throws(() => acquireDuke(tanked, 'ecaz-seat', 3, source), /Tanks/);
+ const receipt = createNexusEcazDukeReceipt(g, 'ecaz-seat');
+ assert.deepEqual(receipt.before, {
+ controller: null, acquiredTurn: null, source: null,
+ leader: { id: 'duke-vidal', dead: true, capturedBy: null, gholaBy: null },
+ });
+ assert.deepEqual(receipt.after, {
+ controller: 'ecaz-seat', acquiredTurn: 3, source: 'ecazNexus',
+ });
+ const acquired = acquireDuke(tanked, 'ecaz-seat', 3, 'ecazNexus');
+ const expectedLeader = { ...before.leader, dead: false };
+ delete expectedLeader.usedAt;
+ assert.deepEqual(acquired.leader, expectedLeader);
+ assert.deepEqual(tanked, before);
+ assert.notEqual(acquired.leader, tanked.leader);
+ assert.deepEqual([acquired.controller, acquired.acquiredTurn, acquired.source],
+ ['ecaz-seat', 3, 'ecazNexus']);
+ g.dukeVidal = expireDuke(acquired, 3);
+ g.turn = 4;
+ g.phase = 1;
+ validateNexusEcazDuke(g, JSON.parse(JSON.stringify(receipt)), 0);
+ assert.equal(g.dukeVidal.controller, null);
+ assert.equal(g.dukeVidal.leader.dead, false);
+ assert.equal(g.dukeVidal.leader.deaths, 3);
+ assert.equal(receipt.signature, nexusEcazDukeSignature(receipt));
+ }
+});
+
+void test('native Basic and Advanced bots can spend actual Ecaz Nexus on a Tanked Duke', () => {
+ for (const advanced of [false, true]) {
+ for (const profile of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
+ const { game: g } = nexusEcazDukePosition(advanced);
+ // Explicit conserved Tanks position after genuine setup and physical Nexus draw.
+ g.dukeVidal = consumeDuke(g.dukeVidal!);
+ g.dukeVidal.leader.dead = true;
+ g.dukeVidal.leader.deaths = 2;
+ g.dukeVidal.leader.usedAt = 'carthag';
+ g.players[0].bot = profile;
+ const before = structuredClone(g);
+ const view = viewGame(g, 'p');
+ assert.equal(view.nexusEcazDuke?.blocked, null);
+ assert.equal(viewGame(g, 'q').nexusEcazDuke, null);
+ const action = botActions(view)[0];
+ assert.deepEqual(action, { type: 'nexusEcazDuke', event: view.nexusEcazDuke!.event });
+ const result = applyAction(g, 'p', action);
+ assert.deepEqual(g, before);
+ assert.equal(result.nexusCards!.cards!.hands.p, null);
+ assert.equal(result.nexusCards!.cards!.discard.filter(card => card === 'ecaz').length, 1);
+ assert.deepEqual([result.dukeVidal!.controller, result.dukeVidal!.source,
+ result.dukeVidal!.acquiredTurn], ['p', 'ecazNexus', result.turn]);
+ assert.equal(result.dukeVidal!.leader.id, before.dukeVidal!.leader.id);
+ assert.equal(result.dukeVidal!.leader.dead, false);
+ assert.equal(result.dukeVidal!.leader.deaths, 2);
+ assert.equal(result.dukeVidal!.leader.usedAt, undefined);
+ assert.equal(result.nexusEcazDukeHistory![0].before.leader.dead, true);
+ assert.deepEqual(result.nexusEcazDukeEvents, [action.event]);
+ assert.equal(result.players.flatMap(player => player.leaders)
+ .some(leader => leader.id === 'duke-vidal'), false);
+ assert.equal(viewGame(result, 'p').players.find(player => player.id === 'p')!.leaders
+ .some(leader => leader.id === 'duke-vidal' && !leader.dead && !leader.usedAt), true);
+ assert.deepEqual(result.players.map(player => [player.spice, player.reserves, player.leaders]),
+ before.players.map(player => [player.spice, player.reserves, player.leaders]));
+ nexusTraitorInventory(result);
+ assert.throws(() => applyAction(result, 'p', action));
+ }
+ }
+});
+
+void test('living already-used Duke cannot gain a new battle use from Ecaz Cunning', () => {
+ const g = game();
+ 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.deepEqual(g.dukeVidal, before);
+ const ordinary = acquireDuke(g.dukeVidal!, 'moritani-seat', 3, 'moritani');
+ assert.deepEqual(ordinary.leader, before!.leader);
+});
+
+void test('Tanked acquisition requires set-aside custody and never bypasses capture or Ghola', () => {
+ const g = game();
+ g.dukeVidal!.leader.dead = true;
+ g.dukeVidal!.leader.deaths = 1;
+ for (const change of [
+ { controller: 'moritani-seat' },
+ { acquiredTurn: 2 },
+ { source: 'moritani' as const },
+ ]) {
+ 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.deepEqual(duke, before);
+ }
+ for (const dead of [false, true]) {
+ for (const custody of ['capturedBy', 'gholaBy'] as const) {
+ const duke = structuredClone(g.dukeVidal!);
+ 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);
+ }
+ }
+});
+
+void test('dead receipts retain the old signature shape and 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 },
+ { source: 'ecaz' as const },
+ ]) {
+ const damaged = structuredClone(original);
+ Object.assign(damaged.before, change);
+ damaged.event = nexusEcazDukeEvent(damaged.turn, damaged.phase, damaged.owner,
+ damaged.sequence, damaged.before.controller, damaged.before.acquiredTurn);
+ damaged.signature = nexusEcazDukeSignature(damaged);
+ assert.throws(() => validateNexusEcazDuke(g, damaged, 0));
+ }
+ const damaged = structuredClone(original);
+ damaged.before.leader.dead = 'true' as never;
+ damaged.signature = nexusEcazDukeSignature(damaged);
+ assert.throws(() => validateNexusEcazDuke(g, damaged, 0));
+});
+
void test('ordinary Ecaz Ambassador custody survives end turn; Nexus custody does not', () => {
const g = game();
const ordinary = acquireDuke(g.dukeVidal!, 'ecaz-seat', 3, 'ecaz');
@@ -85,13 +234,12 @@ void test('ordinary Ecaz Ambassador custody survives end turn; Nexus custody doe
const nexus = acquireDuke(moritani, 'ecaz-seat', 3, 'ecazNexus');
assert.equal(expireDuke(nexus, 2).source, 'ecazNexus');
assert.equal(expireDuke(nexus, 3).controller, null);
- for (const property of ['dead', 'capturedBy', 'gholaBy'] as const) {
+ for (const property of ['capturedBy', 'gholaBy'] as const) {
const unavailable = structuredClone(nexus);
- if (property === 'dead') unavailable.leader.dead = true;
- else unavailable.leader[property] = 'moritani-seat';
+ unavailable.leader[property] = 'moritani-seat';
assert.throws(() => acquireDuke(unavailable, 'ecaz-seat', 3, 'ecazNexus'));
assert.match(quoteNexusEcazDuke({ ...g, dukeVidal: unavailable }, 'ecaz-seat')!.blocked!,
- /living, uncaptured, non-Ghola/);
+ /uncaptured, non-Ghola/);
}
});
@@ -127,7 +275,7 @@ void test('damaged and re-signed impossible history cannot forge the original Du
record.signature = nexusEcazDukeSignature(record);
assert.throws(() => validateNexusEcazDuke(g, record, 0));
};
- invalid(record => { record.before.leader.dead = true as never; });
+ invalid(record => { record.before.leader.dead = true; });
invalid(record => { record.before.leader.capturedBy = 'moritani-seat' as never; });
invalid(record => { record.before.leader.gholaBy = 'ecaz-seat' as never; });
invalid(record => { record.before.controller = 'outsider'; });