import type { Card } from './cards'; import { isAuditorLeader } from './cards'; import type { FactionId } from './catalog'; import { auditCount } from './choam-auditor'; import { validateBattleCleanupContext, type ResolvedBattleReceipt, } from './karama-battle-preflight'; import type { MoritaniRetention } from './moritani-retention'; import { TECH_TOKENS, type TechId, type TechState } from './tech-tokens'; import { CHEAP_HERO_TRAITOR } from './traitors'; import { DUKE_VIDAL_ID, type DukeReleasedCaptureWitness } from './duke-vidal'; export class BattleAftermathQuoteError extends Error { constructor(message: string) { super(message); this.name = 'BattleAftermathQuoteError'; } } export type AftermathPlayer = { id: string; faction: FactionId; hand: readonly Card[]; /** Actual native discs; include shared disc IDs separately in leaderIds. */ leaders: readonly { id: string }[]; }; export type AftermathPending = { retention?: unknown; income?: unknown; tech?: unknown; capture?: unknown; auditor?: unknown; faceDance?: unknown; }; export type AftermathCancellation = { kind: 'capture' | 'choamAudit' | 'choamBattleIncome'; owner: string; intent?: string; }; export type BattleAftermathInput = { status: string; phase: number; turn: number; advanced: boolean; battlePresent: boolean; lastBattle: readonly string[]; territoryIds: readonly string[]; context?: unknown; /** Only independently known old parent values; never fabricate a missing winner. */ legacy?: { territory?: string; winner?: string | null }; players: readonly AftermathPlayer[]; leaderIds: readonly string[]; physicalCards: readonly { id: string; kind?: Card['kind'] }[]; techTokens?: TechState | null; pending: AftermathPending; cancel?: AftermathCancellation; }; export type AftermathContext = | { kind: 'modern'; receipt: ResolvedBattleReceipt } | { kind: 'legacy'; turn: number; combatants: string[]; territory?: string; winner?: string | null; }; export type AftermathCapture = { player: string; loser: string; territory: string; duke?: DukeReleasedCaptureWitness; }; export type AftermathAuditor = { owner: string; opponent: string; territory: string; turn: number; event: string; survived: boolean; usedCards: string[]; stage: 'offer' | 'response' | 'payment'; }; export type AftermathFaceDance = { player: string; winner: string; winners?: string[]; leader: string | null; identity: string | null; territory: string; }; export type AftermathNext = | { kind: 'moritaniRetention'; owner: string; player: string; territory: string; cards: string[]; source?: 'nexus'; event?: string; } | { kind: 'choamBattleIncome'; owner: string; amount: number } | { kind: 'techToken'; player: string; loser: string; choices: TechId[] } | ({ kind: 'captureOffer' } & AftermathCapture) | { kind: 'choamAudit'; player: string; event: string; count: number } | ({ kind: 'faceDance' } & AftermathFaceDance) /** Root resolves remaining battle geometry/collection at this separate pure boundary. */ | { kind: 'board' }; export type AftermathStep = | { kind: 'cancel'; slot: 'capture' | 'income' | 'auditor' } | { kind: 'transferTech'; id: TechId; from: string; to: string } | { kind: 'emptyAuditor'; event: string }; export type AftermathSpending = { player: string; card: string }; export type BattleAftermathQuote = { context: AftermathContext; steps: AftermathStep[]; next: AftermathNext; }; function requireAftermath( condition: unknown, message: string, ): asserts condition { if (!condition) throw new BattleAftermathQuoteError(message); } const id = (value: unknown): value is string => typeof value === 'string' && value.trim().length > 0; const record = (value: unknown): value is Record => !!value && typeof value === 'object' && !Array.isArray(value); const ids = (value: unknown): value is string[] => Array.isArray(value) && value.every(id) && new Set(value).size === value.length; const whole = (value: unknown): value is number => Number.isSafeInteger(value) && (value as number) >= 0; const present = (value: unknown) => value !== undefined && value !== null; /** Follow only currently compulsory aftermath, stopping at the next real choice. * Neither board traversal nor phase initialization is called by this module. */ export function quoteBattleAftermath( input: BattleAftermathInput, spending?: AftermathSpending, ): BattleAftermathQuote { try { // Validate the actual response/source before projecting its cost. A card // leaves this hand but remains a physical card in the discard pile. const current = calculate(input); if (!spending) return current; const owner = input.players.find((p) => p.id === spending.player); requireAftermath( id(spending.card) && owner && owner.hand.filter((c) => c.id === spending.card).length === 1 && input.physicalCards.filter((c) => c.id === spending.card).length === 1, 'The pending Karama cost needs unique physical custody in its owner’s hand.', ); return calculate({ ...input, players: input.players.map((p) => p.id === owner.id ? { ...p, hand: p.hand.filter((c) => c.id !== spending.card) } : p, ), }); } catch (error) { if (error instanceof BattleAftermathQuoteError) throw error; if (error instanceof Error) throw new BattleAftermathQuoteError(error.message); throw error; } } function calculate(input: BattleAftermathInput): BattleAftermathQuote { requireAftermath( input.status === 'playing' && input.phase === 6 && input.battlePresent === false && whole(input.turn) && input.turn > 0 && Array.isArray(input.players) && ids(input.players.map((p) => p.id)) && ids(input.lastBattle) && input.lastBattle.length === 2 && input.lastBattle.every((p) => input.players.some((seat) => seat.id === p), ) && Array.isArray(input.territoryIds) && Array.isArray(input.physicalCards) && Array.isArray(input.leaderIds) && record(input.pending), 'Battle aftermath needs its two resolved seated combatants and current Battle phase.', ); const player = (who: unknown) => { requireAftermath(id(who), 'This aftermath player is missing.'); const p = input.players.find((p) => p.id === who); requireAftermath(p, 'This aftermath player is no longer seated.'); return p; }; let context: AftermathContext; if (present(input.context)) { requireAftermath( record(input.context), 'The completed battle receipt is invalid.', ); const validated = validateBattleCleanupContext({ phase: input.phase, turn: input.turn, battlePresent: input.battlePresent, lastBattle: input.lastBattle, playerIds: input.players.map((p) => p.id), territoryIds: input.territoryIds, territory: input.context.territory as string, winner: input.context.winner as string | null, context: input.context, }); requireAftermath( validated.kind === 'existing', 'A modern aftermath needs its completed receipt.', ); context = { kind: 'modern', receipt: validated.context }; } else context = { kind: 'legacy', turn: input.turn, combatants: [...input.lastBattle], }; function territory(value: unknown) { requireAftermath( id(value) && input.territoryIds.includes(value), 'This aftermath territory is invalid.', ); const previous = context.kind === 'modern' ? context.receipt.territory : context.territory; requireAftermath( previous === undefined || previous === value, 'This aftermath territory differs from its completed battle.', ); if (context.kind === 'legacy') context.territory = value; return value; } function winner(value: unknown) { requireAftermath( value === null || (id(value) && input.lastBattle.includes(value)), 'This aftermath winner is not a resolved combatant.', ); const previous = context.kind === 'modern' ? context.receipt.winner : context.winner; requireAftermath( previous === undefined || previous === value, 'This aftermath winner differs from its completed battle.', ); if (context.kind === 'legacy') context.winner = value; return value; } if (input.legacy?.territory !== undefined) territory(input.legacy.territory); if (input.legacy && 'winner' in input.legacy) winner(input.legacy.winner); const other = (who: string) => { requireAftermath( input.lastBattle.includes(who), 'This aftermath participant was not a combatant.', ); return input.lastBattle.find((p) => p !== who)!; }; function physical(selected: readonly string[], hand?: readonly Card[]) { requireAftermath( selected.every( (card) => input.physicalCards.filter((c) => c?.id === card).length === 1 && (!hand || hand.filter((c) => c?.id === card).length === 1), ), 'The aftermath cards must retain unique physical custody.', ); } function retention(value: unknown): MoritaniRetention { const nexus = record(value) && value.source === 'nexus'; requireAftermath( record(value) && value.stage === 'choose' && whole(value.turn) && value.turn === input.turn && ids(value.played) && (nexus || value.played.length > 0) && ids(value.eligible) && (nexus || value.eligible.length > 0) && value.eligible.every((card) => (value.played as string[]).includes(card), ), 'Resolve the battle card retention response first; its saved cleanup must be current.', ); const owner = player(value.owner), loser = player(value.player); requireAftermath( Array.isArray(loser.hand) && (nexus ? owner.id === loser.id && context.kind === 'modern' && value.event === context.receipt.event && !input.players.some((p) => p.faction === 'moritani') : owner.faction === 'moritani' && owner.id !== loser.id), 'This card cleanup needs its original loser and source.', ); const to = territory(value.territory); winner(other(loser.id)); physical(value.played, loser.hand); return { owner: owner.id, player: loser.id, territory: to, turn: input.turn, played: [...value.played], eligible: [...value.eligible], stage: 'choose', ...(nexus ? { source: 'nexus' as const, event: value.event as string } : {}), }; } function income(value: unknown) { requireAftermath( record(value) && whole(value.amount) && value.amount > 0 && input.advanced, 'The CHOAM battle income receipt is invalid.', ); const owner = player(value.owner); requireAftermath( owner.faction === 'choam' && (context.kind === 'legacy' || ['normal', 'explosion', 'legacy'].includes(context.receipt.result)), 'CHOAM battle income must follow a supported non-traitor result.', ); return { owner: owner.id, amount: value.amount }; } function technology(value: unknown) { requireAftermath( record(value) && ids(value.choices) && value.choices.length > 0 && input.techTokens, 'The battle technology choice is missing its physical tokens.', ); const receiver = player(value.player), loser = player(value.loser); requireAftermath( other(receiver.id) === loser.id, 'The technology transfer needs the defeated combatant.', ); winner(receiver.id); const choices = value.choices; requireAftermath( choices.every((token) => TECH_TOKENS.some((t) => t.id === token)), 'Choose canonical battle technology tokens.', ); const allOwned = TECH_TOKENS.filter( (t) => input.techTokens?.[t.id]?.owner === loser.id, ).map((t) => t.id); requireAftermath( choices.length === allOwned.length && choices.every((token) => allOwned.includes(token as TechId)) && choices.every((token) => whole(input.techTokens![token as TechId].spice), ), 'The defeated faction must still own every pending technology choice.', ); return { player: receiver.id, loser: loser.id, choices: [...choices] as TechId[], }; } function capture(value: unknown): AftermathCapture { requireAftermath( record(value) && input.advanced, 'The capture opportunity is missing.', ); const captor = player(value.player), loser = player(value.loser); requireAftermath( captor.faction === 'harkonnen' && other(captor.id) === loser.id, 'Only the winning Harkonnen combatant may capture from this loser.', ); winner(captor.id); let duke: DukeReleasedCaptureWitness | undefined; if (value.duke !== undefined) { requireAftermath(record(value.duke) && Object.keys(value.duke).sort().join(',') === 'deaths,usedAt' && whole(value.duke.deaths) && value.duke.usedAt === value.territory && context.kind === 'modern' && input.leaderIds.includes(DUKE_VIDAL_ID), 'The capture opportunity lost its original released shared Duke.'); duke = { deaths: value.duke.deaths, usedAt: value.duke.usedAt as string }; } return { player: captor.id, loser: loser.id, territory: territory(value.territory), ...(duke ? { duke } : {}), }; } function auditor( value: unknown, stage: 'offer' | 'response', ): { pending: AftermathAuditor; count: number } { requireAftermath( record(value) && input.advanced && value.stage === stage && id(value.event) && value.turn === input.turn && typeof value.survived === 'boolean' && ids(value.usedCards), 'The Auditor continuation is stale or not at its expected stage.', ); const owner = player(value.owner), opponent = player(value.opponent); requireAftermath( owner.faction === 'choam' && owner.leaders.some(isAuditorLeader) && other(owner.id) === opponent.id, 'This audit requires the actual CHOAM Auditor and opposing combatant.', ); const to = territory(value.territory); requireAftermath( context.kind === 'legacy' || context.receipt.event === value.event, 'The Auditor event differs from its completed battle.', ); physical(value.usedCards); const count = auditCount(opponent.hand, value.usedCards, value.survived); return { count, pending: { owner: owner.id, opponent: opponent.id, territory: to, turn: input.turn, event: value.event, survived: value.survived, usedCards: [...value.usedCards], stage, }, }; } function faceDance(value: unknown): AftermathFaceDance { requireAftermath(record(value), 'The Face Dance opportunity is missing.'); const owner = player(value.player), winning = player(value.winner); requireAftermath( owner.faction === 'tleilaxu' && owner.id !== winning.id, 'The Face Dance opportunity needs its Tleilaxu owner and another winning faction.', ); winner(winning.id); const winners = value.winners; requireAftermath(winners === undefined || (ids(winners) && winners.length === 2 && winners.includes(winning.id) && !winners.includes(owner.id) && winners.every(id => input.players.some(p => p.id === id)) && winners.some(id => player(id).faction === 'ecaz')), 'Face Dance must replace the actual Ecaz winning coside, never its own faction.'); const to = territory(value.territory); requireAftermath( value.leader === null || id(value.leader), 'The Face Dance winning identity is invalid.', ); const hero = input.physicalCards.filter( (c) => c.id === value.leader && c.kind === 'hero', ); if (hero.length) { physical([value.leader as string]); requireAftermath( value.identity === CHEAP_HERO_TRAITOR, 'The winning hero needs its shared Face Dance identity.', ); } else requireAftermath( value.identity === value.leader && (value.leader === null || input.leaderIds.includes(value.leader)), 'The Face Dance identity must match the actual winning leader disc.', ); return { player: owner.id, winner: winning.id, ...(winners === undefined ? {} : { winners: [...winners] }), leader: value.leader, identity: value.identity as string | null, territory: to, }; } const pending = { ...input.pending }, steps: AftermathStep[] = []; if (input.cancel) { const c = input.cancel; if (c.kind === 'choamBattleIncome') { requireAftermath( !present(pending.retention), 'Finish the preceding alliance cleanup before CHOAM income.', ); const current = income(pending.income); requireAftermath( c.owner === current.owner, 'The canceled CHOAM income owner is stale.', ); pending.income = null; steps.push({ kind: 'cancel', slot: 'income' }); } else if (c.kind === 'capture') { requireAftermath( !present(pending.retention) && !present(pending.income) && !present(pending.tech), 'The canceled capture has unresolved earlier aftermath.', ); const current = capture(pending.capture); requireAftermath( c.owner === current.player, 'The canceled capture owner is stale.', ); pending.capture = null; steps.push({ kind: 'cancel', slot: 'capture' }); } else if (c.kind === 'choamAudit') { requireAftermath( !present(pending.retention) && !present(pending.income) && !present(pending.tech) && !present(pending.capture), 'The canceled audit has unresolved earlier aftermath.', ); const current = auditor(pending.auditor, 'response'); requireAftermath( c.owner === current.pending.owner && c.intent === current.pending.event, 'The canceled Auditor response is stale.', ); pending.auditor = null; steps.push({ kind: 'cancel', slot: 'auditor' }); } else requireAftermath(false, 'Unknown canceled battle aftermath.'); } const result = (next: AftermathNext): BattleAftermathQuote => ({ context, steps, next, }); if (present(pending.retention)) { const p = retention(pending.retention); return result({ kind: 'moritaniRetention', owner: p.owner, player: p.player, territory: p.territory, cards: [...p.eligible], ...(p.source === 'nexus' ? { source: 'nexus', event: p.event } : {}), }); } if (present(pending.income)) return result({ kind: 'choamBattleIncome', ...income(pending.income) }); if (present(pending.tech)) { const p = technology(pending.tech); if (p.choices.length > 1) return result({ kind: 'techToken', ...p }); steps.push({ kind: 'transferTech', id: p.choices[0], from: p.loser, to: p.player, }); } if (present(pending.capture)) return result({ kind: 'captureOffer', ...capture(pending.capture) }); if (present(pending.auditor)) { const { pending: p, count } = auditor(pending.auditor, 'offer'); if (count) return result({ kind: 'choamAudit', player: p.owner, event: p.event, count, }); steps.push({ kind: 'emptyAuditor', event: p.event }); } if (present(pending.faceDance)) return result({ kind: 'faceDance', ...faceDance(pending.faceDance) }); return result({ kind: 'board' }); }