import type { CombatPresentationEvent, CombatVfxQuality, } from "../game/combatPresentation"; export interface ActiveCombatEffect { readonly key: string; readonly event: CombatPresentationEvent; } export const COMBAT_EFFECT_SYSTEM_LIMIT: Readonly> = { off: 0, low: 24, high: 32, }; export interface CombatEffectFrameState { readonly elapsedMs: number; readonly motionDeltaSeconds: number; readonly progress: number; readonly done: boolean; } export function advanceCombatEffectFrame( elapsedMs: number, rawDeltaSeconds: number, durationMs: number, ): CombatEffectFrameState { const safeElapsedMs = Number.isFinite(elapsedMs) ? Math.max(0, elapsedMs) : 0; const deltaSeconds = Number.isFinite(rawDeltaSeconds) ? Math.max(0, rawDeltaSeconds) : 0; const safeDurationMs = Number.isFinite(durationMs) ? Math.max(1, durationMs) : 1; // Age follows wall time so a slow frame expires work instead of extending the // overload. Motion remains bounded to avoid a single-frame visual jump. const nextElapsedMs = safeElapsedMs + deltaSeconds * 1_000; const progress = Math.min(1, nextElapsedMs / safeDurationMs); return { elapsedMs: nextElapsedMs, motionDeltaSeconds: Math.min(0.05, deltaSeconds), progress, done: progress >= 1, }; } function actorPriority(event: CombatPresentationEvent): number { const groupPriority = event.actorGroup === "player" ? 30 : event.actorGroup === "party" ? 20 : 10; // Preserve readable combat beats before actor ownership. An enemy cast or // impact is more useful than another periodic tick from any actor group. const phasePriority = event.phase === "cast-start" ? 300 : event.phase === "release" || event.phase === "impact" ? 200 : event.phase === "tick" ? 0 : 100; return groupPriority + phasePriority; } function areaBurstKey(event: CombatPresentationEvent): string | null { if (event.delivery !== "area" || event.phase === "cast-start" || event.phase === "cast-cancel") { return null; } return `${event.sessionId}:${event.sourceActorId}:${event.abilityId}:${event.occurredAt}`; } function effectKey(event: CombatPresentationEvent): string { return `${event.sessionId}:${event.id}`; } function trimCombatEffects( effects: readonly ActiveCombatEffect[], limit: number, ): readonly ActiveCombatEffect[] { if (effects.length <= limit) return effects; const next = [...effects]; while (next.length > limit) { let removalIndex = 0; let removalPriority = actorPriority(next[0].event); for (let index = 1; index < next.length; index += 1) { const priority = actorPriority(next[index].event); if (priority < removalPriority) { removalIndex = index; removalPriority = priority; } } next.splice(removalIndex, 1); } return next; } export function capCombatEffects( effects: readonly ActiveCombatEffect[], quality: CombatVfxQuality, ): readonly ActiveCombatEffect[] { const limit = COMBAT_EFFECT_SYSTEM_LIMIT[quality]; if (limit === 0) return effects.length === 0 ? effects : []; return trimCombatEffects(effects, limit); } export function updateCombatEffectQueue( current: readonly ActiveCombatEffect[], event: CombatPresentationEvent, quality: CombatVfxQuality, ): readonly ActiveCombatEffect[] { const withoutCast = event.phase === "release" || event.phase === "cast-cancel" ? current.filter((effect) => !( effect.event.phase === "cast-start" && effect.event.sourceActorId === event.sourceActorId && effect.event.abilityId === event.abilityId )) : current; if (quality === "off") return current.length === 0 ? current : []; if (event.phase === "cast-cancel") { return withoutCast.length === current.length ? current : withoutCast; } const burstKey = areaBurstKey(event); if (burstKey) { const existingIndex = withoutCast.findIndex((effect) => areaBurstKey(effect.event) === burstKey); if (existingIndex >= 0) { const existing = withoutCast[existingIndex]; if (existing.event.phase === event.phase) return withoutCast; const next = [...withoutCast]; next[existingIndex] = { key: effectKey(event), event }; return capCombatEffects(next, quality); } } const next = capCombatEffects( [...withoutCast, { key: effectKey(event), event }], quality, ); if ( next.length === current.length && next.every((effect, index) => effect.key === current[index]?.key) ) return current; return next; }