import type { DamageSchool } from "./combatAuras"; export type StructuredCombatEventKind = | "damage" | "healing" | "absorb" | "resource" | "aura" | "cast" | "interrupt" | "death" | "resurrection"; export interface StructuredCombatEvent { readonly id: number; readonly occurredAt: number; readonly kind: StructuredCombatEventKind; readonly sourceActorId: string; readonly targetActorId: string; readonly abilityId?: string; /** Source ability that caused an interruption; abilityId remains the canceled cast. */ readonly causeAbilityId?: string; readonly school?: DamageSchool; readonly rawAmount: number; readonly effectiveAmount: number; readonly overheal: number; readonly absorbed: number; readonly critical: boolean; readonly outcome?: string; /** Independent mitigation is preserved even when a blocked spell also crits. */ readonly blocked?: boolean; readonly blockedAmount?: number; /** Source critical-block aura succeeded after the ordinary block roll. */ readonly criticalBlocked?: boolean; /** Explicit weapon channel; unknown spell/native channels remain unlabeled. */ readonly attackHand?: "mainhand" | "offhand" | "ranged" | "extra-mainhand"; /** Original effect that queued an extra swing, independently of its unique attack ID. */ readonly extraAttackSourceSpellId?: number; } export type CombatEventInput = Omit; export const MAX_COMBAT_EVENT_HISTORY = 200; export type CombatEventListener = (event: StructuredCombatEvent) => void; const combatEventListeners = new Set(); /** Lossless event stream for recorders; the in-store history remains a small HUD ring. */ export function subscribeCombatEvents(listener: CombatEventListener): () => void { combatEventListeners.add(listener); return () => combatEventListeners.delete(listener); } function publishCombatEvent(event: StructuredCombatEvent): void { for (const listener of combatEventListeners) { try { listener(event); } catch { // A metrics consumer must never interrupt authoritative combat. } } } export function appendCombatEvent( events: readonly StructuredCombatEvent[], event: CombatEventInput, id: number, maximum = MAX_COMBAT_EVENT_HISTORY, ): readonly StructuredCombatEvent[] { const next = Object.freeze({ ...event, id, occurredAt: Math.max(0, Number.isFinite(event.occurredAt) ? event.occurredAt : 0), rawAmount: Math.max(0, Number.isFinite(event.rawAmount) ? event.rawAmount : 0), effectiveAmount: Math.max(0, Number.isFinite(event.effectiveAmount) ? event.effectiveAmount : 0), overheal: Math.max(0, Number.isFinite(event.overheal) ? event.overheal : 0), absorbed: Math.max(0, Number.isFinite(event.absorbed) ? event.absorbed : 0), }); publishCombatEvent(next); return Object.freeze([...events, next].slice(-Math.max(1, Math.trunc(maximum)))); }