From 78bcf22bca0fae7b5d514ff6e649012ba6ace6e0 Mon Sep 17 00:00:00 2001
From: Quintine <15965533+Quintine@users.noreply.github.com>
Date: Mon, 26 Oct 2026 06:44:16 +1100
Subject: [PATCH] Grant Duke Vidal through occupied Ecaz Homeworld
---
components/game-table.tsx | 2 +
components/homeworld-ecaz-duke.tsx | 51 ++++
docs/CURRENT_STATUS.md | 34 +++
docs/DUKE_VIDAL_RULES.md | 8 +
docs/HOMEWORLD_ECAZ_DUKE.md | 98 +++++++
docs/IMPLEMENTATION_STATUS.md | 35 +++
docs/RULE_DECISIONS.md | 15 ++
game/bots.ts | 3 +
game/duke-vidal.ts | 13 +-
game/engine.ts | 70 ++++-
game/homeworld-ecaz-duke.ts | 153 +++++++++++
game/nexus-ecaz-duke.ts | 3 +
game/reference.ts | 23 ++
tests/fixture-homeworld-ecaz-duke.ts | 278 ++++++++++++++++++++
tests/homeworld-ecaz-duke-bots.test.ts | 79 ++++++
tests/homeworld-ecaz-duke-controls.test.tsx | 117 ++++++++
tests/homeworld-ecaz-duke-engine.test.ts | 110 ++++++++
tests/homeworld-ecaz-duke-quote.test.ts | 208 +++++++++++++++
18 files changed, 1291 insertions(+), 9 deletions(-)
create mode 100644 components/homeworld-ecaz-duke.tsx
create mode 100644 docs/HOMEWORLD_ECAZ_DUKE.md
create mode 100644 game/homeworld-ecaz-duke.ts
create mode 100644 tests/fixture-homeworld-ecaz-duke.ts
create mode 100644 tests/homeworld-ecaz-duke-bots.test.ts
create mode 100644 tests/homeworld-ecaz-duke-controls.test.tsx
create mode 100644 tests/homeworld-ecaz-duke-engine.test.ts
create mode 100644 tests/homeworld-ecaz-duke-quote.test.ts
diff --git a/components/game-table.tsx b/components/game-table.tsx
index 4b14f562..35d5dd62 100644
--- a/components/game-table.tsx
+++ b/components/game-table.tsx
@@ -1,6 +1,7 @@
'use client';
import { TraitorDecisionPromises } from './traitor-decision-promises';
import { MovementPromises } from './movement-promises';
+import { HomeworldEcazDuke } from './homeworld-ecaz-duke';
import { integerInputValue } from '../lib/integer-input';
import { EcazSetup } from './ecaz-setup';
import { StormCardInspector, StormCardLogInspector } from './storm-cards';
@@ -1973,6 +1974,7 @@ export function GameTable({
{g.battle?.revealed && Compare revealed battle plans }
+
{!reactionOwnsControls && g.semutaReaction && }
{g.karamaBlocked &&
{g.karamaBlocked}
}
diff --git a/components/homeworld-ecaz-duke.tsx b/components/homeworld-ecaz-duke.tsx
new file mode 100644
index 00000000..cb0f1e48
--- /dev/null
+++ b/components/homeworld-ecaz-duke.tsx
@@ -0,0 +1,51 @@
+'use client';
+
+import type { Action, GameView } from '@/game/engine';
+import { Button } from './ui/button';
+
+export function HomeworldEcazDuke({
+ game,
+ act,
+ busy,
+}: {
+ game: GameView;
+ act: (action: Action) => void;
+ busy: boolean;
+}) {
+ const offer = game.homeworldEcazDuke;
+ if (!offer || offer.occupier !== game.me) return null;
+ const blocked = offer.blocked ?? (!offer.event
+ ? 'This Duke grant is not currently available.'
+ : !offer.recipients.length ? 'No eligible recipient is currently available.' : null);
+ const disabled = busy || blocked !== null;
+ return (
+
+ Occupied Ecaz: grant Duke Vidal
+
+ Provisional occupation benefit: grant the living shared Duke Vidal to
+ yourself or your reciprocal ally. The recipient may use him in one
+ battle, following the Duke’s ordinary lifecycle. Only Ecaz may revive him.
+
+ {blocked !== null && (
+ {blocked}
+ )}
+
+ {offer.recipients.map((recipient) => (
+ {
+ if (!disabled && offer.event)
+ act({ type: 'homeworldDuke', event: offer.event, recipient: recipient.id });
+ }}
+ >
+ Grant Duke Vidal to {recipient.name}
+
+ ))}
+
+
+ );
+}
diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md
index a868567e..cc3f8bae 100644
--- a/docs/CURRENT_STATUS.md
+++ b/docs/CURRENT_STATUS.md
@@ -16,6 +16,40 @@ open mode gates.
## Current checkpoint and work
+**Occupied Ecaz Duke grant — 26 October. Prototyped / Partial.**
+The printed occupied Ecaz benefit now grants the living shared Duke to its
+qualified occupier or reciprocal ally. A sole recipient receives him automatically;
+an allied occupier chooses in persistent controls before either plan seals.
+Stable occupation supplies authority, native Duke acquisition respects priority,
+and the user's Ecaz-only revival ruling stays intact. The one-choice/turn,
+one-battle and unused turn-end lifecycle is visibly provisional; departed or
+ineligible grants end without restoring an old holder. Captive/Ghola transfer
+and wider profiles remain guarded; no public start gate changes.
+
+Three disjoint helper/native-source/control lanes and root integration pass
+**15 files,129/129**, types/lint/build and **eight native engine/GameTable SSR/
+actual grant-control journeys** on unchanged source. Thirty-five new cases cover
+automatic/explicit recipients, source/actor guards, one use/death, actual Ecaz
+revival, controlled lifetime boundaries and all four minimal profiles. Native
+Basic97/99 and Advanced113/115-action sources retain all33 original cards:
+real deferred bribe, shipments, worm Nexus alliance, four-force World invasion,
+actual victory leaving3 foreign occupiers, and a separate original next battle.
+No played force, wallet, hand, clock, occupation or grant is installed. Pure
+quote/contract controls remain labelled controlled supplements.
+
+Final frozen v2 report/logs:
+`/home/quintine/.local/share/dune/checkpoints/20261026-occupied-ecaz-duke-proof-v2/`;
+journeys/HTML: adjacent `20261026-occupied-ecaz-duke-runtime/`.
+Complete rules, wider interactions and final assurance remain open. No saved-
+room/D1/server, reset/restart, deployment or recovery operation occurs. DOM/
+hydration, browser/mobile and exact-revision deployed acceptance remain due.
+[Source and provisional boundaries](HOMEWORLD_ECAZ_DUKE.md#source-and-selected-ordinary-lifecycle--26-october-2026).
+
+Next concrete content gap found in this bounded lookup: Fremen Nexus Betrayal
+cannot react before an already queued worm ride; its original pre-first-blow
+fence remains. See [the current contract](NEXUS_FREMEN_RULES.md) and
+`game/nexus-fremen-betrayal.ts`. Broad source reconciliation is not certified.
+
**Native early Truthtrance promises — 26 October. Prototyped / Partial.**
Bare registered native factions with selected decks can answer current-turn
shipment and physical ground-movement questions in Basic/Advanced phases0–5.
diff --git a/docs/DUKE_VIDAL_RULES.md b/docs/DUKE_VIDAL_RULES.md
index a534b1d5..70b8fcff 100644
--- a/docs/DUKE_VIDAL_RULES.md
+++ b/docs/DUKE_VIDAL_RULES.md
@@ -1,5 +1,13 @@
# Duke Prad Vidal lifecycle
+## Occupied Ecaz grant — 26 October 2026
+
+The occupied Homeworld now connects its living shared-Duke recipient choice,
+automatic sole recipient, occupied priority and visibly provisional one-battle/
+turn-end lifecycle. The user's Ecaz-only revival rule remains. Captive/Ghola
+transfer, wider combinations and final acceptance are separate.
+[Current source and native proof](HOMEWORLD_ECAZ_DUKE.md).
+
Primary-source audit, 2026-09-06. Scope: the separate leader disc and its acquisition, use, capture and revival. This is a bounded implementation contract; unresolved combinations below remain distinct from verified ordinary behavior.
## Sources and identity
diff --git a/docs/HOMEWORLD_ECAZ_DUKE.md b/docs/HOMEWORLD_ECAZ_DUKE.md
new file mode 100644
index 00000000..13e70230
--- /dev/null
+++ b/docs/HOMEWORLD_ECAZ_DUKE.md
@@ -0,0 +1,98 @@
+# Occupied Ecaz: Duke Vidal grant
+
+## Source and selected ordinary lifecycle — 26 October 2026
+
+**Prototyped / Partial.** The occupied Ecaz
+card grants Duke Vidal to the occupier or their ally and overrides the native
+Ecaz/Moritani Duke advantage. The already visually verified component is indexed
+in [the component audit](HOMEWORLD_COMPONENT_AUDIT.md) and represented in
+`game/homeworld-cards.ts`. Advanced occupation uses the authorized supplied PDF,
+physical22: sole foreign qualification persists until the occupier's last force
+leaves. Basic retains its existing provisional current-sole policy.
+
+The user's [Ecaz-only revival ruling](ECAZ_REVIVAL_RULES.md#selected-product-interpretation-28-september-2026)
+supersedes the card's foreign-revival wording. This grant never revives Duke.
+The shared disc, native identity, strength6, deaths, actual battle use and Tanks
+stay authoritative; neither a leader copy nor a foreign native roster is added.
+Homeworld benefits are Karama-immune under [the global source](HOMEWORLD_RULES.md#karama-and-other-optional-systems).
+
+Under the 14 October choose-recommended authority, the first ordinary lifecycle
+is explicitly provisional: one recipient choice per actual turn/qualification,
+one battle, unused tenure ends at turn end. Continuing occupation permits a new
+choice next turn after native reset. Departure/changed qualification or loss of
+the selected reciprocal ally ends only this source; no earlier holder returns.
+A real source receipt retains the original qualification/occupier/recipient.
+Used-at history blocks repeated same-turn grants even after requalification.
+
+At a settled otherwise quiet boundary, one eligible recipient receives Duke
+automatically. An allied occupier chooses self or reciprocal ally in persistent
+controls, including before either Battle Plan seals. Committed responses,
+arrivals, auctions and sealed plans resolve first. Ordinary living native tenure
+is set aside when occupation overrides it; native Ambassador/Moritani acquisition
+cannot take him back. Living Ecaz Nexus Cunning likewise respects that priority;
+Ecaz's separate resurrection remains distinct. Captured, concealed or foreign
+Ghola custody is explicitly guarded rather than silently removed.
+
+## Connected controls and minimal policies
+
+The owner-only `homeworldEcazDuke` view contains original event, projected
+recipient labels and an availability reason. `homeworldDuke` accepts exactly
+that event/recipient, validates the live entitlement and appends its receipt.
+No printed fee, power use or revival is fabricated. The GameTable panel does
+not time out, and its busy/blocked controls suppress callbacks. All four existing
+profiles choose the first legal projected recipient; strategy remains deferred.
+
+## Native source and scoped proof — 26 October 2026
+
+Three disjoint helper/native-source/control lanes and root integration pass
+**15 files,129/129**, types/lint/build and **eight native engine/GameTable SSR/
+actual grant-control journeys** on unchanged source. Thirty-five new cases cover
+automatic/explicit recipients, source/actor guards, one use/death, actual Ecaz
+revival, controlled lifetime boundaries and all four minimal profiles. Native
+Basic97/99 and Advanced113/115-action sources retain all33 original cards:
+real deferred bribe, shipments, worm Nexus alliance, four-force World invasion,
+actual victory leaving3 foreign occupiers, and a separate original next battle.
+No played force, wallet, hand, clock, occupation or grant is installed. Pure
+quote/contract controls remain labelled controlled supplements.
+
+The [native producer](../tests/fixture-homeworld-ecaz-duke.ts) starts public
+Ecaz/Emperor/Guild/Atreides with exact Ecaz deck and Homeworlds only. Guild's
+actual one-spice bribe waits for Mentat; Emperor's actual paid2 Carthag shipment
+supplies the later army. Turn2's actual worm Nexus optionally allies Emperor/
+Guild. A real quoted four-normal World invasion and native E5+dial1 versus
+Ecaz2+World2 battle leave three Emperor occupiers, zero native counters and the
+original living Duke. Atreides' separate paid entry creates the next actual
+Arrakis battle. Original setup/deal/Spice positions and scalar lotteries alone
+are selected. The first unallied grant is automatic; the allied frame retains
+both original choices before plans.
+
+The [engine cases](../tests/homeworld-ecaz-duke-engine.test.ts) use Duke in that
+separate battle, preserve one-use receipt and actual death, and progress to
+turn3 Revival. A foreign revival rejects. Ecaz really pays5, retaining the same
+disc/deaths while returning him alive and set aside; the ongoing occupier then
+gets its new turn's two-recipient choice. The runtime follows actual component
+callbacks for both recipients and a second actual grant after this revival.
+
+Eighteen [pure quote cases](../tests/homeworld-ecaz-duke-quote.test.ts) explicitly
+use controlled typed/history inputs for Basic loss versus Advanced retained
+occupation, reciprocal recipients, spent/new-turn choices, source/stale denial,
+lifecycle release and unsupported custody. Seven
+[control cases](../tests/homeworld-ecaz-duke-controls.test.tsx) label their
+projected contract rather than invent native gameplay. Three
+[minimal-policy cases](../tests/homeworld-ecaz-duke-bots.test.ts) use genuine
+Basic/Advanced sources and all four actual owner-view proposals; separate
+blocked-projection negatives remain controlled. No strategy/calibration occurs.
+
+Feedback repairs only the native fixture's later Basic dial and typed original
+poison lookup. Final v2 binds2949 files to
+`a6067af16cb7095856c6dc186d8f5c58e4146cb44c1017274caed718d98c3c68` on base `d5c5342d2d620f685cdce52fb941f4141573fdfa`.
+Final frozen v2 report/logs:
+`/home/quintine/.local/share/dune/checkpoints/20261026-occupied-ecaz-duke-proof-v2/`;
+journeys/HTML: adjacent `20261026-occupied-ecaz-duke-runtime/`.
+
+Complete rules, wider interactions and final assurance remain open. No saved-
+room/D1/server, reset/restart, deployment or recovery operation occurs. DOM/
+hydration, browser/mobile and exact-revision deployed acceptance remain due.
+Public mode gates, existing saves and unrelated games remain preserved.
+Captured/concealed/Ghola transfer, wider Nexus/Skill compositions and complete
+Homeworld/Duke acceptance remain open; this ordinary grant is not their release.
diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md
index 8ea816bf..02b51af2 100644
--- a/docs/IMPLEMENTATION_STATUS.md
+++ b/docs/IMPLEMENTATION_STATUS.md
@@ -1,5 +1,40 @@
# Dune implementation status
+## 26 October 2026 — Occupied Ecaz Duke grant
+
+**Prototyped / Partial.** The printed occupied Ecaz benefit now grants the living shared Duke to its
+qualified occupier or reciprocal ally. A sole recipient receives him automatically;
+an allied occupier chooses in persistent controls before either plan seals.
+Stable occupation supplies authority, native Duke acquisition respects priority,
+and the user's Ecaz-only revival ruling stays intact. The one-choice/turn,
+one-battle and unused turn-end lifecycle is visibly provisional; departed or
+ineligible grants end without restoring an old holder. Captive/Ghola transfer
+and wider profiles remain guarded; no public start gate changes.
+
+Three disjoint helper/native-source/control lanes and root integration pass
+**15 files,129/129**, types/lint/build and **eight native engine/GameTable SSR/
+actual grant-control journeys** on unchanged source. Thirty-five new cases cover
+automatic/explicit recipients, source/actor guards, one use/death, actual Ecaz
+revival, controlled lifetime boundaries and all four minimal profiles. Native
+Basic97/99 and Advanced113/115-action sources retain all33 original cards:
+real deferred bribe, shipments, worm Nexus alliance, four-force World invasion,
+actual victory leaving3 foreign occupiers, and a separate original next battle.
+No played force, wallet, hand, clock, occupation or grant is installed. Pure
+quote/contract controls remain labelled controlled supplements.
+
+Feedback repairs a fixture's later Basic dial0 to native legal1 and types its
+known recipient's original poison lookup. Actual rule behavior needs no repair
+after the smallest native case. The final v2 tree remains unchanged through
+shared proof. Existing source authority, Karama immunity and Ecaz-only revival
+are distinct from the selected ordinary tenure/priority policy.
+Final frozen v2 report/logs:
+`/home/quintine/.local/share/dune/checkpoints/20261026-occupied-ecaz-duke-proof-v2/`;
+journeys/HTML: adjacent `20261026-occupied-ecaz-duke-runtime/`.
+Complete rules, wider interactions and final assurance remain open. No saved-
+room/D1/server, reset/restart, deployment or recovery operation occurs. DOM/
+hydration, browser/mobile and exact-revision deployed acceptance remain due.
+[Source and provisional boundaries](HOMEWORLD_ECAZ_DUKE.md#source-and-selected-ordinary-lifecycle--26-october-2026).
+
## 26 October 2026 — Native early Truthtrance promises
**Prototyped / Partial.** Bare registered native factions with selected decks can answer current-turn
diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md
index c288a5fb..a4e7031b 100644
--- a/docs/RULE_DECISIONS.md
+++ b/docs/RULE_DECISIONS.md
@@ -1,5 +1,20 @@
# Rule decision index
+## Occupied Ecaz living Duke grant — 26 October 2026
+
+Connect the printed occupied-face living Duke grant through existing stable
+qualification; the user's Ecaz-only revival ruling still overrides its foreign-
+revival text. Under the 14 October recommended-policy authority, select one
+recipient per turn/qualification, one battle, unused turn-end expiry and no
+restoration of an old holder. A sole recipient receives him automatically;
+two eligible recipients require the occupier's choice at an uncommitted native
+boundary. Loss of occupation/recipient ends only this tenure. Native living
+Ecaz/Moritani acquisition respects occupied priority; Ecaz's independent revival
+remains distinct. No Karama response is created for this immune Homeworld
+benefit. This is visibly provisional tenure/priority composition, not a new
+publisher example. Captive/Ghola/concealed transfer remains guarded.
+[Source, policy and evidence](HOMEWORLD_ECAZ_DUKE.md#source-and-selected-ordinary-lifecycle--26-october-2026).
+
## Native early promises and owned CHOAM opening — 26 October 2026
Extend both current-turn shipment and physical ground-movement questions to
diff --git a/game/bots.ts b/game/bots.ts
index 1f5543d5..dfaff12b 100644
--- a/game/bots.ts
+++ b/game/bots.ts
@@ -1213,6 +1213,9 @@ function policyActions(g: GameView): Action[] {
if (!(me.bot ?? me.autopilot) || g.status === 'finished') return [];
if (g.status === 'lobby') return me.ready ? [] : [{ type: 'ready' }];
if (g.homeworldTupileBlocked) return [];
+ const dukeGrant = g.homeworldEcazDuke;
+ if (dukeGrant && dukeGrant.occupier === me.id && !dukeGrant.blocked && dukeGrant.event && dukeGrant.recipients.length)
+ return [{ type: 'homeworldDuke', event: dukeGrant.event, recipient: dukeGrant.recipients[0].id }];
if (g.decision?.kind === 'homeworldTupileCleanup' || g.homeworldTupileCleanup)
return tupileCleanupActions(g);
if (!g.decision && !g.response && g.richeseBidding?.stage === 'cacheOffer' && g.richeseBidding.offerBlocked)
diff --git a/game/duke-vidal.ts b/game/duke-vidal.ts
index e4e45542..a608453b 100644
--- a/game/duke-vidal.ts
+++ b/game/duke-vidal.ts
@@ -1,7 +1,7 @@
import type { Leader } from './cards';
export const DUKE_VIDAL_ID = 'duke-vidal';
-export type DukeSource = 'moritani' | 'ecaz' | 'ecazNexus' | 'ally';
+export type DukeSource = 'moritani' | 'ecaz' | 'ecazNexus' | 'ally' | 'homeworldEcaz';
export type DukeState = {
leader: Leader;
controller: string | null;
@@ -68,7 +68,7 @@ function validUnderlay(state: Pick turn || !['moritani', 'ecaz', 'ecazNexus', 'ally'].includes(state.source!))
+ state.acquiredTurn! > turn || !['moritani', 'ecaz', 'ecazNexus', 'ally', 'homeworldEcaz'].includes(state.source!))
throw new Error('Duke Vidal lost his underlying custody.');
}
@@ -210,7 +210,7 @@ export function acquireDuke(
validUnderlay(state, turn);
if (typeof controller !== 'string' || !controller.trim())
throw new Error('Choose a player to control Duke Vidal.');
- if (!['moritani', 'ecaz', 'ecazNexus', 'ally'].includes(source))
+ if (!['moritani', 'ecaz', 'ecazNexus', 'ally', 'homeworldEcaz'].includes(source))
throw new Error('Choose a valid source of Duke Vidal custody.');
if (state.leader.dead && source !== 'ecazNexus')
throw new Error('Duke Vidal is in the Tanks and cannot be acquired by this source.');
@@ -261,12 +261,14 @@ export function consumeDuke(state: DukeState): DukeState {
/**
* End-turn custody only. Ecaz's ordinary Ambassador acquisition lasts until
* battle use; Ecaz Nexus Cunning and the Basic Ambassador ally loan last this
- * turn. The prototype sets an unused loan aside, pending return-policy refinement.
+ * turn. Occupied Ecaz grants likewise expire unused, with a new choice on a
+ * continuing qualified occupation next turn. The prototype sets an unused loan
+ * aside, pending return-policy refinement.
*/
export function expireDuke(state: DukeState, turn: number): DukeState {
validTurn(turn);
if (
- (state.source === 'moritani' || state.source === 'ecazNexus' || state.source === 'ally') &&
+ (state.source === 'moritani' || state.source === 'ecazNexus' || state.source === 'ally' || state.source === 'homeworldEcaz') &&
state.acquiredTurn !== null &&
state.acquiredTurn <= turn &&
!state.leader.dead &&
@@ -290,6 +292,7 @@ export const DUKE_VIDAL_RULES = Object.freeze({
'Moritani may use Duke Vidal for one battle. An unused living, uncaptured Duke is set aside at the end of the turn; Moritani must qualify again on a later turn to acquire him again.',
'Karama may prevent Moritani from acquiring Duke Vidal. It does not strip him from Moritani once already controlled and does not cancel his ordinary battle use.',
'The Ecaz Ambassador may acquire Duke Vidal when he is not dead, captured or a ghola. Ecaz keeps him until battle use or until Moritani takes him. The Ambassador’s separate alliance choice can permit a loan to the faction that has just allied with Ecaz for that turn.',
+ 'Occupied Ecaz reserves the living Duke for its occupier or reciprocal ally. A sole recipient receives him automatically; an allied occupier chooses at an otherwise quiet boundary before either Battle Plan seals. The provisional grant permits one battle and expires unused at turn end; occupation loss ends its own tenure without restoring an old holder. Only Ecaz may revive him.',
'Battle death sends the same disc to the Tanks and preserves its death history. Only Ecaz may revive Duke Vidal, including when using the Ghola Treachery Card. His printed revival price is five spice, distinct from his battle strength of six, and this revival does not require other leaders to be dead.',
'Ecaz can begin ordinary leader revival when five of its leaders are in the Tanks, counting Duke Vidal, even if it still has another living leader. These special rules do not create an extra native leader or an extra Traitor card.',
]),
diff --git a/game/engine.ts b/game/engine.ts
index be909ca9..937fc1d0 100644
--- a/game/engine.ts
+++ b/game/engine.ts
@@ -128,6 +128,7 @@ import { beginHomeworldOccupiedIncome, quoteHomeworldOccupiedIncomeChoice, homew
import { createOccupiedPercentageState, quoteOccupiedPercentageSource, allocateOccupiedPercentageReceipt, validateOccupiedPercentageState, type OccupiedPercentageState, type OccupiedPercentageSource } from './homeworld-occupied-percentage';
import { quoteOccupiedBiddingAuthority, requireOccupiedBiddingController, requireOccupiedBiddingBonusRecipient } from './homeworld-occupied-bidding';
import { quoteStableHomeworldOccupation } from './homeworld-stable-occupation';
+import { quoteHomeworldEcazDuke, createHomeworldEcazDukeReceipt, quoteHomeworldEcazDukeLifecycle, type HomeworldEcazDukeReceipt } from './homeworld-ecaz-duke';
import { createOccupiedTupileState, syncOccupiedTupileLease, quoteOccupiedTupileAuthority, quoteOccupiedTupileCleanup, completeOccupiedTupileCleanup, validateOccupiedTupileState, type OccupiedTupileState } from './homeworld-occupied-tupile';
import { ecazHomeworldVictoryProgress, type EcazHomeworldVictoryProgress } from './ecaz-homeworld-victory';
import { quoteGrummanCollection, quoteGrummanCollectionAction } from './grumman-collection';
@@ -2220,6 +2221,8 @@ export type Game = {
/** Null/absent disables the module; custody is installed at force placement. */
homeworlds?: { custody: HomeworldCustody | null; historyVersion?: 1 } | null;
homeworldOccupationHistory?: HomeworldOccupationHistory;
+ /** Actual occupied-Ecaz recipient choices; historical grants never restore a holder. */
+ homeworldEcazDukeGrants?: HomeworldEcazDukeReceipt[];
/** Fresh development occupation entry only; never inferred for an older Homeworld save. */
homeworldOccupationPreview?: true;
/** Fresh printed Ecaz high-card victory, separate from ordinary stronghold points. */
@@ -18487,7 +18490,7 @@ function ambassadorEffectBlock(
return 'Assigning the Ecaz Ambassador effect to an ally is still being implemented.';
if (g.players.some((p) => p.leaders.some((l) => l.id === DUKE_VIDAL_ID)))
return 'Duke Vidal is unavailable for this acquisition.';
- return ecazDukeAcquisitionBlock(g, owner.id);
+ return occupiedEcazDukePriority(g) ?? ecazDukeAcquisitionBlock(g, owner.id);
}
if (
effect === 'ixians' &&
@@ -20507,7 +20510,7 @@ function ecazAmbassadorLoanBlock(g: Game, owner: Player, entrant: Player) {
duke.leader.dead || duke.leader.concealed ||
duke.leader.capturedBy || duke.leader.gholaBy)
return 'Duke Vidal is unavailable for this loan.';
- return duke.controller === owner.id ? null : ecazDukeAcquisitionBlock(g, owner.id);
+ return occupiedEcazDukePriority(g) ?? (duke.controller === owner.id ? null : ecazDukeAcquisitionBlock(g, owner.id));
}
function ecazAllianceIntegrity(g: Game) {
const entry = g.pendingAmbassador;
@@ -20860,6 +20863,7 @@ function decideAmbassador(g: Game, p: Player, action: Action) {
'This entrant cannot trigger the Ecaz Ambassador.',
);
try {
+ requireRule(!occupiedEcazDukePriority(g), occupiedEcazDukePriority(g) ?? 'Duke Vidal is reserved by occupied Ecaz.');
acquiredDuke = quoteEcazDukeAcquisition(g, owner.id);
} catch (error) {
if (error instanceof EcazDukeAcquisitionError)
@@ -22562,7 +22566,7 @@ function projectedBattleOrder(g: Game) {
/** Evaluate only at the committed end of movement, using the normal battle geometry. */
function offerMoritaniDuke(g: Game) {
const owner = byFaction(g, 'moritani');
- if (!owner || g.dukeAcquisitionTurn === g.turn) return false;
+ if (!owner || g.dukeAcquisitionTurn === g.turn || occupiedEcazDukePriority(g)) return false;
g.dukeAcquisitionTurn = g.turn;
g.dukeVidal ??= createDukeVidal();
const duke = g.dukeVidal;
@@ -23920,6 +23924,54 @@ function observeOccupation(g: Game, cause: 'change' | 'turnStart' | 'turnEnd' =
: `homeworld-turn-${g.turn}-${cause}`));
syncTupileSlots(g);
}
+/** A new Duke grant may precede the first sealed plan, but never changes a
+ * committed parent or a chosen physical leader in an existing Battle Plan. */
+function homeworldEcazDukeBoundaryBlocked(g: Game): boolean {
+ const freshBattle = g.battle && !g.battle.revealed && !Object.keys(g.battle.plans).length;
+ return !!g.pendingGuildTransport || nexusCleanPlayBlocked(freshBattle ? { ...g, battle: null } : g);
+}
+function occupiedEcazDukePriority(g: Game): string | null {
+ return homeworldRule(() => quoteHomeworldEcazDuke(g)).nativeAcquisitionBlocked
+ ? 'Occupied Ecaz reserves Duke Vidal for its occupier or reciprocal ally.' : null;
+}
+function homeworldEcazDukeOffer(g: Game, viewer: string) {
+ const quote = homeworldRule(() => quoteHomeworldEcazDuke(g));
+ if (quote.owner !== viewer) return null;
+ const blocked = quote.blocked ?? (homeworldEcazDukeBoundaryBlocked(g)
+ ? 'Finish the current interaction or committed Battle Plans before choosing Duke Vidal.' : null);
+ return { event: blocked ? null : quote.event, blocked, occupier: quote.owner as string | null,
+ recipients: quote.recipients.map(id => ({ id, name: getPlayer(g, id).name })) };
+}
+function claimHomeworldEcazDuke(g: Game, actor: string, event: unknown, recipient: unknown) {
+ requireRule(!homeworldEcazDukeBoundaryBlocked(g),
+ 'Finish the current interaction or committed Battle Plans before choosing Duke Vidal.');
+ requireRule(typeof event === 'string' && typeof recipient === 'string',
+ 'Choose the original occupied Ecaz grant and an eligible recipient.');
+ const receipt = homeworldRule(() => createHomeworldEcazDukeReceipt(g, actor, event, recipient));
+ g.dukeVidal = homeworldRule(() => acquireDuke(g.dukeVidal!, recipient, g.turn, 'homeworldEcaz'));
+ (g.homeworldEcazDukeGrants ??= []).push(receipt);
+ log(g, `${getPlayer(g, actor).name} granted the living Duke Vidal to ${getPlayer(g, recipient).name} through occupied Ecaz. This provisional grant permits one battle and ends unused at turn end; only Ecaz may revive him.`,
+ { faction: getPlayer(g, actor).faction, name: 'Occupied Ecaz Duke grant' });
+}
+function reconcileHomeworldEcazDuke(g: Game) {
+ if (g.status !== 'playing' || !g.homeworldOccupationPreview || !byFaction(g, 'ecaz') || homeworldEcazDukeBoundaryBlocked(g)) return;
+ const lifecycle = homeworldRule(() => quoteHomeworldEcazDukeLifecycle(g));
+ if (lifecycle.blocked) return;
+ if (lifecycle.release && g.dukeVidal?.source === 'homeworldEcaz') {
+ g.dukeVidal = consumeDuke(g.dukeVidal);
+ log(g, `The occupied Ecaz Duke grant ended: ${lifecycle.reason ?? 'its original entitlement ended'}. Duke Vidal is set aside without restoring an earlier controller.`);
+ }
+ const quote = homeworldRule(() => quoteHomeworldEcazDuke(g));
+ const duke = g.dukeVidal;
+ if (quote.nativeAcquisitionBlocked && duke?.controller && duke.source !== 'homeworldEcaz' &&
+ !duke.leader.dead && !duke.leader.capturedBy && !duke.leader.gholaBy && !duke.leader.concealed) {
+ g.dukeVidal = consumeDuke(duke);
+ log(g, 'Occupied Ecaz displaced Duke Vidal’s ordinary faction grant; the occupier chooses its eligible recipient.');
+ }
+ const available = homeworldRule(() => quoteHomeworldEcazDuke(g));
+ if (!available.blocked && available.event && available.owner && available.recipients.length === 1)
+ claimHomeworldEcazDuke(g, available.owner, available.event, available.recipients[0]);
+}
function homeworldHistoryIntegrity(g: Game) {
const initialized = g.homeworlds?.historyVersion === 1;
requireRule(initialized === (g.homeworldOccupationHistory !== undefined),
@@ -29835,7 +29887,9 @@ function finishResponse(g: Game, canceled: boolean, settlement?: BureaucratSettl
g.phase === 5 && g.dukeAcquisitionTurn === g.turn && g.dukeVidal,
'This Duke Vidal acquisition is no longer current.',
);
- if (!canceled && !g.dukeVidal.leader.dead) {
+ if (!canceled && occupiedEcazDukePriority(g)) {
+ log(g, 'Occupied Ecaz retains priority over this Moritani Duke acquisition. No native grant occurred.');
+ } else if (!canceled && !g.dukeVidal.leader.dead) {
g.dukeVidal = acquireDuke(g.dukeVidal, response.owner, g.turn, 'moritani');
log(g, `${getPlayer(g, response.owner).name} acquired Duke Vidal for one battle this turn.`);
} else if (!canceled) {
@@ -34363,6 +34417,7 @@ export function applyAction(state: Game, id: string, action: Action): Game {
reconcileShipmentPromises(g, { actor: id, action, phase: state.phase });
settleAutomaticContinuations(g);
observeOccupation(g);
+ reconcileHomeworldEcazDuke(g);
discoveryIntegrity(g);
greatMakerIntegrity(g);
nexusChoamTradeIntegrity(g);
@@ -34409,6 +34464,7 @@ function finishActionContinuations(g: Game) {
if (g.pendingNexusEmperorSellerPurchase || g.pendingAmbassador?.nexusPurchase) return;
if (pendingNexusTraitors(g)) return;
observeOccupation(g);
+ reconcileHomeworldEcazDuke(g);
resumeHomeworldRevivalReturn(g);
resumeHomeworldVictoryReturn(g);
resumeMarketGhola(g);
@@ -34620,6 +34676,7 @@ export function normalizeAutomaticGame(state: Game): Game {
ecazCollectionIntegrity(state);
ecazAllianceIntegrity(state);
const g = structuredClone(state);
+ reconcileHomeworldEcazDuke(g);
syncTupileSlots(g);
prepareTupileCleanup(g);
if (g.pendingHomeworldTupileCleanup || tupileSourceBlock(g)) return g;
@@ -34860,6 +34917,10 @@ function applyActionInner(
if (t === 'nexusChoamTrade') { playNexusChoamTrade(g, p, action); return g; }
if (t === 'nexusChoamBetrayal') { playNexusChoamBetrayal(g, p, action); return g; }
if (t === 'nexusEcazInquiry') { playNexusEcazInquiry(g, p, action); return g; }
+ if (t === 'homeworldDuke') {
+ requireRule(Object.keys(action).sort().join(',') === 'event,recipient,type', 'Choose only the current occupied Ecaz grant and recipient.');
+ claimHomeworldEcazDuke(g, p.id, action.event, action.recipient); return g;
+ }
if (t === 'nexusEcazDuke') { playNexusEcazDuke(g, p, action); return g; }
if (t === 'nexusEmperorRevive') { playNexusEmperorRevive(g,p,action); return g; }
if (t === 'nexusEmperorPurchase') { playNexusEmperorPurchase(g,p,action); return g; }
@@ -38880,6 +38941,7 @@ export function viewGame(state: Game, id: string) {
advanced: g.advanced,
advancedPreview: g.advancedPreview === true,
dukeVidal: sharedDuke,
+ homeworldEcazDuke: homeworldEcazDukeOffer(g, id),
moritaniTerror: g.moritaniTerror
? projectTerror(g.moritaniTerror, me.faction === 'moritani')
: null,
diff --git a/game/homeworld-ecaz-duke.ts b/game/homeworld-ecaz-duke.ts
new file mode 100644
index 00000000..c4442ebc
--- /dev/null
+++ b/game/homeworld-ecaz-duke.ts
@@ -0,0 +1,153 @@
+import type { Game } from './engine';
+import { acquireDuke, type DukeState } from './duke-vidal';
+import { HomeworldCustodyError } from './homeworld-custody';
+import {
+ quoteStableHomeworldOccupation, type StableHomeworldOccupationContext,
+} from './homeworld-stable-occupation';
+
+export const HOMEWORLD_ECAZ_DUKE_SOURCE = 'homeworldEcaz' as const;
+/** One choice, even after its one battle, departure or an alliance change.
+ * This receipt stores no prior controller to restore and grants no revival. */
+export type HomeworldEcazDukeReceipt = {
+ turn: number;
+ qualification: string;
+ occupier: string;
+ recipient: string;
+};
+export type HomeworldEcazDukeContext = StableHomeworldOccupationContext & Pick & {
+ dukeVidal?: DukeState;
+ homeworldEcazDukeGrants?: HomeworldEcazDukeReceipt[];
+};
+export type HomeworldEcazDukeQuote = {
+ event: string | null;
+ owner: string | null;
+ qualification: string | null;
+ recipients: string[];
+ /** Printed occupation priority persists after the one-battle grant is used. */
+ nativeAcquisitionBlocked: boolean;
+ claimed: HomeworldEcazDukeReceipt | null;
+ blocked: string | null;
+};
+export type HomeworldEcazDukeLifecycleQuote = {
+ release: boolean;
+ owner: string | null;
+ reason: 'turnEnded' | 'occupationEnded' | 'occupationChanged' | 'recipientChanged' | 'used' | 'dead' | null;
+ blocked: string | null;
+};
+export class HomeworldEcazDukeError extends HomeworldCustodyError {}
+const text = (value: unknown): value is string => typeof value === 'string' && value.trim().length > 0;
+const unavailable = 'Duke Vidal is unavailable for the occupied Ecaz grant.';
+const pendingCustody = 'Occupied Ecaz Duke transfer from captured, concealed or Ghola custody is not yet supported.';
+function requireGrant(condition: unknown, message: string): asserts condition {
+ if (!condition) throw new HomeworldEcazDukeError(message);
+}
+export const homeworldEcazDukeEvent = (turn: number, qualification: string, occupier: string) =>
+ JSON.stringify(['homeworldEcazDuke', turn, qualification, occupier]);
+
+/** Validate only the small recorded choice against original source identities.
+ * Historical recipients need not remain allied today. No receipt or history is
+ * synthesized for old games, and no past entitlement is awarded by this read. */
+export function validateHomeworldEcazDukeGrants(g: HomeworldEcazDukeContext): void {
+ if (g.homeworldEcazDukeGrants === undefined) return;
+ requireGrant(Array.isArray(g.homeworldEcazDukeGrants) && Number.isSafeInteger(g.turn) && g.turn >= 1,
+ 'Occupied Ecaz Duke grants need their original current turn.');
+ const native = g.players.find(p => p.faction === 'ecaz');
+ const keys = new Set();
+ for (const receipt of g.homeworldEcazDukeGrants) {
+ requireGrant(receipt && typeof receipt === 'object' && !Array.isArray(receipt) &&
+ Object.keys(receipt).sort().join(',') === 'occupier,qualification,recipient,turn' &&
+ Number.isSafeInteger(receipt.turn) && receipt.turn >= 1 && receipt.turn <= g.turn &&
+ text(receipt.qualification) && text(receipt.occupier) && text(receipt.recipient) &&
+ receipt.occupier !== native?.id &&
+ g.players.some(p => p.id === receipt.occupier) && g.players.some(p => p.id === receipt.recipient),
+ 'Occupied Ecaz Duke lost its original choice.');
+ const key = JSON.stringify([receipt.turn, receipt.qualification]);
+ requireGrant(!keys.has(key), 'Occupied Ecaz cannot choose Duke twice from the same turn and qualification.');
+ keys.add(key);
+ const source = g.homeworldOccupationHistory?.sources.find(row => row.event === receipt.qualification);
+ requireGrant(native && source && source.cause !== 'setup' && source.turn <= receipt.turn,
+ 'Occupied Ecaz Duke lost its original qualification source.');
+ const snapshot: unknown = JSON.parse(g.homeworldOccupationHistory!.snapshots[source.snapshot]);
+ requireGrant(Array.isArray(snapshot) && snapshot.some(row => Array.isArray(row) &&
+ row[0] === `homeworld:${native.id}` && Array.isArray(row[1]) && row[1].some(group =>
+ Array.isArray(group) && group[0] === receipt.occupier && group[1] + group[2] > 0)),
+ 'Occupied Ecaz Duke qualification does not contain its original occupier.');
+ }
+}
+
+/** The printed occupied card overrides native Ecaz/Moritani acquisition.
+ * Provisional ordinary lifecycle: one recipient choice per turn/qualification,
+ * one battle, no same-turn regrant, and no restoration of an earlier holder.
+ * The user-selected Ecaz-only revival rule remains entirely separate. */
+export function quoteHomeworldEcazDuke(g: HomeworldEcazDukeContext): HomeworldEcazDukeQuote {
+ const none: HomeworldEcazDukeQuote = { event: null, owner: null, qualification: null,
+ recipients: [], nativeAcquisitionBlocked: false, claimed: null, blocked: null };
+ if (g.status !== 'playing' || !g.homeworldOccupationPreview || !g.players.some(p => p.faction === 'ecaz')) return none;
+ requireGrant(Number.isSafeInteger(g.turn) && g.turn >= 1, 'Occupied Ecaz Duke needs the current positive turn.');
+ const occupation = quoteStableHomeworldOccupation(g, 'ecaz');
+ if (occupation.blocked) return { ...none, blocked: occupation.blocked };
+ validateHomeworldEcazDukeGrants(g);
+ const source = occupation.entitlement;
+ if (!source) return none;
+ const recipients = [source.occupier, ...(source.ally ? [source.ally] : [])];
+ const claimed = g.homeworldEcazDukeGrants?.find(receipt =>
+ receipt.turn === g.turn && receipt.qualification === source.qualification) ?? null;
+ const quote: HomeworldEcazDukeQuote = {
+ event: homeworldEcazDukeEvent(g.turn, source.qualification, source.occupier),
+ owner: source.occupier, qualification: source.qualification, recipients,
+ nativeAcquisitionBlocked: true, claimed: claimed ? { ...claimed } : null, blocked: null,
+ };
+ if (claimed) return { ...quote, blocked: 'Occupied Ecaz already chose its Duke recipient for this turn and qualification.' };
+ const duke = g.dukeVidal;
+ if (!duke) return { ...quote, blocked: unavailable };
+ if (duke.leader.capturedBy || duke.leader.gholaBy || duke.leader.concealed || duke.captureVisibility)
+ return { ...quote, blocked: pendingCustody };
+ if (duke.leader.dead) return { ...quote, blocked: 'Occupied Ecaz grants no revival; only Ecaz may revive Duke Vidal.' };
+ if (duke.leader.usedAt) return { ...quote, blocked: 'Duke Vidal has already been used in battle this turn.' };
+ try {
+ // Reuse the original living ordinary-disc/underlay validation. This quote
+ // neither commits the provisional ally source nor changes the live disc.
+ acquireDuke(duke, source.occupier, g.turn, 'ally');
+ } catch {
+ return { ...quote, blocked: unavailable };
+ }
+ return quote;
+}
+
+export function createHomeworldEcazDukeReceipt(g: HomeworldEcazDukeContext, actor: string,
+ event: string, recipient: string): HomeworldEcazDukeReceipt {
+ const quote = quoteHomeworldEcazDuke(g);
+ requireGrant(quote.event && quote.qualification && quote.owner === actor && quote.event === event &&
+ !quote.blocked && quote.recipients.includes(recipient),
+ quote.blocked ?? 'Choose the current occupied Ecaz Duke recipient from the original offer.');
+ return { turn: g.turn, qualification: quote.qualification, occupier: actor, recipient };
+}
+
+/** Call at an engine-owned settled boundary, after original battle/arrival
+ * parents. Release means set the same disc aside, never restore native tenure.
+ * Foreign custody is explicitly pending rather than erased by lease cleanup. */
+export function quoteHomeworldEcazDukeLifecycle(g: HomeworldEcazDukeContext): HomeworldEcazDukeLifecycleQuote {
+ const none: HomeworldEcazDukeLifecycleQuote = { release: false, owner: null, reason: null, blocked: null };
+ const duke = g.dukeVidal;
+ if (duke?.source !== HOMEWORLD_ECAZ_DUKE_SOURCE) return none;
+ validateHomeworldEcazDukeGrants(g);
+ const receipt = g.homeworldEcazDukeGrants?.findLast(receipt =>
+ receipt.turn === duke.acquiredTurn && receipt.recipient === duke.controller);
+ requireGrant(receipt && duke.controller, 'The active occupied Ecaz Duke lost its original grant.');
+ const own = { ...none, owner: duke.controller };
+ if (duke.leader.capturedBy || duke.leader.gholaBy || duke.leader.concealed || duke.captureVisibility)
+ return { ...own, blocked: pendingCustody };
+ const release = (reason: NonNullable): HomeworldEcazDukeLifecycleQuote =>
+ ({ ...own, release: true, reason });
+ if (duke.leader.dead) return release('dead');
+ if (duke.leader.usedAt) return release('used');
+ if (duke.acquiredTurn !== g.turn) return release('turnEnded');
+ const occupation = quoteStableHomeworldOccupation(g, 'ecaz');
+ if (occupation.blocked) return { ...own, blocked: occupation.blocked };
+ const source = occupation.entitlement;
+ if (!source) return release('occupationEnded');
+ if (source.qualification !== receipt.qualification || source.occupier !== receipt.occupier)
+ return release('occupationChanged');
+ if (duke.controller !== source.occupier && duke.controller !== source.ally) return release('recipientChanged');
+ return own;
+}
diff --git a/game/nexus-ecaz-duke.ts b/game/nexus-ecaz-duke.ts
index d5ff621f..b26df2f1 100644
--- a/game/nexus-ecaz-duke.ts
+++ b/game/nexus-ecaz-duke.ts
@@ -3,6 +3,7 @@ import type { Game } from './engine';
import { acquireDuke, dukeController, DUKE_VIDAL_ID, type DukeSource } from './duke-vidal';
import { nexusCardMode } from './nexus-cards';
import { nexusCleanPlayBlocked } from './nexus-play-boundary';
+import { quoteHomeworldEcazDuke } from './homeworld-ecaz-duke';
import { mixedE3NexusLeaderSkillsProfile, pairedE3NexusLeaderSkillsProfile, standaloneE3NexusLeaderSkillsProfile } from './leader-skill-profile';
import { mixedE3NexusProfile, pairedE3NexusProfile, standaloneE3NexusProfile } from './nexus-module-profile';
import { nexusCoreProfile } from './faction-module-profile';
@@ -125,6 +126,8 @@ export function quoteNexusEcazDuke(g: Game, owner: string, automaticPending = fa
let blocked: string | null = null;
if (!nexusEcazDukeModeSupported(g) || nexusCardMode('ecaz', holder.faction, roster) !== 'cunning')
blocked = 'Ecaz Nexus Cunning requires a supported original paired or standalone Ecaz Nexus profile.';
+ else if (duke && !duke.leader.dead && quoteHomeworldEcazDuke(g).nativeAcquisitionBlocked)
+ blocked = 'Occupied Ecaz reserves Duke Vidal for its occupier or reciprocal ally.';
else if (holder.ally) blocked = 'Use Ecaz Nexus Cunning while unallied.';
else if (g.status !== 'playing')
blocked = 'Wait until play begins before using Ecaz Nexus Cunning.';
diff --git a/game/reference.ts b/game/reference.ts
index 333f8126..abdd4dd4 100644
--- a/game/reference.ts
+++ b/game/reference.ts
@@ -4979,6 +4979,29 @@ export const RULE_TOPICS: RuleTopic[] = [
},
],
},
+ {
+ id: 'homeworld-ecaz-duke',
+ title: 'Occupied Ecaz: grant Duke Vidal',
+ category: 'Advanced & expansions',
+ coverage: 'Partial',
+ developmentStage: 'Prototyped',
+ summary: 'The qualified occupier of Ecaz grants the living shared Duke to self or a reciprocal ally.',
+ steps: [
+ 'Use the existing original Homeworld qualification. Advanced retains occupied benefits until the occupier’s last force leaves; Basic uses the visibly provisional current-sole policy. A Homeworld benefit cannot be canceled by Karama.',
+ 'When only the occupier is eligible, the native grant settles automatically. With a reciprocal ally, the occupier chooses a recipient in persistent controls at an otherwise quiet boundary before either Battle Plan seals. Finish committed interactions first.',
+ 'The provisional ordinary grant permits one battle and expires unused at turn end. A continuing occupation gives a new recipient choice next turn. A choice is recorded once per turn and original qualification; current used-at history cannot be evaded by leaving and returning.',
+ 'Loss of occupation, a changed qualifier or loss of the selected ally ends only this grant and never restores an old controller. Occupation overrides ordinary native Ecaz/Moritani Duke acquisition; living Nexus Cunning also respects that priority.',
+ 'Only Ecaz may revive Duke Vidal under the user-selected rule, even while Ecaz is occupied. This grant cannot revive him. Captured, concealed or foreign-Ghola transfer remains unfinished. The same shared disc, physical death history and native battle use remain.',
+ ],
+ related: ['homeworlds', 'duke-vidal', 'implementation-checklist'],
+ checklist: [
+ { area: 'Implementation', status: 'Partial', detail: 'Stable source qualification, original recipient receipt, ordinary shared-disc tenure, native priority, automatic sole choice and settled loss/turn/use release are connected. Captive/Ghola transfer remains bounded.', evidence: ['game/homeworld-ecaz-duke.ts', 'game/duke-vidal.ts', 'game/engine.ts'] },
+ { area: 'Player controls', status: 'Partial', detail: 'Owner-only projected labels, persistent provisional guidance and event-bound busy/blocked recipient controls connect through GameTable.', evidence: ['components/homeworld-ecaz-duke.tsx', 'components/game-table.tsx'] },
+ { area: 'AI', status: 'Partial', detail: 'Four existing profiles use only the shared legal recipient descriptor; no strategic refinement or calibration.', evidence: ['game/bots.ts'] },
+ { area: 'Documentation', status: 'Partial', detail: 'Printed grant, user revival override and selected ordinary tenure are distinct from complete Homeworld/Duke acceptance.', evidence: ['docs/HOMEWORLD_ECAZ_DUKE.md', 'docs/RULE_DECISIONS.md', 'docs/DUKE_VIDAL_RULES.md'] },
+ { area: 'Verification', status: 'Partial', detail: 'Controlled pure/controls cases and a separate genuine native occupation source provide scoped evidence. Current frozen counts/runtime limitations are in the source contract; complete modes and final assurance remain open.', evidence: ['tests/homeworld-ecaz-duke-quote.test.ts', 'tests/homeworld-ecaz-duke-controls.test.tsx', 'tests/homeworld-ecaz-duke-engine.test.ts', 'tests/fixture-homeworld-ecaz-duke.ts'] },
+ ],
+ },
{
id: 'discoveries',
title: 'Discoveries: first playable functions',
diff --git a/tests/fixture-homeworld-ecaz-duke.ts b/tests/fixture-homeworld-ecaz-duke.ts
new file mode 100644
index 00000000..0196c3fa
--- /dev/null
+++ b/tests/fixture-homeworld-ecaz-duke.ts
@@ -0,0 +1,278 @@
+import assert from 'node:assert/strict';
+import { createGame, joinGame, newPlayer, viewGame, type Action, type Game, type Plan } from '../game/engine';
+import { homeworldContext } from '../game/homeworld-game';
+import { homeworldForceGroups } from '../game/homeworld-custody';
+import { homeworldShipmentChoice } from '../game/homeworld-shipment-options';
+import { nativeShipmentSources } from '../game/homeworld-options';
+import { DUKE_VIDAL_ID } from '../game/duke-vidal';
+import { actAdvisorPreparation } from './fixture-advisor-preparation';
+import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions';
+
+export type HomeworldEcazDukeStep = { actor: string; action: Action };
+export const actHomeworldEcazDuke = actAdvisorPreparation;
+export const homeworldEcazDukePlayer = (game: Game, id: string) => {
+ const player = game.players.find(player => player.id === id); assert.ok(player); return player;
+};
+export const homeworldEcazDukeClean = (game: Game) => !game.phaseOpening && !game.response &&
+ !game.decision && !game.truthtrance && !game.pendingTreacheryDiscard;
+export function homeworldEcazDukeInventory(game: Game) {
+ const ids = [...game.deck, ...game.discard, ...game.players.flatMap(player => player.hand),
+ ...(game.auction?.cards.slice(game.auction.index + Number(game.currentAuctionSale?.origin === 'normal')) ?? [])]
+ .map(card => card.id).sort();
+ assert.equal(new Set(ids).size, ids.length, 'The original native Treachery Cards retain distinct physical slots'); return ids;
+}
+export const homeworldEcazDukePhysical = (game: Game) => ({ homeworlds: game.homeworlds,
+ players: game.players.map(player => ({ id: player.id, spice: player.spice, bribes: player.bribes,
+ forces: player.forces, elites: player.elites, reserves: player.reserves, tanks: player.tanks,
+ leaders: player.leaders, shipped: player.shipped, moved: player.moved })) });
+
+/** Resolve only native controls. Quiet unsealed Battle Plans and Duke choice
+ * remain owned; a bot cannot select the newly offered grant for the source. */
+export function nextHomeworldEcazDukeStep(game: Game): HomeworldEcazDukeStep | null {
+ if (game.truthtrance || game.pendingTreacheryDiscard) return null;
+ if (game.phaseOpening) {
+ const actor = game.players.find(player => !game.phaseOpening!.passed.includes(player.id)); assert.ok(actor);
+ return { actor: actor.id, action: { type: 'ready' } };
+ }
+ if (game.response) {
+ const actor = game.players.find(player => !game.response!.passed.includes(player.id)); assert.ok(actor);
+ return { actor: actor.id, action: { type: 'passResponse' } };
+ }
+ const decision = game.decision;
+ if (decision?.kind === 'ecazPlacement') return { actor: decision.player, action: { type: 'decision', decline: true } };
+ if (decision?.kind === 'guildTiming') return { actor: decision.player, action: { type: 'decision', take: false } };
+ if (decision?.kind === 'homeworldShipmentGuild')
+ return { actor: decision.player, action: { type: 'decision', event: decision.event, allow: true } };
+ if (decision?.kind === 'guildShipment') return { actor: decision.player, action: { type: 'decision', allow: true } };
+ if (decision?.kind === 'auctionPayment') return { actor: decision.player, action: { type: 'decision', karama: false } };
+ if (decision?.kind === 'fullPlanOffer') return { actor: decision.player, action: { type: 'decision', decline: true } };
+ if (!decision && game.status === 'setup' && game.setupStage === 'forces' &&
+ homeworldEcazDukePlayer(game, 'ecaz').reserves === 20)
+ return { actor: 'ecaz', action: { type: 'ecazSetup', placements: { 'imperial_basin:10': 6 } } };
+ if (!decision && game.battle) {
+ const battle = game.battle;
+ if (battle.preLeader && !battle.preLeader.closed) {
+ const actor = [battle.attacker, battle.defender].find(id => !battle.preLeader!.ready.includes(id)); assert.ok(actor);
+ return { actor, action: { type: 'battlePreparationReady', event: battle.preLeader.event } };
+ }
+ if (battle.preparation) return { actor: battle.preparation.owner, action: { type: 'declineBattlePower' } };
+ if (!battle.revealed) return null;
+ const voters = viewGame(game, game.host).battle!.traitorVoters;
+ const voter = voters.find(id => battle.traitorCalls[id] === undefined);
+ if (voter) return { actor: voter, action: { type: 'traitorCall', call: false } };
+ if (battle.outcomeWindow && !battle.outcomeWindow.closed) {
+ const actor = [battle.attacker, battle.defender].find(id => !battle.outcomeWindow!.ready.includes(id)); assert.ok(actor);
+ return { actor, action: { type: 'battleOutcomeReady', event: battle.outcomeWindow.event } };
+ }
+ }
+ if (!decision && game.status === 'playing') {
+ if (game.phase === 0 && game.stormPending === null) {
+ const actor = game.stormDialers.find(id => game.stormDials[id] === undefined);
+ if (actor) return { actor, action: { type: 'stormDial', amount: game.turn === 1 ? 0 : 1 } };
+ }
+ if (game.phase === 3 && game.auction) return { actor: game.auction.active, action: { type: 'passBid' } };
+ if (game.phase === 5) return { actor: game.active!, action: { type: 'endMovement' } };
+ if ([1, 2, 4, 7, 8].includes(game.phase) || game.phase === 0 && game.stormPending !== null) {
+ const actor = game.players.find(player => !game.ready.includes(player.id));
+ if (actor) return { actor: actor.id, action: { type: 'ready' } };
+ }
+ }
+ return nextStrongholdFactionsNativeStep(game);
+}
+export function advanceHomeworldEcazDuke(game: Game, until: (state: Game) => boolean,
+ actions: HomeworldEcazDukeStep[] = []): Game {
+ for (let count = 0; count < 2200; count++) {
+ if (until(game)) return game;
+ assert.notEqual(game.status, 'finished');
+ const step = nextHomeworldEcazDukeStep(game);
+ assert.ok(step, 'Choose the original Battle Plan or Duke recipient before continuing');
+ game = actHomeworldEcazDuke(game, step.actor, step.action, actions);
+ }
+ throw Error('The genuine occupied-Ecaz chronology did not reach its finite boundary');
+}
+function planReady(game: Game) {
+ return homeworldEcazDukeClean(game) && !!game.battle && !game.battle.preparation &&
+ (!game.battle.preLeader || game.battle.preLeader.closed) && !game.battle.revealed;
+}
+function settleNativeBattle(game: Game, plans: Record, actions: HomeworldEcazDukeStep[]) {
+ assert.ok(planReady(game)); const battle = game.battle!;
+ for (const actor of [battle.attacker, battle.defender])
+ game = actHomeworldEcazDuke(game, actor, { type: 'battlePlan', ...plans[actor] }, actions);
+ return advanceHomeworldEcazDuke(game, state => !state.battle && homeworldEcazDukeClean(state), actions);
+}
+
+/** Genuine two-turn source: paid Dune garrison, deferred real funding, native
+ * worm alliance, typed invasion and a real World victory. All physical sources
+ * and later Duke battle are earned; only unopened card/Spice orders are chosen. */
+export function createHomeworldEcazDukeFixture(options: { advanced?: boolean; allied?: boolean } = {}) {
+ const advanced = options.advanced ?? false, allied = options.allied ?? false;
+ const ecaz = 'ecaz', occupier = 'emperor', guild = 'guild', opponent = 'atreides', ally = allied ? guild : null;
+ let game = createGame('HOMEWORLDECAZDUKE', newPlayer(ecaz, 'Ecaz', 'ecaz'), advanced, ['ecaz']);
+ for (const faction of [occupier, guild, opponent] as const) joinGame(game, newPlayer(faction, faction, faction));
+ const initial = structuredClone(game), actions: HomeworldEcazDukeStep[] = [];
+ const act = (actor: string, action: Action) => { game = actHomeworldEcazDuke(game, actor, action, actions); };
+ const reach = (until: (state: Game) => boolean) => { game = advanceHomeworldEcazDuke(game, until, actions); };
+ const transition = (run: () => void) => {
+ const before = structuredClone(game), start = actions.length; run();
+ return { before, steps: structuredClone(actions.slice(start)), after: structuredClone(game) };
+ };
+ const enableHomeworlds = transition(() => { act(ecaz, { type: 'homeworlds', enabled: true }); });
+ for (const player of game.players) act(player.id, { type: 'ready' });
+ const lobby = structuredClone(game);
+ act(ecaz, advanced ? { type: 'start', advancedPreview: true } : { type: 'start' });
+ const setup = structuredClone(game);
+ assert.equal(game.status, 'setup'); assert.equal(game.advanced, advanced); assert.ok(game.homeworlds);
+ assert.deepEqual(game.expansions, ['ecaz']);
+ assert.ok(!game.nexusCards && !game.leaderSkills && !game.techTokens && !game.strongholdCards &&
+ !game.discoveryEnabled && !game.ecazTreachery);
+ assert.ok(game.players.every(player => !player.hand.length));
+ const originalInventory = homeworldEcazDukeInventory(game); assert.equal(originalInventory.length, 33);
+ const poisons = game.deck.filter(card => card.kind === 'poison' && !card.effect).slice(0, 2);
+ const projectile = game.deck.find(card => card.kind === 'projectile' && !card.effect),
+ snooper = game.deck.find(card => card.kind === 'snooper');
+ assert.equal(poisons.length, 2); assert.ok(projectile && snooper);
+ const selected = [snooper, poisons[0], poisons[1], projectile];
+ const dealOrder = selected.map((card, position) => {
+ const from = game.deck.findIndex(candidate => candidate.id === card.id); assert.ok(from >= position);
+ [game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
+ return { position, from, card: card.id };
+ });
+ const beforeStartingDeal = structuredClone(game);
+ reach(state => state.status === 'playing');
+ const afterSetup = structuredClone(game);
+ assert.deepEqual(game.players.flatMap(player => player.hand.map(card => card.id)), selected.map(card => card.id));
+ assert.equal(game.dukeVidal?.leader.id, DUKE_VIDAL_ID); assert.equal(game.dukeVidal?.leader.dead, false);
+ assert.equal(game.dukeVidal?.controller, null);
+ const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort();
+ const spiceOrder: { position: number; from: number; card: Game['spiceDeck'][number] }[] = [];
+ const orderBlow = (worm: boolean, position: number) => {
+ const from = game.spiceDeck.findIndex((card, index) => index >= position && (worm
+ ? 'worm' in card && !card.greatMaker && !card.suppressed
+ : 'territory' in card && !card.discovery));
+ assert.ok(from >= position); const card = game.spiceDeck.splice(from, 1)[0];
+ game.spiceDeck.splice(position, 0, card); spiceOrder.push({ position, from, card: structuredClone(card) });
+ };
+ const firstTurnBlows = advanced ? 2 : 1;
+ for (let index = 0; index < firstTurnBlows; index++) orderBlow(false, index);
+ orderBlow(true, firstTurnBlows);
+ for (let index = 0; index < firstTurnBlows; index++) orderBlow(false, firstTurnBlows + 1 + index);
+ assert.deepEqual(game.spiceDeck.map(card => JSON.stringify(card)).sort(), originalSpice);
+ reach(state => state.turn === 1 && state.phase === 2 && homeworldEcazDukeClean(state));
+ const funding = transition(() => { act(guild, { type: 'bribe', target: occupier, amount: 1 }); });
+ assert.equal(homeworldEcazDukePlayer(funding.after, occupier).spice, homeworldEcazDukePlayer(funding.before, occupier).spice);
+ assert.equal(homeworldEcazDukePlayer(funding.after, occupier).bribes, homeworldEcazDukePlayer(funding.before, occupier).bribes + 1);
+ reach(state => state.turn === 1 && state.phase === 5 && state.active === occupier && homeworldEcazDukeClean(state));
+ const duneShipment = transition(() => {
+ const sources = nativeShipmentSources(viewGame(game, occupier), 2, 0); assert.ok(sources);
+ act(occupier, { type: 'ship', territory: 'carthag', sector: 11, amount: 2, elite: 0, allyPayment: 0,
+ homeworldSources: sources });
+ reach(state => homeworldEcazDukeClean(state) && !state.pendingShipment);
+ });
+ assert.equal(homeworldEcazDukePlayer(game, occupier).forces['carthag:11'], 2);
+ reach(state => state.turn === 2 && state.phase === 1 && state.nexus && !state.spiceWindow &&
+ !state.spiceResolution && homeworldEcazDukeClean(state));
+ const nexus = structuredClone(game);
+ const alliance = transition(() => {
+ if (allied) { act(occupier, { type: 'alliance', target: guild }); act(guild, { type: 'alliance', target: occupier }); }
+ });
+ assert.equal(homeworldEcazDukePlayer(game, occupier).ally, ally);
+ const secondTerritory = allied ? 'tueks_sietch' : 'carthag', secondSector = allied ? 5 : 11;
+ const recipient = ally ?? occupier, world = 'homeworld:ecaz';
+ let invasion: ReturnType | null = null, secondEntrant: ReturnType | null = null;
+ reach(state => state.turn === 2 && state.phase === 5 && homeworldEcazDukeClean(state));
+ for (let count = 0; game.phase === 5 && count < 300; count++) {
+ if (!homeworldEcazDukeClean(game)) {
+ const step = nextHomeworldEcazDukeStep(game); assert.ok(step); act(step.actor, step.action); continue;
+ }
+ const actor = game.active!;
+ if (actor === occupier && !homeworldEcazDukePlayer(game, actor).shipped) invasion = transition(() => {
+ const sources = nativeShipmentSources(viewGame(game, actor), 4, 0); assert.ok(sources);
+ const selected = Object.fromEntries(Object.entries(sources).filter(([, group]) => group.normal + group.elite > 0));
+ const choice = homeworldShipmentChoice(viewGame(game, actor), world, selected);
+ assert.equal(choice.blocked, null); assert.ok(choice.action);
+ act(actor, choice.action); reach(state => homeworldEcazDukeClean(state) && !state.pendingHomeworldShipment);
+ });
+ if (actor === opponent && !homeworldEcazDukePlayer(game, actor).shipped) secondEntrant = transition(() => {
+ const sources = nativeShipmentSources(viewGame(game, actor), 1, 0); assert.ok(sources);
+ act(actor, { type: 'ship', territory: secondTerritory, sector: secondSector, amount: 1, elite: 0,
+ allyPayment: 0, homeworldSources: sources });
+ reach(state => homeworldEcazDukeClean(state) && !state.pendingShipment);
+ });
+ act(actor, { type: 'endMovement' });
+ }
+ assert.ok(invasion && secondEntrant); assert.equal(game.phase, 6);
+ reach(state => homeworldEcazDukeClean(state) && !state.battle && !!state.active);
+ const beforeWorldBattle = structuredClone(game), chooser = game.active!;
+ const worldChoice = viewGame(game, chooser).battleChoices.find(choice => choice.territory === world); assert.ok(worldChoice);
+ const worldBattle = transition(() => {
+ act(chooser, { type: 'chooseBattle', territory: world, target: chooser === occupier ? ecaz : occupier });
+ reach(planReady);
+ });
+ const emperorLeader = homeworldEcazDukePlayer(game, occupier).leaders.filter(leader => !leader.dead)
+ .sort((a, b) => b.strength - a.strength)[0];
+ const ecazLeader = homeworldEcazDukePlayer(game, ecaz).leaders.filter(leader => !leader.dead)
+ .sort((a, b) => a.strength - b.strength)[0];
+ assert.ok(emperorLeader && ecazLeader);
+ const worldPlans: Record = {
+ [occupier]: { leader: emperorLeader.id, dial: 1, support: advanced ? 1 : 0, weapon: null, defense: null },
+ [ecaz]: { leader: ecazLeader.id, dial: 0, support: 0, weapon: null, defense: null },
+ };
+ const worldResolution = transition(() => { game = settleNativeBattle(game, worldPlans, actions); });
+ const occupied = structuredClone(game), groups = homeworldForceGroups(homeworldContext(game), game.homeworlds!.custody!);
+ const ecazWorld = groups.find(group => group.id === world); assert.ok(ecazWorld);
+ assert.equal((ecazWorld.forces[ecaz]?.normal ?? 0) + (ecazWorld.forces[ecaz]?.elite ?? 0), 0);
+ assert.equal(ecazWorld.forces[occupier]?.normal, 3); assert.equal(ecazWorld.forces[occupier]?.elite, 0);
+ const qualification = game.homeworldOccupationHistory!.qualifications.find(record =>
+ record.world === world && record.player === occupier && record.turn === game.turn); assert.ok(qualification);
+ assert.equal(qualification.cause, 'sole'); assert.equal(game.dukeVidal?.leader.dead, false);
+ if (!allied) assert.equal(game.dukeVidal?.controller, occupier);
+ else assert.equal(game.dukeVidal?.controller, null);
+ reach(state => homeworldEcazDukeClean(state) && !state.battle && !!state.active &&
+ viewGame(state, state.active).battleChoices.some(choice => choice.territory === secondTerritory));
+ const secondBattle = transition(() => {
+ const actor = game.active!;
+ act(actor, { type: 'chooseBattle', territory: secondTerritory, target: actor === recipient ? opponent : recipient });
+ reach(planReady);
+ });
+ assert.ok(game.battle && [game.battle.attacker, game.battle.defender].includes(recipient));
+ assert.deepEqual(game.battle.plans, {}); assert.equal(game.battle.revealed, false);
+ assert.deepEqual(homeworldEcazDukeInventory(game), originalInventory);
+ return { game, beforePower: structuredClone(game), advanced, allied, occupier, ally, ecaz, guild, opponent,
+ recipient, dukeId: DUKE_VIDAL_ID, world, secondTerritory, secondSector,
+ secondLocation: `${secondTerritory}:${secondSector}`,
+ poison: { [occupier]: poisons[0].id, [guild]: poisons[1].id }, projectile: projectile.id,
+ initial, enableHomeworlds, lobby, setup, beforeStartingDeal, afterSetup, dealOrder, originalInventory,
+ inventory: originalInventory, originalSpice, spiceOrder, funding, duneShipment, nexus, alliance,
+ invasion, secondEntrant, beforeWorldBattle, worldBattle, worldPlans, worldResolution, occupied,
+ qualification: structuredClone(qualification), secondBattle,
+ actions, sourceActions: actions, sourceActionCount: actions.length,
+ staging: [
+ 'Actual public Basic or Advanced preview Ecaz/E/G/A enables only Homeworlds, readies and starts. Only original unopened four-card/Spice positions and original scalar lotteries are selected; all33 physical cards and native pools remain.',
+ 'Native Ecaz setup places Basin6. G really bribes E one, deferred to Mentat; E really pays to ship two native counters to empty Carthag. Native Storm, auctions, Collection and Mentat run before the actual turn2 worm Nexus.',
+ allied ? 'E and G actually form their reciprocal alliance during that real Nexus; no alliance field is installed.'
+ : 'The real Nexus is completed without an alliance; the later single eligible Duke recipient needs no manual confirmation.',
+ 'E submits a real owner-view typed four-normal invasion of Ecaz World. A separately pays to ship one native counter into the existing E Carthag group or G Tueks group, preserving a real second battle. Native Guild decisions and payments complete normally.',
+ 'The real World battle uses E native leader5/dial1 (one paid support in Advanced) against Ecaz native leader2/dial0 and its native World bonus. Actual loss/cleanup leaves three E visitors alone, zero Ecaz natives, and the living shared Duke. No force, wallet, custody, leader, clock or occupation receipt is installed.',
+ 'Stop at the actual next unsealed native Arrakis battle. Unallied source already receives the real automatic self-grant; allied source retains both actual recipient choices before either plan seals.',
+ ],
+ };
+}
+export type HomeworldEcazDukeFixture = ReturnType;
+
+/** Actual one-battle use, with optional real opposing projectile/own poison
+ * killing both leaders. No result, death or shared-disc state is assigned. */
+export function playHomeworldEcazDukeBattle(f: HomeworldEcazDukeFixture, source: Game,
+ options: { killDuke?: boolean } = {}) {
+ let game = source; const actions: HomeworldEcazDukeStep[] = [];
+ assert.equal(game.dukeVidal?.controller, f.recipient); assert.ok(planReady(game));
+ const opponentLeader = homeworldEcazDukePlayer(game, f.opponent).leaders.filter(leader => !leader.dead && !leader.usedAt)
+ .sort((a, b) => a.strength - b.strength)[0]; assert.ok(opponentLeader);
+ const plans: Record = {
+ [f.recipient]: { leader: DUKE_VIDAL_ID, dial: 1, support: f.advanced ? 1 : 0,
+ weapon: options.killDuke ? f.poison[f.recipient as keyof typeof f.poison] : null, defense: null },
+ [f.opponent]: { leader: opponentLeader.id, dial: 0, support: 0,
+ weapon: options.killDuke ? f.projectile : null, defense: null },
+ };
+ const before = structuredClone(game); game = settleNativeBattle(game, plans, actions);
+ return { game, before, plans, actions };
+}
diff --git a/tests/homeworld-ecaz-duke-bots.test.ts b/tests/homeworld-ecaz-duke-bots.test.ts
new file mode 100644
index 00000000..cdb45477
--- /dev/null
+++ b/tests/homeworld-ecaz-duke-bots.test.ts
@@ -0,0 +1,79 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { applyAction, viewGame, type Game } from '../game/engine';
+import { botActions } from '../game/bots';
+import { DIFFICULTIES } from '../game/bot-profiles';
+import {
+ createHomeworldEcazDukeFixture as fixture,
+ homeworldEcazDukePlayer as player,
+ homeworldEcazDukeInventory as inventory,
+ homeworldEcazDukePhysical as physical,
+} from './fixture-homeworld-ecaz-duke';
+
+const cards = (game: Game) => ({
+ deck: game.deck, discard: game.discard, auction: game.auction,
+ hands: game.players.map(p => ({ id: p.id, hand: p.hand })),
+});
+
+for (const advanced of [false, true]) void test(
+ `all four minimal profiles natively grant the ${advanced ? 'Advanced' : 'Basic'} occupied Ecaz Duke to an offered recipient`, () => {
+ const f = fixture({ advanced, allied: true }), before = structuredClone(f.game);
+ assert.ok(f.ally); assert.equal(player(f.game, f.occupier).ally, f.ally);
+ assert.equal(player(f.game, f.ally).ally, f.occupier);
+ assert.equal(f.game.dukeVidal?.controller, null); assert.equal(f.game.dukeVidal?.leader.dead, false);
+ assert.equal(f.game.dukeVidal?.leader.id, f.dukeId);
+ assert.deepEqual(f.game.battle?.plans, {}); assert.equal(f.game.battle?.revealed, false);
+ assert.deepEqual(inventory(f.game), f.originalInventory);
+ const originalPhysical = structuredClone(physical(f.game)), originalCards = structuredClone(cards(f.game));
+ for (const profile of DIFFICULTIES) {
+ const own = viewGame(f.game, f.occupier), offer = own.homeworldEcazDuke;
+ assert.ok(offer && offer.event); assert.equal(offer.blocked, null); assert.equal(offer.occupier, f.occupier);
+ assert.deepEqual(offer.recipients.map(recipient => recipient.id).sort(), [f.occupier, f.ally].sort());
+ own.players.find(p => p.id === f.occupier)!.bot = profile;
+ const projected = structuredClone(own), proposals = botActions(own);
+ assert.deepEqual(own, projected, profile); assert.equal(proposals.length, 1, profile);
+ const action = proposals[0];
+ assert.equal(action.type, 'homeworldDuke'); assert.equal(action.event, offer.event);
+ assert.equal(typeof action.recipient, 'string');
+ assert.ok(offer.recipients.some(recipient => recipient.id === action.recipient), profile);
+ assert.deepEqual(action, { type: 'homeworldDuke', event: offer.event, recipient: action.recipient });
+ const granted = applyAction(f.game, f.occupier, action);
+ assert.deepEqual(f.game, before, 'Bot metadata and native application leave the original genuine source unchanged');
+ assert.equal(granted.dukeVidal?.leader.id, f.dukeId);
+ assert.deepEqual(granted.dukeVidal?.leader, before.dukeVidal!.leader);
+ assert.equal(granted.dukeVidal?.controller, action.recipient);
+ assert.equal(granted.dukeVidal?.source, 'homeworldEcaz');
+ assert.equal(granted.dukeVidal?.acquiredTurn, f.game.turn);
+ assert.deepEqual(granted.homeworldEcazDukeGrants, [{
+ turn: f.game.turn, qualification: f.qualification.event,
+ occupier: f.occupier, recipient: action.recipient,
+ }]);
+ assert.deepEqual(physical(granted), originalPhysical); assert.deepEqual(cards(granted), originalCards);
+ assert.deepEqual(granted.battle, before.battle); assert.deepEqual(inventory(granted), f.originalInventory);
+ const unavailable = viewGame(granted, f.occupier).homeworldEcazDuke;
+ assert.ok(unavailable?.blocked); assert.equal(unavailable.event, null);
+ }
+ },
+);
+
+void test('minimal profiles propose no occupied Ecaz Duke grant from blocked missing-event or foreign projections', () => {
+ const f = fixture({ allied: true }), before = structuredClone(f.game);
+ for (const profile of DIFFICULTIES) {
+ // Deliberately controlled projection-only priority states. Native custody,
+ // occupation, battle and the original two-choice source remain untouched.
+ for (const unavailable of ['blocked', 'event', 'absent'] as const) {
+ const own = viewGame(f.game, f.occupier); assert.ok(own.homeworldEcazDuke);
+ own.players.find(p => p.id === f.occupier)!.bot = profile;
+ if (unavailable === 'blocked') own.homeworldEcazDuke.blocked = 'Finish the current interaction before granting Duke Vidal.';
+ if (unavailable === 'event') own.homeworldEcazDuke.event = null;
+ if (unavailable === 'absent') own.homeworldEcazDuke = null;
+ const projected = structuredClone(own);
+ assert.ok(botActions(own).every(action => action.type !== 'homeworldDuke'), `${profile}: ${unavailable}`);
+ assert.deepEqual(own, projected);
+ }
+ const foreign = viewGame(f.game, f.ally!); assert.equal(foreign.homeworldEcazDuke, null);
+ foreign.players.find(p => p.id === f.ally)!.bot = profile;
+ assert.ok(botActions(foreign).every(action => action.type !== 'homeworldDuke'), profile);
+ }
+ assert.deepEqual(f.game, before); assert.deepEqual(inventory(f.game), f.originalInventory);
+});
diff --git a/tests/homeworld-ecaz-duke-controls.test.tsx b/tests/homeworld-ecaz-duke-controls.test.tsx
new file mode 100644
index 00000000..8cfec515
--- /dev/null
+++ b/tests/homeworld-ecaz-duke-controls.test.tsx
@@ -0,0 +1,117 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { Children, createElement, isValidElement, type ReactNode } from 'react';
+import { renderToStaticMarkup } from 'react-dom/server';
+import { HomeworldEcazDuke } from '../components/homeworld-ecaz-duke';
+import { createGame, joinGame, newPlayer, viewGame, type Action, type GameView } from '../game/engine';
+
+// Controlled owner projections isolate this hook-free UI. They do not claim
+// native occupation, shared-Duke custody or grant eligibility; those belong to
+// the separate helper/native source suite. No played Game state is installed.
+function projected(advanced = true): GameView {
+ const game = createGame('HOMEWORLDECAZDUKECONTROLS', newPlayer('a', 'Atreides', 'atreides'), advanced, ['ecaz']);
+ joinGame(game, newPlayer('e', 'Ecaz', 'ecaz'));
+ joinGame(game, newPlayer('g', 'Guild', 'guild'));
+ const view = viewGame(game, 'a');
+ view.homeworldEcazDuke = {
+ event: 'controlled-occupation-duke-event', blocked: null, occupier: 'a',
+ recipients: [{ id: 'a', name: 'Atreides' }, { id: 'g', name: 'Guild' }],
+ };
+ return view;
+}
+function markup(game: GameView, busy = false) {
+ return renderToStaticMarkup(createElement(HomeworldEcazDuke, { game, busy, act() {} }));
+}
+function click(node: ReactNode, recipient: string): boolean {
+ for (const child of Children.toArray(node)) {
+ if (!isValidElement<{
+ children?: ReactNode; onClick?: () => void; 'data-testid'?: string;
+ }>(child)) continue;
+ if (child.props['data-testid'] === `homeworld-ecaz-duke-${recipient}` && child.props.onClick) {
+ child.props.onClick(); return true;
+ }
+ if (click(child.props.children, recipient)) return true;
+ }
+ return false;
+}
+function grant(game: GameView, recipient = 'a', busy = false): Action[] {
+ const actions: Action[] = [];
+ click(HomeworldEcazDuke({ game, busy, act(action) { actions.push(action); } }), recipient);
+ return actions;
+}
+
+void test('the occupied Ecaz controls send one exact event-bound action for each projected recipient', () => {
+ for (const advanced of [false, true]) {
+ const game = projected(advanced), html = markup(game), event = game.homeworldEcazDuke!.event;
+ assert.match(html, /aria-label="Occupied Ecaz Duke grant"/);
+ assert.match(html, /Grant Duke Vidal to Atreides/); assert.match(html, /Grant Duke Vidal to Guild/);
+ assert.doesNotMatch(html, /disabled=""/);
+ assert.deepEqual(grant(game), [{ type: 'homeworldDuke', event, recipient: 'a' }]);
+ assert.deepEqual(grant(game, 'g'), [{ type: 'homeworldDuke', event, recipient: 'g' }]);
+ assert.deepEqual(grant(game, 'e'), [], 'The control never invents an unoffered recipient');
+ }
+});
+
+void test('busy controls suppress their actual callback and retain readable provisional guidance', () => {
+ const game = projected();
+ assert.deepEqual(grant(game, 'a', true), []); assert.deepEqual(grant(game, 'g', true), []);
+ const html = markup(game, true);
+ assert.equal((html.match(/]*\sdisabled=""/g) ?? []).length, 2);
+ assert.match(html, /Provisional occupation benefit/); assert.match(html, /Only Ecaz may revive him/);
+});
+
+void test('authoritative unavailable reasons remain visible across renders and block every grant', () => {
+ for (const reason of [
+ 'Finish the current response before granting Duke Vidal.',
+ 'Finish the current decision or phase opening first.',
+ 'The shared Duke is dead and must be revived by Ecaz.',
+ 'The shared Duke is captured or held as a foreign Ghola.',
+ ]) {
+ const game = projected(); game.homeworldEcazDuke!.blocked = reason;
+ for (let render = 0; render < 3; render++) {
+ const html = markup(game);
+ assert.ok(html.includes(reason)); assert.match(html, /aria-describedby="homeworld-ecaz-duke-unavailable"/);
+ assert.match(html, /Provisional occupation benefit/);
+ assert.deepEqual(grant(game), []); assert.deepEqual(grant(game, 'g'), []);
+ }
+ }
+});
+
+void test('an unavailable event or empty recipient projection cannot manufacture a grant', () => {
+ const missingEvent = projected(); missingEvent.homeworldEcazDuke!.event = null;
+ assert.match(markup(missingEvent), /This Duke grant is not currently available/);
+ assert.deepEqual(grant(missingEvent), []);
+ const empty = projected(); empty.homeworldEcazDuke!.recipients = [];
+ assert.match(markup(empty), /No eligible recipient is currently available/);
+ assert.doesNotMatch(markup(empty), / {
+ const absent = projected(); absent.homeworldEcazDuke = null;
+ assert.equal(markup(absent), ''); assert.deepEqual(grant(absent), []);
+ const other = projected(); other.me = 'g';
+ assert.equal(markup(other), ''); assert.deepEqual(grant(other), []);
+ const noOccupier = projected(); noOccupier.homeworldEcazDuke!.occupier = null;
+ assert.equal(markup(noOccupier), ''); assert.deepEqual(grant(noOccupier), []);
+});
+
+void test('persistent grant guidance describes the one-battle shared Duke without inventing a new numeric allowance', () => {
+ const game = projected();
+ for (let render = 0; render < 3; render++) {
+ const html = markup(game);
+ assert.match(html, /Provisional occupation benefit/);
+ assert.match(html, /grant the living shared Duke Vidal to yourself or your reciprocal ally/);
+ assert.match(html, /may use him in one battle, following the Duke’s ordinary lifecycle/);
+ assert.match(html, /Only Ecaz may revive him/);
+ assert.doesNotMatch(html, /once per (?:turn|phase|game)|\d spice|Confirm|Acknowledge/);
+ }
+});
+
+void test('recipient labels and eligibility come entirely from the projected grant', () => {
+ const game = projected();
+ Object.defineProperty(game, 'players', { get() { throw Error('The grant control read hidden player state'); } });
+ assert.match(markup(game), /Grant Duke Vidal to Guild/);
+ assert.deepEqual(grant(game, 'g'), [{
+ type: 'homeworldDuke', event: 'controlled-occupation-duke-event', recipient: 'g',
+ }]);
+});
diff --git a/tests/homeworld-ecaz-duke-engine.test.ts b/tests/homeworld-ecaz-duke-engine.test.ts
new file mode 100644
index 00000000..f4261b62
--- /dev/null
+++ b/tests/homeworld-ecaz-duke-engine.test.ts
@@ -0,0 +1,110 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { applyAction, viewGame, type Action, type Game } from '../game/engine';
+import { quoteEcazDukeRevival } from '../game/ecaz-duke-revival';
+import {
+ createHomeworldEcazDukeFixture as fixture, actHomeworldEcazDuke as act,
+ advanceHomeworldEcazDuke as advance, homeworldEcazDukeClean as clean,
+ homeworldEcazDukePlayer as player, homeworldEcazDukeInventory as inventory,
+ homeworldEcazDukePhysical as physical, playHomeworldEcazDukeBattle as playBattle,
+ type HomeworldEcazDukeFixture,
+} from './fixture-homeworld-ecaz-duke';
+
+function reject(game: Game, actor: string, action: Action, pattern?: RegExp) {
+ const before = structuredClone(game);
+ if (pattern) assert.throws(() => applyAction(game, actor, action), pattern);
+ else assert.throws(() => applyAction(game, actor, action));
+ assert.deepEqual(game, before);
+}
+function nativeSource(f: HomeworldEcazDukeFixture) {
+ assert.equal(f.setup.status, 'setup'); assert.equal(f.setup.advanced, f.advanced); assert.ok(f.setup.homeworlds);
+ assert.deepEqual(f.setup.expansions, ['ecaz']); assert.ok(f.beforeStartingDeal.players.every(player => !player.hand.length));
+ assert.equal(f.originalInventory.length, 33); assert.deepEqual(inventory(f.game), f.originalInventory);
+ assert.equal(f.funding.steps[0].actor, f.guild); assert.equal(f.funding.steps[0].action.type, 'bribe');
+ assert.equal(player(f.funding.after, f.occupier).spice, player(f.funding.before, f.occupier).spice);
+ assert.equal(player(f.funding.after, f.occupier).bribes, player(f.funding.before, f.occupier).bribes + 1);
+ assert.equal(player(f.duneShipment.after, f.occupier).forces['carthag:11'], 2);
+ assert.equal(f.nexus.nexus, true); assert.equal(player(f.game, f.occupier).ally, f.ally);
+ assert.equal(f.alliance.steps.length, f.allied ? 2 : 0);
+ assert.ok(f.invasion && f.secondEntrant);
+ assert.equal(f.invasion.steps[0].actor, f.occupier); assert.equal(f.invasion.steps[0].action.type, 'homeworldShip');
+ assert.equal(f.secondEntrant.steps[0].actor, f.opponent); assert.equal(f.secondEntrant.steps[0].action.type, 'ship');
+ assert.equal(f.qualification.world, f.world); assert.equal(f.qualification.player, f.occupier);
+ assert.equal(f.qualification.turn, 2); assert.equal(f.qualification.cause, 'sole');
+ assert.equal(f.game.turn, 2); assert.equal(f.game.phase, 6); assert.ok(clean(f.game));
+ assert.equal(f.game.battle?.territory, f.secondTerritory); assert.deepEqual(f.game.battle?.plans, {});
+ assert.equal(f.game.dukeVidal?.leader.dead, false); assert.equal(f.game.dukeVidal?.leader.id, f.dukeId);
+ assert.equal(f.sourceActionCount, f.actions.length);
+}
+function grant(f: HomeworldEcazDukeFixture, recipient: string) {
+ const game = structuredClone(f.game), option = viewGame(game, f.occupier).homeworldEcazDuke;
+ assert.ok(option); assert.equal(option.blocked, null); assert.ok(option.recipients.some(player => player.id === recipient));
+ const action: Action = { type: 'homeworldDuke', event: option.event, recipient };
+ const before = structuredClone(physical(game)), granted = act(game, f.occupier, action);
+ assert.deepEqual(physical(granted), before); assert.equal(granted.dukeVidal?.controller, recipient);
+ assert.equal(granted.dukeVidal?.source, 'homeworldEcaz'); assert.equal(granted.dukeVidal?.acquiredTurn, granted.turn);
+ return { game: granted, action, option };
+}
+
+for (const advanced of [false, true]) void test(
+ `${advanced ? 'Advanced' : 'Basic'} native sole Ecaz World occupation automatically grants the single recipient Duke for one actual battle`, () => {
+ const f = fixture({ advanced }); nativeSource(f);
+ assert.equal(f.game.dukeVidal?.controller, f.occupier); assert.equal(f.game.dukeVidal?.source, 'homeworldEcaz');
+ assert.equal(f.game.homeworldEcazDukeGrants?.length, 1);
+ const battle = playBattle(f, structuredClone(f.game));
+ assert.equal(battle.game.dukeVidal?.controller, null); assert.equal(battle.game.dukeVidal?.leader.dead, false);
+ assert.equal(battle.game.homeworldEcazDukeGrants?.length, 1);
+ assert.deepEqual(inventory(battle.game), f.originalInventory);
+ });
+
+for (const advanced of [false, true]) void test(
+ `${advanced ? 'Advanced' : 'Basic'} native allied Ecaz World occupation retains both real Duke choices and the ally uses one battle`, () => {
+ const f = fixture({ advanced, allied: true }); nativeSource(f); assert.ok(f.ally);
+ assert.equal(f.game.dukeVidal?.controller, null);
+ const option = viewGame(f.game, f.occupier).homeworldEcazDuke; assert.ok(option);
+ assert.deepEqual(option.recipients.map(player => player.id).sort(), [f.occupier, f.ally].sort());
+ const self = grant(f, f.occupier);
+ assert.equal(self.game.dukeVidal?.controller, f.occupier);
+ const allied = grant(f, f.ally); assert.equal(allied.game.homeworldEcazDukeGrants?.length, 1);
+ reject(allied.game, f.occupier, allied.action);
+ const battle = playBattle(f, allied.game);
+ assert.equal(battle.game.dukeVidal?.controller, null); assert.equal(battle.game.dukeVidal?.leader.dead, false);
+ assert.equal(battle.game.homeworldEcazDukeGrants?.length, 1);
+ reject(battle.game, f.occupier, allied.action);
+ assert.deepEqual(inventory(battle.game), f.originalInventory);
+ });
+
+void test('native occupied Ecaz Duke choice rejects stale events, foreign actors and unrelated recipients before any control changes', () => {
+ const f = fixture({ allied: true }); nativeSource(f);
+ const option = viewGame(f.game, f.occupier).homeworldEcazDuke; assert.ok(option);
+ assert.equal(viewGame(f.game, f.opponent).homeworldEcazDuke ?? null, null);
+ const action: Action = { type: 'homeworldDuke', event: option.event, recipient: f.occupier };
+ reject(f.game, f.occupier, { ...action, event: `${option.event}:stale` });
+ reject(f.game, f.opponent, action); reject(f.game, f.occupier, { ...action, recipient: f.opponent });
+ reject(f.game, f.occupier, { ...action, recipient: f.ecaz });
+ assert.equal(f.game.dukeVidal?.controller, null); assert.deepEqual(inventory(f.game), f.originalInventory);
+});
+
+for (const advanced of [false, true]) void test(
+ `${advanced ? 'Advanced' : 'Basic'} occupied Ecaz Duke use can die natively without granting its foreign ally revival permission`, () => {
+ const f = fixture({ advanced, allied: true }); nativeSource(f); assert.ok(f.ally);
+ const granted = grant(f, f.ally), battle = playBattle(f, granted.game, { killDuke: true });
+ assert.equal(battle.game.dukeVidal?.leader.dead, true); assert.equal(battle.game.dukeVidal?.controller, null);
+ const game = advance(battle.game, state => state.turn === 3 && state.phase === 4 && clean(state));
+ assert.equal(game.dukeVidal?.leader.dead, true);
+ reject(game, f.ally, { type: 'reviveLeader', leader: f.dukeId }, /Ecaz/i);
+ assert.equal(game.dukeVidal?.leader.dead, true);
+ const dead = structuredClone(game.dukeVidal!.leader), cash = player(game, f.ecaz).spice;
+ const quote = quoteEcazDukeRevival(game, f.ecaz, false);
+ assert.equal(quote.cost, 5); assert.ok(cash >= quote.cost, 'Original native Ecaz cash must fund its actual five-spice revival');
+ const revived = act(game, f.ecaz, { type: 'reviveLeader', leader: f.dukeId });
+ assert.equal(player(revived, f.ecaz).spice, cash - quote.cost);
+ assert.equal(revived.dukeVidal?.leader.id, dead.id); assert.equal(revived.dukeVidal?.leader.deaths, dead.deaths);
+ assert.equal(revived.dukeVidal?.leader.dead, false); assert.equal(revived.dukeVidal?.controller, null);
+ assert.equal(revived.dukeVidal?.source, null); assert.equal(revived.dukeVidal?.acquiredTurn, null);
+ assert.equal(player(revived, f.ecaz).leaders.some(leader => leader.id === f.dukeId), false);
+ const renewed = viewGame(revived, f.occupier).homeworldEcazDuke; assert.ok(renewed);
+ assert.equal(renewed.blocked, null);
+ assert.deepEqual(renewed.recipients.map(recipient => recipient.id).sort(), [f.occupier, f.ally].sort());
+ assert.deepEqual(inventory(revived), f.originalInventory);
+ });
diff --git a/tests/homeworld-ecaz-duke-quote.test.ts b/tests/homeworld-ecaz-duke-quote.test.ts
new file mode 100644
index 00000000..78afd5d7
--- /dev/null
+++ b/tests/homeworld-ecaz-duke-quote.test.ts
@@ -0,0 +1,208 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { newPlayer } from '../game/engine';
+import { acquireDuke, captureDuke, consumeDuke, createDukeVidal } from '../game/duke-vidal';
+import { createHomeworldCustody } from '../game/homeworld-custody';
+import { homeworldContext } from '../game/homeworld-game';
+import { createHomeworldOccupationHistory, observeHomeworldOccupation } from '../game/homeworld-occupation-history';
+import {
+ HOMEWORLD_ECAZ_DUKE_SOURCE, HomeworldEcazDukeError,
+ quoteHomeworldEcazDuke as quote, createHomeworldEcazDukeReceipt as receipt,
+ quoteHomeworldEcazDukeLifecycle as lifecycle, validateHomeworldEcazDukeGrants,
+ type HomeworldEcazDukeContext as Context,
+} from '../game/homeworld-ecaz-duke';
+
+const player = (g: Context, id: string) => { const p = g.players.find(p => p.id === id); assert.ok(p); return p; };
+function observe(g: Context, event: string, cause: 'change' | 'turnStart' = 'change') {
+ g.homeworldOccupationHistory = observeHomeworldOccupation(g.homeworldOccupationHistory!, homeworldContext(g),
+ g.homeworlds!.custody!, g.turn, cause, event);
+}
+/** Controlled pure-rule inputs, not a claim of native gameplay chronology.
+ * Native20 pools, conserved casualties/visitor1 and the canonical observer
+ * produce actual valid qualification history; no engine receipt is installed. */
+function position(advanced = true, allied = false): Context {
+ const players = ['ecaz', 'guild', 'atreides', 'moritani'].map(faction => {
+ const id = faction as 'ecaz' | 'guild' | 'atreides' | 'moritani';
+ return newPlayer(id, id, id);
+ });
+ const context = homeworldContext({ advanced, players });
+ const custody = createHomeworldCustody(context);
+ const g: Context = { status: 'playing', advanced, turn: 1, players,
+ homeworlds: { custody, historyVersion: 1 }, homeworldOccupationPreview: true,
+ homeworldOccupationHistory: createHomeworldOccupationHistory(context, custody, 1, 'controlled-native-pools'),
+ dukeVidal: createDukeVidal() };
+ player(g, 'ecaz').tanks += player(g, 'ecaz').reserves; player(g, 'ecaz').reserves = 0;
+ player(g, 'guild').reserves--;
+ custody.visitors['homeworld:ecaz'] = { guild: { normal: 1, elite: 0 } };
+ observe(g, 'controlled-ecaz-sole-guild');
+ if (allied) { player(g, 'guild').ally = 'atreides'; player(g, 'atreides').ally = 'guild'; }
+ return g;
+}
+function grant(g: Context, recipient = 'guild') {
+ const offered = quote(g); assert.equal(offered.blocked, null); assert.ok(offered.owner && offered.event);
+ const earned = receipt(g, offered.owner, offered.event, recipient);
+ (g.homeworldEcazDukeGrants ??= []).push(earned);
+ g.dukeVidal = acquireDuke(g.dukeVidal!, recipient, g.turn, HOMEWORLD_ECAZ_DUKE_SOURCE);
+ return earned;
+}
+function depart(g: Context, id = 'guild', event = 'controlled-occupier-departure') {
+ const group = g.homeworlds!.custody!.visitors['homeworld:ecaz'][id];
+ player(g, id).reserves += group.normal + group.elite;
+ delete g.homeworlds!.custody!.visitors['homeworld:ecaz'][id];
+ observe(g, event);
+}
+
+for (const advanced of [false, true]) {
+ const band = advanced ? 'Advanced' : 'Basic';
+ void test(`${band} occupied Ecaz quotes the real sole occupier without acquiring or inventing a grant`, () => {
+ const g = position(advanced), before = structuredClone(g), offered = quote(g);
+ assert.equal(offered.owner, 'guild'); assert.deepEqual(offered.recipients, ['guild']);
+ assert.equal(offered.qualification, 'controlled-ecaz-sole-guild');
+ assert.equal(offered.event, JSON.stringify(['homeworldEcazDuke', 1, 'controlled-ecaz-sole-guild', 'guild']));
+ assert.equal(offered.nativeAcquisitionBlocked, true); assert.equal(offered.claimed, null); assert.equal(offered.blocked, null);
+ assert.deepEqual(receipt(g, 'guild', offered.event!, 'guild'), {
+ turn: 1, qualification: offered.qualification, occupier: 'guild', recipient: 'guild',
+ });
+ assert.deepEqual(g, before); assert.equal(Object.hasOwn(g, 'homeworldEcazDukeGrants'), false);
+ const old = structuredClone(g); delete old.homeworldOccupationPreview;
+ assert.equal(quote(old).owner, null); assert.equal(quote(old).nativeAcquisitionBlocked, false);
+ assert.equal(Object.hasOwn(old, 'homeworldEcazDukeGrants'), false);
+ });
+
+ void test(`${band} occupied Ecaz recipient choice belongs to the occupier and rechecks its reciprocal ally`, () => {
+ const g = position(advanced); player(g, 'guild').ally = 'atreides';
+ assert.deepEqual(quote(g).recipients, ['guild']);
+ player(g, 'atreides').ally = 'guild'; const offered = quote(g); assert.ok(offered.event);
+ assert.deepEqual(offered.recipients, ['guild', 'atreides']);
+ assert.equal(receipt(g, 'guild', offered.event, 'atreides').recipient, 'atreides');
+ const before = structuredClone(g);
+ assert.throws(() => receipt(g, 'atreides', offered.event!, 'atreides'), HomeworldEcazDukeError);
+ assert.throws(() => receipt(g, 'guild', offered.event!, 'moritani'), HomeworldEcazDukeError);
+ assert.throws(() => receipt(g, 'guild', `${offered.event}-stale`, 'guild'), HomeworldEcazDukeError);
+ player(g, 'atreides').ally = null;
+ assert.throws(() => receipt(g, 'guild', offered.event!, 'atreides'), HomeworldEcazDukeError);
+ player(g, 'atreides').ally = 'guild'; assert.deepEqual(g, before);
+ });
+
+ void test(`${band} occupation priority overrides ordinary native holders and a later loss restores nobody`, () => {
+ for (const native of ['ecaz', 'moritani'] as const) {
+ const g = position(advanced);
+ g.dukeVidal = acquireDuke(g.dukeVidal!, native, g.turn, native);
+ const held = structuredClone(g.dukeVidal); assert.equal(quote(g).nativeAcquisitionBlocked, true);
+ assert.equal(quote(g).blocked, null); assert.deepEqual(g.dukeVidal, held);
+ grant(g); assert.equal(g.dukeVidal!.controller, 'guild');
+ depart(g); const ending = lifecycle(g);
+ assert.deepEqual(ending, { release: true, owner: 'guild', reason: 'occupationEnded', blocked: null });
+ g.dukeVidal = consumeDuke(g.dukeVidal!);
+ assert.equal(g.dukeVidal.controller, null); assert.equal(g.dukeVidal.source, null);
+ assert.equal(g.dukeVidal.leader.dead, false); assert.equal(g.dukeVidal.leader.deaths, held.leader.deaths);
+ assert.equal(quote(g).nativeAcquisitionBlocked, false);
+ }
+ });
+
+ void test(`${band} the original choice survives use and prevents a second same-turn grant`, () => {
+ const g = position(advanced, true), chosen = grant(g, 'atreides');
+ assert.deepEqual(lifecycle(g), { release: false, owner: 'atreides', reason: null, blocked: null });
+ assert.deepEqual(quote(g).claimed, chosen); assert.equal(quote(g).nativeAcquisitionBlocked, true);
+ g.dukeVidal!.leader.usedAt = 'arrakeen';
+ assert.equal(lifecycle(g).reason, 'used'); g.dukeVidal = consumeDuke(g.dukeVidal!);
+ const offered = quote(g); assert.ok(offered.blocked); assert.equal(offered.nativeAcquisitionBlocked, true);
+ assert.throws(() => receipt(g, 'guild', offered.event!, 'guild'), HomeworldEcazDukeError);
+ assert.equal(g.homeworldEcazDukeGrants!.length, 1); assert.equal(g.dukeVidal.controller, null);
+ });
+
+ void test(`${band} losing the selected ally ends only this grant and does not reopen its choice`, () => {
+ const g = position(advanced, true); grant(g, 'atreides');
+ player(g, 'atreides').ally = null;
+ assert.deepEqual(lifecycle(g), { release: true, owner: 'atreides', reason: 'recipientChanged', blocked: null });
+ g.dukeVidal = consumeDuke(g.dukeVidal!);
+ assert.deepEqual(quote(g).recipients, ['guild']); assert.ok(quote(g).blocked);
+ assert.equal(quote(g).nativeAcquisitionBlocked, true);
+ assert.throws(() => receipt(g, 'guild', quote(g).event!, 'guild'), HomeworldEcazDukeError);
+ });
+
+ void test(`${band} a continuing qualification offers a fresh choice next turn after unused release`, () => {
+ const g = position(advanced, true), prior = grant(g);
+ g.turn++; observe(g, 'controlled-next-turn', 'turnStart');
+ assert.equal(lifecycle(g).release, true); assert.equal(lifecycle(g).reason, 'turnEnded');
+ g.dukeVidal = consumeDuke(g.dukeVidal!);
+ const offered = quote(g); assert.equal(offered.blocked, null); assert.equal(offered.claimed, null);
+ assert.notEqual(offered.event, JSON.stringify(['homeworldEcazDuke', prior.turn, prior.qualification, prior.occupier]));
+ const renewed = grant(g, 'atreides'); assert.equal(renewed.turn, 2);
+ assert.equal(g.homeworldEcazDukeGrants!.length, 2); assert.equal(lifecycle(g).release, false);
+ });
+}
+
+void test('Basic contest/repopulation ends a grant while Advanced retains its observed occupation', () => {
+ for (const advanced of [false, true]) for (const change of ['contest', 'repopulation'] as const) {
+ const g = position(advanced); grant(g);
+ if (change === 'contest') {
+ player(g, 'moritani').reserves--;
+ g.homeworlds!.custody!.visitors['homeworld:ecaz'].moritani = { normal: 1, elite: 0 };
+ } else {
+ player(g, 'ecaz').reserves += player(g, 'ecaz').tanks; player(g, 'ecaz').tanks = 0;
+ }
+ observe(g, `controlled-${change}`);
+ assert.equal(lifecycle(g).release, !advanced); assert.equal(quote(g).nativeAcquisitionBlocked, advanced);
+ assert.equal(lifecycle(g).reason, advanced ? null : 'occupationEnded');
+ }
+});
+
+void test('a fresh Advanced sole qualification can replace an unused grant without losing the latest receipt identity', () => {
+ const g = position(true); const first = grant(g);
+ depart(g); assert.equal(lifecycle(g).release, true); g.dukeVidal = consumeDuke(g.dukeVidal!);
+ player(g, 'guild').reserves--;
+ g.homeworlds!.custody!.visitors['homeworld:ecaz'].guild = { normal: 1, elite: 0 };
+ observe(g, 'controlled-new-sole-guild');
+ const second = grant(g); assert.notEqual(second.qualification, first.qualification);
+ assert.equal(g.homeworldEcazDukeGrants!.length, 2); assert.equal(lifecycle(g).release, false);
+ const used = position(true); grant(used); used.dukeVidal!.leader.usedAt = 'carthag';
+ used.dukeVidal = consumeDuke(used.dukeVidal!); depart(used);
+ player(used, 'guild').reserves--;
+ used.homeworlds!.custody!.visitors['homeworld:ecaz'].guild = { normal: 1, elite: 0 };
+ observe(used, 'controlled-new-qualification-after-use');
+ assert.match(quote(used).blocked!, /already been used/);
+ assert.throws(() => receipt(used, 'guild', quote(used).event!, 'guild'), HomeworldEcazDukeError);
+ assert.equal(used.homeworldEcazDukeGrants!.length, 1, 'A new qualification cannot erase actual prior battle use');
+});
+
+void test('occupied Ecaz cannot acquire used or dead Duke and supplies no foreign revival', () => {
+ const g = position(); g.dukeVidal!.leader.usedAt = 'arrakeen';
+ assert.match(quote(g).blocked!, /already been used/); assert.equal(quote(g).nativeAcquisitionBlocked, true);
+ delete g.dukeVidal!.leader.usedAt; g.dukeVidal!.leader.dead = true; g.dukeVidal!.leader.deaths = 1;
+ const before = structuredClone(g);
+ assert.match(quote(g).blocked!, /only Ecaz may revive/);
+ assert.throws(() => receipt(g, 'guild', quote(g).event!, 'guild'), HomeworldEcazDukeError);
+ assert.deepEqual(g, before); assert.equal(g.dukeVidal!.leader.dead, true);
+});
+
+void test('captured and malformed foreign-Ghola custody stay explicitly unsupported and are never erased', () => {
+ const g = position(); g.dukeVidal = acquireDuke(g.dukeVidal!, 'ecaz', 1, 'ecaz');
+ g.dukeVidal = captureDuke(g.dukeVidal, 'moritani', 'ecaz', 1, 'arrakeen');
+ const before = structuredClone(g); assert.match(quote(g).blocked!, /captured, concealed or Ghola/);
+ assert.equal(quote(g).nativeAcquisitionBlocked, true); assert.deepEqual(g, before);
+ const active = position(); grant(active);
+ active.dukeVidal = captureDuke(active.dukeVidal!, 'moritani', 'guild', 1, 'arrakeen');
+ const held = structuredClone(active); assert.equal(lifecycle(active).release, false); assert.ok(lifecycle(active).blocked);
+ assert.deepEqual(active, held);
+ // Deliberately invalid negative input, not an allegedly legal foreign revival.
+ const malformed = position(); malformed.dukeVidal!.leader.gholaBy = 'moritani';
+ assert.match(quote(malformed).blocked!, /captured, concealed or Ghola/);
+});
+
+void test('ordinary native tenure is outside this lifecycle and missing occupation evidence awards nothing', () => {
+ const g = position(); g.dukeVidal = acquireDuke(g.dukeVidal!, 'ecaz', 1, 'ecaz');
+ assert.deepEqual(lifecycle(g), { release: false, owner: null, reason: null, blocked: null });
+ delete g.homeworldOccupationHistory;
+ const before = structuredClone(g); assert.ok(quote(g).blocked); assert.equal(quote(g).owner, null);
+ assert.throws(() => receipt(g, 'guild', 'invented', 'guild'), HomeworldEcazDukeError); assert.deepEqual(g, before);
+});
+
+void test('one original qualification cannot carry duplicate or source-swapped grant choices', () => {
+ const g = position(); const original = grant(g);
+ const duplicate = structuredClone(g); duplicate.homeworldEcazDukeGrants!.push({ ...original });
+ assert.throws(() => validateHomeworldEcazDukeGrants(duplicate), HomeworldEcazDukeError);
+ const swapped = structuredClone(g); swapped.homeworldEcazDukeGrants![0].qualification = 'absent-source';
+ assert.throws(() => quote(swapped), HomeworldEcazDukeError);
+ assert.equal(g.homeworldEcazDukeGrants!.length, 1); assert.equal(quote(g).claimed!.qualification, original.qualification);
+});