import { enemyDamageTypeSourceForSpell, resolveEnemyDamageType, type EnemyDamageOrigin } from "./mobDamageTypes"; import { romNativeEnemyPeriodicDamageSources } from "./romNativePeriodicDamage"; import { normalizeRunewakerDamageFormula, type RunewakerDamageFormulaSource, } from "./runewakerCombatFormula"; 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 = "confuse" | "fear" | "sleep" | "root" | "slow" | "stun" | "silence" | "interrupt"; /** Frost-style slows preserve actions while reducing authored movement speed. */ export const ENEMY_SLOW_MOVEMENT_MULTIPLIER = 0.6; export function enemyControlPreventsActions( mechanic: EnemyControlMechanic | null, ): boolean { return mechanic !== null && mechanic !== "root" && mechanic !== "slow" && mechanic !== "interrupt"; } export function enemyControlPreventsMovement( mechanic: EnemyControlMechanic | null, ): boolean { return mechanic !== null && mechanic !== "slow" && mechanic !== "interrupt"; } export function enemyControlMovementMultiplier( mechanic: EnemyControlMechanic | null, ): number { return mechanic === "slow" ? ENEMY_SLOW_MOVEMENT_MULTIPLIER : 1; } /** * A persistent enemy aura that procs a short-lived, charged critical-strike * modifier from successful main-hand auto attacks. */ export interface EnemyMeleeAutoCriticalProc { readonly chance: number; readonly triggeredAura: string; readonly durationMs: number; readonly criticalChancePercent: number; readonly charges: number; } export type EnemyTelegraphShape = "circle" | "cone" | "line"; export type EnemyTelegraphAnchor = "source" | "target"; /** World-space warning geometry shared by simulation and Three presentation. */ export interface EnemyAttackTelegraph { readonly shape: EnemyTelegraphShape; readonly anchor: EnemyTelegraphAnchor; /** Minimum warning time before the mechanic resolves. */ readonly warningMs: number; readonly radius?: number; readonly length?: number; readonly width?: number; readonly angleDegrees?: number; } export type EnemyAtomicAbilityEffect = | { readonly kind: "damage"; readonly multiplier: number } | { readonly kind: "dot"; readonly multiplier: number; readonly ticks: number; readonly intervalMs: number; readonly sourceSkillId?: number; readonly componentId?: number } | { readonly kind: "heal"; readonly multiplier: number } | { readonly kind: "leech"; readonly damageMultiplier: number; readonly healMultiplier: number; } | { readonly kind: "aura"; readonly aura: string; readonly durationMs: number; readonly magnitude?: number; /** Multiplier applied to damage received while this aura is active. */ readonly damageTakenMultiplier?: number; readonly damageSchool?: EnemyDamageSchool; /** Absorb capacity expressed in multiples of the caster's attack power. */ readonly absorbMultiplier?: number; readonly reflectSpellChance?: number; /** Portion of subsequent outgoing attack power returned as healing. */ readonly lifeStealPercent?: number; readonly meleeAutoCriticalProc?: EnemyMeleeAutoCriticalProc; } | { readonly kind: "target-aura"; readonly aura: string; readonly durationMs: number; readonly damageTakenMultiplier?: number; readonly damageSchool?: EnemyDamageSchool; } | { readonly kind: "control"; readonly mechanic: EnemyControlMechanic; readonly durationMs: number; /** Optional damage resolved against the same targets as the control. */ readonly damageMultiplier?: number; } | { readonly kind: "transform"; readonly form: string } | { readonly kind: "pull"; readonly destination: "source" } | { readonly kind: "summon"; readonly creatureEntry: number; readonly count: number; /** Optional stable namespace for content-authored summoned entities. */ readonly entityId?: string; /** Optional owner group used by encounter counters and reset cleanup. */ readonly spawnGroupId?: string; } | { readonly kind: "call-for-help"; readonly radius: number } | { readonly kind: "encounter-event"; readonly eventId: string }; export type EnemyAbilityEffect = EnemyAtomicAbilityEffect | { /** A single source spell that resolves more than one supported mechanic. */ readonly kind: "composite"; readonly effects: readonly EnemyAtomicAbilityEffect[]; }; 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; /** Hit-table channel, independent from projectile/area presentation and damage school. */ readonly attackKind?: "melee" | "ranged" | "spell"; readonly damageClass?: number; /** Original WoW school bits; composite schools retain the authored scalar policy. */ readonly schoolMask?: number; /** Source RuneWaker HP calculation channel (0 magic, 1 physical). */ readonly runewakerEffectType?: number; readonly runewakerComponentId?: number; readonly damageOrigin?: EnemyDamageOrigin; /** Runtime pulse of a previously applied periodic debuff. */ readonly periodicTick?: boolean; readonly targetMitigationSnapshotted?: boolean; readonly periodicNoKill?: boolean; 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; /** Source-shaped RuneWaker formula evidence for imported damage mechanics. */ readonly runewakerFormula?: RunewakerDamageFormulaSource; readonly radius?: number; readonly telegraph?: EnemyAttackTelegraph; readonly effect?: EnemyAbilityEffect; readonly condition?: EnemyAbilityCondition; readonly source?: EnemyAbilitySource; } export interface EnemyCombatDefinition { readonly serverEntry?: number; /** RuneWaker NPC source identity; independent from AzerothCore creature entries. */ readonly runewakerNpcId?: number; readonly level?: number; readonly healthMultiplier?: number; readonly damageMultiplier?: number; readonly moveSpeed?: number; readonly aggroRange?: number; readonly leashRange?: number; /** Stable special-attack ids available for future encounter loadout randomization. */ readonly mechanicIds?: readonly string[]; 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)]); } function harmfulEnemyAttack(value: EnemyAttackDefinition): boolean { if (!value.effect) return true; if (value.effect.kind === "composite") { return value.effect.effects.some((effect) => ( effect.kind === "damage" || effect.kind === "dot" || effect.kind === "leech" || effect.kind === "control" || effect.kind === "target-aura" || effect.kind === "pull" )); } return value.effect.kind === "damage" || value.effect.kind === "dot" || value.effect.kind === "leech" || value.effect.kind === "control" || value.effect.kind === "target-aura" || value.effect.kind === "pull"; } /** * Legacy/imported encounters receive safe defaults while authored mechanics can * override every dimension in the modular mechanic catalog. */ export function inferEnemyAttackTelegraph( value: EnemyAttackDefinition, ): EnemyAttackTelegraph | undefined { if (value.id === "basic-attack" || !harmfulEnemyAttack(value)) return undefined; const radius = value.radius ?? Math.max(3, value.range * 0.35); if (/\b(?:breath|cleave|frontal|sweep|cone)\b/i.test(value.name)) { return { shape: "cone", anchor: "source", warningMs: 1_650, length: Math.max(5.5, value.range), angleDegrees: /breath/i.test(value.name) ? 70 : 95, }; } if (/\b(?:beam|charge|line|lance)\b/i.test(value.name)) { return { shape: "line", anchor: "source", warningMs: 1_650, length: Math.max(7, value.range), width: Math.max(2, radius * 0.45), }; } if (value.delivery === "area" || value.radius !== undefined) { const targetAnchored = /\b(?:blizzard|rain|barrage|volley|eruption|ground|meteor)\b/i .test(value.name) && value.target !== "self"; return { shape: "circle", anchor: targetAnchored ? "target" : "source", warningMs: 1_650, radius, }; } return undefined; } function normalizeTelegraph(value: EnemyAttackTelegraph): EnemyAttackTelegraph { const warningMs = Math.round(finiteAtLeast(value.warningMs, 1_650, 250)); if (value.shape === "circle") { return Object.freeze({ shape: value.shape, anchor: value.anchor, warningMs, radius: finiteAtLeast(value.radius, 4, 0.5), }); } if (value.shape === "cone") { return Object.freeze({ shape: value.shape, anchor: "source", warningMs, length: finiteAtLeast(value.length, 8, 1), angleDegrees: Math.min(180, finiteAtLeast(value.angleDegrees, 90, 5)), }); } return Object.freeze({ shape: value.shape, anchor: "source", warningMs, length: finiteAtLeast(value.length, 8, 1), width: finiteAtLeast(value.width, 3, 0.5), }); } export function normalizeEnemyAttack( value: EnemyAttackDefinition, fallback: EnemyAttackDefinition = DEFAULT_ENEMY_ATTACK, runewakerNpcId?: number, ): EnemyAttackDefinition { const cooldownMs = Math.round(finiteAtLeast(value.cooldownMs, fallback.cooldownMs, 1)); const telegraph = value.telegraph ?? inferEnemyAttackTelegraph(value); const inferredCastTimeMs = value.castTimeMs === undefined && value.animation === "cast" ? (fallback.castTimeMs ?? 1_500) : telegraph?.warningMs ?? fallback.castTimeMs ?? 0; const castTimeMs = Math.max( Math.round(finiteAtLeast(value.castTimeMs, inferredCastTimeMs, 0)), telegraph?.warningMs ?? 0, ); const runewakerFormula = normalizeRunewakerDamageFormula(value.runewakerFormula); const damageOrigin = value.damageOrigin ?? (runewakerFormula || value.id.startsWith("rw-") ? "runewaker" : "wow335"); const sourceType = value.spellId ? enemyDamageTypeSourceForSpell(damageOrigin, value.spellId) : undefined; // Script/control placeholder spell IDs must not override authored damage packets. const sourceHasDamage = sourceType && ("components" in sourceType ? sourceType.components.length > 0 : sourceType.damageEffects.length > 0); const damageType = resolveEnemyDamageType({ ...value, ...(sourceHasDamage ? { origin: damageOrigin, componentId: value.runewakerComponentId ?? runewakerFormula?.componentId } : {}), }); const periodicSources = damageOrigin === "runewaker" && value.spellId ? romNativeEnemyPeriodicDamageSources(value.spellId, runewakerNpcId) : []; const uniquePeriodicSources = periodicSources.filter((source, index, all) => all.findIndex((entry) => entry.sourceSkillId === source.sourceSkillId && entry.component.componentId === source.component.componentId) === index); const periodicEffects: EnemyAtomicAbilityEffect[] = uniquePeriodicSources.map((source) => ({ kind: "dot", multiplier: value.damageMultiplier, ticks: Math.max(1, Math.trunc(source.component.effectTimeSeconds / source.component.dotTimeSeconds)), intervalMs: source.component.dotTimeSeconds * 1_000, sourceSkillId: source.sourceSkillId, componentId: source.component.componentId, })); const baseEffect = value.effect ?? { kind: "damage" as const, multiplier: value.damageMultiplier }; const existingEffects = baseEffect.kind === "composite" ? baseEffect.effects : [baseEffect]; const directEffects = (runewakerFormula?.powerKind === "dot-only" ? [] : existingEffects) .filter((effect) => !(effect.kind === "dot" && periodicEffects.some((periodic) => periodic.kind === "dot" && periodic.componentId === effect.componentId && periodic.sourceSkillId === effect.sourceSkillId))); const resolvedEffect = periodicEffects.length ? { kind: "composite" as const, effects: [...directEffects, ...periodicEffects] } : value.effect; 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: damageType.school ?? value.school, damageOrigin, ...(damageType.attackKind ? { attackKind: damageType.attackKind } : {}), ...(damageType.damageClass !== undefined ? { damageClass: damageType.damageClass } : {}), ...(damageType.schoolMask !== undefined ? { schoolMask: damageType.schoolMask } : {}), ...(damageType.sourceDamageClass === "magical" ? { runewakerEffectType: 0 } : damageType.sourceDamageClass === "physical" ? { runewakerEffectType: 1 } : value.runewakerEffectType !== undefined ? { runewakerEffectType: value.runewakerEffectType } : {}), ...(value.runewakerComponentId ? { runewakerComponentId: value.runewakerComponentId } : {}), animation: value.animation, castTimeMs, 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), ...(runewakerFormula ? { runewakerFormula } : {}), ...(value.radius !== undefined ? { radius: finiteAtLeast(value.radius, 0, 0) } : {}), ...(telegraph ? { telegraph: normalizeTelegraph(telegraph) } : {}), ...(resolvedEffect ? { effect: resolvedEffect } : {}), ...(value.condition ? { condition: value.condition } : {}), ...(value.source ? { source: value.source } : {}), }); } export function normalizeEnemyAttacks( attacks: readonly EnemyAttackDefinition[] | undefined, fallback: EnemyAttackDefinition = DEFAULT_ENEMY_ATTACK, runewakerNpcId?: number, ): readonly EnemyAttackDefinition[] { const source = attacks?.length ? attacks : [fallback]; const normalized = source.map((attack) => normalizeEnemyAttack(attack, fallback, runewakerNpcId)); 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); }