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" | "stun" | "silence"; /** * 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 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 meleeAutoCriticalProc?: EnemyMeleeAutoCriticalProc; } | { readonly kind: "target-aura"; readonly aura: string; readonly durationMs: number; } | { 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: "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 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; /** 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; 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 { return !value.effect || value.effect.kind === "damage" || value.effect.kind === "control" || value.effect.kind === "target-aura"; } /** * 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, ): 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); 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, 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) } : {}), ...(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); }