import type { ClassId } from "../app/characterCatalog"; import type { DamageSchool } from "./combatAuras"; export type CombatActorKind = "player" | "companion" | "enemy" | "pet" | "totem" | "summon"; export type CombatResourceId = string; export type DeathKnightRuneType = "blood" | "frost" | "unholy" | "death"; export type CombatActorPosition = readonly [number, number, number]; export interface CombatResourcePoolState { readonly id: CombatResourceId; readonly current: number; readonly maximum: number; readonly regenerationPerSecond: number; /** Mana regeneration observes the five-second rule from this timestamp. */ readonly lastSpentAt: number; } export interface DeathKnightRuneState { readonly slot: number; readonly baseType: Exclude; readonly type: DeathKnightRuneType; readonly readyAt: number; } export interface CombatActorCastState { readonly abilityId: string; readonly school: DamageSchool; readonly mode: "cast" | "channel"; readonly startedAt: number; readonly completesAt: number; readonly originalDurationMs: number; readonly pushbackHits: number; /** Simulation-space origin used for movement cancellation and completion validation. */ readonly origin?: CombatActorPosition; readonly targetId?: string | null; readonly channelTicksCompleted?: number; } /** * Renderer-free snapshot shared by every combatant. Specialized player, party, * and mob records may retain presentation fields, but combat rules consume * this common contract. */ export interface CombatActorState { readonly id: string; /** Stable id used by threat tables even when a renderer entity is replaced. */ readonly threatActorId?: string; readonly kind: CombatActorKind; readonly ownerId?: string; readonly classId?: ClassId; readonly level: number; readonly health: number; readonly maxHealth: number; readonly absorb: number; readonly resources: Readonly>; readonly cooldowns: Readonly>; readonly globalCooldownEndsAt: number; readonly cast: CombatActorCastState | null; readonly controlledUntil: number; readonly schoolLockedUntil: Readonly>>; readonly comboPoints: number; readonly comboTargetId: string | null; readonly runes: readonly DeathKnightRuneState[]; /** Executable catalog abilities owned by pets, summons, and companions. */ readonly abilityIds?: readonly string[]; readonly alive: boolean; } export function createDeathKnightRunes(now = 0): readonly DeathKnightRuneState[] { const types = ["blood", "blood", "frost", "frost", "unholy", "unholy"] as const; return Object.freeze(types.map((type, slot) => Object.freeze({ slot, baseType: type, type, readyAt: Math.max(0, now), }))); } export function normalizeActorResourcePool( id: CombatResourceId, current: number, maximum: number, regenerationPerSecond = 0, lastSpentAt = 0, ): CombatResourcePoolState { const safeMaximum = Math.max(0, Number.isFinite(maximum) ? maximum : 0); return Object.freeze({ id, current: Math.max(0, Math.min(safeMaximum, Number.isFinite(current) ? current : 0)), maximum: safeMaximum, regenerationPerSecond: Math.max(0, Number.isFinite(regenerationPerSecond) ? regenerationPerSecond : 0), lastSpentAt: Math.max(0, Number.isFinite(lastSpentAt) ? lastSpentAt : 0), }); } export function spendActorResource( pool: CombatResourcePoolState, amount: number, now: number, ): CombatResourcePoolState | null { const requested = Math.max(0, Number.isFinite(amount) ? amount : 0); if (pool.current + Number.EPSILON < requested) return null; return Object.freeze({ ...pool, current: Math.max(0, pool.current - requested), lastSpentAt: Math.max(pool.lastSpentAt, Number.isFinite(now) ? now : 0), }); } export function restoreActorResource( pool: CombatResourcePoolState, amount: number, ): CombatResourcePoolState { return Object.freeze({ ...pool, current: Math.min(pool.maximum, pool.current + Math.max(0, Number.isFinite(amount) ? amount : 0)), }); }