82 lines
2.9 KiB
TypeScript
82 lines
2.9 KiB
TypeScript
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<StructuredCombatEvent, "id">;
|
|
|
|
export const MAX_COMBAT_EVENT_HISTORY = 200;
|
|
|
|
export type CombatEventListener = (event: StructuredCombatEvent) => void;
|
|
|
|
const combatEventListeners = new Set<CombatEventListener>();
|
|
|
|
/** 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))));
|
|
}
|