import { FACTIONS, type FactionId } from './catalog'; import { VOICE_KINDS } from './battle-cards'; import { quoteEcazOccupyBattle, type EcazOccupyBattleProfile } from './ecaz-occupy-battle'; import { validateNexusBorrowedVoice, type NexusBorrowedVoiceFrame } from './nexus-borrowed-voice'; export type CombatCheckKind = | 'kwisatz' | 'eliteStrength' | 'fremenSupport' | 'choamBattleAid'; export type CombatResponseKind = CombatCheckKind | 'voice' | 'prescience'; export type CombatCheck = { kind: CombatCheckKind; owner: string }; export type CombatResponseInput = { ecazOccupy?: EcazOccupyBattleProfile; status: string; phase: number; advanced: boolean; turn?: number; territoryIds: readonly string[]; players: readonly { id: string; faction: FactionId; ally?: string | null; specialKaramaUsed?: boolean; }[]; battle: { event?: string; nexusBorrowedVoice?: NexusBorrowedVoiceFrame; attacker: string; defender: string; territory: string; revealed: boolean; plans: unknown; powerChecks?: unknown; eliteBlocked?: unknown; fremenSupportBlocked?: unknown; kwisatzBlocked?: unknown; choamAidBlocked?: unknown; fullPlanOffered?: unknown; preparation?: unknown; voice?: unknown; prescience?: unknown; } | null; }; export type CombatResponseOperation = | { kind: 'next' } | { kind: 'finishPreparation' } | { kind: 'response'; response: { kind: CombatResponseKind; owner: string }; canceled: boolean; }; export type CombatResponseQuote = { /** Undefined preserves the legacy absence of a queue. */ powerChecks: CombatCheck[] | undefined; response: (CombatCheck & { passed: string[] }) | null; patches: { eliteBlocked?: string[]; fremenSupportBlocked?: boolean; kwisatzBlocked?: boolean; choamAidBlocked?: boolean; deleteVoice?: true; deletePrescience?: true; deletePreparation?: true; fullPlanOffered?: true; }; decision: { kind: 'fullPlanOffer'; player: string } | null; }; export class CombatResponseQuoteError extends Error { constructor(message: string) { super(message); this.name = 'CombatResponseQuoteError'; } } function requireCombat(value: unknown, message: string): asserts value { if (!value) throw new CombatResponseQuoteError(message); } function record(value: unknown): value is Record { return !!value && typeof value === 'object' && !Array.isArray(value); } function ids(value: unknown): value is string[] { return ( Array.isArray(value) && value.every((id) => typeof id === 'string' && id.length > 0) && new Set(value).size === value.length ); } export function isCombatResponseKind(kind: string): kind is CombatResponseKind { return [ 'kwisatz', 'eliteStrength', 'fremenSupport', 'choamBattleAid', 'voice', 'prescience', ].includes(kind); } /** A finite response/preparation successor. It neither evaluates plans nor * generates checks, reveals private information, or resolves a battle. */ export function quoteCombatResponse( input: CombatResponseInput, operation: CombatResponseOperation, ): CombatResponseQuote { try { return calculate(input, operation); } catch (error) { if (error instanceof CombatResponseQuoteError) throw error; throw new CombatResponseQuoteError( error instanceof Error ? error.message : 'Malformed combat response.', ); } } function calculate( input: CombatResponseInput, operation: CombatResponseOperation, ): CombatResponseQuote { const b = input.battle; requireCombat( input.status === 'playing' && input.phase === 6 && typeof input.advanced === 'boolean' && b && b.revealed === false, 'Combat responses require an unrevealed battle in the Battle Phase.', ); requireCombat( Array.isArray(input.players) && ids(input.players.map((p) => p.id)) && input.players.every((p) => FACTIONS.some((f) => f.id === p.faction)), 'Combat response seats are malformed.', ); const combatants = [b.attacker, b.defender]; requireCombat( ids(combatants) && combatants.every((id) => input.players.some((p) => p.id === id)) && typeof b.territory === 'string' && Array.isArray(input.territoryIds) && input.territoryIds.includes(b.territory), 'Combat responses need two distinct seated combatants and their territory.', ); const occupy = input.ecazOccupy; if (occupy) { const ecaz = input.players.find(player => player.id === occupy.ecaz); const ally = input.players.find(player => player.id === occupy.ally); requireCombat(input.advanced === occupy.advanced && ecaz?.faction === 'ecaz' && ally && combatants.includes(occupy.lead), 'Occupy force powers need the actual Ecaz coalition and selected lead.'); const profile = quoteEcazOccupyBattle({ advanced: input.advanced, battleOrderActor: occupy.battleOrderActor, ecaz: { id: ecaz.id, faction: 'ecaz', ally: ecaz.ally, forces: occupy.ecazForces }, ally: { id: ally.id, faction: ally.faction, ally: ally.ally, forces: occupy.allyForces, ...(occupy.allyNoFieldPresence ? { noFieldPresence: true as const } : {}) }, lead: occupy.lead, canceled: occupy.canceled, }); requireCombat(profile.forceOwner === occupy.forceOwner, 'Occupy lost its actual physical force owner.'); } requireCombat( record(b.plans) && Object.keys(b.plans).every( (id) => combatants.includes(id) && record((b.plans as Record)[id]), ), 'Combat response plans are malformed.', ); for (const flag of [ 'fremenSupportBlocked', 'kwisatzBlocked', 'choamAidBlocked', 'fullPlanOffered', ] as const) requireCombat( b[flag] === undefined || typeof b[flag] === 'boolean', 'Combat response flags are malformed.', ); requireCombat( b.eliteBlocked === undefined || (ids(b.eliteBlocked) && b.eliteBlocked.every((id) => combatants.some(seat => (seat === occupy?.lead ? occupy.forceOwner : seat) === id))), 'Blocked elite owners are malformed.', ); // This is only the possible ordering of already declared checks. Current // elite counts or KH availability are deliberately not benefit prerequisites. const possible: CombatCheck[] = []; for (const id of combatants) { const p = input.players.find(player => player.id === id)!; const opponent = combatants.find(seat => seat !== id)!; const other = input.players.find(player => player.id === (opponent === occupy?.lead ? occupy.forceOwner : opponent))!; if (input.advanced && p.faction === 'atreides') possible.push({ kind: 'kwisatz', owner: id }); const forceOwner = id === occupy?.lead ? occupy.forceOwner : id; const forcePlayer = input.players.find(player => player.id === forceOwner)!; if ( ((input.advanced && ['emperor', 'fremen'].includes(forcePlayer.faction)) || (!input.advanced && forcePlayer.faction === 'ixians')) && !(forcePlayer.faction === 'emperor' && other.faction === 'fremen') ) possible.push({ kind: 'eliteStrength', owner: forceOwner }); if (input.advanced && forcePlayer.faction === 'fremen') possible.push({ kind: 'fremenSupport', owner: forceOwner }); } const choam = input.players.find((p) => p.faction === 'choam'); if ( input.advanced && choam?.ally && combatants.includes(choam.ally) && input.players.find((p) => p.id === choam.ally)?.ally === choam.id ) possible.push({ kind: 'choamBattleAid', owner: choam.id }); const rank = (value: unknown): number => { requireCombat( record(value) && typeof value.kind === 'string' && typeof value.owner === 'string', 'Malformed combat response check.', ); const at = possible.findIndex( (c) => c.kind === value.kind && c.owner === value.owner, ); requireCombat( at >= 0, 'Combat response check has an invalid kind, owner or faction benefit.', ); return at; }; requireCombat( b.powerChecks === undefined || Array.isArray(b.powerChecks), 'Combat response queue is malformed.', ); const raw = (b.powerChecks ?? []) as unknown[], ranks = raw.map(rank); requireCombat( ranks.every((v, i) => i === 0 || v > ranks[i - 1]), 'Combat response queue is duplicated or out of order.', ); const queue = b.powerChecks === undefined ? undefined : ranks.map((i) => ({ ...possible[i] })); const result: CombatResponseQuote = { powerChecks: queue, response: null, patches: {}, decision: null, }; const next = () => { const check = result.powerChecks?.shift(); if (check) result.response = { ...check, passed: [] }; }; const finish = () => { result.patches.deletePreparation = true; const atreides = input.players.find((p) => p.faction === 'atreides'); if ( input.advanced && atreides && !atreides.specialKaramaUsed && !b.fullPlanOffered && !Object.keys(b.plans as object).length ) { result.patches.fullPlanOffered = true; result.decision = { kind: 'fullPlanOffer', player: atreides.id }; } }; if (operation.kind === 'next') { next(); return result; } if (operation.kind === 'finishPreparation') { finish(); return result; } requireCombat( operation.kind === 'response' && typeof operation.canceled === 'boolean' && record(operation.response), 'Malformed combat response operation.', ); const response = operation.response; if (response.kind !== 'voice' && response.kind !== 'prescience') { const current = rank(response); requireCombat( ranks.every((i) => i > current), 'The current combat response does not precede its remaining checks.', ); if (response.kind === 'eliteStrength' && operation.canceled) { const blocked = (b.eliteBlocked ?? []) as string[]; requireCombat( !blocked.includes(response.owner), 'This elite response was already canceled.', ); result.patches.eliteBlocked = [...blocked, response.owner]; } else if (response.kind === 'fremenSupport') result.patches.fremenSupportBlocked = operation.canceled; else if (response.kind === 'kwisatz') result.patches.kwisatzBlocked = operation.canceled; else if (response.kind === 'choamBattleAid') result.patches.choamAidBlocked = operation.canceled; next(); return result; } const owner = input.players.find((p) => p.id === response.owner), faction = response.kind === 'voice' ? 'beneGesserit' : 'atreides'; const borrowed = response.kind === 'voice' && b.nexusBorrowedVoice; if (borrowed) { requireCombat(typeof input.turn === 'number' && typeof b.event === 'string', 'Borrowed Voice has lost its original battle occurrence.'); validateNexusBorrowedVoice({ turn: input.turn, battle: { event: b.event, attacker: b.attacker, defender: b.defender } }, borrowed); requireCombat(borrowed.stage === 'declared' && borrowed.owner === response.owner && owner && !owner.ally && combatants.includes(owner.id) && !input.players.some(p => p.faction === 'beneGesserit') && record(b.voice) && b.voice.target === borrowed.voice?.target && b.voice.kind === borrowed.voice?.kind && b.voice.must === borrowed.voice?.must, 'Borrowed Voice does not match the declared combatant source.'); } else requireCombat(owner?.faction === faction, 'Combat preparation response has the wrong faction owner.'); requireCombat(owner, 'Combat preparation response has no owner.'); // Match battlePreparation's existing provider selection, including its // one-way ally link. CHOAM's queue above independently requires mutual links. const beneficiary = combatants.includes(owner.id) ? owner.id : owner.ally && combatants.includes(owner.ally) ? owner.ally : null; requireCombat( beneficiary, 'Combat preparation owner has no combatant beneficiary.', ); const opponent = combatants.find((id) => id !== beneficiary)!; if (response.kind === 'voice') { requireCombat( record(b.voice) && b.voice.target === opponent && typeof b.voice.kind === 'string' && VOICE_KINDS.includes(b.voice.kind) && typeof b.voice.must === 'boolean', 'The declared Voice response is malformed or targets the wrong opponent.', ); // Voice has already advanced preparation to Prescience (or its finish). if (b.preparation !== undefined) { const atreides = input.players.find((p) => p.faction === 'atreides'); const recipient = atreides && (combatants.includes(atreides.id) ? atreides.id : atreides.ally && combatants.includes(atreides.ally) ? atreides.ally : null); requireCombat( record(b.preparation) && b.preparation.kind === 'prescience' && recipient && b.preparation.owner === atreides!.id && b.preparation.beneficiary === recipient, 'The Voice response has a malformed remaining Prescience preparation.', ); } if (operation.canceled) result.patches.deleteVoice = true; } else { requireCombat( record(b.prescience) && b.prescience.player === beneficiary && ['leader', 'weapon', 'defense', 'dial'].includes( String(b.prescience.field), ), 'The declared Prescience response is malformed or has the wrong beneficiary.', ); requireCombat( record(b.preparation) && b.preparation.kind === 'prescienceAnswer' && b.preparation.owner === opponent && b.preparation.beneficiary === beneficiary, 'Prescience needs its pending opposing answer.', ); if (operation.canceled) { result.patches.deletePrescience = true; finish(); } } return result; }