export type EnemyAttackAnimation = "attack" | "cast"; export type EnemyAttackDelivery = "melee" | "projectile" | "area"; export type EnemyAttackTarget = | "primary" | "random-party" | "nearby-party" | "self" | "lowest-health-friendly"; export type EnemyDamageSchool = | "physical" | "arcane" | "fire" | "frost" | "nature" | "shadow" | "holy"; export type EnemyControlMechanic = "fear" | "sleep" | "root" | "stun" | "silence"; export type EnemyAbilityEffect = | { readonly kind: "damage"; readonly multiplier: number } | { readonly kind: "heal"; readonly multiplier: number } | { readonly kind: "aura"; readonly aura: string; readonly durationMs: number; readonly magnitude?: number; } | { readonly kind: "control"; readonly mechanic: EnemyControlMechanic; readonly durationMs: number; } | { readonly kind: "transform"; readonly form: string } | { readonly kind: "summon"; readonly creatureEntry: number; readonly count: number } | { readonly kind: "call-for-help"; readonly radius: number } | { readonly kind: "encounter-event"; readonly eventId: string }; export type EnemyAbilityCondition = | { readonly kind: "in-combat" } | { readonly kind: "self-health-percent"; readonly minimum: number; readonly maximum: number; } | { readonly kind: "friendly-missing-health"; readonly amount: number; readonly range: number; } | { readonly kind: "encounter-state"; readonly encounterId: string; readonly states: readonly string[]; }; export interface EnemyAbilitySource { readonly entry: number; readonly sourceType: number; readonly rowId: number; readonly eventType: number; readonly actionType: number; readonly targetType: number; readonly comment: string; } export interface EnemyAttackDefinition { readonly id: string; readonly name: string; readonly spellId?: number; readonly delivery: EnemyAttackDelivery; readonly target: EnemyAttackTarget; readonly school: EnemyDamageSchool; readonly animation: EnemyAttackAnimation; /** Zero keeps the legacy instant-execution behavior. */ readonly castTimeMs?: number; /** Casts are interruptible unless explicitly disabled. */ readonly interruptible?: boolean; readonly range: number; readonly cooldownMs: number; readonly initialCooldownMs?: readonly [minimum: number, maximum: number]; readonly repeatCooldownMs?: readonly [minimum: number, maximum: number]; readonly maximumCasts?: number; readonly damageMultiplier: number; readonly radius?: number; readonly effect?: EnemyAbilityEffect; readonly condition?: EnemyAbilityCondition; readonly source?: EnemyAbilitySource; } export interface EnemyCombatDefinition { readonly serverEntry?: number; readonly level?: number; readonly healthMultiplier?: number; readonly damageMultiplier?: number; readonly moveSpeed?: number; readonly aggroRange?: number; readonly leashRange?: number; readonly attacks: readonly EnemyAttackDefinition[]; } export const DEFAULT_ENEMY_ATTACK: EnemyAttackDefinition = Object.freeze({ id: "basic-attack", name: "Basic Attack", delivery: "melee", target: "primary", school: "physical", animation: "attack", range: 4.5, cooldownMs: 1_900, damageMultiplier: 1, }); function finiteAtLeast(value: number | undefined, fallback: number, minimum: number): number { return Number.isFinite(value) ? Math.max(minimum, value!) : fallback; } function normalizeCooldownRange( value: readonly [number, number] | undefined, fallback: number, minimum: number, ): readonly [number, number] { const lower = finiteAtLeast(value?.[0], fallback, minimum); const upper = finiteAtLeast(value?.[1], lower, lower); return Object.freeze([Math.round(lower), Math.round(upper)]); } export function normalizeEnemyAttack( value: EnemyAttackDefinition, fallback: EnemyAttackDefinition = DEFAULT_ENEMY_ATTACK, ): EnemyAttackDefinition { const cooldownMs = Math.round(finiteAtLeast(value.cooldownMs, fallback.cooldownMs, 1)); const inferredCastTimeMs = value.castTimeMs === undefined && value.animation === "cast" ? (fallback.castTimeMs ?? 1_500) : (fallback.castTimeMs ?? 0); return Object.freeze({ id: value.id.trim() || fallback.id, name: value.name.trim() || fallback.name, ...(Number.isInteger(value.spellId) && value.spellId! > 0 ? { spellId: value.spellId } : {}), delivery: value.delivery, target: value.target, school: value.school, animation: value.animation, castTimeMs: Math.round(finiteAtLeast(value.castTimeMs, inferredCastTimeMs, 0)), interruptible: value.interruptible ?? fallback.interruptible ?? true, range: finiteAtLeast(value.range, fallback.range, 0), cooldownMs, ...(value.initialCooldownMs ? { initialCooldownMs: normalizeCooldownRange(value.initialCooldownMs, cooldownMs, 0) } : {}), ...(value.repeatCooldownMs ? { repeatCooldownMs: normalizeCooldownRange(value.repeatCooldownMs, cooldownMs, 1) } : {}), ...(Number.isInteger(value.maximumCasts) && value.maximumCasts! > 0 ? { maximumCasts: value.maximumCasts } : {}), damageMultiplier: finiteAtLeast(value.damageMultiplier, fallback.damageMultiplier, 0), ...(value.radius !== undefined ? { radius: finiteAtLeast(value.radius, 0, 0) } : {}), ...(value.effect ? { effect: value.effect } : {}), ...(value.condition ? { condition: value.condition } : {}), ...(value.source ? { source: value.source } : {}), }); } export function normalizeEnemyAttacks( attacks: readonly EnemyAttackDefinition[] | undefined, fallback: EnemyAttackDefinition = DEFAULT_ENEMY_ATTACK, ): readonly EnemyAttackDefinition[] { const source = attacks?.length ? attacks : [fallback]; const normalized = source.map((attack) => normalizeEnemyAttack(attack, fallback)); const seen = new Set(); return Object.freeze(normalized.filter((attack) => { if (seen.has(attack.id)) return false; seen.add(attack.id); return true; })); } export function maximumEnemyAttackRange(attacks: readonly EnemyAttackDefinition[]): number { return attacks.reduce((maximum, attack) => Math.max(maximum, attack.range), 0); }