Grant Duke Vidal through occupied Ecaz Homeworld

This commit is contained in:
Quintine committed 2026-10-26 06:44:16 +11:00
1 parent d5c5342d2d
commit 78bcf22bca
18 files changed
+1291 -9

No files matched your search

+3
View File
@@ -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)
+8 -5
View File
@@ -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<DukeState, 'controller' | 'acquiredTurn' | 's
? state.acquiredTurn !== null || state.source !== null
: typeof state.controller !== 'string' || !state.controller.trim() ||
!Number.isSafeInteger(state.acquiredTurn) || state.acquiredTurn! < 1 ||
state.acquiredTurn! > 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.',
]),
+66 -4
View File
@@ -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,
+153
View File
@@ -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<Game, 'status'> & {
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<string>();
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['reason']>): 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;
}
+3
View File
@@ -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.';
+23
View File
@@ -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',