Track repeated Ecaz revival cohorts by native disc identity

This commit is contained in:
Quintine committed 2026-10-15 14:37:15 +11:00
1 parent 60f338e2e1
commit 559b42dd78
16 files changed
+645 -52

No files matched your search

+2 -3
View File
@@ -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.'}
</p>
)}
{g.revival.ecazCyclePolicy && <p className="fine">{g.revival.ecazCyclePolicy}</p>}
</>
)}
{g.phase === 5 && (
+17
View File
@@ -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
+12
View File
@@ -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).
+63
View File
@@ -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
+46
View File
@@ -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
+19 -1
View File
@@ -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) |
+97
View File
@@ -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<Leader, 'id' | 'dead' | 'deaths' | 'capturedBy' | 'gholaBy'>[];
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);
}
+17 -5
View File
@@ -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),
+6 -10
View File
@@ -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;
+13 -2
View File
@@ -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',
+40 -17
View File
@@ -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<Leader, 'id'>): 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<Leader, 'id'>): 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;
-14
View File
@@ -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'));
+50
View File
@@ -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);
});
+98
View File
@@ -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<typeof boundary>) {
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);
});
+148
View File
@@ -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<NativeEcazCohortFixture, 'owner' | 'captor' | 'plan' | 'actions'>,
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<NativeEcazCohortFixture, 'game' | 'owner' | 'captor' | 'plan' | 'actions'> & {
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 };
}
+17
View File
@@ -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];