diff --git a/components/game-table.tsx b/components/game-table.tsx
index dd617f9a..408bf94b 100644
--- a/components/game-table.tsx
+++ b/components/game-table.tsx
@@ -4430,11 +4430,10 @@ export function GameTable({
? 'Tleilaxu prevented your normal revivals this turn.'
: g.revival.dukeBlocked && me.faction === 'ecaz' && g.dukeVidal?.leader.dead
? g.revival.dukeBlocked
- : g.revival.cycleBlock
- ? g.revival.cycleBlock
- : 'No leader is currently eligible for normal revival.'}
+ : 'No leader is currently eligible for normal revival.'}
)}
+ {g.revival.ecazCyclePolicy && {g.revival.ecazCyclePolicy}
}
>
)}
{g.phase === 5 && (
diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md
index 6149fddd..156047a1 100644
--- a/docs/CURRENT_STATUS.md
+++ b/docs/CURRENT_STATUS.md
@@ -29,6 +29,23 @@ optional skill bands, combinations and public expansion starts remain open.
Rules implementation continues; comprehensive assurance and administration
remain deferred until all rules are implemented.
+**Ecaz identity-tracked repeated cohorts — 14 October.** The repeated-history
+fence is replaced by provisional native-disc accounting: five Tanks including
+Duke or the existing unavailable-roster exception opens ordinary returns;
+completed native IDs preserve the remainder and later first deaths do not
+need to match a global death number. Duke stays physically separate and
+independently revivable. Actual paid, Ghola/Ambassador/foreign returns record
+completion, not requests; the absent-Tleilaxu waiver remains local.
+Affected19-file union230/230 and types/lint/build pass. Two original native
+chronologies, both four-profile paths and actual styled390px controls prove
+first-cohort11→8 and second-cohort12→10, one normal slot and original death
+history. The fifth native can remain alive at deaths0; Duke stays dead1
+without holding the native cohort. Actual bribes fund QA, not wallet staging.
+No overflow/browser errors or saved/deployed room touched.
+[Current policy/source and proof](ECAZ_REVIVAL_RULES.md#identity-tracked-repeated-cohorts--14-october-2026).
+Full combinations, assurance, calibration and publication remain open.
+Existing build deprecation/chunk/route warnings remain.
+
**Original quiet-phase Ecaz Cunning — 14 October.** Printed current-turn
tenure no longer has an artificial Battle-only fence. Existing quiet
boundaries preserve committed decisions, native chooser/phase, costs and
diff --git a/docs/DUKE_VIDAL_RULES.md b/docs/DUKE_VIDAL_RULES.md
index e071dbb6..c6a29601 100644
--- a/docs/DUKE_VIDAL_RULES.md
+++ b/docs/DUKE_VIDAL_RULES.md
@@ -105,3 +105,15 @@ use; separate Cunning can then acquire him. Only Ecaz revives him: no
Tleilaxu-owned foreign Ghola is manufactured. Native identity, strength6,
five-disc faction rosters, absent Traitor card and death history remain.
[Current controls, verification and remaining gates](NEXUS_ECAZ_RULES.md#original-captured-duke-and-ecaz-ghola-composition--14-october-2026).
+
+## Native repeated revival cohorts — 14 October 2026
+
+Ecaz's normal native returns no longer stop merely because any shared or
+native disc has died more than once. A provisional identity-tracked cohort
+preserves five-Tanks opening, native unavailable-roster exceptions and
+remaining ordinary returns without equating Duke's independent death history
+to the native cohort number. Duke remains separate, costs5 normally and
+returns set aside; he neither becomes a sixth native leader nor a foreign
+Ghola. Actual ordinary/Ghola returns, native controls and four legal profiles
+are connected. The old repeated-cycle guard above is historical.
+[Current policy, original native proof and remaining gates](ECAZ_REVIVAL_RULES.md#identity-tracked-repeated-cohorts--14-october-2026).
diff --git a/docs/ECAZ_REVIVAL_RULES.md b/docs/ECAZ_REVIVAL_RULES.md
index a00cc79c..c9269ec8 100644
--- a/docs/ECAZ_REVIVAL_RULES.md
+++ b/docs/ECAZ_REVIVAL_RULES.md
@@ -24,6 +24,69 @@ available, without making a repeated six-disc algorithm up. Captured, Ghola
or concealed shared-disc custody and Advanced Harkonnen combinations retain
their existing uniform boundary.
+## Identity-tracked repeated cohorts — 14 October 2026
+
+The blanket repeated-history gate is replaced by an explicit **provisional
+product composition**, chosen under the recommendation-first instruction:
+
+- Five actual leaders in Tanks, including Duke, open ordinary Ecaz returns.
+ The existing no-available-native-leader captured/foreign-custody exception
+ remains distinct from counting a living captive as physically dead.
+- Each opened cohort records completed **native disc identities**, not equality
+ between every disc's death count and one global counter. Unreturned native
+ deaths remain eligible after another native is revived and killed again.
+ A late first death can join a later open cohort without being misclassified
+ as a repeat. An already returned identity waits for the remaining native
+ cohort and another legal opening.
+- Duke remains outside the five ordinary discs. His actual presence in Tanks
+ can open a cohort, but his independent death counter does not number or hold
+ the native cohort. An independent Duke resurrection preserves an already
+ satisfied opening before removing that fifth Tanked disc.
+- Actual normal, Ghola, first-death Ambassador and foreign-ghola native returns
+ record the identity only when the physical return commits. A first capture
+ execution retains the original face-down first-cohort exclusion; it does
+ not create a permanent native-disc ban.
+- One ordinary leader allowance, printed native price, Duke5/discount3,
+ prevention, payment/income and original return custody remain unchanged.
+ Ghola and Nexus Cunning keep their own physical-card costs and allowances.
+ The absent-Tleilaxu waiver explicitly quotes the unopened first cohort
+ without opening ordinary permission or reusing a consumed identity.
+
+The optional server-only `Player.ecazRevivalCohort` stores the current cohort
+and native returned IDs. Valid older first-cohort states seed their existing
+face-down/revived identities lazily; no disc is recreated, no death history
+is rewritten, and no database migration/reset is needed. Its new state is
+included conditionally in the related ordinary-discard signature; absence
+preserves the old grammar. This is not historical-save certification.
+
+The original mixed-death rejection fails before and passes after. The affected
+nineteen-file union passes **230/230**, with types/lint/build. Actual native setup
+and weapon battles produce two consumer boundaries: five native deaths plus
+a paid return/repeat, and four native returns (three paid, one real Ghola)
+followed by four real repeat deaths while the never-killed fifth native lives
+and the original dead Duke remains at deaths1. The second cohort is used
+inside the original ten-turn game. Funding is an actual private15/25-spice
+bribe held until Mentat, not assigned wallet credit.
+
+Actual API paths pay11→8 for an unreturned first-cohort disc and12→10 for a
+second-cohort return. All four legal profiles in both positions perform
+original Cunning, ordinary forces and a paid native return, preserving cohort
+permission even when Cunning resurrects Duke. Actual canonical-styled390px
+GameTable selectors and revival buttons reach the same prices, one ordinary
+slot and retained death history; the repeat stays dead in the first path,
+and Whitmore remains alive at deaths0 in the second. No overflow/browser
+errors or saved/deployed room mutation. The native smoke chooses its next
+battlefield after the real Storm rather than silently skipping past turn ten.
+The final build retains existing module-deprecation, large-chunk and route
+classification warnings; no complete-mode or publication gate is opened.
+
+See [cohort rules](../game/ecaz-revival-cohort.ts),
+[boundary regressions](../tests/ecaz-revival-cohort.test.ts) and
+[original native runtime](../tests/ecaz-revival-cohort-runtime.test.ts).
+This supersedes the repeated-cohort guard above and historical algorithm
+boundary below, not the binding Ecaz-only revival rule, broader profile
+guards or complete-mode/deployed acceptance.
+
## Captured return and legal Ghola composition — 14 October 2026
The original standalone Ecaz/classic no-Skills Nexus envelope now admits
diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md
index 7d99ce8e..98d4d0fc 100644
--- a/docs/IMPLEMENTATION_STATUS.md
+++ b/docs/IMPLEMENTATION_STATUS.md
@@ -1,5 +1,51 @@
# Dune implementation status
+## 14 October 2026 — Ecaz identity-tracked repeated native cohorts
+
+Replaced the blanket repeated-six-disc guard with explicit provisional native
+identity accounting. Actual five-Tanks opening includes Duke; the original
+unavailable-native-roster exception stays separate. Completed native returns
+preserve the remaining cohort, a late first death need not equal its global
+generation, and a returned identity waits for completion plus another opening.
+Duke's independent death counter neither indexes nor holds native cohorts.
+
+Paid completion, own/foreign Ghola and first-death Ambassador returns record
+actual native use; a first capture execution retains its face-down exclusion.
+Independent Duke normal/Ghola/Nexus resurrection preserves a satisfied opening.
+Pending normal requests do not commit the new cohort. The absent-Tleilaxu
+waiver is explicit and local rather than a fabricated copied cycle. Removed
+obsolete cycleBlock producer/UI/caller and its guard test, not re-pinned.
+Ordinary slot, printed prices, prevention/payment/income, separate card costs,
+native five-disc identity, Duke history and Ecaz-only set-aside return remain.
+
+The optional server-only cohort field preserves exact old ordinary-discard
+signature grammar when absent. No schema migration, saved-room reset or
+historical assurance campaign is introduced. Provisional accounting is labelled,
+not presented as a publisher's missing six-disc example or user numeric ruling.
+
+The mixed-death rejection fails before/passes after. Affected19-file union
+230/230 and types/lint/build pass. Original native setup, physical shipments and
+poison battles create five native deaths, a paid return and its repeat, then
+an independently completed four-native cohort with original Ghola, four repeat
+deaths and the same Tanked Duke. The never-killed fifth native survives and
+the second cohort is used inside turn ten. Original private15/25-spice bribes
+become spendable at Mentat; no wallet/death/history assignment funds this proof.
+The fixture selects its next battlefield after the actual Storm to avoid
+running a skipped-battle chronology past the original game end.
+
+Actual API and canonical-styled390px production controls pay11→8 for the
+unreturned native while the repeat stays dead, and12→10 in the second cohort
+while Whitmore stays alive/deaths0 and Duke stays Tanked/deaths1. One ordinary
+leader slot is spent. All four policies in both original positions use
+actual Cunning, ordinary force revival and a paid native return; Cunning's
+independent Duke resurrection retains the open native permission.
+No overflow/browser errors or saved/deployed room mutation. Proof JSON stays
+outside Git; broader combination/recovery review, calibration and deployed/
+publication acceptance remain open.
+Final build retains the existing module-deprecation, chunk-size and route
+classification warnings.
+[Canonical policy, source and current evidence](ECAZ_REVIVAL_RULES.md#identity-tracked-repeated-cohorts--14-october-2026).
+
## 14 October 2026 — Original quiet-phase Ecaz Cunning
Removed the artificial Battle-only availability fence from printed
diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md
index f1489f85..08c5c435 100644
--- a/docs/RULE_DECISIONS.md
+++ b/docs/RULE_DECISIONS.md
@@ -66,6 +66,24 @@ These decisions do not open public module/faction or complete-mode gates.
These decisions do not open public module/faction or complete-mode gates.
+## Recommended Ecaz repeated native cohorts — 14 October 2026
+
+Choose explicit native-disc cohort accounting instead of the unresolved
+blanket repeated-history gate or equality with a global death count.
+Five physical Tanks including Duke, or the existing unavailable-native-roster
+exception, opens ordinary permission. Completed native identities determine
+the remainder; another legal opening follows completion. A late first death
+may join the current cohort. Duke's independent deaths do not index or hold
+the native cohort, while his actual presence can satisfy its opening.
+
+This is **provisional product composition**, not a publisher-specific
+six-disc example or a new numerical user ruling. Preserve native five-disc
+identity, ordinary one-leader use, native prices and Duke5/discount3,
+Ecaz-only revival/set-aside custody, printed first-execution exclusion and
+separate Ghola/Nexus allowances. Record actual returns rather than pending
+requests. Broader rule/privacy/persistence review remains deferred.
+[Source, exact policy and current runtime proof](ECAZ_REVIVAL_RULES.md#identity-tracked-repeated-cohorts--14-october-2026).
+
## Recommended quiet-phase Duke Cunning — 14 October 2026
Remove the prototype's Battle-only fence: the printed face specifies
@@ -630,7 +648,7 @@ dedicated retrieved worked FAQs. [Contract and remaining limits](HOMEWORLD_OCCUP
| Guild Secret Ally | One explicit Guild-priced ordinary shipment; borrowed prices/routes preserve native identity. Free Fremen reinforcement remains a separate choice. | [Route contract](NEXUS_GUILD_SECRET_ALLY_RULES.md), [verified runtime](NEXUS_GUILD_SECRET_ALLY_RUNTIME.md) |
| Homeworld custody and mobility | Physical home/visitor groups, population thresholds, tariffs and transport permissions retain distinct sources; Homeworld immunity is not universal immunity. | [Homeworld rules](HOMEWORLD_RULES.md), [benefits](HOMEWORLD_BENEFITS_RULES.md), [mobility](HOMEWORLD_MOBILITY_RULES.md), [invasion](HOMEWORLD_INVASION_RULES.md), [Guild transport](GUILD_HOMEWORLD_TRANSPORT_RULES.md) |
| Homeworld effects | Keep original payment/revival/placement identities and qualification observations; observations alone do not choose an occupied-benefit owner. | [Payments](HOMEWORLD_PAYMENT_INCOME_RULES.md), [card economy](HOMEWORLD_CARD_ECONOMY_RULES.md), [Collection](HOMEWORLD_COLLECTION_RULES.md), [revival deployment](HOMEWORLD_REVIVAL_DEPLOYMENT_RULES.md), [victory reinforcement](HOMEWORLD_VICTORY_REINFORCEMENT_RULES.md), [replacement](HOMEWORLD_REPLACEMENT_RULES.md), [Tupile](HOMEWORLD_TUPILE_INTELLIGENCE_RULES.md) |
-| Duke Vidal revival custody | The user selected set-aside custody after Ecaz revives the shared disc: revival clears the Tanks but does not itself grant battle control. Ecaz still needs a separate Ambassador or Nexus acquisition; only Ecaz may revive Duke, including by Ghola. This resolves one post-revival product interpretation, **not** the separate six-disc repeated-cycle, exceptional capture or battle-used Ghola questions. No publisher clarification assigning a post-revival controller was located. | [Publisher Ecaz/Moritani pp. 7–9](https://gamers-hq.de/media/pdf/0f/7a/86/Dune_EcazMoritani_Rulebook_EN.pdf#page=9), [revival readiness](ECAZ_DUKE_REVIVAL_READINESS.md) |
+| Duke Vidal revival custody | The user selected living set-aside custody after Ecaz revives Duke; a separate Ambassador/Nexus acquisition grants battle control. Only Ecaz may revive him, including Ghola. Native repeated returns now use the separately labelled provisional identity-cohort policy, not a new publisher example or user number. Captured scope and broad combinations remain separately bounded. | [Selected custody](ECAZ_REVIVAL_RULES.md#selected-product-interpretation-28-september-2026), [current cohort policy](ECAZ_REVIVAL_RULES.md#identity-tracked-repeated-cohorts--14-october-2026) |
| Mirror Weapon copied-card cleanup | The user selected ordinary winner retention for the physical Mirror even if its effective copy activates Tooth or Artillery. The original Tooth/Artillery cards retain their named mandatory-discard exceptions. This is a product interpretation, **not** a located publisher ruling. Independent copy-first Tooth/Stone choices and physical cleanup now have a bounded CHOAM/Richese-deck battle runtime; combined modules and full Richese games remain gated. | [Printed Mirror face and source audit](MIRROR_WEAPON_ENGINE_AUDIT.md#user-selected-cleanup-interpretation-28-september-2026) |
| Kull Wahad preview timing | User selected a **distinct eligible counter** while the interrupted physical card stays reserved, and BG Worthless interception **before conversion/discard**. Pending declaration is not a ban; only success stamps turn/phase and retains original/special once-use. Decline or prevention resumes the original once. Old `kull` stays printed-only; fresh `nexus-kull` admits native CHOAM plus classic Basic/Advanced seats, CHOAM/Ix Treachery and Nexus alone, without physical Sandtrout. Nexus Use needs actual unallied CHOAM Nexus and canonical eligible own fuel: accepted declaration spends Nexus even if prevented, fuel stays held until success. Use requires source/event/card, with no old-source action shim or proactive Kull. Verified prior printed counter saves migrate only their selection binding; no new-choice retrofit. Reservations, promises and remaining winning-payment coverage constrain fuel/counters; ordinary retained Karama payment is not a canceled Cunning effect. Priority/custody/overbid are user policy; neutral timing/spend conventions are application policy. Bounded checks/CLI/runtime/phone cases pass; broader verification is Partial and no full/public/deployed gate opens. | [Selected preview policy](CHOAM_KULL_SOURCE_UPDATE.md#user-selected-preview-timing--30-september-2026), [source/cost boundary](NEXUS_CHOAM_RULES.md#kull-source-cost-and-action-boundary), [runtime and remaining limits](CHOAM_KULL_DESIGN.md), [new evidence](NEXUS_CHOAM_RUNTIME.md#five-part-status-for-the-new-interaction) |
| Semuta full-hand exchange | User selected **Allow exchange**: acquisition followed by Semuta disposal is an atomic net-zero hand exchange at the faction limit. Existing incoming reservations still consume capacity; this does not permit an already overfull hand or settle Box/Richese purchase timing. | [Selected capacity policy](SEMUTA_SOURCE_UPDATE_20260907.md#user-selected-full-hand-exchange--30-september-2026) |
diff --git a/game/ecaz-revival-cohort.ts b/game/ecaz-revival-cohort.ts
new file mode 100644
index 00000000..8b2ec0c5
--- /dev/null
+++ b/game/ecaz-revival-cohort.ts
@@ -0,0 +1,97 @@
+import type { Leader } from './cards';
+
+/** Native ordinary-disc accounting. Duke contributes to opening, never this roster. */
+export type EcazRevivalCohort = {
+ cycle: number;
+ returned: string[];
+};
+type Context = {
+ leaders: readonly Pick[];
+ revivalCycle: number;
+ ecazRevivalCohort?: EcazRevivalCohort;
+};
+type Quote = { cycle: number; returned: readonly string[] };
+const invalid = () => { throw new Error('Ecaz revival cohort lost its original native discs.'); };
+
+/** Provisional E3 composition: five actual Tanks open a native cohort; an
+ * unavailable five-disc roster retains the base captured-leader exception.
+ * Completed native returns, not death-counter equality, determine its remainder.
+ * Duke's own death counter never indexes or delays a native cohort. */
+export function quoteEcazRevivalCohort(p: Context, dukeInTanks: boolean, waiveOpening = false): Quote {
+ const saved = p.ecazRevivalCohort;
+ if (p.leaders.length !== 5 || !Number.isSafeInteger(p.revivalCycle) || p.revivalCycle < 0)
+ invalid();
+ if (saved !== undefined) {
+ if (!saved || typeof saved !== 'object' || Array.isArray(saved) ||
+ !Number.isSafeInteger(saved.cycle) || saved.cycle < 0 || !Array.isArray(saved.returned))
+ invalid();
+ let keys = 0;
+ for (const key in saved) {
+ if (key !== 'cycle' && key !== 'returned') invalid();
+ keys++;
+ }
+ if (keys !== 2) invalid();
+ for (let i = 0; i < saved.returned.length; i++) {
+ if (!p.leaders.some(l => l.id === saved.returned[i]) ||
+ saved.returned.indexOf(saved.returned[i]) !== i) invalid();
+ }
+ }
+ let cycle = saved?.cycle ?? p.revivalCycle;
+ // Old valid Ecaz states have only the first cohort. Face-down/revived native
+ // histories seed its already-used identities once, not a shared death minimum.
+ let returned: readonly string[];
+ if (saved) returned = saved.returned;
+ else {
+ const seed: string[] = [];
+ for (const leader of p.leaders)
+ if (leader.deaths > 1 || (!leader.dead && leader.deaths > 0)) seed.push(leader.id);
+ returned = seed;
+ }
+ let dead = dukeInTanks ? 1 : 0, unavailable = true, pending = false;
+ for (const leader of p.leaders) {
+ const tanked = leader.dead && !leader.capturedBy && !leader.gholaBy;
+ if (tanked) {
+ dead++;
+ if (!returned.includes(leader.id)) pending = true;
+ }
+ if (!leader.dead && !leader.capturedBy && !leader.gholaBy) unavailable = false;
+ }
+ const opening = dead >= 5 || unavailable;
+ if (cycle === 0 && (opening || waiveOpening)) cycle = 1;
+ if (cycle > 0 && opening && !pending && returned.length > 0) {
+ cycle++;
+ returned = [];
+ }
+ return { cycle, returned };
+}
+
+/** Preserve an opening consumed by an independent Duke resurrection. */
+export function recordEcazRevivalOpening(p: Context, dukeInTanks: boolean): void {
+ const quote = quoteEcazRevivalCohort(p, dukeInTanks);
+ if (p.ecazRevivalCohort?.cycle === quote.cycle &&
+ p.ecazRevivalCohort.returned === quote.returned) return;
+ if (!p.ecazRevivalCohort && quote.cycle === 0 && quote.returned.length === 0) return;
+ p.ecazRevivalCohort = { cycle: quote.cycle, returned: [...quote.returned] };
+ p.revivalCycle = Math.max(p.revivalCycle, quote.cycle);
+}
+
+/** Record only a completed native resurrection. Call before clearing dead. */
+export function completeEcazRevivalCohortUse(p: Context, dukeInTanks: boolean, leader: string): void {
+ if (!p.leaders.some(l => l.id === leader && l.dead)) invalid();
+ const quote = quoteEcazRevivalCohort(p, dukeInTanks);
+ if (p.ecazRevivalCohort?.cycle === quote.cycle &&
+ quote.returned.includes(leader)) return;
+ p.ecazRevivalCohort = { cycle: quote.cycle,
+ returned: quote.returned.includes(leader) ? [...quote.returned] : [...quote.returned, leader] };
+ p.revivalCycle = Math.max(p.revivalCycle, quote.cycle);
+}
+
+/** A first-death capture execution is physically face down. It skips the first
+ * ordinary cohort; once that cohort is complete it has no extra lifetime ban. */
+export function recordEcazFirstExecution(p: Context, dukeInTanks: boolean, leader: string): void {
+ const quote = quoteEcazRevivalCohort(p, dukeInTanks);
+ p.ecazRevivalCohort = { cycle: quote.cycle,
+ returned: quote.cycle <= 1 && !quote.returned.includes(leader)
+ ? [...quote.returned, leader] : [...quote.returned] };
+ p.revivalCycle = Math.max(p.revivalCycle, quote.cycle);
+}
diff --git a/game/engine.ts b/game/engine.ts
index 1ecc7b49..5702e564 100644
--- a/game/engine.ts
+++ b/game/engine.ts
@@ -533,6 +533,7 @@ import {
} from './truthtrance';
import type { MixedShipmentExpression } from './mixed-shipment-question';
import { controlsLeader, nativeAvailable } from './leader-control';
+import type { EcazRevivalCohort } from './ecaz-revival-cohort';
import {
newRevivalRules,
eliteRevivalRemaining,
@@ -546,6 +547,8 @@ import {
freeRevivalRemaining,
normalRevivalAllowance,
leaderRevivalOptions,
+ recordEcazLeaderReturn,
+ recordEcazLeaderExecution,
type RevivalRules,
type PendingRevival,
} from './revival';
@@ -662,6 +665,8 @@ export type Player = {
freeForcesRevived?: number;
leaderRevived: boolean;
revivalCycle: number;
+ /** Completed native disc identities in the current ordinary Ecaz cohort. */
+ ecazRevivalCohort?: EcazRevivalCohort;
shipped: boolean;
moved: number;
battleLosses: number;
@@ -3138,6 +3143,7 @@ function playNexusEcazDuke(g: Game, p: Player, action: Action) {
offer?.blocked ?? 'Choose the current Ecaz Nexus Duke acquisition.');
const index = g.nexusEcazDukeHistory?.length ?? 0;
const receipt = nexusRule(() => createNexusEcazDukeReceipt(g, p.id, index));
+ if (g.dukeVidal!.leader.dead) recordEcazLeaderReturn(g, g.dukeVidal!.leader);
g.nexusCards!.cards = nexusRule(() => discardNexusCard(g.nexusCards!.cards!, p.id, g.players));
g.dukeVidal = nexusRule(() => acquireDuke(g.dukeVidal!, p.id, g.turn, 'ecazNexus'));
nexusRule(() => validateNexusEcazDuke(g, receipt, index));
@@ -3477,7 +3483,8 @@ function playNexusTleilaxuRevival(g: Game, p: Player, action: Action) {
g.nexusCards!.cards=nexusRule(()=>discardNexusCard(g.nexusCards!.cards!,p.id,g.players));
g.revivalRules??=newRevivalRules();
(g.revivalRules.nexusTleilaxu??=[]).push(p.id);
- if(quote.kind!=='duke'||ordinary.cycle>p.revivalCycle)p.revivalCycle=ordinary.cycle;
+ if (p.faction !== 'ecaz' && (quote.kind !== 'duke' || ordinary.cycle > p.revivalCycle))
+ p.revivalCycle = ordinary.cycle;
// The card quote establishes a free own return; the original mutation keeps
// Kwisatz/Duke custody, one-leader usage and skill revival lifecycle.
finishRevival(g,{
@@ -5213,6 +5220,7 @@ function ordinaryDiscardSignature(g: Game, next: OrdinaryDiscardContinuation) {
shipped: p.shipped,
revived: p.revived,
revivalCycle: p.revivalCycle,
+ ...(p.ecazRevivalCohort === undefined ? {} : { ecazRevivalCohort: p.ecazRevivalCohort }),
leaderRevived: p.leaderRevived,
leaders: p.leaders,
kwisatz: p.kwisatz,
@@ -17612,6 +17620,7 @@ function decideAmbassador(g: Game, p: Player, action: Action) {
kwisatz.revivalCycle = Math.max(p.revivalCycle, kwisatz.revivalCycle ?? 1) + 1;
name = 'Kwisatz Haderach';
} else {
+ recordEcazLeaderReturn(g, selected);
selected.dead = false;
delete selected.usedAt;
name = selected.name;
@@ -21288,6 +21297,7 @@ function applyGholaEffect(g: Game, p: Player, action: Action, cardId?: string) {
);
const l = gholaLeaders(g, p).find((l) => l.id === action.leader);
requireRule(l?.dead && !l.capturedBy, 'Choose a dead leader in your pool.');
+ recordEcazLeaderReturn(g, l);
l.dead = false;
delete l.concealed;
// Ghola permits a return in another battle this turn (GF9 FAQ p.9).
@@ -24565,6 +24575,7 @@ function finishRevival(
const leader = sharedDuke
? g.dukeVidal!.leader
: g.players.flatMap((owner) => owner.leaders).find((l) => l.id === revival.leader)!;
+ recordEcazLeaderReturn(g, leader);
if (revival.kind === 'foreignGhola') leader.gholaBy = p.id;
else delete leader.gholaBy;
leader.dead = false;
@@ -30891,10 +30902,12 @@ function applyActionInner(
if (l.id === DUKE_VIDAL_ID && g.dukeVidal)
g.dukeVidal = executeCapturedDuke(g.dukeVidal, p.id, g.turn);
else {
+ const firstDeath = l.deaths === 0;
l.dead = true;
l.deaths = Math.max(2, l.deaths + 1);
delete l.usedAt;
delete l.capturedBy;
+ if (firstDeath) recordEcazLeaderExecution(g, l);
}
p.spice += 2;
log(g, `${p.name} executed a captive for 2 spice.`);
@@ -32296,9 +32309,8 @@ function applyActionInner(
const cost = dukeQuote?.cost ?? (revivalDiscount(g, p) ? Math.ceil(normalCost / 2) : normalCost);
requireRule(p.spice >= cost, 'Not enough spice.');
if (cost < normalCost) checks.push('revivalDiscount');
- // Duke may be revived independently even while later six-disc native
- // cycles are gated; that request must not erase an opened first cohort.
- if (!duke || options.cycle > p.revivalCycle)
+ // Ecaz records its physical cohort only after the return actually commits.
+ if (p.faction !== 'ecaz' && (!duke || options.cycle > p.revivalCycle))
p.revivalCycle = options.cycle;
beginRevival(g, {
player: id,
@@ -34179,8 +34191,8 @@ export function viewGame(state: Game, id: string) {
: null,
leaders: leaderRevivals.leaders,
kwisatz: leaderRevivals.kwisatz,
- cycleBlock: leaderRevivals.cycleBlock,
...(leaderRevivals.auditorCyclePolicy ? { auditorCyclePolicy: leaderRevivals.auditorCyclePolicy } : {}),
+ ...(leaderRevivals.ecazCyclePolicy ? { ecazCyclePolicy: leaderRevivals.ecazCyclePolicy } : {}),
dukeBlocked: leaderRevivals.dukeBlocked,
prevented: revivalPrevented(g, me.id),
eliteRemaining: eliteRevivalRemaining(me, g.advanced),
diff --git a/game/nexus-tleilaxu-revival.ts b/game/nexus-tleilaxu-revival.ts
index f781e8cc..47ab560b 100644
--- a/game/nexus-tleilaxu-revival.ts
+++ b/game/nexus-tleilaxu-revival.ts
@@ -185,12 +185,9 @@ function ownDisc(p: Player, id: string) {
function leaderCandidates(
g: Game,
p: Player,
-): { leaders: TleilaxuSecretAllyLeader[]; dukeBlocked: string | null; cycleBlock: string | null } {
+): { leaders: TleilaxuSecretAllyLeader[]; dukeBlocked: string | null } {
const canonical = leaderRevivalOptions(g, p);
- const waived = leaderRevivalOptions(g, {
- ...p,
- revivalCycle: Math.max(1, p.revivalCycle),
- });
+ const waived = leaderRevivalOptions(g, p, true);
const canonicalIds = new Set(canonical.leaders.map((option) => option.id));
const blockedEarly = (id: string) =>
!!g.revivalRules?.earlyBlocked?.includes(`${p.id}:${id}`);
@@ -221,7 +218,6 @@ function leaderCandidates(
return {
leaders,
dukeBlocked: canonical.dukeBlocked,
- cycleBlock: canonical.cycleBlock,
};
}
@@ -275,12 +271,12 @@ export function tleilaxuSecretAllyOptions(
policy: NEXUS_TLEILAXU_POLICY,
};
const bounds = tleilaxuSecretAllyForceBounds(g, p);
- const { leaders, dukeBlocked, cycleBlock } = leaderCandidates(g, p);
+ const { leaders, dukeBlocked } = leaderCandidates(g, p);
return {
event,
blocked: leaders.length
? null
- : dukeBlocked ?? cycleBlock ?? leaderBlock,
+ : dukeBlocked ?? leaderBlock,
leaders,
maxForces: bounds.maxForces,
maxElite: bounds.maxElite,
@@ -301,11 +297,11 @@ export function quoteTleilaxuSecretAlly(
): TleilaxuSecretAllyQuote {
const source = sourceState(g, p);
if (source) throw new TleilaxuSecretAllyError(source.message, source.reason);
- const { leaders, dukeBlocked, cycleBlock } = leaderCandidates(g, p);
+ const { leaders, dukeBlocked } = leaderCandidates(g, p);
const chosen = leaders.find((leader) => leader.id === selection?.leader);
if (!chosen)
throw new TleilaxuSecretAllyError(
- leaders.length ? 'Choose one offered own leader.' : dukeBlocked ?? cycleBlock ?? leaderBlock,
+ leaders.length ? 'Choose one offered own leader.' : dukeBlocked ?? leaderBlock,
'leader',
);
const amount = selection.amount;
diff --git a/game/reference.ts b/game/reference.ts
index 55310a18..23451856 100644
--- a/game/reference.ts
+++ b/game/reference.ts
@@ -136,6 +136,7 @@ const phaseDetails = [
'In the base game, Fremen can take their available free force revivals but cannot buy normal force revivals. When the Tleilaxu faction is in the game, Fremen may also pay for normal returns within the current limit. The limit remains three unless Tleilaxu permits five; merely enabling the Ix expansion does not grant this exception. Emperor-funded extras and Ghola use separate rules.',
'An allied Fremen player can grant three free force revivals during this phase.',
'Use the leader selector to choose a dead leader. Revival cycles determine whether it is eligible; strength determines its normal cost.',
+ 'Ecaz ordinary revival uses a provisional native-identity cohort: five actual Tanks including Duke, or no available native leaders, opens it. Remaining native returns stay legal after another native dies again. Late first deaths do not need to match a global death count; Duke remains independently revivable outside the five native discs.',
'In development Ecaz fixtures, finishing Revival opens Ambassador placement after any CHOAM closing market. See Ecaz Ambassadors for the supported placement controls and remaining effects. Full expansion starts remain disabled.',
],
[
@@ -2480,6 +2481,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'Choose a dead leader for leader revival, or leave the leader choice empty to return up to five forces. A card pays for one of these choices; it cannot revive both a leader and the Kwisatz Haderach.',
'A binding battle answer or prescience commitment may require this preparation. The private guide identifies a Ghola play that can fulfill the answer, without spending or revealing the card until you choose to play it. Resolve any revival-income response before using that income to support a battle.',
'Returned forces go to reserves. A leader revived with this card can fight again this turn, including in another territory. Atreides can also select a dead Kwisatz Haderach; revive it separately from its accompanying leader. Returning a controlled foreign ghola does not transfer it back to its original faction. Additional advanced and expansion interactions remain under audit.',
+ 'An actual Ecaz native Ghola return uses that disc in its current ordinary cohort without consuming the ordinary phase slot. A repeat waits for the remaining native cohort and another opening. Independent Duke Ghola preserves a satisfied five-Tanks opening and returns him set aside; only Ecaz may revive him.',
'A saved play preserves the revived pieces, any earned technology income and the discarded card. Recovery restores any pending Tleilaxu income response without reviving or paying twice. Private preparation guidance waits for recovery to finish.',
],
checklist: [
@@ -2487,8 +2489,8 @@ export const RULE_TOPICS: RuleTopic[] = [
area: 'Implementation',
status: 'Partial',
detail:
- 'Ordinary and typed forces, controlled leaders and Kwisatz return with conserved usage and recovery. Ecaz-only dead shared Duke paid revival and physical Ghola return the same living set-aside disc; exceptional custody and broader expansion combinations remain unfinished.',
- evidence: ['game/engine.ts', 'game/ecaz-duke-revival.ts'],
+ 'Ordinary and typed forces, controlled leaders and Kwisatz retain original use and custody. Actual Ecaz native Ghola/foreign returns now record completed native cohort identity; independent Duke resurrection preserves its five-Tanks opening without aligning death counters. Ecaz-only living set-aside custody remains; broader combinations and assurance stay unfinished.',
+ evidence: ['game/engine.ts', 'game/ecaz-duke-revival.ts', 'game/ecaz-revival-cohort.ts', 'game/revival.ts'],
},
{
area: 'Player controls',
@@ -2908,6 +2910,7 @@ export const RULE_TOPICS: RuleTopic[] = [
title: 'Duke Prad Vidal: shared leader',
category: 'Advanced & expansions',
coverage: 'Partial',
+ developmentStage: 'Prototyped',
summary:
'A separate Ecaz leader disc of strength six, with changing control and no Traitor Card.',
steps: [
@@ -2916,10 +2919,18 @@ export const RULE_TOPICS: RuleTopic[] = [
'Karama can prevent Moritani from acquiring Vidal. That cancellation does not remove him once Moritani already controls him.',
'Ecaz acquires Vidal through its Ambassador outside Tanks/captured/ghola custody. The alternative forms a consensual alliance while both factions are unallied. Basic can then lend Duke optionally; the supplied Advanced physical p30 adds an available Duke to the new ally automatically and sets an unused disc aside at turn end.',
'Only Ecaz may revive Vidal, including with Ghola Treachery. Ecaz may revive him for five spice regardless of how many of its leaders are in the Tanks. Five spice is his special revival cost; his printed battle strength is six. Ecaz may begin ordinary leader revival when five leaders are in the Tanks, counting Vidal, even if it still holds another leader.',
+ 'Ordinary Ecaz returns now use an explicit provisional native cohort. Five actual Tanked discs including Duke, or the existing unavailable-native-roster exception, opens it; completed native IDs preserve the remainder and later openings. A late first death can join a later cohort. Independent Duke deaths do not set or hold its ordinary number, and a real Duke resurrection preserves an already satisfied opening. Original native prices and one ordinary leader slot remain.',
'An unallied native Ecaz holder may spend its actual Ecaz Nexus at a quiet boundary in any phase to take Duke for the current turn, including original captured custody or the set-aside disc in Tanks. Finish committed interactions first. One battle or unused turn expiry sets him aside. Nexus liberation permanently clears captivity; it does not return him to a former captor. Actual Ecaz Ghola returns him alive and set aside before a separate Cunning play.',
'Standalone Ecaz/classic no-Skills Nexus connects Advanced Harkonnen capture of the same shared disc, including a surviving just-used losing Duke, original Keep/Execute and captive returns. Capture priority and restoring only valid unused prior tenure are provisional compositions. Five native discs, no Duke Traitor, strength six and physical death history remain.',
'Development support also includes Moritani acquisition, Ecaz self-acquisition, consensual alliances, Basic optional loan, Advanced automatic new-ally grant, one-battle use and Ecaz-only revival to living set-aside custody. Loan release is provisional. Native ordinary-disc skills coexist without assigning Duke a skill. Broader captured acquisition, shared training, paired Advanced Harkonnen and wider interactions remain for refinement; complete modes are not certified.',
],
+ checklist: [
+ { area: 'Implementation', status: 'Partial', detail: 'Explicit native Ecaz cohort tracks completed disc identities instead of freezing repeat history or equating independent Duke deaths. Paid, Ghola, Ambassador/foreign returns and first-execution exclusion connect; Duke normal/Ghola/Nexus resurrection preserves opening. Native five-disc identity, Ecaz-only set-aside return, prices, slots and existing guards remain. Wider interactions/assurance are open.', evidence: ['game/ecaz-revival-cohort.ts', 'game/revival.ts', 'game/ecaz-duke-revival.ts', 'game/duke-vidal.ts', 'game/nexus-ecaz-duke.ts', 'game/nexus-tleilaxu-revival.ts', 'game/engine.ts'] },
+ { area: 'Player controls', status: 'Partial', detail: 'Original native Revival selector/buttons show remaining and second-cohort candidates with visible provisional policy. Actual styled390px Ilesa11 to8 leaves the repeat dead; Bindikk12 to10 preserves Whitmore alive/deaths0 and Duke Tanked/deaths1. One ordinary slot and original histories remain; no overflow/browser errors. Full journeys remain open.', evidence: ['components/game-table.tsx', 'components/duke-vidal.tsx'] },
+ { area: 'AI', status: 'Partial', detail: 'All four minimal legal profiles in both original cohort positions use actual Cunning, ordinary force revival and a paid native return. Independent Duke resurrection retains open cohort permission. No hidden rival data or native duplication is used; strategic calibration remains deferred.', evidence: ['game/bots.ts', 'game/nexus-ecaz-duke-options.ts', 'game/revival.ts', 'tests/ecaz-duke-bots.test.ts', 'tests/normal-revival-bots.test.ts'] },
+ { area: 'Documentation', status: 'Partial', detail: 'Printed five-Tanks/own unavailable-roster opening and Ecaz-only Duke5/set-aside are separate from the provisional identity-cohort interpretation. Pending requests do not consume new history; independent card costs/allowances remain distinct. No publisher six-disc example or new numerical user ruling is claimed.', evidence: ['docs/ECAZ_REVIVAL_RULES.md', 'docs/DUKE_VIDAL_RULES.md', 'docs/RULE_DECISIONS.md'] },
+ { area: 'Verification', status: 'Partial', detail: 'Affected19-file union230/230 and types/lint pass. Two original native death/return chronologies plus all four profiles in each and actual390px11 to8/12 to10 prove unreturned and second-cohort use. Actual Ghola completes a cohort inside the original ten-turn game; bribes, not wallet staging, fund proof. Late first-death boundary and local Tleilaxu waiver regressions remain. No saved/deployed rooms touched; broad assurance/full acceptance remain open.', evidence: ['tests/ecaz-revival-cohort.test.ts', 'tests/fixture-ecaz-revival-cohort.ts', 'tests/ecaz-revival-cohort-runtime.test.ts', 'tests/ecaz-duke-revival.test.ts', 'tests/nexus-tleilaxu-revival.test.ts', 'tests/tleilaxu-ambassador-engine.test.ts', 'tests/foreign-gholas.test.ts'] },
+ ],
related: [
'faction-ecaz',
'faction-moritani',
diff --git a/game/revival.ts b/game/revival.ts
index a26e95a7..898ad7d9 100644
--- a/game/revival.ts
+++ b/game/revival.ts
@@ -1,10 +1,15 @@
import type { Game, Player } from './engine';
+import type { Leader } from './cards';
import { faction, type FactionId } from './catalog';
import { isAuditorLeader } from './choam-auditor';
import { homeworldLowBonus } from './homeworld-benefits';
import { recruitsRevivalAllowance } from './recruits';
import { DUKE_VIDAL_ID } from './duke-vidal';
import { ecazDukeRevivalBlock, quoteEcazDukeRevival } from './ecaz-duke-revival';
+import {
+ quoteEcazRevivalCohort, completeEcazRevivalCohortUse,
+ recordEcazRevivalOpening, recordEcazFirstExecution,
+} from './ecaz-revival-cohort';
type RevivalRateContext = Pick<
Game,
| 'advanced'
@@ -159,9 +164,8 @@ export function normalRevivalCycle(
return cycle;
}
-/** The five-disc Ecaz opening is recorded in revivalCycle on the first
- * accepted ordinary return. Later six-disc cohorts are not established by E3:
- * keep their native returns gated rather than guessing from death counters. */
+/** Ordinary Ecaz cohorts keep native identity separate from independent Duke deaths.
+ * Only the absent-Tleilaxu card quote may waive the unopened first cohort. */
export function leaderRevivalOptions(
g: Pick<
Game,
@@ -180,26 +184,20 @@ export function leaderRevivalOptions(
| 'faction'
| 'leaders'
| 'revivalCycle'
+ | 'ecazRevivalCohort'
| 'leaderRevived'
| 'spice'
| 'kwisatz'
>,
+ waiveOpening = false,
) {
const shared = p.faction === 'ecaz' && g.dukeVidal;
const dukeContext = shared && g.status === 'playing' ? g : null;
const dukeBlocked = dukeContext ? ecazDukeRevivalBlock(dukeContext, p.id) : null;
const dukeAvailable = !!dukeContext && !dukeBlocked;
- const repeatHistory = !!shared &&
- (g.dukeVidal!.leader.deaths > 1 || p.leaders.some((l) => l.deaths > 1) ||
- p.revivalCycle > 1);
- const firstOpening = !!shared && !repeatHistory &&
- (p.leaders.filter((l) => l.dead && !l.capturedBy && !l.gholaBy && l.deaths === 1).length +
- (g.dukeVidal!.leader.dead && dukeAvailable &&
- g.dukeVidal!.leader.deaths === 1 ? 1 : 0) >= 5 ||
- p.leaders.every((l) => l.dead || l.capturedBy || l.gholaBy));
- const cycle = shared
- ? (!repeatHistory && (p.revivalCycle === 1 || firstOpening) ? 1 : 0)
- : normalRevivalCycle(p);
+ const cohort = shared ? quoteEcazRevivalCohort(p, dukeAvailable, waiveOpening) : null;
+ let cycle = cohort?.cycle ?? normalRevivalCycle(p);
+ if (waiveOpening && !cohort) cycle = Math.max(1, cycle);
const enabled = g.phase === 4 && !revivalPrevented(g, p.id);
const discount = revivalDiscount(g, p);
const leaders = p.leaders.flatMap((leader) => {
@@ -213,7 +211,8 @@ export function leaderRevivalOptions(
isAuditorLeader(leader);
const normal =
!p.leaderRevived &&
- (auditorReturn || (cycle > 0 && leader.deaths === cycle));
+ (auditorReturn || (cycle > 0 && (cohort
+ ? !cohort.returned.includes(leader.id) : leader.deaths === cycle)));
if (
!normal &&
(p.faction !== 'tleilaxu' ||
@@ -257,8 +256,32 @@ export function leaderRevivalOptions(
...(g.advanced && p.faction === 'choam' && p.leaders.some(isAuditorLeader)
? { auditorCyclePolicy: 'Supplied Advanced p29 permits Auditor revival each turn. Provisional ordinary-cycle policy: only the five ordinary CHOAM discs determine revival cycles; a living or repeatedly killed Auditor neither delays nor opens them. Auditor still uses the normal one-leader allowance.' }
: {}),
- cycleBlock: repeatHistory
- ? 'Ecaz six-disc repeated revival cycles are not yet resolved.' : null };
+ ...(shared ? { ecazCyclePolicy: 'Provisional Ecaz cohort policy: five physical leaders in Tanks, including Duke, or no available native leaders opens ordinary returns. A completed native return waits for the remaining native cohort and another opening. A late first death may join the current cohort; independent Duke deaths do not set ordinary cohort numbers.' } : {}) };
+}
+
+function ecazCohortOwner(g: Game, leader: string): Player | null {
+ if (!g.dukeVidal) return null;
+ const owner = g.players.find(player => player.faction === 'ecaz');
+ return owner && (leader === DUKE_VIDAL_ID || owner.leaders.some(l => l.id === leader))
+ ? owner : null;
+}
+
+/** Every actual native return, including independent Ghola/Ambassador/foreign
+ * return, consumes that disc's current ordinary cohort, never an extra slot. */
+export function recordEcazLeaderReturn(g: Game, leader: Pick): void {
+ const owner = ecazCohortOwner(g, leader.id);
+ if (!owner) return;
+ const dukeInTanks = g.dukeVidal!.leader.dead && !ecazDukeRevivalBlock(g, owner.id);
+ if (leader.id === DUKE_VIDAL_ID) recordEcazRevivalOpening(owner, dukeInTanks);
+ else completeEcazRevivalCohortUse(owner, dukeInTanks, leader.id);
+}
+
+/** Only the first execution skips the first ordinary death cohort. */
+export function recordEcazLeaderExecution(g: Game, leader: Pick): void {
+ if (leader.id === DUKE_VIDAL_ID) return;
+ const owner = ecazCohortOwner(g, leader.id);
+ if (owner) recordEcazFirstExecution(owner,
+ g.dukeVidal!.leader.dead && !ecazDukeRevivalBlock(g, owner.id), leader.id);
}
export type PendingRevival = {
player: string;
diff --git a/tests/ecaz-duke-revival.test.ts b/tests/ecaz-duke-revival.test.ts
index 19bebe60..72882ff0 100644
--- a/tests/ecaz-duke-revival.test.ts
+++ b/tests/ecaz-duke-revival.test.ts
@@ -422,20 +422,6 @@ void test('four native deaths plus Duke open Ecaz first cohort, which stays open
assert.equal(p(g, 'ec').leaders[4].dead, false);
assert.equal(g.dukeVidal!.controller, null);
});
-void test('Ecaz native repeated six-disc cycles stay gated while Duke remains independently revivable', () => {
- const g = fixture();
- p(g, 'ec').leaders.forEach((l) => { l.dead = true; l.deaths = 1; });
- p(g, 'ec').leaders[0].deaths = 2;
- p(g, 'ec').spice = 10;
- p(g, 'ec').revivalCycle = 1;
- const options = viewGame(g, 'ec').revival;
- assert.match(options.cycleBlock!, /six-disc repeated/);
- assert.deepEqual(options.leaders.map((l) => l.id), [DUKE_VIDAL_ID]);
- assert.equal(options.leaders[0].cost, 5);
- const returned = applyAction(g, 'ec', { type: 'reviveLeader', leader: DUKE_VIDAL_ID });
- assert.equal(p(returned, 'ec').revivalCycle, 1);
- assert.equal(returned.dukeVidal!.leader.deaths, 1);
-});
void test('saved discounted Duke request can be canceled back to five and paid once with income', () => {
let g = fixture(true);
g.players.push(newPlayer('t', 'Tleilaxu', 'tleilaxu'));
diff --git a/tests/ecaz-revival-cohort-runtime.test.ts b/tests/ecaz-revival-cohort-runtime.test.ts
new file mode 100644
index 00000000..bee512d3
--- /dev/null
+++ b/tests/ecaz-revival-cohort-runtime.test.ts
@@ -0,0 +1,50 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { viewGame } from '../game/engine';
+import { createNativeEcazCohortFixture, createNativeEcazSecondCohortFixture } from './fixture-ecaz-revival-cohort';
+import { assertNexusDukeCustody, nexusDukePlayer, stepNexusDuke } from './fixture-nexus-duke-capture';
+
+void test('five original native deaths and a real repeat death leave the unreturned Ecaz cohort revivable', () => {
+ const f = createNativeEcazCohortFixture();
+ const owner = nexusDukePlayer(f.game, f.owner);
+ const first = owner.leaders.find(l => l.id === f.first.leader)!;
+ assert.equal(first.dead, true);
+ assert.equal(first.deaths, 2);
+ const remaining = owner.leaders.filter(l => l.id !== first.id);
+ const options = viewGame(f.game, f.owner).revival.leaders;
+ assert.deepEqual(options.map(l => l.id).sort(), remaining.map(l => l.id).sort());
+ const next = remaining.slice().sort((a, b) => a.strength - b.strength)[0];
+ const after = stepNexusDuke(f.game, f.plan, { actor: f.owner,
+ action: { type: 'reviveLeader', leader: next.id } }, f.actions);
+ const revived = nexusDukePlayer(after, f.owner);
+ assert.equal(revived.leaders.find(l => l.id === next.id)!.dead, false);
+ assert.equal(revived.leaders.find(l => l.id === first.id)!.dead, true);
+ assert.equal(revived.spice, owner.spice - next.strength);
+ assert.equal(revived.leaderRevived, true);
+ assert.equal(revived.leaders.length, 5);
+ assert.equal(after.dukeVidal!.leader.capturedBy, f.captor);
+ assert.equal(after.dukeVidal!.leader.dead, false);
+ assert.equal(after.dukeVidal!.leader.deaths, 0);
+ assertNexusDukeCustody(after);
+});
+
+void test('a second original Ecaz cohort opens with four repeated native deaths and the same Duke in Tanks', () => {
+ const f = createNativeEcazSecondCohortFixture();
+ const owner = nexusDukePlayer(f.game, f.owner);
+ const returned = f.deaths.map(d => d.leader);
+ const options = viewGame(f.game, f.owner).revival.leaders.filter(l => l.id !== f.game.dukeVidal!.leader.id);
+ assert.deepEqual(options.map(l => l.id).sort(), returned.slice().sort());
+ assert.equal(owner.leaders.find(l => !returned.includes(l.id))!.deaths, 0);
+ assert.equal(owner.leaders.find(l => !returned.includes(l.id))!.dead, false);
+ assert.ok(returned.every(id => owner.leaders.find(l => l.id === id)!.deaths === 2));
+ const selected = owner.leaders.find(l => l.id === returned[0])!;
+ const after = stepNexusDuke(f.game, f.plan, { actor: f.owner,
+ action: { type: 'reviveLeader', leader: selected.id } }, f.actions);
+ assert.equal(nexusDukePlayer(after, f.owner).leaders.find(l => l.id === selected.id)!.dead, false);
+ assert.equal(nexusDukePlayer(after, f.owner).spice, owner.spice - selected.strength);
+ assert.equal(nexusDukePlayer(after, f.owner).leaderRevived, true);
+ assert.equal(after.dukeVidal!.leader.dead, true);
+ assert.equal(after.dukeVidal!.leader.deaths, 1);
+ assert.equal(after.dukeVidal!.controller, null);
+ assertNexusDukeCustody(after);
+});
diff --git a/tests/ecaz-revival-cohort.test.ts b/tests/ecaz-revival-cohort.test.ts
new file mode 100644
index 00000000..45b4c197
--- /dev/null
+++ b/tests/ecaz-revival-cohort.test.ts
@@ -0,0 +1,98 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { applyAction, createGame, newPlayer, viewGame } from '../game/engine';
+import { createDukeVidal } from '../game/duke-vidal';
+import { newRevivalRules } from '../game/revival';
+import { baseDeck } from '../game/cards';
+
+function boundary() {
+ const g = createGame('ECAZCOHORT', newPlayer('e', 'Ecaz', 'ecaz'), false, ['ecaz']);
+ g.players.push(newPlayer('m', 'Moritani', 'moritani'));
+ Object.assign(g, { status: 'playing', phase: 4, turn: 3, order: ['e', 'm'],
+ dukeVidal: createDukeVidal(), revivalRules: newRevivalRules() });
+ g.players[0].spice = 20;
+ return g;
+}
+function dealGhola(g: ReturnType) {
+ g.deck = baseDeck();
+ const index = g.deck.findIndex(card => card.effect === 'ghola');
+ assert.ok(index >= 0);
+ const [card] = g.deck.splice(index, 1);
+ g.players[0].hand.push(card);
+ return card;
+}
+
+void test('an Ecaz repeat death does not suppress other native first-cohort returns', () => {
+ const g = boundary();
+ const owner = g.players[0];
+ owner.spice = 20;
+ owner.revivalCycle = 1;
+ for (const leader of owner.leaders) { leader.dead = true; leader.deaths = 1; }
+ owner.leaders[0].deaths = 2;
+ const options = viewGame(g, owner.id).revival.leaders;
+ assert.deepEqual(options.map(l => l.id).sort(), owner.leaders.slice(1).map(l => l.id).sort());
+ const selected = owner.leaders[1];
+ const after = applyAction(g, owner.id, { type: 'reviveLeader', leader: selected.id });
+ assert.equal(after.players[0].leaders.find(l => l.id === selected.id)!.dead, false);
+ assert.equal(after.players[0].leaders[0].dead, true);
+ assert.equal(after.players[0].spice, owner.spice - selected.strength);
+ assert.equal(after.players[0].leaderRevived, true);
+ assert.equal(after.players[0].leaders.length, 5);
+ assert.equal(after.dukeVidal!.leader.dead, false);
+});
+
+void test('a late first death joins an open later cohort without matching its generation number', () => {
+ const g = boundary(), owner = g.players[0];
+ owner.revivalCycle = 3;
+ owner.ecazRevivalCohort = { cycle: 3, returned: owner.leaders.slice(0, 4).map(l => l.id) };
+ for (const leader of owner.leaders.slice(0, 4)) { leader.dead = true; leader.deaths = 4; }
+ const late = owner.leaders[4];
+ late.dead = true;
+ late.deaths = 1;
+ const options = viewGame(g, owner.id).revival.leaders;
+ assert.deepEqual(options.map(l => l.id), [late.id]);
+ const after = applyAction(g, owner.id, { type: 'reviveLeader', leader: late.id });
+ assert.equal(after.players[0].leaders.find(l => l.id === late.id)!.dead, false);
+ assert.equal(after.players[0].spice, owner.spice - late.strength);
+ assert.equal(after.players[0].leaders.filter(l => l.dead).length, 4);
+});
+
+void test('an actual native Ghola return spends no ordinary slot but cannot re-enter the same cohort after dying again', () => {
+ const g = boundary(), owner = g.players[0];
+ for (const leader of owner.leaders) { leader.dead = true; leader.deaths = 1; }
+ const card = dealGhola(g), first = owner.leaders[0];
+ const returned = applyAction(g, owner.id, { type: 'card', card: card.id, leader: first.id });
+ assert.equal(returned.players[0].spice, owner.spice);
+ assert.equal(returned.players[0].leaderRevived, false);
+ const repeated = returned.players[0].leaders[0];
+ repeated.dead = true;
+ repeated.deaths++;
+ const next = viewGame(returned, owner.id).revival.leaders;
+ assert.deepEqual(next.map(l => l.id).sort(), owner.leaders.slice(1).map(l => l.id).sort());
+ const second = owner.leaders[1];
+ const after = applyAction(returned, owner.id, { type: 'reviveLeader', leader: second.id });
+ assert.equal(after.players[0].leaders.find(l => l.id === second.id)!.dead, false);
+ assert.equal(after.players[0].leaders[0].dead, true);
+ assert.equal(after.players[0].spice, owner.spice - second.strength);
+});
+
+void test('an independent Duke Ghola preserves a real five-Tanks opening without aligning its repeated deaths', () => {
+ const g = boundary(), owner = g.players[0];
+ for (const leader of owner.leaders.slice(0, 4)) { leader.dead = true; leader.deaths = 1; }
+ g.dukeVidal!.leader.dead = true;
+ g.dukeVidal!.leader.deaths = 7;
+ const card = dealGhola(g);
+ const returned = applyAction(g, owner.id, { type: 'card', card: card.id, leader: g.dukeVidal!.leader.id });
+ assert.equal(returned.dukeVidal!.leader.dead, false);
+ assert.equal(returned.dukeVidal!.leader.deaths, 7);
+ assert.equal(returned.dukeVidal!.controller, null);
+ assert.equal(returned.players[0].leaderRevived, false);
+ assert.equal(returned.players[0].spice, owner.spice);
+ const options = viewGame(returned, owner.id).revival.leaders;
+ assert.deepEqual(options.map(l => l.id).sort(), owner.leaders.slice(0, 4).map(l => l.id).sort());
+ const native = owner.leaders[0];
+ const after = applyAction(returned, owner.id, { type: 'reviveLeader', leader: native.id });
+ assert.equal(after.players[0].leaders[0].dead, false);
+ assert.equal(after.players[0].spice, owner.spice - native.strength);
+ assert.equal(after.dukeVidal!.leader.deaths, 7);
+});
diff --git a/tests/fixture-ecaz-revival-cohort.ts b/tests/fixture-ecaz-revival-cohort.ts
new file mode 100644
index 00000000..286727aa
--- /dev/null
+++ b/tests/fixture-ecaz-revival-cohort.ts
@@ -0,0 +1,148 @@
+import assert from 'node:assert/strict';
+import { viewGame, type Game } from '../game/engine';
+import { TERRITORIES } from '../game/board';
+import {
+ advanceNexusDuke, assertNexusDukeCustody, closeNexusDukeDraw,
+ createNexusDukeCaptureFixture, createNexusDukeDeathFixture, nexusDukeClean, nexusDukePlayer,
+ openNexusDukeBattle, sealNexusDukeBattle, stepNexusDuke,
+ type NexusDukePlan, type NexusDukeStep,
+} from './fixture-nexus-duke-capture';
+
+type Transition = { before: Game; after: Game; leader: string };
+export type NativeEcazCohortFixture = {
+ game: Game; owner: string; captor: string; plan: NexusDukePlan; actions: NexusDukeStep[];
+ deaths: Transition[]; funding: { before: Game; after: Game; amount: number };
+ first: Transition; repeat: Transition;
+};
+
+function poisonDeath(game: Game, context: Pick,
+ leader: string, territory = 'imperial_basin'): Transition {
+ const { owner, captor, plan, actions } = context;
+ // Choose after the real next Storm; a storm-separated target can otherwise
+ // skip that entire Battle phase and run this bounded chronology past turn ten.
+ if (game.phase > 6)
+ game = advanceNexusDuke(game, g => g.phase === 4 && nexusDukeClean(g), plan, actions);
+ if (territory === 'imperial_basin') {
+ const usable = (target: typeof TERRITORIES[number]) => target.type === 'sand' &&
+ !target.sectors.includes(game.storm) &&
+ !game.players.some(p => p.id !== owner && p.id !== captor &&
+ Object.entries(p.forces).some(([key, amount]) => amount > 0 && key.startsWith(`${target.id}:`)));
+ const target = TERRITORIES.find(t => t.id === territory && usable(t)) ?? TERRITORIES.find(usable);
+ assert.ok(target, 'The original board must offer an uncontested non-storm desert for both shipments.');
+ territory = target.id;
+ }
+ plan.ship = { [owner]: { territory, amount: 1 }, [captor]: { territory, amount: 1 } };
+ const battle = openNexusDukeBattle(game, plan, actions, { territory, first: owner, second: captor });
+ const poison = nexusDukePlayer(battle.game, captor).hand.find(c => c.kind === 'poison');
+ assert.ok(poison, 'The original winning Harkonnen hand retains its dealt poison.');
+ const enemy = nexusDukePlayer(battle.game, captor).leaders
+ .filter(l => !l.dead).sort((a, b) => a.strength - b.strength)[0];
+ assert.ok(enemy);
+ const before = structuredClone(battle.game);
+ const revealed = sealNexusDukeBattle(battle.game, plan, actions, [
+ { actor: owner, leader, dial: 0, support: 0 },
+ { actor: captor, leader: enemy.id, dial: 0, support: 0, weapon: poison.id },
+ ]);
+ const after = advanceNexusDuke(revealed, g => !g.battle && nexusDukeClean(g), plan, actions);
+ assert.equal(nexusDukePlayer(after, owner).leaders.find(l => l.id === leader)!.dead, true);
+ assertNexusDukeCustody(after);
+ return { before, after, leader };
+}
+
+/** No death/history/hand/wallet assignment: original setup deal, five native
+ * poison deaths, a paid return and that same disc's real repeat death. */
+export function createNativeEcazCohortFixture(): NativeEcazCohortFixture {
+ const f = createNexusDukeCaptureFixture({ variant: 'unused', deal: faction =>
+ faction === 'harkonnen' ? ['worthless', 'poison'] : ['worthless'] });
+ const { owner, captor, plan, actions } = f;
+ plan.capture = 'decline';
+ const closing = closeNexusDukeDraw(f.game, plan, actions);
+ const native = nexusDukePlayer(closing.game, owner).leaders
+ .slice().sort((a, b) => a.strength - b.strength).map(l => l.id);
+ let game = closing.game;
+ const kill = (leader: string): Transition => {
+ const result = poisonDeath(game, f, leader);
+ game = result.after;
+ return result;
+ };
+ const deaths = native.map(kill);
+ assert.ok(nexusDukePlayer(game, owner).leaders.every(l => l.dead && l.deaths === 1));
+ game = advanceNexusDuke(game, g => g.phase === 7 && nexusDukeClean(g), plan, actions);
+ const fundingBefore = structuredClone(game), amount = 15;
+ assert.ok(nexusDukePlayer(game, captor).spice >= amount);
+ game = stepNexusDuke(game, plan, { actor: captor,
+ action: { type: 'bribe', target: owner, amount } }, actions);
+ const funding = { before: fundingBefore, after: structuredClone(game), amount };
+ game = advanceNexusDuke(game, g => g.phase === 4 && nexusDukeClean(g), plan, actions);
+ const firstBefore = structuredClone(game);
+ const firstLeader = native[0];
+ assert.ok(viewGame(game, owner).revival.leaders.some(l => l.id === firstLeader));
+ game = stepNexusDuke(game, plan, { actor: owner,
+ action: { type: 'reviveLeader', leader: firstLeader } }, actions);
+ const first = { before: firstBefore, after: structuredClone(game), leader: firstLeader };
+ const repeat = kill(firstLeader);
+ game = advanceNexusDuke(game, g => g.phase === 4 && nexusDukeClean(g), plan, actions);
+ plan.ship = {};
+ assert.equal(nexusDukePlayer(game, owner).leaders.find(l => l.id === firstLeader)!.deaths, 2);
+ assertNexusDukeCustody(game);
+ return { game, owner, captor, plan, actions, deaths, funding, first, repeat };
+}
+
+export type NativeEcazSecondCohortFixture = Pick & {
+ dukeDeath: Game; deaths: Transition[]; paid: Transition[]; repeats: Transition[];
+ ghola: Transition; funding: { before: Game; after: Game; amount: number };
+};
+
+/** The original Duke and four native discs die; three native paid returns and
+ * one actual Ghola complete the cohort. All four then really die again, within
+ * the original ten-turn limit. The never-killed fifth native remains alive. */
+export function createNativeEcazSecondCohortFixture(): NativeEcazSecondCohortFixture {
+ const death = createNexusDukeDeathFixture({ deal: faction => faction === 'ecaz' ? ['ghola']
+ : faction === 'harkonnen' ? ['worthless', 'poison'] : ['worthless'] });
+ const { owner, captor, plan, actions } = death;
+ plan.capture = 'decline';
+ const native = nexusDukePlayer(death.window, owner).leaders
+ .slice().sort((a, b) => a.strength - b.strength).map(l => l.id);
+ let game = death.window;
+ const deaths: Transition[] = [];
+ for (let i = 0; i < 4; i++) {
+ const result = poisonDeath(game, death, native[i], i === 0 ? 'carthag' : 'imperial_basin');
+ deaths.push(result);
+ game = result.after;
+ }
+ game = advanceNexusDuke(game, g => g.phase === 7 && nexusDukeClean(g), plan, actions);
+ const fundingBefore = structuredClone(game), amount = 25;
+ assert.ok(nexusDukePlayer(game, captor).spice >= amount);
+ game = stepNexusDuke(game, plan, { actor: captor,
+ action: { type: 'bribe', target: owner, amount } }, actions);
+ const funding = { before: fundingBefore, after: structuredClone(game), amount };
+ const paid: Transition[] = [], repeats: Transition[] = [];
+ let ghola!: Transition;
+ for (let i = 0; i < 3; i++) {
+ game = advanceNexusDuke(game, g => g.phase === 4 && nexusDukeClean(g), plan, actions);
+ const before = structuredClone(game);
+ game = stepNexusDuke(game, plan, { actor: owner,
+ action: { type: 'reviveLeader', leader: native[i] } }, actions);
+ paid.push({ before, after: structuredClone(game), leader: native[i] });
+ if (i === 2) {
+ const card = nexusDukePlayer(game, owner).hand.find(c => c.effect === 'ghola');
+ assert.ok(card);
+ const before = structuredClone(game);
+ game = stepNexusDuke(game, plan, { actor: owner,
+ action: { type: 'card', card: card.id, leader: native[3] } }, actions);
+ ghola = { before, after: structuredClone(game), leader: native[3] };
+ }
+ const repeated = poisonDeath(game, death, native[i]);
+ repeats.push(repeated);
+ game = repeated.after;
+ }
+ const last = poisonDeath(game, death, native[3]);
+ repeats.push(last);
+ game = advanceNexusDuke(last.after, g => g.phase === 4 && nexusDukeClean(g), plan, actions);
+ plan.ship = {};
+ assert.equal(nexusDukePlayer(game, owner).leaders.find(l => l.id === native[4])!.dead, false);
+ assert.equal(game.dukeVidal!.leader.dead, true);
+ assert.equal(game.dukeVidal!.leader.deaths, 1);
+ assertNexusDukeCustody(game);
+ return { game, owner, captor, plan, actions, dukeDeath: death.game, deaths, paid, repeats, ghola, funding };
+}
diff --git a/tests/nexus-tleilaxu-revival.test.ts b/tests/nexus-tleilaxu-revival.test.ts
index b37cb40d..f866d693 100644
--- a/tests/nexus-tleilaxu-revival.test.ts
+++ b/tests/nexus-tleilaxu-revival.test.ts
@@ -152,6 +152,23 @@ void test('the roster waiver belongs to the card alone and never opens the ordin
);
});
+void test('the Tleilaxu waiver reaches an unopened saved Ecaz cohort without reopening a previously returned disc', () => {
+ const g = fixture(false, 'ecaz'), p = g.players[0];
+ g.dukeVidal = createDukeVidal();
+ kill(p, [0], 2);
+ kill(p, [1]);
+ p.ecazRevivalCohort = { cycle: 0, returned: [p.leaders[0].id] };
+ const before = structuredClone(g);
+ assert.deepEqual(leaderRevivalOptions(g, p).leaders, []);
+ assert.deepEqual(tleilaxuSecretAllyOptions(g, p).leaders.map(l => l.id), [p.leaders[1].id]);
+ const quote = quoteTleilaxuSecretAlly(g, p, { leader: p.leaders[1].id, amount: 0, elite: 0 });
+ assert.equal(quote.kind, 'leader');
+ assert.equal(quote.cost, 0);
+ assert.throws(() => quoteTleilaxuSecretAlly(g, p, { leader: p.leaders[0].id, amount: 0, elite: 0 }));
+ assert.deepEqual(leaderRevivalOptions(g, p).leaders, []);
+ assert.deepEqual(g, before);
+});
+
void test('an opened ordinary cohort keeps canonical eligibility and is never marked early', () => {
const g = fixture();
const p = g.players[0];