Files
Dune/game/ecaz-duke-revival.ts
Quintine 677ad7aa3c Compose independent cards with Discovery and optional Nexus
Connect original selected-deck Discovery setup, optional Nexus/World/Skills/phase modules and physical card consumers through shared public capabilities. Retain Cunning revealed-location arrival, Sandtrout suppression, paid nested R/Harass and actual later Recruits revival. Preserve native trained-capture and unrelated-preview boundaries.

Validation: frozen 15-file118/118, types/lint/build and six finite original-action/rendered-control programmes; four minimal legal policies. External smoke corrections changed no application source. Existing persistent notices also rechecked23/23. Full combinations, assurance and deployed acceptance remain open; no saved-game, restart or deployment operation.
2026-10-18 11:48:45 +11:00

175 lines
6.0 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { Game, Player } from './engine';
import type { Leader } from './cards';
import { DUKE_VIDAL_ID, returnedDukeCapture, type DukeState } from './duke-vidal';
import { independentDiscoveryProfile, nexusCoreProfile, type FactionModuleProfile } from './faction-module-profile';
import { mixedE3NexusProfile, pairedE3NexusProfile, standaloneE3NexusProfile } from './nexus-module-profile';
export const DUKE_REVIVAL_NORMAL_COST = 5 as const;
export type EcazDukeRevivalContext = Partial<FactionModuleProfile> & {
status: Game['status'];
turn: number;
advanced: boolean;
players: readonly (Pick<Player, 'id' | 'faction'> & {
leaders: readonly Pick<Leader, 'id'>[];
})[];
dukeVidal?: DukeState;
};
export type EcazDukeRevivalSource = {
source: 'sharedDuke';
player: string;
leader: typeof DUKE_VIDAL_ID;
normalCost: typeof DUKE_REVIVAL_NORMAL_COST;
/** The original dead disc, detached for inspection. This is NOT a revival outcome. */
duke: DukeState;
};
export type EcazDukeRevivalQuote = EcazDukeRevivalSource & {
cost: 3 | 5;
discounted: boolean;
};
export type EcazDukeRevivalFailure =
| 'context'
| 'owner'
| 'table'
| 'custody'
| 'price';
const unavailable = 'Duke Vidal is unavailable for this revival.';
const tableBlock =
'Duke Vidal revival with Advanced Harkonnen is still being implemented.';
export class EcazDukeRevivalError extends Error {
constructor(
message: string,
readonly reason: EcazDukeRevivalFailure = 'custody',
) {
super(message);
this.name = 'EcazDukeRevivalError';
}
}
function requireDuke(
condition: unknown,
reason: EcazDukeRevivalFailure = 'custody',
): asserts condition {
if (!condition)
throw new EcazDukeRevivalError(
reason === 'table' ? tableBlock : unavailable,
reason,
);
}
const whole = (n: unknown): n is number =>
Number.isSafeInteger(n) && (n as number) >= 0;
/** E3 pp.8–9: the same shared Ecaz disc costs five to revive, irrespective
* of native dead-leader count, and only Ecaz may revive it (including Ghola).
* This validates existing identity/custody only. It neither revives nor assigns
* a future controller, advances a cycle, spends a slot, or authorizes a window. */
export function resolveEcazDukeRevival(
g: EcazDukeRevivalContext,
ownerId: string,
): EcazDukeRevivalSource {
requireDuke(
g.status === 'playing' &&
whole(g.turn) &&
g.turn > 0 &&
typeof g.advanced === 'boolean' &&
Array.isArray(g.players) &&
g.players.every(
(p) => p && typeof p.id === 'string' && p.id.length > 0,
) &&
new Set(g.players.map((p) => p.id)).size === g.players.length,
'context',
);
requireDuke(
typeof ownerId === 'string' &&
g.players.filter((p) => p.faction === 'ecaz').length === 1 &&
g.players.some((p) => p.id === ownerId && p.faction === 'ecaz'),
'owner',
);
requireDuke(
!(g.advanced && g.players.some((p) => p.faction === 'harkonnen')) ||
(!!g.expansions && (independentDiscoveryProfile(g as FactionModuleProfile) || nexusCoreProfile(g as FactionModuleProfile) || standaloneE3NexusProfile(g as FactionModuleProfile) ||
pairedE3NexusProfile(g as FactionModuleProfile) || mixedE3NexusProfile(g as FactionModuleProfile))),
'table',
);
const duke = g.dukeVidal;
requireDuke(
duke &&
typeof duke === 'object' &&
!Array.isArray(duke) &&
duke.leader &&
typeof duke.leader === 'object' &&
!Array.isArray(duke.leader),
);
const leader = duke.leader;
requireDuke(
leader.id === DUKE_VIDAL_ID &&
leader.faction === 'ecaz' &&
leader.strength === 6 &&
typeof leader.name === 'string' &&
leader.name.length > 0 &&
leader.dead === true &&
whole(leader.deaths) &&
leader.deaths > 0 &&
(leader.usedAt === undefined ||
(typeof leader.usedAt === 'string' && leader.usedAt.length > 0)) &&
leader.capturedBy === undefined &&
leader.gholaBy === undefined &&
(leader.concealed === undefined ||
(returnedDukeCapture(duke) &&
g.players.some(player => player.id === leader.concealed?.captor && player.faction === 'harkonnen') &&
g.players.some(player => player.id === leader.concealed?.controller))),
);
requireDuke(
g.players.every(
(p) =>
Array.isArray(p.leaders) &&
p.leaders.every(
(l: Pick<Leader, 'id'>) =>
l && typeof l.id === 'string' && l.id !== DUKE_VIDAL_ID,
),
),
);
requireDuke(
duke.controller === null
? duke.acquiredTurn === null && duke.source === null
: typeof duke.controller === 'string' &&
g.players.some((p) => p.id === duke.controller) &&
whole(duke.acquiredTurn) &&
duke.acquiredTurn > 0 &&
duke.acquiredTurn <= g.turn &&
['ecaz', 'ecazNexus', 'moritani', 'ally'].includes(duke.source!),
);
return {
source: 'sharedDuke',
player: ownerId,
leader: DUKE_VIDAL_ID,
normalCost: DUKE_REVIVAL_NORMAL_COST,
duke: structuredClone(duke),
};
}
/** The caller supplies revivalDiscount(g, owner) for a NEW paid quote.
* Keeping that calculation outside this module permits revival.ts to reuse
* the resolver without an import cycle. Saved quotes must never be repriced
* merely because a current discount has changed. Ghola uses the resolver only. */
export function quoteEcazDukeRevival(
g: EcazDukeRevivalContext,
ownerId: string,
discounted: boolean,
): EcazDukeRevivalQuote {
const source = resolveEcazDukeRevival(g, ownerId);
requireDuke(typeof discounted === 'boolean', 'price');
return { ...source, cost: discounted ? 3 : 5, discounted };
}
/** No exceptional hidden custody detail appears in the projected explanation. */
export function ecazDukeRevivalBlock(
g: EcazDukeRevivalContext,
ownerId: string,
): string | null {
try {
resolveEcazDukeRevival(g, ownerId);
return null;
} catch (error) {
return error instanceof EcazDukeRevivalError && error.reason === 'table'
? tableBlock
: unavailable;
}
}