diff --git a/components/ecaz-duke-acquisition-choices.tsx b/components/ecaz-duke-acquisition-choices.tsx
new file mode 100644
index 00000000..1d85e18b
--- /dev/null
+++ b/components/ecaz-duke-acquisition-choices.tsx
@@ -0,0 +1,78 @@
+'use client';
+
+import type { Action, GameView } from '@/game/engine';
+import { Button } from './ui/button';
+
+export function EcazDukeAcquisitionChoices({
+ game,
+ act,
+ busy,
+}: {
+ game: GameView;
+ act: (action: Action) => void;
+ busy: boolean;
+}) {
+ const entry = game.ambassadorEntry;
+ if (
+ !entry ||
+ entry.stage !== 'offer' ||
+ entry.effect !== 'ecaz' ||
+ entry.owner !== game.me ||
+ game.decision?.kind !== 'ecazAmbassador' ||
+ game.decision.player !== game.me
+ )
+ return null;
+
+ const recipients = entry.beneficiaries.length
+ ? entry.beneficiaries
+ : [{
+ player: entry.owner,
+ blocked: entry.dukeAcquisition
+ ? entry.dukeAcquisition.blocked
+ : 'Duke acquisition is unavailable for this opportunity.',
+ }];
+
+ return (
+
+
+ Acquire Duke Vidal for a listed recipient. The Ambassador returns to
+ your supply.
+
+
+ Provisional ordinary acquisition: the chosen recipient keeps Duke Vidal
+ until battle use, including when unused beyond this turn, unless another
+ rule takes him away. The separate Duke loan after a new alliance lasts
+ for the current turn. Only Ecaz may revive him.
+
+ {recipients.map((recipient) => {
+ const blocked = recipient.blocked;
+ const disabled = busy || !!blocked;
+ const reasonId = `ecaz-duke-${entry.event}-${recipient.player}-blocked`;
+ const name = game.players.find((player) => player.id === recipient.player)?.name ?? recipient.player;
+ return (
+
+
{
+ if (!disabled)
+ act({
+ type: 'decision',
+ event: entry.event,
+ trigger: true,
+ beneficiary: recipient.player,
+ choice: 'duke',
+ });
+ }}
+ >
+ Acquire Duke Vidal for {name}
+
+ {blocked &&
{blocked}
}
+
+ );
+ })}
+
+ );
+}
diff --git a/components/ecaz-entry.tsx b/components/ecaz-entry.tsx
index e8081f2f..787f6270 100644
--- a/components/ecaz-entry.tsx
+++ b/components/ecaz-entry.tsx
@@ -13,6 +13,7 @@ import { LeaderInspector } from './leader-inspector';
import { AmbassadorInspector, AMBASSADOR_COVERAGE } from './ecaz-ambassadors';
import { AmbassadorMovement } from './ambassador-movement';
import { AmbassadorShipment } from './ambassador-shipment';
+import { EcazDukeAcquisitionChoices } from './ecaz-duke-acquisition-choices';
const buttonClass = 'min-h-11 whitespace-normal motion-reduce:transition-none';
@@ -51,11 +52,6 @@ export function EcazEntry({
(choice) => !choice.blocked && selected.includes(choice.card.id),
);
const legalSelection = entry.effect === 'choam' || chosen.length === 1;
- const dukeBlock =
- entry.dukeAcquisition?.blocked ??
- (entry.dukeAcquisition
- ? null
- : 'Duke acquisition is unavailable for this opportunity.');
const allianceBlock =
entry.allianceOffer?.blocked ??
(entry.allianceOffer ? null : 'This alliance opportunity is unavailable.');
@@ -84,11 +80,6 @@ export function EcazEntry({
<>
{entry.effect === 'ecaz' ? (
<>
-
- Acquire Duke Vidal for Ecaz. The Ambassador returns to your
- supply. Ecaz keeps Duke until battle use or until Moritani takes
- him.
-
{duke && (
)}
- {
- if (!dukeBlock)
- send({
- trigger: true,
- beneficiary: entry.owner,
- choice: 'duke',
- });
- }}
- >
- Acquire Duke Vidal for Ecaz
-
- {dukeBlock && (
-
- {dukeBlock}
-
- )}
+
!option.blocked)?.player ??
+ (entry.dukeAcquisition && !entry.dukeAcquisition.blocked ? entry.owner : null);
return [
entry.allianceOffer && !entry.allianceOffer.blocked
? {
@@ -2012,15 +2014,16 @@ function policyActions(g: GameView): Action[] {
beneficiary: entry.owner,
choice: 'alliance',
}
- : entry.dukeAcquisition && !entry.dukeAcquisition.blocked
+ : recipient
? {
...action,
trigger: true,
- beneficiary: entry.owner,
+ beneficiary: recipient,
choice: 'duke',
}
: { ...action, decline: true },
];
+ }
const available = entry.beneficiaries.filter(
(option) =>
!option.blocked &&
diff --git a/game/duke-vidal.ts b/game/duke-vidal.ts
index a608453b..5c13f729 100644
--- a/game/duke-vidal.ts
+++ b/game/duke-vidal.ts
@@ -1,6 +1,8 @@
import type { Leader } from './cards';
export const DUKE_VIDAL_ID = 'duke-vidal';
+/** Granting power, rather than controller faction: ordinary Ecaz acquisition
+ * may belong to its existing ally and still differs from a new-alliance loan. */
export type DukeSource = 'moritani' | 'ecaz' | 'ecazNexus' | 'ally' | 'homeworldEcaz';
export type DukeState = {
leader: Leader;
@@ -291,7 +293,7 @@ export const DUKE_VIDAL_RULES = Object.freeze({
'At the end of Shipment and Movement, Moritani gains the living Duke when its forces face battles in at least two different strongholds, excluding battles involving Ecaz. This checks the battles about to occur, rather than past battle results or several opponents in one stronghold.',
'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.',
+ 'A triggered Ecaz Ambassador may acquire the living Duke outside captured or Ghola custody for Ecaz or its current reciprocal ally. The chosen recipient keeps him until battle use or another rule takes him away, including unused control across turns. This shared tenure is provisional. The separate newly formed alliance can instead grant a loan for the current 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.',
@@ -304,7 +306,7 @@ export const DUKE_VIDAL_RULES = Object.freeze({
ambassadorAcquisition: 'ecaz-self-implemented',
ambassadorAlliance: 'consent-implemented',
ambassadorNewAllyLoan: 'basic-prototype',
- ambassadorAllyAcquisition: 'unresolved',
+ ambassadorAllyAcquisition: 'ordinary-tenure-prototype',
allyLoanReturn: 'basic-set-aside-provisional',
sourceAudit: 'docs/DUKE_VIDAL_RULES.md',
}),
diff --git a/game/ecaz-duke-acquisition.ts b/game/ecaz-duke-acquisition.ts
index 5506ab48..a5e75396 100644
--- a/game/ecaz-duke-acquisition.ts
+++ b/game/ecaz-duke-acquisition.ts
@@ -4,11 +4,11 @@ import { acquireDuke, DUKE_VIDAL_ID, type DukeState } from './duke-vidal';
import { independentDiscoveryProfile, nexusCoreProfile, type FactionModuleProfile } from './faction-module-profile';
import { mixedE3NexusProfile, pairedE3NexusProfile, standaloneE3NexusProfile } from './nexus-module-profile';
-export type EcazDukeContext = Partial & {
+export type EcazDukeContext = Omit, 'players'> & {
status: Game['status'];
turn: number;
advanced: boolean;
- players: readonly { id: string; faction: FactionId }[];
+ players: readonly { id: string; faction: FactionId; ally?: string | null }[];
dukeVidal?: DukeState;
};
type Failure = 'context' | 'custody' | 'table' | 'alreadyControlled';
@@ -34,14 +34,17 @@ const tableBlock =
'Duke Vidal acquisition with Advanced Harkonnen is still being implemented.';
const alreadyBlock =
'Acquiring Duke Vidal when Ecaz already controls him is still being implemented.';
+const allyAlreadyBlock = 'The chosen beneficiary already controls Duke Vidal.';
const unavailable = 'Duke Vidal is unavailable for this acquisition.';
-/** Direct Ecaz self-acquisition only. The caller owns the actual entry event,
- * physical Ambassador and explicit choice; this quote never creates a disc,
- * commits the token, grants an ally loan, or changes leader battle eligibility. */
+/** Ordinary Ambassador acquisition for Ecaz or its current reciprocal ally.
+ * Both use source Ecaz's until-battle tenure, not the new-alliance turn loan.
+ * The caller owns the actual entry event, physical Ambassador, occupied-Ecaz
+ * priority and explicit choice. No disc or new battle permission is created. */
export function quoteEcazDukeAcquisition(
g: EcazDukeContext,
ownerId: string,
+ recipientId: string = ownerId,
): DukeState {
requireDuke(
g.status === 'playing' &&
@@ -50,11 +53,20 @@ export function quoteEcazDukeAcquisition(
typeof g.advanced === 'boolean' &&
Array.isArray(g.players) &&
new Set(g.players.map((p) => p.id)).size === g.players.length &&
+ g.players.filter((p) => p.faction === 'ecaz').length === 1 &&
g.players.filter((p) => p.id === ownerId && p.faction === 'ecaz')
.length === 1,
'This acquisition needs the current seated Ecaz owner.',
'context',
);
+ const owner = g.players.find((p) => p.id === ownerId)!;
+ const recipient = g.players.find((p) => p.id === recipientId);
+ requireDuke(
+ recipient && (recipientId === ownerId ||
+ (owner.ally === recipientId && recipient.ally === ownerId)),
+ 'Choose Ecaz or its current reciprocal ally for this acquisition.',
+ 'context',
+ );
requireDuke(
!(g.advanced && g.players.some((p) => p.faction === 'harkonnen')) ||
(!!g.expansions && (independentDiscoveryProfile(g as FactionModuleProfile) || nexusCoreProfile(g as FactionModuleProfile) || standaloneE3NexusProfile(g as FactionModuleProfile) ||
@@ -86,7 +98,8 @@ export function quoteEcazDukeAcquisition(
// This explicit unfinished case is public controller information. Check it
// before private capture/ghola details so its projected reason cannot vary
// with an undisclosed custody marker.
- requireDuke(duke.controller !== ownerId, alreadyBlock, 'alreadyControlled');
+ requireDuke(duke.controller !== recipientId,
+ recipientId === ownerId ? alreadyBlock : allyAlreadyBlock, 'alreadyControlled');
requireDuke(
duke.controller === null
? duke.acquiredTurn === null && duke.source === null
@@ -94,7 +107,7 @@ export function quoteEcazDukeAcquisition(
whole(duke.acquiredTurn) &&
duke.acquiredTurn > 0 &&
duke.acquiredTurn <= g.turn &&
- ['ecaz', 'moritani', 'ally'].includes(duke.source!),
+ ['ecaz', 'ecazNexus', 'moritani', 'ally'].includes(duke.source!),
'The shared Duke Vidal disc has invalid current custody.',
);
requireDuke(
@@ -103,21 +116,23 @@ export function quoteEcazDukeAcquisition(
);
// acquireDuke clones every existing leader field, including usedAt and death
// history; retaining those fields does not permit a second battle use.
- return acquireDuke(duke, ownerId, g.turn, 'ecaz');
+ return acquireDuke(duke, recipientId, g.turn, 'ecaz');
}
/** An owner-offer descriptor may be publicly compared. Exceptional custody
* never produces a distinct reason that discloses a hidden captured/ghola ID. */
export function ecazDukeAcquisitionBlock(
g: EcazDukeContext,
ownerId: string,
+ recipientId: string = ownerId,
): string | null {
try {
- quoteEcazDukeAcquisition(g, ownerId);
+ quoteEcazDukeAcquisition(g, ownerId, recipientId);
return null;
} catch (error) {
if (error instanceof EcazDukeAcquisitionError) {
if (error.reason === 'table') return tableBlock;
- if (error.reason === 'alreadyControlled') return alreadyBlock;
+ if (error.reason === 'alreadyControlled')
+ return recipientId === ownerId ? alreadyBlock : allyAlreadyBlock;
}
return unavailable;
}
diff --git a/game/engine.ts b/game/engine.ts
index 2799ebe4..eb7e4ea1 100644
--- a/game/engine.ts
+++ b/game/engine.ts
@@ -18562,11 +18562,10 @@ function ambassadorEffectBlock(
return 'This Ambassador effect is still being implemented.';
if (effect === 'ecaz') {
const owner = byFaction(g, 'ecaz');
- if (!owner || beneficiary.id !== owner.id)
- return 'Assigning the Ecaz Ambassador effect to an ally is still being implemented.';
+ if (!owner) return 'Duke Vidal is unavailable for this acquisition.';
if (g.players.some((p) => p.leaders.some((l) => l.id === DUKE_VIDAL_ID)))
return 'Duke Vidal is unavailable for this acquisition.';
- return occupiedEcazDukePriority(g) ?? ecazDukeAcquisitionBlock(g, owner.id);
+ return occupiedEcazDukePriority(g) ?? ecazDukeAcquisitionBlock(g, owner.id, beneficiary.id);
}
if (
effect === 'ixians' &&
@@ -20924,8 +20923,8 @@ function decideAmbassador(g: Game, p: Player, action: Action) {
let acquiredDuke: DukeState | null = null;
if (token.effect === 'ecaz') {
requireRule(
- action.choice === 'duke' && beneficiary.id === owner.id,
- 'Choose the Ecaz Ambassador’s direct acquisition of Duke Vidal for Ecaz.',
+ action.choice === 'duke',
+ 'Choose the Ecaz Ambassador’s direct acquisition of Duke Vidal for its beneficiary.',
);
requireRule(
canTriggerAmbassador({
@@ -20940,7 +20939,7 @@ function decideAmbassador(g: Game, p: Player, action: Action) {
);
try {
requireRule(!occupiedEcazDukePriority(g), occupiedEcazDukePriority(g) ?? 'Duke Vidal is reserved by occupied Ecaz.');
- acquiredDuke = quoteEcazDukeAcquisition(g, owner.id);
+ acquiredDuke = quoteEcazDukeAcquisition(g, owner.id, beneficiary.id);
} catch (error) {
if (error instanceof EcazDukeAcquisitionError)
throw new RuleError(error.message);
@@ -20959,7 +20958,7 @@ function decideAmbassador(g: Game, p: Player, action: Action) {
g.dukeVidal = acquiredDuke;
log(
g,
- `${owner.name} acquired Duke Prad Vidal${previous ? ` from ${getPlayer(g, previous).name}` : ''} through the Ecaz Ambassador. Ecaz keeps the shared Duke until battle use or Moritani acquisition; the entrant’s remaining actions resume.`,
+ `${beneficiary.name} acquired Duke Prad Vidal${previous ? ` from ${getPlayer(g, previous).name}` : ''} through the Ecaz Ambassador. ${beneficiary.name} keeps the shared Duke until battle use or another rule takes him away; the entrant’s remaining actions resume.`,
{ faction: owner.faction, name: 'Ecaz Ambassador — Duke Vidal' },
);
finishAmbassador(g);
diff --git a/game/nexus-ecaz-duke.ts b/game/nexus-ecaz-duke.ts
index b26df2f1..fa809d97 100644
--- a/game/nexus-ecaz-duke.ts
+++ b/game/nexus-ecaz-duke.ts
@@ -297,7 +297,7 @@ export function validateNexusEcazDuke(
(before.source === 'moritani' &&
(g.players.find(player => player.id === before.controller)?.faction !== 'moritani' ||
before.acquiredTurn !== receipt.turn)) ||
- ((before.source === 'ecaz' || before.source === 'ecazNexus') &&
+ (before.source === 'ecazNexus' &&
g.players.find(player => player.id === before.controller)?.faction !== 'ecaz') ||
(before.source === 'ecazNexus' && before.acquiredTurn !== receipt.turn)) ||
before.controller === receipt.owner ||
@@ -345,7 +345,7 @@ function validateCapturedReceipt(g: Game, receipt: CapturedReceipt, index: numbe
: !controller || !turn(before.acquiredTurn) || before.acquiredTurn > receipt.turn ||
!['moritani', 'ecaz', 'ecazNexus', 'ally'].includes(before.source as string) ||
(before.source === 'moritani' && controller.faction !== 'moritani') ||
- ((before.source === 'ecaz' || before.source === 'ecazNexus') && controller.faction !== 'ecaz')) ||
+ (before.source === 'ecazNexus' && controller.faction !== 'ecaz')) ||
!(hidden === null || (object(hidden) &&
keys(hidden, 'captor,controller,dead,deaths,usedAt') &&
hidden.captor === leader.capturedBy && hidden.dead === false &&
diff --git a/game/reference.ts b/game/reference.ts
index fcc3bad8..4d984792 100644
--- a/game/reference.ts
+++ b/game/reference.ts
@@ -1651,7 +1651,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'The first placement costs one spice, the second two, and so on that turn. A declaration opens a Karama response before spice is paid or the token moves. Canceling stops the remaining placement opportunity for that turn; previous placements and payments remain.',
'Finish Ambassador placement advances the phase. On a later turn the price begins at one again and the earlier placement block expires. Pending declarations survive reload without charging twice.',
'Destroyed tokens return to supply; triggered regular tokens are set aside, Bene Gesserit is permanently removed, and replenishment waits for all five random tokens. Storm/explosion destruction remains separate. The reusable Ecaz token returns to supply after acquisition or alliance proposal. Basic has an optional new-ally Duke loan; the supplied Advanced p30 automatically grants an available Duke after accepted consent.',
- 'When an eligible entrant reaches the reusable Ecaz token, Ecaz may acquire the available shared Duke. The token returns to supply and its companions are unchanged. Unused Ecaz control persists until battle use or Moritani acquisition. Basic optional and Advanced automatic new-ally grants are connected; standing ally assignment and wider captured-loan combinations remain for refinement.',
+ 'When an eligible entrant reaches the reusable Ecaz token, Ecaz may acquire the available shared Duke for itself or its current reciprocal ally. The token returns to supply and its companions are unchanged. Direct acquisition persists unused until battle use or Moritani acquisition; that shared tenure is provisional. It is distinct from the optional Basic or automatic Advanced new-alliance turn loan. A fresh token entry is required, with no standing transfer action.',
'Unallied Ecaz may propose an alliance to an unallied entrant. Acceptance activates alliance abilities immediately. Basic then offers an optional Duke loan; Advanced automatically adds an available Duke under supplied PDF p30, without another acknowledgement. The first version releases a surviving loan after one battle, provisionally; an unused living loan is set aside at turn end. Unavailable Duke never prevents consent.',
'When another eligible faction enters a marked stronghold, Ecaz may trigger its Ambassador for itself or its current ally, or leave it in place. Ecaz itself, its ally, advisors and the faction matching the physical Ambassador do not trigger it. Ordinary shipment and native Guild transport settle their own income, BG Intrusion and accompaniment controls before queued entry, without replaying payment or forces. Actual busy controls and unsupported paired or module combinations remain guarded.',
'Accompaniment joining retained BG fighters creates a distinct eligible child entry before the original shipper. Declining the child leaves the live token for the original entry; consuming it does not create a token to offer again. Advisors remain exempt. This existing child-first, live-token composition is explicitly provisional, not a publisher priority ruling.',
@@ -1730,6 +1730,11 @@ export const RULE_TOPICS: RuleTopic[] = [
'tests/guild-ambassador-income-arrival.test.ts',
'tests/bg-ambassador-arrival.test.ts',
'tests/ecaz-duke-acquisition.test.ts',
+ 'tests/ecaz-duke-ally-quote.test.ts',
+ 'tests/ecaz-duke-ally-engine.test.ts',
+ 'tests/ecaz-duke-ally-controls.test.ts',
+ 'tests/fixture-ecaz-duke-ally.ts',
+ 'components/ecaz-duke-acquisition-choices.tsx',
'tests/ecaz-duke-bots.test.ts',
'tests/ecaz-duke-recovery.test.ts',
'tests/ecaz-alliance.test.ts',
@@ -3358,7 +3363,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'Duke Vidal is separate from both factions’ five ordinary leaders. His Ecaz identity does not mean Ecaz currently controls him. Inspect the shared disc to see its current controller, whether it is set aside, or whether it is in the Tanks.',
'At the end of Shipping and Movement, Moritani gains Vidal if it faces battles in at least two strongholds, excluding battles involving Ecaz, and Vidal is not in the Tanks. The printed rule takes him from the current controller. Moritani has him for one battle; it may acquire him again on a later turn by meeting the condition again. At turn end, set him aside unless he is in the Tanks or captured.',
'Karama can prevent Moritani from acquiring Vidal. That cancellation does not remove him once Moritani already controls him.',
- 'Ecaz acquires Vidal through its Ambassador outside Tanks/captured/ghola custody. The alternative forms a consensual alliance while both factions are unallied. Basic can then lend Duke optionally; the supplied Advanced physical p30 adds an available Duke to the new ally automatically and sets an unused disc aside at turn end.',
+ 'Ecaz may acquire Vidal through a triggered Ambassador for itself or its current reciprocal ally outside Tanks/captured/ghola custody. Ordinary acquisition lasts until battle use, including unused control across turns; this shared tenure is provisional. The alternative forms a consensual alliance while both factions are unallied. Basic can then lend Duke optionally; the supplied Advanced physical p30 adds an available Duke to the new ally automatically and sets an unused disc aside at turn end.',
'Only Ecaz may revive Vidal, including with Ghola Treachery. Ecaz may revive him for five spice regardless of how many of its leaders are in the Tanks. Five spice is his special revival cost; his printed battle strength is six. Ecaz may begin ordinary leader revival when five leaders are in the Tanks, counting Vidal, even if it still holds another leader.',
'Ordinary Ecaz returns now use an explicit provisional native cohort. Five actual Tanked discs including Duke, or the existing unavailable-native-roster exception, opens it; completed native IDs preserve the remainder and later openings. A late first death can join a later cohort. Independent Duke deaths do not set or hold its ordinary number, and a real Duke resurrection preserves an already satisfied opening. Original native prices and one ordinary leader slot remain.',
'An unallied native Ecaz holder may spend its actual Ecaz Nexus at a quiet boundary in any phase to take Duke for the current turn, including original captured custody or the set-aside disc in Tanks. Finish committed interactions first. One battle or unused turn expiry sets him aside. Nexus liberation permanently clears captivity; it does not return him to a former captor. Actual Ecaz Ghola returns him alive and set aside before a separate Cunning play.',
diff --git a/tests/ecaz-duke-acquisition.test.ts b/tests/ecaz-duke-acquisition.test.ts
index 4cf77038..b8fcc895 100644
--- a/tests/ecaz-duke-acquisition.test.ts
+++ b/tests/ecaz-duke-acquisition.test.ts
@@ -286,10 +286,17 @@ void test('explicit Duke choice/owner/event and eligibility are checked before t
reject(g, 'ec', { ...action, event: 'stale' });
reject(g, 'ec', { ...action, choice: undefined });
reject(g, 'ec', { ...action, choice: 'loan' });
+ reject(g, 'ec', { ...action, beneficiary: 'al' });
const allied = reload(g);
p(allied, 'ec').ally = 'al';
p(allied, 'al').ally = 'ec';
- reject(allied, 'ec', { ...action, beneficiary: 'al' });
+ const shared = applyAction(allied, 'ec', { ...action, beneficiary: 'al' });
+ assert.equal(shared.dukeVidal!.controller, 'al');
+ assert.equal(shared.dukeVidal!.source, 'ecaz');
+ assert.deepEqual(shared.players, allied.players);
+ const staleAlly = reload(allied);
+ p(staleAlly, 'al').ally = null;
+ reject(staleAlly, 'ec', { ...action, beneficiary: 'al' });
for (const mutate of [
(b: Game) => {
delete b.dukeVidal;
diff --git a/tests/ecaz-duke-ally-controls.test.ts b/tests/ecaz-duke-ally-controls.test.ts
new file mode 100644
index 00000000..07a40169
--- /dev/null
+++ b/tests/ecaz-duke-ally-controls.test.ts
@@ -0,0 +1,189 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { registerHooks } from 'node:module';
+import { Children, createElement, isValidElement, type ReactNode } from 'react';
+import { renderToStaticMarkup } from 'react-dom/server';
+import { EcazDukeAcquisitionChoices } from '../components/ecaz-duke-acquisition-choices';
+import { createDukeVidal } from '../game/duke-vidal';
+import { createGame, joinGame, newPlayer, viewGame, type Action, type GameView } from '../game/engine';
+
+const aliases = registerHooks({
+ resolve(specifier, context, next) {
+ return next(specifier === 'next/image' ? 'vinext/shims/image' : specifier, context);
+ },
+});
+const { EcazEntry } = await import('../components/ecaz-entry');
+aliases.deregister();
+
+// Controlled projections supplement the separate native acquisition sources.
+// Only the public view is changed: no played alliance, Duke custody, Ambassador
+// token, force, hand or entry is installed in a Game. React renders the stateful
+// wrapper; only the hook-free choices leaf is called directly for callbacks.
+function projected(advanced = false): GameView {
+ const game = createGame('ECAZDUKEALLYCONTROLS', newPlayer('e', 'Ecaz', 'ecaz'), advanced, ['ecaz']);
+ joinGame(game, newPlayer('a', 'Atreides ally', 'atreides'));
+ joinGame(game, newPlayer('i', 'Emperor entrant', 'emperor'));
+ const view = viewGame(game, 'e');
+ view.status = 'playing'; view.phase = 5; view.turn = 2;
+ view.decision = { kind: 'ecazAmbassador', player: 'e' };
+ view.dukeVidal = createDukeVidal();
+ view.ambassadorEntry = {
+ event: 'controlled-ecaz-duke-entry', owner: 'e', entrant: 'i',
+ territory: 'arrakeen', stage: 'offer', effect: 'ecaz', beneficiary: null,
+ noField: null, shipment: null, movement: null,
+ allianceOffer: { blocked: 'The entrant is already allied.' },
+ dukeAcquisition: { blocked: null }, dukeLoan: null,
+ beneficiaries: [{ player: 'e', blocked: null }, { player: 'a', blocked: null }],
+ copies: [], cards: [], revival: null,
+ };
+ return view;
+}
+function markup(game: GameView, busy = false, wrapper = false) {
+ return renderToStaticMarkup(createElement(wrapper ? EcazEntry : EcazDukeAcquisitionChoices, {
+ game, busy, act() {},
+ }));
+}
+function button(html: string, recipient: string) {
+ const found = html.match(new RegExp(`]*data-testid="ecaz-duke-acquisition-${recipient}")[^>]*>[\\s\\S]*?<\\/button>`))?.[0];
+ assert.ok(found, `Duke choice for ${recipient}`); return found;
+}
+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'] === `ecaz-duke-acquisition-${recipient}` && child.props.onClick) {
+ child.props.onClick(); return true;
+ }
+ if (click(child.props.children, recipient)) return true;
+ }
+ return false;
+}
+function choose(game: GameView, recipient: string, busy = false): Action[] {
+ const actions: Action[] = [];
+ click(EcazDukeAcquisitionChoices({ game, busy, act(action) { actions.push(action); } }), recipient);
+ return actions;
+}
+
+void test('Ecaz Duke choices allow the projected existing ally when self acquisition is blocked', () => {
+ for (const advanced of [false, true]) {
+ const game = projected(advanced), entry = game.ambassadorEntry!;
+ entry.dukeAcquisition!.blocked = 'Ecaz already controls Duke Vidal.';
+ entry.beneficiaries[0].blocked = 'Ecaz already controls Duke Vidal.';
+ const html = markup(game);
+ assert.match(button(html, 'e'), /\sdisabled=/);
+ assert.doesNotMatch(button(html, 'a'), /\sdisabled=/);
+ assert.match(html, /Ecaz already controls Duke Vidal/);
+ assert.match(html, /Acquire Duke Vidal for Atreides ally/);
+ assert.deepEqual(choose(game, 'e'), []);
+ assert.deepEqual(choose(game, 'a'), [{
+ type: 'decision', event: entry.event, trigger: true, beneficiary: 'a', choice: 'duke',
+ }]);
+ }
+});
+
+void test('each offered Duke recipient sends exactly its own event-bound acquisition action', () => {
+ const game = projected(), before = structuredClone(game);
+ for (const recipient of ['e', 'a']) {
+ assert.deepEqual(choose(game, recipient), [{
+ type: 'decision', event: 'controlled-ecaz-duke-entry', trigger: true,
+ beneficiary: recipient, choice: 'duke',
+ }]);
+ assert.doesNotMatch(button(markup(game), recipient), /\sdisabled=/);
+ }
+ assert.deepEqual(choose(game, 'i'), [], 'No unoffered entrant choice is manufactured');
+ assert.deepEqual(game, before);
+});
+
+void test('busy and per-recipient blocks suppress actual Duke callbacks while reasons remain visible', () => {
+ const game = projected();
+ assert.deepEqual(choose(game, 'e', true), []); assert.deepEqual(choose(game, 'a', true), []);
+ const busy = markup(game, true);
+ assert.match(button(busy, 'e'), /\sdisabled=/); assert.match(button(busy, 'a'), /\sdisabled=/);
+ game.ambassadorEntry!.beneficiaries[1].blocked = 'The chosen beneficiary already controls Duke Vidal.';
+ for (let render = 0; render < 3; render++) {
+ const html = markup(game);
+ assert.match(html, /The chosen beneficiary already controls Duke Vidal/);
+ assert.match(button(html, 'a'), /\sdisabled=/);
+ assert.match(button(html, 'a'), /aria-describedby="ecaz-duke-controlled-ecaz-duke-entry-a-blocked"/);
+ assert.doesNotMatch(button(html, 'e'), /\sdisabled=/);
+ assert.deepEqual(choose(game, 'a'), []);
+ }
+});
+
+void test('an empty beneficiary list retains only the old self descriptor and its unavailable reason', () => {
+ const game = projected(), entry = game.ambassadorEntry!;
+ entry.beneficiaries = [];
+ assert.match(markup(game), /Acquire Duke Vidal for Ecaz/);
+ assert.doesNotMatch(markup(game), /Acquire Duke Vidal for Atreides ally/);
+ assert.deepEqual(choose(game, 'e'), [{
+ type: 'decision', event: entry.event, trigger: true, beneficiary: 'e', choice: 'duke',
+ }]);
+ assert.deepEqual(choose(game, 'a'), []);
+ entry.dukeAcquisition!.blocked = 'Duke Vidal is unavailable for this acquisition.';
+ assert.match(markup(game), /Duke Vidal is unavailable for this acquisition/);
+ assert.deepEqual(choose(game, 'e'), []);
+ entry.dukeAcquisition = null;
+ assert.match(markup(game), /Duke acquisition is unavailable for this opportunity/);
+ assert.deepEqual(choose(game, 'e'), []);
+});
+
+void test('waiting foreign and closed entry views never expose a standalone Duke transfer', () => {
+ for (const change of ['absent', 'viewer', 'owner', 'actor', 'decision', 'stage', 'effect'] as const) {
+ const game = projected();
+ if (change === 'absent') game.ambassadorEntry = null;
+ if (change === 'viewer') game.me = 'a';
+ if (change === 'owner') game.ambassadorEntry!.owner = 'i';
+ if (change === 'actor') game.decision!.player = 'i';
+ if (change === 'decision') game.decision = null;
+ if (change === 'stage') game.ambassadorEntry!.stage = 'loan';
+ if (change === 'effect') game.ambassadorEntry!.effect = 'fremen';
+ assert.equal(markup(game), '', change);
+ assert.deepEqual(choose(game, 'e'), [], change); assert.deepEqual(choose(game, 'a'), [], change);
+ }
+ const waiting = projected(); waiting.decision!.player = 'i';
+ assert.equal(markup(waiting, false, true), '');
+});
+
+void test('ordinary Duke tenure guidance stays readable and distinguishes the new-alliance turn loan', () => {
+ const game = projected();
+ for (let render = 0; render < 3; render++) {
+ const html = markup(game);
+ assert.match(html, /Provisional ordinary acquisition/);
+ assert.match(html, /until battle use, including when unused beyond this turn/);
+ assert.match(html, /The separate Duke loan after a new alliance lasts for the current turn/);
+ assert.match(html, /Only Ecaz may revive him/);
+ assert.match(html, /The Ambassador returns to your supply/);
+ assert.doesNotMatch(html, /Confirm|Acknowledge|countdown|once per turn/);
+ }
+});
+
+void test('the React-rendered entry retains one Duke inspector and its original alliance and loan controls', () => {
+ const game = projected();
+ game.ambassadorEntry!.beneficiaries[0].blocked = 'The self choice is blocked.';
+ const html = markup(game, false, true);
+ assert.equal((html.match(/aria-label="Inspect leader: Duke Prad Vidal"/g) ?? []).length, 1);
+ assert.match(html, /Acquire Duke Vidal for Atreides ally/);
+ assert.match(html, /Offer alliance to Emperor entrant/);
+ assert.match(html, /The entrant is already allied/);
+ assert.match(html, /Leave Ambassador in place/);
+ assert.doesNotMatch(button(html, 'a'), /\sdisabled=/);
+ game.ambassadorEntry!.stage = 'loan';
+ const loan = markup(game, false, true);
+ assert.match(loan, /Lend Duke Vidal to the new ally/);
+ assert.match(loan, /Continue without lending Duke/);
+ assert.match(loan, /unused living Duke is set aside at turn end/);
+ assert.doesNotMatch(loan, /ecaz-duke-acquisition-|Provisional ordinary acquisition/);
+});
+
+void test('Duke recipient controls use only public names and authoritative beneficiary reasons', () => {
+ const game = projected();
+ Object.defineProperty(game.ambassadorEntry!, 'dukeAcquisition', { get() { throw Error('Read the compatibility self descriptor despite offered beneficiaries'); } });
+ Object.defineProperty(game, 'dukeVidal', { get() { throw Error('Read Duke custody to recreate projected eligibility'); } });
+ for (const player of game.players) for (const field of ['hand', 'traitors', 'faceDancers', 'spice', 'leaders', 'forces'])
+ Object.defineProperty(player, field, { get() { throw Error(`Duke choices read private or custody field ${field}`); } });
+ assert.match(markup(game), /Acquire Duke Vidal for Atreides ally/);
+ assert.deepEqual(choose(game, 'a'), [{
+ type: 'decision', event: 'controlled-ecaz-duke-entry', trigger: true, beneficiary: 'a', choice: 'duke',
+ }]);
+});
diff --git a/tests/ecaz-duke-ally-engine.test.ts b/tests/ecaz-duke-ally-engine.test.ts
new file mode 100644
index 00000000..f2b0a368
--- /dev/null
+++ b/tests/ecaz-duke-ally-engine.test.ts
@@ -0,0 +1,149 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { viewGame, type Action, type Game } from '../game/engine';
+import { botActions } from '../game/bots';
+import { DIFFICULTIES } from '../game/bot-profiles';
+import {
+ actEcazDukeAlly, advanceEcazDukeAlly, battleEcazDukeAlly, createEcazDukeAllyFixture,
+ ecazDukeAllyClean, ecazDukeAllyInventory, ecazDukeAllyPhysical,
+ ecazDukeAllyPlayer as player, finishEcazDukeAlly, grantEcazDukeAlly,
+} from './fixture-ecaz-duke-ally';
+
+function rejected(game: Game, actor: string, action: Action) {
+ const before = structuredClone(game);
+ assert.throws(() => actEcazDukeAlly(game, actor, action));
+ assert.deepEqual(game, before);
+}
+
+for (const advanced of [false, true]) {
+ void test(`${advanced ? 'Advanced' : 'Basic'} native already-allied Ecaz Ambassador transfers its held ordinary Duke to the ally`, () => {
+ const f = createEcazDukeAllyFixture({ advanced });
+ assert.equal(f.game.status, 'playing');
+ assert.equal(f.game.turn, 2);
+ assert.equal(f.game.phase, 5);
+ assert.equal(f.game.active, f.entrant);
+ assert.equal(f.game.pendingAmbassador!.stage, 'offer');
+ assert.equal(f.game.pendingAmbassador!.owner, f.owner);
+ assert.equal(f.game.pendingAmbassador!.entrant, f.entrant);
+ assert.equal(f.game.pendingAmbassador!.token, f.token);
+ assert.equal(f.game.pendingAmbassador!.territory, 'carthag');
+ assert.equal(f.game.response ?? null, null);
+ assert.equal(f.game.phaseOpening ?? null, null);
+ assert.equal(f.game.battle, null);
+ assert.equal(player(f.game, f.owner).ally, f.ally);
+ assert.equal(player(f.game, f.ally).ally, f.owner);
+ assert.equal(player(f.game, f.entrant).ally, null);
+ assert.deepEqual([f.afterSelf.dukeVidal!.controller, f.afterSelf.dukeVidal!.source, f.afterSelf.dukeVidal!.acquiredTurn],
+ [f.owner, 'ecaz', 1]);
+ assert.equal(f.afterMentat.dukeVidal!.controller, f.owner);
+ assert.deepEqual([f.game.dukeVidal!.controller, f.game.dukeVidal!.source, f.game.dukeVidal!.acquiredTurn],
+ [f.owner, 'ecaz', 1]);
+ assert.equal(f.placements.length, 2);
+ assert.ok(f.placements.every(placement => placement.action.token === f.token));
+ assert.equal(f.shipments.length, 3);
+ assert.equal(f.shipments[0].step.action.territory, 'habbanya_ridge_sietch');
+ assert.equal(f.shipments[1].step.actor, f.ally);
+ assert.equal(f.shipments[2].step.actor, f.entrant);
+ assert.ok(f.alliance.actions.some(step => step.actor === f.owner && step.action.type === 'alliance'));
+ assert.ok(f.alliance.actions.some(step => step.actor === f.ally && step.action.type === 'alliance'));
+ const offer = viewGame(f.game, f.owner).ambassadorEntry;
+ assert.ok(offer);
+ assert.ok(offer.beneficiaries!.find(target => target.player === f.owner)!.blocked);
+ assert.equal(offer.beneficiaries!.find(target => target.player === f.ally)!.blocked, null);
+ assert.equal(player(f.game, f.ally).forces['carthag:11'], 1);
+ assert.equal(player(f.game, f.entrant).forces['carthag:11'], 1);
+ assert.equal(f.sourceActionCount, f.actions.length);
+ assert.deepEqual(ecazDukeAllyInventory(f.game), f.originalInventory);
+ let game = grantEcazDukeAlly(f);
+ game = finishEcazDukeAlly(f, game);
+ assert.deepEqual([game.dukeVidal!.controller, game.dukeVidal!.source, game.dukeVidal!.acquiredTurn],
+ [f.ally, 'ecaz', 2]);
+ assert.deepEqual(game.dukeVidal!.leader, f.originalDuke.leader);
+ assert.ok(game.log.filter(entry => entry.text.includes('acquired Duke Prad Vidal')).at(-1)!.text
+ .startsWith(`${player(game, f.ally).name} acquired Duke Prad Vidal`));
+ assert.deepEqual(ecazDukeAllyPhysical(game), f.originalPhysical,
+ 'Only the existing shared disc controller changes after the already-paid native entry');
+ assert.equal(game.pendingAmbassador ?? null, null);
+ assert.equal(game.ecazAmbassadors!.tokens.find(token => token.id === f.token)!.zone, 'supply');
+ assert.deepEqual(game.ecazAmbassadors!.cohort, f.cohort);
+ assert.deepEqual(ecazDukeAllyInventory(game), f.originalInventory);
+ });
+
+ void test(`${advanced ? 'Advanced' : 'Basic'} native existing-ally ordinary Duke fights once and releases the same living shared disc`, () => {
+ const f = createEcazDukeAllyFixture({ advanced });
+ const granted = finishEcazDukeAlly(f, grantEcazDukeAlly(f));
+ const beforeRosters = granted.players.map(seat => ({ id: seat.id, leaders: seat.leaders.map(leader => leader.id) }));
+ const fought = battleEcazDukeAlly(f, granted);
+ assert.equal(fought.beforePlans.dukeVidal!.controller, f.ally);
+ assert.equal(fought.beforePlans.dukeVidal!.source, 'ecaz');
+ assert.equal(fought.plans[0].actor, f.ally);
+ assert.equal(fought.plans[0].action.leader, f.duke);
+ assert.equal(fought.sealed.battle!.plans[f.ally].leader, f.duke);
+ assert.equal(fought.game.battle, null);
+ assert.equal(fought.game.lastBattleContext!.winner, f.ally);
+ assert.equal(fought.game.lastBattleContext!.territory, 'carthag');
+ assert.deepEqual([fought.game.dukeVidal!.controller, fought.game.dukeVidal!.source, fought.game.dukeVidal!.acquiredTurn],
+ [null, null, null]);
+ assert.equal(fought.game.dukeVidal!.leader.id, f.duke);
+ assert.equal(fought.game.dukeVidal!.leader.dead, false);
+ assert.equal(fought.game.dukeVidal!.leader.deaths, f.originalDuke.leader.deaths);
+ assert.equal(player(fought.game, f.ally).forces['carthag:11'], 1);
+ assert.equal(player(fought.game, f.opponent).forces['carthag:11'] ?? 0, 0);
+ assert.deepEqual(fought.game.players.map(seat => ({ id: seat.id, leaders: seat.leaders.map(leader => leader.id) })), beforeRosters);
+ assert.deepEqual(ecazDukeAllyInventory(fought.game), f.originalInventory);
+ });
+
+ void test(`${advanced ? 'Advanced' : 'Basic'} unused existing-ally direct acquisition persists through a real native battle and next Mentat`, () => {
+ const f = createEcazDukeAllyFixture({ advanced });
+ const granted = finishEcazDukeAlly(f, grantEcazDukeAlly(f));
+ const fought = battleEcazDukeAlly(f, granted, [], { useDuke: false });
+ assert.notEqual(fought.plans[0].action.leader, f.duke);
+ assert.deepEqual(fought.game.dukeVidal, granted.dukeVidal);
+ const nextTurn = advanceEcazDukeAlly(fought.game, state => state.turn === 3 && state.phase === 1 && ecazDukeAllyClean(state));
+ assert.equal(nextTurn.turn, 3);
+ assert.deepEqual([nextTurn.dukeVidal!.controller, nextTurn.dukeVidal!.source, nextTurn.dukeVidal!.acquiredTurn],
+ [f.ally, 'ecaz', 2]);
+ assert.deepEqual(nextTurn.dukeVidal!.leader, granted.dukeVidal!.leader);
+ assert.equal(player(nextTurn, f.owner).ally, f.ally);
+ assert.equal(player(nextTurn, f.ally).ally, f.owner);
+ assert.deepEqual(ecazDukeAllyInventory(nextTurn), f.originalInventory);
+ });
+
+ void test(`${advanced ? 'Advanced' : 'Basic'} live Ecaz Duke entry rejects foreign, stale, self-held and nonally beneficiaries before consuming its token`, () => {
+ const f = createEcazDukeAllyFixture({ advanced });
+ rejected(f.game, f.ally, f.action);
+ rejected(f.game, f.entrant, f.action);
+ rejected(f.game, f.owner, { ...f.action, event: 'stale' });
+ rejected(f.game, f.owner, { ...f.action, beneficiary: f.owner });
+ rejected(f.game, f.owner, { ...f.action, beneficiary: f.entrant });
+ rejected(f.game, f.owner, { ...f.action, beneficiary: 'guild' });
+ assert.equal(player(f.beforeSelf, f.owner).ally, null);
+ assert.equal(player(f.beforeSelf, f.ally).ally, null);
+ rejected(f.beforeSelf, f.owner, { ...f.action, event: f.beforeSelf.pendingAmbassador!.event });
+ assert.deepEqual(f.game.dukeVidal, f.originalDuke);
+ assert.deepEqual(f.game.pendingAmbassador, f.originalParent);
+ assert.deepEqual(ecazDukeAllyPhysical(f.game), f.originalPhysical);
+ assert.equal(f.game.ecazAmbassadors!.tokens.find(token => token.id === f.token)!.zone, 'placed');
+ const granted = finishEcazDukeAlly(f, grantEcazDukeAlly(f));
+ rejected(granted, f.owner, f.action);
+ assert.deepEqual(ecazDukeAllyInventory(granted), f.originalInventory);
+ });
+}
+
+void test('four minimal owner profiles choose the actual existing ally when Ecaz already holds the shared Duke', () => {
+ for (const advanced of [false, true]) {
+ const f = createEcazDukeAllyFixture({ advanced });
+ for (const difficulty of DIFFICULTIES) {
+ const view = viewGame(f.game, f.owner);
+ view.players.find(seat => seat.id === f.owner)!.bot = difficulty;
+ const action = botActions(view)[0];
+ assert.deepEqual(action, f.action, `${advanced}/${difficulty}`);
+ const game = finishEcazDukeAlly(f, actEcazDukeAlly(structuredClone(f.game), f.owner, action));
+ assert.deepEqual([game.dukeVidal!.controller, game.dukeVidal!.source, game.dukeVidal!.acquiredTurn],
+ [f.ally, 'ecaz', 2]);
+ assert.deepEqual(game.dukeVidal!.leader, f.originalDuke.leader);
+ assert.deepEqual(ecazDukeAllyPhysical(game), f.originalPhysical);
+ assert.deepEqual(ecazDukeAllyInventory(game), f.originalInventory);
+ }
+ }
+});
diff --git a/tests/ecaz-duke-ally-quote.test.ts b/tests/ecaz-duke-ally-quote.test.ts
new file mode 100644
index 00000000..5426c474
--- /dev/null
+++ b/tests/ecaz-duke-ally-quote.test.ts
@@ -0,0 +1,131 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { newPlayer, type Game } from '../game/engine';
+import { acquireDuke, consumeDuke, createDukeVidal, expireDuke } from '../game/duke-vidal';
+import {
+ quoteEcazDukeAcquisition as quote, ecazDukeAcquisitionBlock as block,
+ EcazDukeAcquisitionError, type EcazDukeContext,
+} from '../game/ecaz-duke-acquisition';
+
+type Position = Omit & { players: Game['players'] };
+/** Controlled pure-rule inputs only: no native setup, acquisition, alliance or
+ * battle chronology is claimed. The canonical shared disc is never copied to
+ * one of the four native leader rosters. */
+function position(advanced = false): Position {
+ const players = [newPlayer('ec', 'Ecaz', 'ecaz'), newPlayer('al', 'Ally', 'atreides'),
+ newPlayer('g', 'Guild', 'guild'), newPlayer('m', 'Moritani', 'moritani')];
+ players[0].ally = 'al'; players[1].ally = 'ec';
+ return { status: 'playing', turn: 2, advanced, expansions: ['ecaz'], players, dukeVidal: createDukeVidal() };
+}
+const seat = (g: Position, id: string) => { const p = g.players.find(p => p.id === id); assert.ok(p); return p; };
+function rejected(g: Position, owner = 'ec', recipient = 'al') {
+ const before = structuredClone(g); assert.throws(() => quote(g, owner, recipient));
+ assert.equal(block(g, owner, recipient), 'Duke Vidal is unavailable for this acquisition.');
+ assert.deepEqual(g, before);
+}
+
+for (const advanced of [false, true]) {
+ const band = advanced ? 'Advanced' : 'Basic';
+ void test(`${band} ordinary Ecaz Duke quote preserves the existing default self acquisition`, () => {
+ const g = position(advanced), before = structuredClone(g);
+ const own = quote(g, 'ec');
+ assert.deepEqual(own, quote(g, 'ec', 'ec'));
+ assert.deepEqual(own, { leader: g.dukeVidal!.leader, controller: 'ec', acquiredTurn: 2, source: 'ecaz' });
+ assert.equal(block(g, 'ec'), null); assert.equal(block(g, 'ec', 'ec'), null); assert.deepEqual(g, before);
+ const legacy: EcazDukeContext = { ...g, players: g.players.map(({ id, faction }) => ({ id, faction })) };
+ assert.deepEqual(quote(legacy, 'ec'), own, 'Old self-only context callers need no ally property');
+ assert.equal(block(legacy, 'ec', 'al'), 'Duke Vidal is unavailable for this acquisition.');
+ });
+
+ void test(`${band} a reciprocal ally receives the same living or set-aside Duke with ordinary until-battle tenure`, () => {
+ for (const prior of ['setAside', 'ecaz', 'ecazNexus', 'moritani', 'ally'] as const) {
+ const g = position(advanced);
+ if (prior !== 'setAside')
+ g.dukeVidal = acquireDuke(g.dukeVidal!, prior === 'ecaz' || prior === 'ecazNexus' ? 'ec' : 'm',
+ prior === 'ecazNexus' ? g.turn : 1, prior);
+ g.dukeVidal!.leader.deaths = 2; g.dukeVidal!.leader.usedAt = 'arrakeen';
+ const before = structuredClone(g), shared = quote(g, 'ec', 'al');
+ assert.equal(shared.controller, 'al'); assert.equal(shared.source, 'ecaz'); assert.equal(shared.acquiredTurn, g.turn);
+ assert.deepEqual(shared.leader, g.dukeVidal!.leader); assert.equal(shared.leader.faction, 'ecaz');
+ assert.equal(shared.leader.deaths, 2); assert.equal(shared.leader.usedAt, 'arrakeen');
+ assert.equal(block(g, 'ec', 'al'), null); assert.deepEqual(g, before);
+ assert.notEqual(shared, g.dukeVidal); assert.notEqual(shared.leader, g.dukeVidal!.leader);
+ assert.deepEqual(expireDuke(shared, g.turn + 1), shared, 'Unused ordinary acquisition does not inherit the turn-loan expiry');
+ const loan = acquireDuke(createDukeVidal(), 'al', g.turn, 'ally');
+ assert.equal(expireDuke(loan, g.turn).controller, null, 'New-alliance loan remains a distinct turn-limited source');
+ const used = consumeDuke(shared); assert.equal(used.controller, null); assert.equal(used.source, null);
+ assert.equal(used.leader.usedAt, 'arrakeen'); assert.equal(used.leader.deaths, 2);
+ shared.leader.deaths = 99; assert.deepEqual(g, before, 'Mutating the detached quote cannot mutate a physical source disc');
+ assert.deepEqual(g.players.map(p => p.leaders), before.players.map(p => p.leaders));
+ }
+ });
+
+ void test(`${band} Ecaz-held Duke may transfer to its ally while same-recipient acquisition remains a no-op refusal`, () => {
+ const g = position(advanced); g.dukeVidal = acquireDuke(g.dukeVidal!, 'ec', 1, 'ecaz');
+ const oldReason = block(g, 'ec');
+ assert.equal(oldReason, 'Acquiring Duke Vidal when Ecaz already controls him is still being implemented.');
+ assert.throws(() => quote(g, 'ec'), error => error instanceof EcazDukeAcquisitionError && error.reason === 'alreadyControlled');
+ const shared = quote(g, 'ec', 'al'); assert.equal(shared.controller, 'al'); assert.equal(shared.source, 'ecaz');
+ g.dukeVidal = shared; const before = structuredClone(g);
+ assert.equal(block(g, 'ec', 'al'), 'The chosen beneficiary already controls Duke Vidal.');
+ assert.throws(() => quote(g, 'ec', 'al'), error => error instanceof EcazDukeAcquisitionError && error.reason === 'alreadyControlled');
+ assert.deepEqual(g, before);
+ // Public same-controller descriptors do not vary with a private capture or
+ // Ghola marker. They confer no permission to transfer that custody.
+ g.dukeVidal.leader.capturedBy = 'g';
+ assert.equal(block(g, 'ec', 'al'), 'The chosen beneficiary already controls Duke Vidal.');
+ rejected(g, 'ec', 'ec');
+ delete g.dukeVidal.leader.capturedBy; g.dukeVidal.leader.gholaBy = 'g';
+ assert.equal(block(g, 'ec', 'al'), 'The chosen beneficiary already controls Duke Vidal.');
+ rejected(g, 'ec', 'ec');
+ });
+}
+
+void test('shared ordinary Duke acquisition rechecks the current unique Ecaz owner and reciprocal recipient', () => {
+ for (const change of [
+ (g: Position) => { seat(g, 'ec').ally = null; },
+ (g: Position) => { seat(g, 'al').ally = null; },
+ (g: Position) => { seat(g, 'ec').ally = 'g'; },
+ (g: Position) => { seat(g, 'al').ally = 'g'; },
+ (g: Position) => { g.players = g.players.filter(p => p.id !== 'al'); },
+ (g: Position) => { g.players.push(newPlayer('al', 'Duplicate recipient', 'emperor')); },
+ (g: Position) => { g.players.push(newPlayer('secondEcaz', 'Duplicate native owner', 'ecaz')); },
+ (g: Position) => { g.status = 'setup'; },
+ (g: Position) => { g.turn = 0; },
+ ]) { const g = position(); change(g); rejected(g); }
+ const g = position(); rejected(g, 'al', 'ec'); rejected(g, 'ec', 'g'); rejected(g, 'ec', 'missing');
+ const earlier = quote(g, 'ec', 'al'); seat(g, 'al').ally = null; rejected(g);
+ assert.equal(earlier.controller, 'al', 'An earlier detached quote cannot authorize a now-broken alliance');
+});
+
+void test('shared ordinary Duke quote blocks exceptional physical custody with one generic public reason and supplies no revival', () => {
+ for (const advanced of [false, true]) for (const change of [
+ (g: Position) => { delete g.dukeVidal; },
+ (g: Position) => { g.dukeVidal!.leader.dead = true; },
+ (g: Position) => { g.dukeVidal!.leader.capturedBy = 'g'; },
+ (g: Position) => { g.dukeVidal!.leader.gholaBy = 'g'; },
+ (g: Position) => { g.dukeVidal!.leader.concealed = { captor: 'g', controller: 'ec', dead: false, deaths: 0 }; },
+ (g: Position) => { g.dukeVidal!.captureVisibility = { controller: 'ec', acquiredTurn: 1, source: 'ecaz' }; },
+ (g: Position) => { g.dukeVidal!.leader.id = 'replacement-disc'; },
+ (g: Position) => { g.dukeVidal!.leader.faction = 'atreides'; },
+ (g: Position) => { g.dukeVidal!.leader.strength = 5; },
+ (g: Position) => { g.dukeVidal!.leader.deaths = -1; },
+ (g: Position) => { g.dukeVidal!.controller = 'absent'; },
+ (g: Position) => { g.dukeVidal = acquireDuke(g.dukeVidal!, 'm', 3, 'moritani'); },
+ ]) {
+ const g = position(advanced); change(g); rejected(g);
+ assert.equal(g.players.flatMap(p => p.leaders).some(leader => leader.id === 'duke-vidal'), false);
+ }
+});
+
+void test('sharing ordinary acquisition preserves the existing Advanced Harkonnen table boundary and admitted no-Skills profile', () => {
+ const g = position(true); seat(g, 'g').faction = 'harkonnen';
+ const reason = block(g, 'ec', 'al'); assert.match(reason!, /Advanced Harkonnen/);
+ assert.throws(() => quote(g, 'ec', 'al'), error => error instanceof EcazDukeAcquisitionError && error.reason === 'table');
+ g.dukeVidal!.leader.capturedBy = 'g'; assert.equal(block(g, 'ec', 'al'), reason);
+ delete g.dukeVidal!.leader.capturedBy;
+ const basic = structuredClone(g); basic.advanced = false; assert.equal(block(basic, 'ec', 'al'), null);
+ g.ecazTreachery = true; g.discoveryEnabled = true;
+ assert.equal(quote(g, 'ec', 'al').controller, 'al'); assert.equal(block(g, 'ec', 'al'), null);
+ g.leaderSkills = {}; assert.equal(block(g, 'ec', 'al'), reason);
+});
diff --git a/tests/ecaz-duke-bots.test.ts b/tests/ecaz-duke-bots.test.ts
index ed7c7989..f386f4a9 100644
--- a/tests/ecaz-duke-bots.test.ts
+++ b/tests/ecaz-duke-bots.test.ts
@@ -148,12 +148,6 @@ const unavailable: [string, (g: Game) => void][] = [
g.dukeVidal!.leader.deaths = 1;
},
],
- [
- 'already controlled by Ecaz',
- (g) => {
- g.dukeVidal = acquireDuke(g.dukeVidal!, 'ec', 1, 'ecaz');
- },
- ],
[
'Advanced Harkonnen table remains gated',
(g) => {
@@ -199,7 +193,23 @@ void test('only the actual owner gets the choice, and bots cannot acquire from a
assert.deepEqual(botActions(botView(g, level, id)), []);
}
});
-void test('all profiles rely on the server descriptor, with safe decline for a missing descriptor', () => {
+void test('all profiles share an Ecaz-held Duke with its offered existing ally', () => {
+ const state = fixture(false, true);
+ state.dukeVidal = acquireDuke(state.dukeVidal!, 'ec', 1, 'ecaz');
+ const g = enter(state);
+ for (const level of DIFFICULTIES) {
+ const selected = action(g, level);
+ assert.deepEqual(selected, { type: 'decision', event: g.pendingAmbassador!.event,
+ trigger: true, beneficiary: 'al', choice: 'duke' });
+ const done = applyAction(g, 'ec', selected);
+ assert.equal(done.dukeVidal!.controller, 'al');
+ assert.equal(done.dukeVidal!.source, 'ecaz');
+ assert.deepEqual(done.players, g.players);
+ assert.equal(done.ecazAmbassadors!.tokens.find(token => token.effect === 'ecaz')!.zone, 'supply');
+ assert.deepEqual(done.ecazAmbassadors!.cohort, g.ecazAmbassadors!.cohort);
+ }
+});
+void test('all profiles rely on offered recipient descriptors, with safe decline when all are missing', () => {
const g = enter(fixture(false, true));
for (const level of DIFFICULTIES) {
const view = botView(g, level);
@@ -210,6 +220,8 @@ void test('all profiles rely on the server descriptor, with safe decline for a m
});
assert.equal(botActions(view)[0].choice, 'duke');
view.ambassadorEntry!.dukeAcquisition = null;
+ assert.equal(botActions(view)[0].choice, 'duke');
+ view.ambassadorEntry!.beneficiaries = [];
assert.deepEqual(botActions(view), [
{ type: 'decision', event: g.pendingAmbassador!.event, decline: true },
]);
diff --git a/tests/fixture-ecaz-duke-ally.ts b/tests/fixture-ecaz-duke-ally.ts
new file mode 100644
index 00000000..91fec489
--- /dev/null
+++ b/tests/fixture-ecaz-duke-ally.ts
@@ -0,0 +1,171 @@
+import assert from 'node:assert/strict';
+import { createGame, joinGame, newPlayer, type Action, type Game } from '../game/engine';
+import { DUKE_VIDAL_ID } from '../game/duke-vidal';
+import { validateNexusCards, NEXUS_FACTIONS } from '../game/nexus-cards';
+import { actDukeAmbassador, dukeAmbassadorClean, dukeAmbassadorPlayer,
+ nextDukeAmbassadorStep } from './fixture-duke-ambassador';
+import { advisorPreparationInventory } from './fixture-advisor-preparation';
+
+export type EcazDukeAllyStep = { actor: string; action: Action };
+export const actEcazDukeAlly = actDukeAmbassador;
+export const ecazDukeAllyClean = dukeAmbassadorClean;
+export const ecazDukeAllyPlayer = dukeAmbassadorPlayer;
+export function ecazDukeAllyInventory(game: Game) {
+ const treachery = advisorPreparationInventory(game);
+ assert.equal(treachery.length, 33, 'The source retains the original base deck without selecting E3 Treachery');
+ assert.ok(game.nexusCards?.cards);
+ validateNexusCards(game.nexusCards.cards, game.players);
+ const cards = game.nexusCards.cards, nexus = [...cards.deck, ...cards.discard,
+ ...Object.values(cards.hands).filter(card => card !== null)].sort();
+ assert.deepEqual(nexus, [...NEXUS_FACTIONS].sort());
+ assert.ok(game.players.every(player => !player.leaders.some(leader => leader.id === DUKE_VIDAL_ID)));
+ assert.ok(!(game.traitorReserve ?? []).includes(DUKE_VIDAL_ID));
+ assert.ok(game.players.every(player => ![...player.traitors, ...player.traitorChoices].includes(DUKE_VIDAL_ID)));
+ return { treachery, nexus };
+}
+export function ecazDukeAllyPhysical(game: Game) {
+ return { active: game.active, movementRemaining: [...(game.movementRemaining ?? [])],
+ players: game.players.map(player => ({ id: player.id, spice: player.spice, forces: structuredClone(player.forces),
+ reserves: player.reserves, tanks: player.tanks, elites: structuredClone(player.elites),
+ shipped: player.shipped, moved: player.moved })) };
+}
+export function nextEcazDukeAllyStep(game: Game): EcazDukeAllyStep | null {
+ if (ecazDukeAllyClean(game) && game.nexusCards?.phase?.stage === 'drawing') {
+ const actor = game.nexusCards.phase.eligible.find(id => !game.nexusCards!.phase!.done.includes(id));
+ assert.ok(actor);
+ return { actor, action: { type: 'nexusCardChoice', turn: game.turn,
+ card: game.nexusCards.cards!.hands[actor], choice: 'keep', ownRedraws: 0 } };
+ }
+ return nextDukeAmbassadorStep(game);
+}
+export function advanceEcazDukeAlly(game: Game, until: (state: Game) => boolean,
+ actions: EcazDukeAllyStep[] = []): Game {
+ for (let count = 0; count < 1800; count++) {
+ if (until(game)) return game;
+ assert.notEqual(game.status, 'finished', 'The native existing-ally Duke source ended before its owned boundary');
+ const step = nextEcazDukeAllyStep(game);
+ assert.ok(step, `Choose the actual ${game.turn}/${game.phase}/${game.decision?.kind} before continuing`);
+ game = actEcazDukeAlly(game, step.actor, step.action, actions);
+ }
+ throw Error('The native existing-ally Duke source did not reach its finite boundary');
+}
+function orderSpice(game: Game, names: readonly string[]) {
+ const cards = names.map(name => {
+ const at = game.spiceDeck.findIndex(card => name === 'worm'
+ ? 'worm' in card && !card.greatMaker && !card.thumper && !card.suppressed
+ : 'territory' in card && !card.discovery && card.territory === name);
+ assert.ok(at >= 0, `The original ${name} Spice Card must remain unopened`);
+ return game.spiceDeck.splice(at, 1)[0];
+ });
+ game.spiceDeck.unshift(...cards);
+}
+
+/** Native self-acquisition is deliberately left unused through Mentat. A real
+ * next-turn Nexus forms the alliance before the reusable token is placed and
+ * a different nonally entry earns the existing-ally beneficiary offer. */
+export function createEcazDukeAllyFixture(options: { advanced?: boolean } = {}) {
+ const advanced = options.advanced ?? false;
+ let game = createGame('ECAZDUKEALLY', newPlayer('ecaz', 'Ecaz', 'ecaz'), advanced, ['ecaz']);
+ for (const faction of ['atreides', 'emperor', 'guild'] as const)
+ joinGame(game, newPlayer(faction, faction, faction));
+ const actions: EcazDukeAllyStep[] = [];
+ const act = (actor: string, action: Action) => { game = actEcazDukeAlly(game, actor, action, actions); };
+ const reach = (until: (state: Game) => boolean) => { game = advanceEcazDukeAlly(game, until, actions); };
+ act(game.host, { type: 'moduleOption', module: 'nexusCards', enabled: true });
+ for (const player of game.players) act(player.id, { type: 'ready' });
+ const initial = structuredClone(game);
+ act(game.host, advanced ? { type: 'start', advancedPreview: true } : { type: 'start' });
+ const setup = structuredClone(game);
+ reach(state => state.status === 'playing');
+ const afterSetup = structuredClone(game), originalInventory = ecazDukeAllyInventory(game);
+ const token = game.ecazAmbassadors!.tokens.find(token => token.effect === 'ecaz');
+ assert.ok(token && token.zone === 'supply');
+ const cohort = [...game.ecazAmbassadors!.cohort];
+ orderSpice(game, ['broken_land', ...(advanced ? ['south_mesa'] : [])]);
+ const placements: { before: Game; action: Action; after: Game }[] = [];
+ const place = (territory: string) => {
+ reach(state => !state.response && !state.phaseOpening && state.decision?.kind === 'ecazPlacement');
+ const before = structuredClone(game), action: Action = { type: 'decision', token: token.id, territory };
+ act('ecaz', action);
+ reach(state => !state.response && !state.phaseOpening && state.decision?.kind === 'ecazPlacement');
+ placements.push({ before, action, after: structuredClone(game) });
+ act('ecaz', { type: 'decision', decline: true });
+ };
+ const shipments: { before: Game; step: EcazDukeAllyStep; after: Game }[] = [];
+ const ship = (actor: string, territory: string, sector: number, offer: boolean) => {
+ reach(state => state.phase === 5 && state.active === actor && ecazDukeAllyClean(state));
+ const before = structuredClone(game), step: EcazDukeAllyStep = { actor,
+ action: { type: 'ship', territory, sector, amount: 1, elite: 0, allyPayment: 0 } };
+ act(actor, step.action);
+ reach(state => offer
+ ? !state.response && !state.phaseOpening && state.pendingAmbassador?.stage === 'offer'
+ : ecazDukeAllyClean(state));
+ shipments.push({ before, step, after: structuredClone(game) });
+ };
+ place('habbanya_ridge_sietch');
+ ship('emperor', 'habbanya_ridge_sietch', 17, true);
+ const beforeSelf = structuredClone(game), selfAction: Action = { type: 'decision',
+ event: game.pendingAmbassador!.event, trigger: true, beneficiary: 'ecaz', choice: 'duke' };
+ act('ecaz', selfAction); reach(ecazDukeAllyClean);
+ const afterSelf = structuredClone(game);
+ assert.deepEqual([game.dukeVidal!.controller, game.dukeVidal!.source, game.dukeVidal!.acquiredTurn], ['ecaz', 'ecaz', 1]);
+ assert.equal(game.ecazAmbassadors!.tokens.find(current => current.id === token.id)!.zone, 'supply');
+ reach(state => state.turn === 2 && state.phase === 1 && ecazDukeAllyClean(state));
+ const afterMentat = structuredClone(game);
+ assert.equal(game.dukeVidal!.controller, 'ecaz', 'The unused ordinary Ecaz acquisition persists across the turn boundary');
+ orderSpice(game, ['worm', 'hagga_basin', ...(advanced ? ['red_chasm'] : [])]);
+ reach(state => state.nexus && !state.spiceWindow && !state.spiceResolution && ecazDukeAllyClean(state));
+ const beforeAlliance = structuredClone(game), allianceStart = actions.length;
+ act('ecaz', { type: 'alliance', target: 'atreides' });
+ act('atreides', { type: 'alliance', target: 'ecaz' });
+ const alliance = { before: beforeAlliance, actions: structuredClone(actions.slice(allianceStart)), after: structuredClone(game) };
+ place('carthag');
+ ship('atreides', 'carthag', 11, false);
+ ship('emperor', 'carthag', 11, true);
+ assert.equal(game.turn, 2);
+ assert.equal(game.pendingAmbassador!.owner, 'ecaz');
+ assert.equal(game.pendingAmbassador!.entrant, 'emperor');
+ assert.equal(game.pendingAmbassador!.token, token.id);
+ assert.equal(game.dukeVidal!.controller, 'ecaz');
+ const event = game.pendingAmbassador!.event;
+ const action: Action = { type: 'decision', event, trigger: true, beneficiary: 'atreides', choice: 'duke' };
+ return { game, advanced, owner: 'ecaz', holder: 'ecaz', ally: 'atreides', recipient: 'atreides', entrant: 'emperor',
+ opponent: 'emperor', duke: DUKE_VIDAL_ID, token: token.id, event, action, initial, setup, afterSetup,
+ originalInventory, cohort, placements, shipments, beforeSelf, selfAction, afterSelf, afterMentat, alliance,
+ originalDuke: structuredClone(game.dukeVidal!), originalParent: structuredClone(game.pendingAmbassador),
+ originalPhysical: ecazDukeAllyPhysical(game), actions, sourceActionCount: actions.length,
+ staging: ['Only original unopened Spice positions and native scalar lotteries are selected. Two actual Ecaz-token placements, Emperor entries, native self-acquisition, full unused Mentat, real worm/Nexus alliance and Atreides opposing shipment create this source.'] };
+}
+export type EcazDukeAllyFixture = ReturnType;
+export function grantEcazDukeAlly(fixture: EcazDukeAllyFixture, game = structuredClone(fixture.game),
+ actions: EcazDukeAllyStep[] = []) {
+ return actEcazDukeAlly(game, fixture.owner, fixture.action, actions);
+}
+export function finishEcazDukeAlly(fixture: EcazDukeAllyFixture, game: Game,
+ actions: EcazDukeAllyStep[] = []) {
+ return advanceEcazDukeAlly(game, state => state.turn === fixture.game.turn && !state.pendingAmbassador && ecazDukeAllyClean(state), actions);
+}
+export function battleEcazDukeAlly(fixture: EcazDukeAllyFixture, game: Game,
+ actions: EcazDukeAllyStep[] = [], options: { useDuke?: boolean } = {}) {
+ game = advanceEcazDukeAlly(game, state => state.phase === 6 && ecazDukeAllyClean(state), actions);
+ assert.ok(game.active === fixture.ally || game.active === fixture.opponent);
+ const chooser = game.active;
+ game = actEcazDukeAlly(game, chooser, { type: 'chooseBattle', territory: 'carthag',
+ target: chooser === fixture.ally ? fixture.opponent : fixture.ally }, actions);
+ game = advanceEcazDukeAlly(game, state => !!state.battle && (!state.battle.preLeader || state.battle.preLeader.closed) &&
+ !state.battle.preparation && ecazDukeAllyClean(state), actions);
+ const beforePlans = structuredClone(game), opponent = [...ecazDukeAllyPlayer(game, fixture.opponent).leaders]
+ .filter(leader => !leader.dead && !leader.usedAt).sort((a, b) => a.strength - b.strength)[0];
+ const native = [...ecazDukeAllyPlayer(game, fixture.ally).leaders]
+ .filter(leader => !leader.dead && !leader.usedAt).sort((a, b) => b.strength - a.strength)[0];
+ const useDuke = options.useDuke ?? true;
+ assert.ok(opponent && native && (useDuke ? 6 : native.strength) > opponent.strength);
+ const plans: EcazDukeAllyStep[] = [
+ { actor: fixture.ally, action: { type: 'battlePlan', leader: useDuke ? DUKE_VIDAL_ID : native.id, dial: 0, support: 0 } },
+ { actor: fixture.opponent, action: { type: 'battlePlan', leader: opponent.id, dial: 0, support: 0 } },
+ ];
+ for (const step of plans) game = actEcazDukeAlly(game, step.actor, step.action, actions);
+ const sealed = structuredClone(game);
+ game = advanceEcazDukeAlly(game, state => !state.battle && ecazDukeAllyClean(state), actions);
+ return { game, beforePlans, sealed, plans, actions };
+}
diff --git a/tests/nexus-ecaz-duke-captured.test.ts b/tests/nexus-ecaz-duke-captured.test.ts
index 44bba5f0..0b380530 100644
--- a/tests/nexus-ecaz-duke-captured.test.ts
+++ b/tests/nexus-ecaz-duke-captured.test.ts
@@ -33,6 +33,18 @@ function position(): Game {
} as unknown as Game;
}
+void test('Cunning validates captured ordinary Ambassador tenure held by a former ally recipient', () => {
+ // Controlled custody input, separate from the native token/alliance source.
+ const g = position();
+ const shared = acquireDuke(g.dukeVidal!, 'g', 2, 'ecaz');
+ g.dukeVidal = captureDuke(shared, 'h', 'g', 3, 'carthag');
+ assert.equal(quoteNexusEcazDuke(g, 'e')!.blocked, null);
+ const receipt = createNexusEcazDukeReceipt(g, 'e');
+ assert.equal(receipt.before.controller, 'g');
+ assert.equal(receipt.before.source, 'ecaz');
+ assert.doesNotThrow(() => validateNexusEcazDuke(g, receipt, 0));
+});
+
void test('Cunning sees actual capture over its own underlying Ecaz tenure and liberates it for this turn only', () => {
const g = position();
const owned = acquireDuke(g.dukeVidal!, 'e', 1, 'ecaz');
diff --git a/tests/nexus-ecaz-duke-quote.test.ts b/tests/nexus-ecaz-duke-quote.test.ts
index b92c1fa1..c9a88856 100644
--- a/tests/nexus-ecaz-duke-quote.test.ts
+++ b/tests/nexus-ecaz-duke-quote.test.ts
@@ -30,6 +30,21 @@ function game(advanced = false): Game {
} as unknown as Game;
}
+void test('Ecaz Cunning validates an ordinary Ambassador acquisition held by another seated recipient', () => {
+ // Controlled prior custody: ordinary sharing can outlast the earlier alliance.
+ const g = game();
+ g.dukeVidal = acquireDuke(g.dukeVidal!, 'atreides-seat', 2, 'ecaz');
+ assert.equal(quoteNexusEcazDuke(g, 'ecaz-seat')!.blocked, null);
+ const record = createNexusEcazDukeReceipt(g, 'ecaz-seat');
+ assert.equal(record.before.controller, 'atreides-seat');
+ assert.equal(record.before.source, 'ecaz');
+ g.dukeVidal = acquireDuke(g.dukeVidal!, 'ecaz-seat', g.turn, 'ecazNexus');
+ g.nexusCards!.cards = discardNexusCard(g.nexusCards!.cards!, 'ecaz-seat', g.players);
+ g.nexusEcazDukeHistory = [record];
+ g.nexusEcazDukeEvents = [record.event];
+ assert.doesNotThrow(() => validateNexusEcazDuke(g, record, 0));
+});
+
void test('native physical Ecaz Cunning overrides Moritani at a quiet Battle boundary', () => {
const g = game(true);
g.dukeVidal = acquireDuke(g.dukeVidal!, 'moritani-seat', 3, 'moritani');
@@ -253,7 +268,9 @@ void test('damaged and re-signed impossible history cannot forge the original Du
invalid(record => { record.before.acquiredTurn = 4; });
invalid(record => { record.before.acquiredTurn = 2; });
invalid(record => { record.before.source = null; });
- invalid(record => { record.before.source = 'ecaz'; });
+ // Ordinary Ecaz tenure may now belong to a shared recipient; Nexus tenure
+ // still cannot be forged for a non-Ecaz controller.
+ invalid(record => { record.before.source = 'ecazNexus'; });
invalid(record => {
record.before.acquiredTurn = 2;
record.event = nexusEcazDukeEvent(3, 6, 'ecaz-seat', 0, 'moritani-seat', 2);