import type { StructuredCombatEvent } from "./combatEvents"; export interface DpsMeterActor { readonly id: string; readonly name: string; readonly color: string; } export interface DpsMeterState { readonly startedAt: number | null; readonly endedAt: number | null; readonly updatedAt: number | null; readonly elapsedMs: number; readonly damage: Readonly>; } export interface DpsMeterRow extends DpsMeterActor { readonly total: number; readonly dps: number; readonly percent: number; } export interface DpsMeterSnapshot { readonly active: boolean; readonly durationSeconds: number; readonly totalDamage: number; readonly totalDps: number; readonly rows: readonly DpsMeterRow[]; } export function emptyDpsMeter(): DpsMeterState { return { startedAt: null, endedAt: null, updatedAt: null, elapsedMs: 0, damage: {} }; } /** Keep the last pull visible until the next starts; menus do not add combat time. */ export function advanceDpsMeter(state: DpsMeterState, now: number, inCombat: boolean, suspended = false): DpsMeterState { if (!Number.isFinite(now)) return state; if (state.startedAt === null || state.endedAt !== null) { return inCombat ? { ...emptyDpsMeter(), startedAt: now, updatedAt: now } : state; } const updatedAt = Math.max(state.updatedAt ?? now, now); return { ...state, updatedAt, elapsedMs: state.elapsedMs + (suspended ? 0 : updatedAt - (state.updatedAt ?? updatedAt)), endedAt: inCombat ? null : updatedAt }; } /** Consume the lossless stream, never the truncated combat-log history. */ export function recordDpsDamage(state: DpsMeterState, event: StructuredCombatEvent, actor: DpsMeterActor): DpsMeterState { if (event.kind !== "damage" || !Number.isFinite(event.effectiveAmount) || event.effectiveAmount <= 0) return state; const next = advanceDpsMeter(state, event.occurredAt, true); return { ...next, damage: { ...next.damage, [actor.id]: { ...actor, total: (next.damage[actor.id]?.total ?? 0) + event.effectiveAmount } } }; } export function dpsMeterSnapshot(state: DpsMeterState, actors: readonly DpsMeterActor[] = []): DpsMeterSnapshot { const durationSeconds = state.elapsedMs / 1_000; const seconds = Math.max(1, durationSeconds); const damage = state.startedAt === null ? {} : { ...Object.fromEntries(actors.map((actor) => [actor.id, { ...actor, total: 0 }])), ...state.damage, }; const ordered = Object.values(damage).sort((a, b) => b.total - a.total || a.name.localeCompare(b.name)); const maximum = Math.max(1, ordered[0]?.total ?? 0); const totalDamage = ordered.reduce((sum, actor) => sum + actor.total, 0); return { active: state.startedAt !== null && state.endedAt === null, durationSeconds, totalDamage, totalDps: totalDamage / seconds, rows: ordered.map((actor) => ({ ...actor, dps: actor.total / seconds, percent: actor.total / maximum * 100 })) }; }