Files
Dune/game/ecaz-duke-revival.ts
T
Quintine 8793e23d6b Compose selected E3 independent cards with Nexus rules
Selected Ecaz/Moritani decks with independent cards and Nexus now retain native Occupy, Advanced assassination, arrival ordering and shared Duke consumers, including original optional modules. Keep the trained E3/Harkonnen capture boundary and remaining individual Nexus effect limits explicit.

Validation: frozen 17-file 168/168, types/lint/build, seven finite original-action and table-SSR programmes, plus four minimal legal Cunning/expiry policies. Fixture corrections preserve the printed alliance draw gate and real paid placement affordability. No stored-game or production change; comprehensive and deployed acceptance remain open.
2026-10-18 10:59:57 +11:00

175 lines
5.9 KiB
TypeScript
Raw 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 { 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 && (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;
}
}