/** A separate question from ordinary leader revival or merely playing Ghola. */ export type GholaLeaderClaim = { turn: number; leader: string }; export type GholaLeaderPromise = GholaLeaderClaim & { event: string; player: string; asker: string; answer: boolean; fulfilled?: boolean; released?: boolean; }; export class GholaLeaderPromiseError extends Error { constructor(message: string) { super(message); this.name = 'GholaLeaderPromiseError'; } } const identifier = (value: unknown): value is string => typeof value === 'string' && value.length > 0 && value === value.trim(); const optionalBoolean = (value: unknown) => value === undefined || typeof value === 'boolean'; /** Question envelopes may contain their own kind and target; extract only the * canonical claim, never coercing an identity or turn. */ export function parseGholaLeaderClaim(value: unknown): GholaLeaderClaim { if (!value || typeof value !== 'object' || Array.isArray(value)) throw new GholaLeaderPromiseError('Choose a current-turn named Ghola leader question.'); const input = value as Record; if (typeof input.turn !== 'number' || !Number.isSafeInteger(input.turn) || input.turn < 1 || !identifier(input.leader)) throw new GholaLeaderPromiseError('Choose the current turn and exact leader identity.'); return { turn: input.turn, leader: input.leader }; } export function gholaLeaderClaimText(claim: GholaLeaderClaim, leaderName: (id: string) => string = id => id === 'kwisatz' ? 'Kwisatz Haderach' : id): string { return `revive ${leaderName(claim.leader)} with Ghola from now until the end of this turn`; } export function liveGholaLeaderPromises(promises: readonly GholaLeaderPromise[], player: string, turn: number): GholaLeaderPromise[] { return promises.filter(promise => promise.player === player && promise.turn === turn && !promise.fulfilled && !promise.released); } /** This describes obligations, not permission or a guarantee that enough cards * exist. The engine's shared completion planner must establish feasibility. */ export function gholaLeaderRequirements(promises: readonly GholaLeaderPromise[], player: string, turn: number): { required: string[]; forbidden: string[] } | null { const required = new Set(), forbidden = new Set(); for (const promise of liveGholaLeaderPromises(promises, player, turn)) (promise.answer ? required : forbidden).add(promise.leader); if ([...required].some(leader => forbidden.has(leader))) return null; return { required: [...required], forbidden: [...forbidden] }; } /** Call only for an actual successful Ghola leader return, never for a play * returning forces, a proposal, or any other source of revival. */ export function recordPromisedGholaLeader(promises: readonly GholaLeaderPromise[], player: string, turn: number, leader: string): GholaLeaderPromise[] { if (!identifier(leader)) throw new GholaLeaderPromiseError('Retain the exact Ghola-returned leader identity.'); const matching = liveGholaLeaderPromises(promises, player, turn).filter(promise => promise.leader === leader); if (matching.some(promise => !promise.answer)) throw new GholaLeaderPromiseError('This Ghola leader return conflicts with your Truthtrance No answer.'); return promises.map(promise => matching.includes(promise) ? { ...promise, fulfilled: true } : promise); } /** Validate durable event and participant identities; native engine rules own * the physical leader, ownership, costs, and legal Ghola source. */ export function validateGholaLeaderPromises(promises: readonly GholaLeaderPromise[], players: readonly string[]): void { if (!Array.isArray(promises)) throw new GholaLeaderPromiseError('The Ghola leader commitment record is invalid.'); const events = new Set(); for (const promise of promises) { parseGholaLeaderClaim(promise); if (!identifier(promise.event) || events.has(promise.event) || !identifier(promise.player) || !identifier(promise.asker) || !players.includes(promise.player) || !players.includes(promise.asker) || promise.player === promise.asker || typeof promise.answer !== 'boolean' || !optionalBoolean(promise.fulfilled) || !optionalBoolean(promise.released) || promise.fulfilled && promise.released) throw new GholaLeaderPromiseError('The Ghola leader commitment lost its event or participants.'); events.add(promise.event); } }