import type { Game } from './engine'; /** Early questions use the native pre-resolution Storm or pre-draw Spice * window. Other phases retain their existing quiet-interaction checks. */ export function futureBattleLeaderTiming(g: Pick): boolean { if (g.phase === 0) return Number.isInteger(g.stormPending) && !g.stormResolution; if (g.phase === 1) return !g.spiceSequence && !g.spiceWindow && !g.spiceResolution && !g.nexus && !g.summonedWorm && !g.wormRides.length; return [2, 3, 4, 5].includes(g.phase); } /** A conditional commitment for the respondent's first later native battle. * Waiting does not require a battle to occur. The engine owns plan legality, * answer feasibility and the decision that an obligation became impossible. */ export type FutureBattleLeaderReceipt = { turn: number; asker: string; player: string; leader: string; answer: boolean; battle?: string; status: 'waiting' | 'bound' | 'settled' | 'expired' | 'released'; }; export type FutureBattleLeaderSource = { turn: number; event: string; attacker: string; defender: string; native: boolean; plansOpen: boolean; }; export class FutureBattleLeaderError extends Error {} function check(condition: unknown, message: string): asserts condition { if (!condition) throw new FutureBattleLeaderError(message); } function validId(value: unknown): value is string { return typeof value === 'string' && value.length <= 256 && value.trim() === value && value.length > 0; } function validTurn(value: unknown): value is number { return Number.isSafeInteger(value) && (value as number) > 0; } function validateReceipt(receipt: FutureBattleLeaderReceipt): void { check(!!receipt && typeof receipt === 'object' && !Array.isArray(receipt), 'Invalid future battle receipt.'); check(validTurn(receipt.turn) && validId(receipt.asker) && validId(receipt.player) && receipt.asker !== receipt.player && validId(receipt.leader) && typeof receipt.answer === 'boolean', 'Invalid future battle question.'); check(['waiting', 'bound', 'settled', 'expired', 'released'].includes(receipt.status), 'Invalid future battle status.'); check(receipt.battle === undefined || validId(receipt.battle), 'Invalid future battle identity.'); check(receipt.status !== 'waiting' && receipt.status !== 'expired' || receipt.battle === undefined, 'A waiting or expired question cannot name a battle.'); check(receipt.status !== 'bound' && receipt.status !== 'settled' || receipt.battle !== undefined, 'A bound or settled question must name its battle.'); } /** Caller supplies the respondent's public, native leader identities; no hand is inspected. */ export function createFutureBattleLeader( question: Pick, nativeLeaders: readonly string[], ): FutureBattleLeaderReceipt { check(!!question && typeof question === 'object', 'Invalid future battle question.'); const receipt: FutureBattleLeaderReceipt = { turn: question.turn, asker: question.asker, player: question.player, leader: question.leader, answer: question.answer, status: 'waiting' }; validateReceipt(receipt); check(Array.isArray(nativeLeaders) && nativeLeaders.every(validId) && new Set(nativeLeaders).size === nativeLeaders.length && nativeLeaders.includes(receipt.leader), 'Choose one of the respondent’s native leader identities.'); return receipt; } /** Observe a real battle; unrelated battles and nonmatching turns leave it waiting. */ export function bindFutureBattleLeader( receipt: FutureBattleLeaderReceipt, source: FutureBattleLeaderSource, ): FutureBattleLeaderReceipt { validateReceipt(receipt); check(!!source && typeof source === 'object' && validTurn(source.turn) && validId(source.event) && validId(source.attacker) && validId(source.defender) && source.attacker !== source.defender && typeof source.native === 'boolean' && typeof source.plansOpen === 'boolean', 'Invalid observed battle.'); if (receipt.status !== 'waiting' || receipt.turn !== source.turn || !source.native || (receipt.player !== source.attacker && receipt.player !== source.defender)) return receipt; check(source.plansOpen, 'The respondent’s first qualifying battle is already past plan preparation.'); return { ...receipt, battle: source.event, status: 'bound' }; } /** Only the actual revealed respondent plan can settle the linked receipt. */ export function settleFutureBattleLeader( receipt: FutureBattleLeaderReceipt, observed: { turn: number; battle: string; player: string; revealed: boolean; leader: string | null }, ): FutureBattleLeaderReceipt { validateReceipt(receipt); check(!!observed && typeof observed === 'object' && validTurn(observed.turn) && validId(observed.battle) && validId(observed.player) && typeof observed.revealed === 'boolean' && (observed.leader === null || validId(observed.leader)), 'Invalid revealed battle plan.'); check(receipt.turn === observed.turn && receipt.battle === observed.battle && receipt.player === observed.player, 'The revealed plan does not belong to this question.'); check((receipt.status === 'bound' || receipt.status === 'settled') && observed.revealed, 'Only an actual revealed bound plan can settle this question.'); check((observed.leader === receipt.leader) === receipt.answer, 'The revealed plan contradicts the active future battle answer.'); if (receipt.status === 'settled') return receipt; return { ...receipt, status: 'settled' }; } /** The caller must independently establish impossibility before invoking this. */ export function releaseFutureBattleLeader( receipt: FutureBattleLeaderReceipt, source: { turn: number; battle?: string }, ): FutureBattleLeaderReceipt { validateReceipt(receipt); check(!!source && typeof source === 'object' && validTurn(source.turn) && (source.battle === undefined || validId(source.battle)) && source.turn === receipt.turn && source.battle === receipt.battle, 'The release does not belong to this question.'); if (receipt.status === 'released') return receipt; check(receipt.status === 'waiting' || receipt.status === 'bound', 'This question cannot be released.'); return { ...receipt, status: 'released' }; } /** Only unbound questions expire when their original Battle phase has ended. */ export function expireFutureBattleLeader( receipt: FutureBattleLeaderReceipt, source: { turn: number; battlePhaseEnded: boolean }, ): FutureBattleLeaderReceipt { validateReceipt(receipt); check(!!source && typeof source === 'object' && validTurn(source.turn) && typeof source.battlePhaseEnded === 'boolean' && source.turn === receipt.turn, 'The phase end does not belong to this question.'); if (!source.battlePhaseEnded || receipt.status !== 'waiting') return receipt; return { ...receipt, status: 'expired' }; }