Connect original Duke capture and Ecaz Nexus liberation
This commit is contained in:
1 parent
172363d9d3
commit
a24600ae1d
24 files changed
+2082
-235
No files matched your search
+182
-13
@@ -7,8 +7,22 @@ export type DukeState = {
|
||||
controller: string | null;
|
||||
acquiredTurn: number | null;
|
||||
source: DukeSource | null;
|
||||
/** Server-only original underlay retained across private capture and execution. */
|
||||
captureVisibility?: {
|
||||
controller: string | null;
|
||||
acquiredTurn: number | null;
|
||||
source: DukeSource | null;
|
||||
};
|
||||
};
|
||||
|
||||
/** A losing battle's living disc, recorded before its one-battle release. */
|
||||
export type DukeReleasedCaptureWitness = { deaths: number; usedAt: string };
|
||||
|
||||
/** Server-only effective custody. Projected clients must use projected leader fields. */
|
||||
export function dukeController(state: DukeState): string | null {
|
||||
return state.leader.capturedBy ?? state.leader.gholaBy ?? state.controller;
|
||||
}
|
||||
|
||||
/** One shared disc, deliberately outside every native roster and traitor inventory. */
|
||||
export function createDukeVidal(): DukeState {
|
||||
return {
|
||||
@@ -32,6 +46,7 @@ function cloneDuke(state: DukeState): DukeState {
|
||||
controller: state.controller,
|
||||
acquiredTurn: state.acquiredTurn,
|
||||
source: state.source,
|
||||
...(state.captureVisibility ? { captureVisibility: { ...state.captureVisibility } } : {}),
|
||||
};
|
||||
}
|
||||
function validTurn(turn: number) {
|
||||
@@ -39,11 +54,150 @@ function validTurn(turn: number) {
|
||||
throw new Error('Choose a valid turn for Duke Vidal custody.');
|
||||
}
|
||||
|
||||
function validDisc(state: DukeState) {
|
||||
const leader = state.leader;
|
||||
if (leader.id !== DUKE_VIDAL_ID || leader.faction !== 'ecaz' ||
|
||||
leader.strength !== 6 || typeof leader.dead !== 'boolean' ||
|
||||
!Number.isSafeInteger(leader.deaths) || leader.deaths < 0 ||
|
||||
(leader.usedAt !== undefined && (typeof leader.usedAt !== 'string' || !leader.usedAt)))
|
||||
throw new Error('Duke Vidal lost his shared physical identity or death history.');
|
||||
}
|
||||
|
||||
function validUnderlay(state: Pick<DukeState, 'controller' | 'acquiredTurn' | 'source'>, turn: number) {
|
||||
if (state.controller === null
|
||||
? 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!))
|
||||
throw new Error('Duke Vidal lost his underlying custody.');
|
||||
}
|
||||
|
||||
function validCaptive(state: DukeState, turn: number) {
|
||||
validDisc(state);
|
||||
validUnderlay(state, turn);
|
||||
const { leader } = state;
|
||||
const hidden = leader.concealed;
|
||||
const visibility = state.captureVisibility;
|
||||
if (!visibility || visibility.controller !== state.controller ||
|
||||
visibility.acquiredTurn !== state.acquiredTurn || visibility.source !== state.source)
|
||||
throw new Error('Duke Vidal lost his original captured visibility.');
|
||||
if (!leader.capturedBy || typeof leader.capturedBy !== 'string' ||
|
||||
!leader.capturedBy.trim() || leader.capturedBy === state.controller || leader.gholaBy ||
|
||||
(hidden && (hidden.captor !== leader.capturedBy ||
|
||||
typeof hidden.controller !== 'string' || !hidden.controller.trim() ||
|
||||
hidden.controller === hidden.captor || hidden.dead !== false ||
|
||||
!Number.isSafeInteger(hidden.deaths) || hidden.deaths < 0 ||
|
||||
hidden.deaths !== leader.deaths - (leader.dead ? 1 : 0) ||
|
||||
(hidden.usedAt !== undefined && (typeof hidden.usedAt !== 'string' || !hidden.usedAt)) ||
|
||||
(state.controller !== null && hidden.controller !== state.controller))))
|
||||
throw new Error('Duke Vidal lost his original captured custody.');
|
||||
}
|
||||
|
||||
/** A secretly executed captive is back in the Tanks, not still controlled by its captor.
|
||||
* Keep this exact snapshot until a real public return; it is not foreign-Ghola custody. */
|
||||
export function returnedDukeCapture(state: DukeState): boolean {
|
||||
const { leader } = state;
|
||||
const hidden = leader.concealed;
|
||||
const visibility = state.captureVisibility;
|
||||
if (!visibility) return false;
|
||||
try {
|
||||
validUnderlay(visibility, Number.MAX_SAFE_INTEGER);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
return leader.id === DUKE_VIDAL_ID && leader.faction === 'ecaz' &&
|
||||
leader.strength === 6 && leader.dead === true &&
|
||||
Number.isSafeInteger(leader.deaths) && leader.deaths > 0 &&
|
||||
leader.usedAt === undefined && leader.capturedBy === undefined &&
|
||||
leader.gholaBy === undefined && state.controller === null &&
|
||||
state.acquiredTurn === null && state.source === null &&
|
||||
!!hidden && typeof hidden.captor === 'string' && !!hidden.captor.trim() &&
|
||||
typeof hidden.controller === 'string' && !!hidden.controller.trim() &&
|
||||
hidden.controller !== hidden.captor && hidden.dead === false &&
|
||||
Number.isSafeInteger(hidden.deaths) && hidden.deaths >= 0 &&
|
||||
Math.max(2, hidden.deaths + 1) === leader.deaths &&
|
||||
(visibility.controller === null || visibility.controller === hidden.controller) &&
|
||||
(hidden.usedAt === undefined || (typeof hidden.usedAt === 'string' && !!hidden.usedAt));
|
||||
}
|
||||
|
||||
/** Provisional capture policy: retain an unused disc's underlying tenure.
|
||||
* A just-released battle disc instead requires the caller's original battle witness. */
|
||||
export function captureDuke(
|
||||
state: DukeState, captor: string, loser: string, turn: number,
|
||||
territory: string, releasedWitness?: DukeReleasedCaptureWitness,
|
||||
): DukeState {
|
||||
validTurn(turn);
|
||||
validDisc(state);
|
||||
validUnderlay(state, turn);
|
||||
if (typeof captor !== 'string' || !captor.trim() ||
|
||||
typeof loser !== 'string' || !loser.trim() || captor === loser ||
|
||||
typeof territory !== 'string' || !territory.trim() ||
|
||||
state.leader.dead || state.leader.capturedBy || state.leader.gholaBy ||
|
||||
state.leader.concealed || state.captureVisibility)
|
||||
throw new Error('Choose the original living losing Duke for capture.');
|
||||
if (releasedWitness
|
||||
? state.controller !== null || state.acquiredTurn !== null || state.source !== null ||
|
||||
!Number.isSafeInteger(releasedWitness.deaths) ||
|
||||
releasedWitness.deaths !== state.leader.deaths ||
|
||||
releasedWitness.usedAt !== territory || state.leader.usedAt !== territory
|
||||
: dukeController(state) !== loser || !!state.leader.usedAt ||
|
||||
(state.source !== 'ecaz' && state.acquiredTurn !== turn))
|
||||
throw new Error('Duke Vidal no longer matches the original losing battle custody.');
|
||||
const captured = cloneDuke(state);
|
||||
captured.captureVisibility = {
|
||||
controller: state.controller, acquiredTurn: state.acquiredTurn, source: state.source,
|
||||
};
|
||||
captured.leader.capturedBy = captor;
|
||||
captured.leader.concealed = {
|
||||
captor, controller: loser, dead: false, deaths: state.leader.deaths,
|
||||
...(state.leader.usedAt === undefined ? {} : { usedAt: state.leader.usedAt }),
|
||||
};
|
||||
return captured;
|
||||
}
|
||||
|
||||
/** Early unused release restores valid prior tenure, never native-roster custody.
|
||||
* Death, battle use and expired temporary tenure all return the same disc set aside. */
|
||||
export function releaseCapturedDuke(
|
||||
state: DukeState, captor: string, turn: number,
|
||||
): DukeState {
|
||||
validTurn(turn);
|
||||
validCaptive(state, turn);
|
||||
if (captor !== state.leader.capturedBy)
|
||||
throw new Error('Only the actual captor can release Duke Vidal.');
|
||||
const released = cloneDuke(state);
|
||||
delete released.leader.capturedBy;
|
||||
delete released.leader.concealed;
|
||||
delete released.captureVisibility;
|
||||
if (released.leader.dead || released.leader.usedAt ||
|
||||
(released.source !== 'ecaz' && released.acquiredTurn !== turn))
|
||||
return consumeDuke(released);
|
||||
return released;
|
||||
}
|
||||
|
||||
/** Original Harkonnen execution returns the actual dead disc to Ecaz's Tanks.
|
||||
* The caller pays the original two-spice bounty; unrelated seats retain their snapshot. */
|
||||
export function executeCapturedDuke(
|
||||
state: DukeState, captor: string, turn: number,
|
||||
): DukeState {
|
||||
validTurn(turn);
|
||||
validCaptive(state, turn);
|
||||
if (state.leader.dead || captor !== state.leader.capturedBy || !state.leader.concealed)
|
||||
throw new Error('Only the actual captor can execute the original living Duke.');
|
||||
const executed = cloneDuke(state);
|
||||
executed.leader.dead = true;
|
||||
executed.leader.deaths = Math.max(2, executed.leader.deaths + 1);
|
||||
delete executed.leader.usedAt;
|
||||
delete executed.leader.capturedBy;
|
||||
executed.controller = null;
|
||||
executed.acquiredTurn = null;
|
||||
executed.source = null;
|
||||
return executed;
|
||||
}
|
||||
|
||||
/**
|
||||
* The caller authorizes the faction power and resolves any applicable Karama.
|
||||
* Ecaz Nexus Cunning independently revives the set-aside shared disc from Tanks.
|
||||
* Captured/ghola acquisition remains an unresolved implementation boundary,
|
||||
* not a verified blanket restriction on the printed powers.
|
||||
* Ecaz Nexus Cunning liberates an actual captive; no foreign-Ghola source is legal.
|
||||
*/
|
||||
export function acquireDuke(
|
||||
state: DukeState,
|
||||
@@ -52,22 +206,35 @@ export function acquireDuke(
|
||||
source: DukeSource,
|
||||
): DukeState {
|
||||
validTurn(turn);
|
||||
validDisc(state);
|
||||
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))
|
||||
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.');
|
||||
if (state.leader.capturedBy || state.leader.gholaBy)
|
||||
throw new Error(
|
||||
'Acquiring Duke Vidal from captured or ghola custody is still being implemented.',
|
||||
);
|
||||
if (state.leader.gholaBy)
|
||||
throw new Error('Duke Vidal cannot be acquired from foreign Ghola custody.');
|
||||
if (state.leader.capturedBy) {
|
||||
if (source !== 'ecazNexus' || state.leader.dead)
|
||||
throw new Error('Only Ecaz Nexus Cunning can acquire the living captured Duke.');
|
||||
validCaptive(state, turn);
|
||||
} else if ((state.leader.concealed || state.captureVisibility) &&
|
||||
!(source === 'ecazNexus' && returnedDukeCapture(state))) {
|
||||
throw new Error('Duke Vidal has unresolved concealed custody.');
|
||||
}
|
||||
if (state.leader.dead &&
|
||||
(state.controller !== null || state.acquiredTurn !== null || state.source !== null))
|
||||
throw new Error('Duke Vidal must be set aside in the Tanks before Ecaz Nexus acquisition.');
|
||||
if (source === 'ecazNexus' && !state.leader.dead && state.leader.usedAt)
|
||||
throw new Error('Duke Vidal has already been used in battle this turn.');
|
||||
const acquired = cloneDuke(state);
|
||||
if (source === 'ecazNexus') {
|
||||
delete acquired.leader.capturedBy;
|
||||
delete acquired.leader.concealed;
|
||||
delete acquired.captureVisibility;
|
||||
}
|
||||
if (state.leader.dead) {
|
||||
acquired.leader.dead = false;
|
||||
// An independent resurrection permits a new battle use without erasing deaths.
|
||||
@@ -81,12 +248,14 @@ export function acquireDuke(
|
||||
|
||||
/** The battle caller resolves leader death and bounty before releasing temporary control. */
|
||||
export function consumeDuke(state: DukeState): DukeState {
|
||||
return {
|
||||
...cloneDuke(state),
|
||||
controller: null,
|
||||
acquiredTurn: null,
|
||||
source: null,
|
||||
};
|
||||
const released = cloneDuke(state);
|
||||
delete released.leader.capturedBy;
|
||||
delete released.leader.concealed;
|
||||
delete released.captureVisibility;
|
||||
released.controller = null;
|
||||
released.acquiredTurn = null;
|
||||
released.source = null;
|
||||
return released;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -127,7 +296,7 @@ export const DUKE_VIDAL_RULES = Object.freeze({
|
||||
/** Developer metadata: the guide describes rules beyond this module's custody helpers. */
|
||||
implementation: Object.freeze({
|
||||
custody: 'implemented',
|
||||
capture: 'not-implemented',
|
||||
capture: 'original-harkonnen-provisional-return',
|
||||
revival: 'ecaz-only-set-aside',
|
||||
ambassadorAcquisition: 'ecaz-self-implemented',
|
||||
ambassadorAlliance: 'consent-implemented',
|
||||
|
||||
Reference in new issue
Block a user