Retire occupied Ecaz Duke tenure through captivity

This commit is contained in:
Quintine committed 2026-10-27 15:36:44 +11:00
1 parent f0ebdf9c87
commit b5fb6aeb64
15 files changed
+767 -45

No files matched your search

+36 -2
View File
@@ -260,6 +260,36 @@ export function consumeDuke(state: DukeState): DukeState {
return released;
}
/** End only the original occupied-Ecaz lease. Harkonnen still holds the same
* disc, and a concealed return keeps its original public leader snapshot. */
export function retireHomeworldDukeTenure(state: DukeState, turn: number): DukeState {
validTurn(turn);
const original = state.source === 'homeworldEcaz' ? state :
state.captureVisibility?.source === 'homeworldEcaz' ? state.captureVisibility : null;
if (!original) return cloneDuke(state);
validDisc(state);
validUnderlay(state, turn);
validUnderlay(original, turn);
if (state.leader.capturedBy) validCaptive(state, turn);
else if ((state.leader.concealed || state.captureVisibility) && !returnedDukeCapture(state))
throw new Error('Duke Vidal lost his original concealed return.');
if (state.leader.gholaBy)
throw new Error('Occupied Ecaz cannot retire foreign Ghola custody.');
const retired = cloneDuke(state);
if (retired.source === 'homeworldEcaz') {
retired.controller = null;
retired.acquiredTurn = null;
retired.source = null;
}
if (retired.captureVisibility?.source === 'homeworldEcaz') {
retired.captureVisibility = { controller: null, acquiredTurn: null, source: null };
}
if (retired.leader.capturedBy) validCaptive(retired, turn);
else if ((retired.leader.concealed || retired.captureVisibility) && !returnedDukeCapture(retired))
throw new Error('Duke Vidal lost his original concealed return.');
return retired;
}
/**
* End-turn custody only. Ecaz's ordinary Ambassador acquisition lasts until
* battle use; Ecaz Nexus Cunning and the Basic Ambassador ally loan last this
@@ -269,8 +299,12 @@ export function consumeDuke(state: DukeState): DukeState {
*/
export function expireDuke(state: DukeState, turn: number): DukeState {
validTurn(turn);
const homeworld = state.source === 'homeworldEcaz' ? state :
state.captureVisibility?.source === 'homeworldEcaz' ? state.captureVisibility : null;
if (homeworld && homeworld.acquiredTurn !== null && homeworld.acquiredTurn <= turn)
return retireHomeworldDukeTenure(state, turn);
if (
(state.source === 'moritani' || state.source === 'ecazNexus' || state.source === 'ally' || state.source === 'homeworldEcaz') &&
(state.source === 'moritani' || state.source === 'ecazNexus' || state.source === 'ally') &&
state.acquiredTurn !== null &&
state.acquiredTurn <= turn &&
!state.leader.dead &&
@@ -294,7 +328,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.',
'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.',
'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. Captivity does not extend this grant; the captor keeps normal capture rights, and a later return cannot restore an ended grant.',
'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.',
]),
+8 -7
View File
@@ -182,7 +182,8 @@ 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 { quoteHomeworldEcazDuke, createHomeworldEcazDukeReceipt, quoteHomeworldEcazDukeLifecycle,
releaseCapturedHomeworldDuke, 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';
@@ -565,7 +566,7 @@ import {
expireDuke,
dukeController,
captureDuke,
releaseCapturedDuke,
retireHomeworldDukeTenure,
executeCapturedDuke,
returnedDukeCapture,
type DukeReleasedCaptureWitness,
@@ -27411,9 +27412,9 @@ 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.`);
if (lifecycle.release && g.dukeVidal) {
g.dukeVidal = retireHomeworldDukeTenure(g.dukeVidal, g.turn);
log(g, `The occupied Ecaz Duke grant ended: ${lifecycle.reason ?? 'its original entitlement ended'}. Its original tenure is retired.`);
}
const quote = homeworldRule(() => quoteHomeworldEcazDuke(g));
const duke = g.dukeVidal;
@@ -28381,7 +28382,7 @@ function returnCaptives(g: Game, captor: Player) {
returned++;
}
if (g.dukeVidal?.leader.capturedBy === captor.id) {
g.dukeVidal = releaseCapturedDuke(g.dukeVidal, captor.id, g.turn);
g.dukeVidal = homeworldRule(() => releaseCapturedHomeworldDuke(g, captor.id));
returned++;
}
if (returned)
@@ -31431,7 +31432,7 @@ function resolveBattle(g: Game, retreat?: DiplomatRetreatSelection) {
if (l && !l.dead) l.usedAt = b.territory;
if (l?.capturedBy === p.id) {
if (l.id === DUKE_VIDAL_ID && g.dukeVidal)
g.dukeVidal = releaseCapturedDuke(g.dukeVidal, p.id, g.turn);
g.dukeVidal = homeworldRule(() => releaseCapturedHomeworldDuke(g, p.id));
else {
delete l.capturedBy;
delete l.concealed;
+36 -15
View File
@@ -1,5 +1,5 @@
import type { Game } from './engine';
import { acquireDuke, type DukeState } from './duke-vidal';
import { acquireDuke, releaseCapturedDuke, retireHomeworldDukeTenure, type DukeState } from './duke-vidal';
import { HomeworldCustodyError } from './homeworld-custody';
import {
quoteStableHomeworldOccupation, type StableHomeworldOccupationContext,
@@ -123,31 +123,52 @@ export function createHomeworldEcazDukeReceipt(g: HomeworldEcazDukeContext, acto
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 {
/** The original public tenure can outlive actual secret execution. Only its
* public snapshot and qualification decide when that tenure ends. */
function homeworldDukeLifecycle(g: HomeworldEcazDukeContext, custodyBlocked: boolean): 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;
if (!duke) return none;
const tenure = duke.source === HOMEWORLD_ECAZ_DUKE_SOURCE ? duke :
duke.captureVisibility?.source === HOMEWORLD_ECAZ_DUKE_SOURCE ? duke.captureVisibility : null;
if (!tenure) 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 };
receipt.turn === tenure.acquiredTurn && receipt.recipient === tenure.controller);
requireGrant(receipt && tenure.controller, 'The active occupied Ecaz Duke lost its original grant.');
const own = { ...none, owner: tenure.controller };
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 visibleLeader = duke.leader.concealed ?? duke.leader;
if (visibleLeader.dead) return release('dead');
if (visibleLeader.usedAt) return release('used');
if (tenure.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');
if (tenure.controller !== source.occupier && tenure.controller !== source.ally) return release('recipientChanged');
if (custodyBlocked && (duke.leader.capturedBy || duke.leader.gholaBy || duke.leader.concealed || duke.captureVisibility))
return { ...own, blocked: pendingCustody };
return own;
}
/** Call after original battle/arrival parents. Release retires only the lease;
* it never frees a captured disc or changes a concealed public snapshot. */
export function quoteHomeworldEcazDukeLifecycle(g: HomeworldEcazDukeContext): HomeworldEcazDukeLifecycleQuote {
return homeworldDukeLifecycle(g, true);
}
/** Recheck the original lease at the actual captive return, even when the
* ordinary settled-boundary reconciliation is still waiting on its parent. */
export function releaseCapturedHomeworldDuke(g: HomeworldEcazDukeContext,
captor: string, turn = g.turn): DukeState {
requireGrant(g.dukeVidal, 'Choose the original captured Duke Vidal.');
const current = turn === g.turn ? g : { ...g, turn };
const lifecycle = homeworldDukeLifecycle(current, false);
requireGrant(!lifecycle.blocked, lifecycle.blocked ?? 'Occupied Ecaz Duke lost its current qualification.');
const state = lifecycle.release ? retireHomeworldDukeTenure(g.dukeVidal, turn) : g.dukeVidal;
return releaseCapturedDuke(state, captor, turn);
}
+3 -3
View File
@@ -5714,15 +5714,15 @@ export const RULE_TOPICS: RuleTopic[] = [
'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.',
'Only Ecaz may revive Duke Vidal under the user-selected rule, even while Ecaz is occupied. This grant cannot revive him. Fresh captured, concealed or foreign-Ghola acquisition stays guarded conservatively. Captivity does not extend an original occupied grant: the captor keeps normal capture rights, and a later return cannot restore tenure ended by public deadline or entitlement loss. 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: '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. Public captive lease expiry and actual-return entitlement checks preserve the existing capture; fresh liberation remains guarded.', 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'] },
{ 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-captive.test.ts', 'tests/homeworld-ecaz-duke-captive-engine.test.ts', 'tests/fixture-homeworld-ecaz-duke-captive.ts', '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'] },
],
},
{