import { BASE_CLASS_IDS, COA_CLASS_IDS, ROM_CLASS_IDS, type BaseClassId, type ClassId, type CoaClassId, type RomClassId, } from "../app/characterCatalog"; import { loadCoaAbilityCatalogShard } from "./coaAbilityCatalogLoader"; import { registerAbilityAnimationLookup } from "./abilityAnimationLookup"; import type { AuraDefinition, DamageSchool, DispelCategory } from "./combatAuras"; import { applyAbilityBalanceTuning, currentBalanceTuningPolicy } from "./balanceTuning"; export type RomSkillGroup = "primary" | "general" | "elite" | "passive"; export type ResourceType = | "mana" | "rage" | "energy" | "runic-power" | "focus" | "nature-power" | "psi" | "health" | "holy-power" | "chi" | "soul-power" | `custom:${string}`; export type AbilityTarget = "hostile" | "friendly" | "any" | "self"; export type AbilityCastMode = "instant" | "cast" | "channel"; export type AbilityTargetShape = "unit" | "circle" | "cone" | "chain" | "ground"; export interface AbilityTargetingRules { readonly shape: AbilityTargetShape; readonly requiresLineOfSight?: boolean; readonly requiresFacing?: boolean; readonly requiresBehindTarget?: boolean; readonly attackArcRadians?: number; readonly radius?: number; readonly maximumTargets?: number; readonly jumpRange?: number; readonly groundRange?: number; } export interface AbilityCastRules { readonly school?: DamageSchool; readonly canCastWhileMoving?: boolean; readonly pushbackImmune?: boolean; /** A fraction from zero to one, applied independently to each pushback hit. */ readonly pushbackResistance?: number; } export interface AbilityRuneCost { readonly blood?: number; readonly frost?: number; readonly unholy?: number; } export interface AbilityAiHints { readonly categories: readonly ( | "damage" | "execute" | "heal" | "emergency-heal" | "area-heal" | "mitigation" | "taunt" | "interrupt" | "dispel" | "resurrection" | "control" | "debuff" | "buff" | "resource" | "summon" | "pet" | "movement" | "threat" | "area-damage" )[]; readonly priority?: number; } export type MobStatusKind = | "slow" | "stun" | "root" | "fear" | "interrupt" | "attack-speed" | "confuse" | "blind" | "charm" | "disarm" | "freeze" | "knockdown" | "pacify" | "silence" | "sleep" | "marked" | "pulled"; export type AbilityAuraControlKind = | "confuse" | "charm" | "fear" | "stun" | "pacify" | "root" | "silence" | "disarm" | "blind" | "freeze" | "knockdown" | "sleep"; export interface AbilityAuraControl { readonly kind: AbilityAuraControlKind; readonly magnitude?: number; } export interface AbilityRange { readonly min: number; readonly max: number; } export interface AbilityAmountScaling { /** * The client DBC's deterministic amount before its random die roll. Imported * spells use this instead of treating the spell-power coefficient as the * spell's entire amount. */ readonly basePoints?: number; readonly dieSides?: number; readonly pointsPerLevel?: number; /** Additional power coefficient gained per source skill level. */ readonly coefficientPerLevel?: number; readonly bonusPowerCoefficient?: number; readonly attackPowerCoefficient?: number; readonly spiritCoefficient?: number; readonly sourceLevel?: number; readonly maxScalingLevel?: number; } export interface AbilityHealingRecipients { readonly recipient?: "caster" | "ability-target"; readonly scope?: "party"; readonly center?: "caster" | "ability-target"; readonly radius?: number; readonly maximumTargets?: number; readonly selection?: "random"; } export interface AbilityDamageResource { /** Restore the caster's resource only when this hit deals effective damage. */ readonly onDamageResource?: { readonly resource: ResourceType; readonly amount: number }; } export type AbilityEffect = | ({ readonly kind: "damage"; readonly coefficient: number; readonly bonusPerSunderStack?: number; readonly bonusFlat?: number } & AbilityAmountScaling & AbilityDamageResource) | { readonly kind: "finisher-damage"; readonly baseCoefficient: number; readonly perComboCoefficient: number; /** Optional stack-based payload used by poison-consuming finishers. */ readonly poison?: import("./roguePoison").RoguePoisonFinisherScaling; } | ({ readonly kind: "heal"; readonly coefficient: number; readonly percentMaxHealth?: number } & AbilityAmountScaling & AbilityHealingRecipients) | ({ readonly kind: "shield"; readonly coefficient: number; readonly durationMs: number } & AbilityAmountScaling & AbilityHealingRecipients) | ({ readonly kind: "dot"; readonly coefficient: number; readonly ticks: number; readonly intervalMs: number; readonly startsImmediately?: boolean; readonly tag?: string } & AbilityAmountScaling & AbilityDamageResource) | ({ readonly kind: "hot"; readonly coefficient: number; readonly ticks: number; readonly intervalMs: number } & AbilityAmountScaling & AbilityHealingRecipients) | { readonly kind: "resurrection"; readonly percentMaxHealth: number } | { readonly kind: "summon"; readonly creatureId: number; readonly coefficient: number; readonly durationMs: number } | { readonly kind: "resource"; readonly amount: number; readonly resource?: ResourceType; readonly percentage?: boolean } | { readonly kind: "combo"; readonly amount: number } | { readonly kind: "taunt"; readonly durationMs: number; readonly maxTargets?: number } | { readonly kind: "status"; readonly status: MobStatusKind; readonly durationMs: number; readonly magnitude?: number; readonly maxTargets?: number; readonly breakOnDamage?: boolean } | { readonly kind: "apply-aura"; readonly aura: AuraDefinition; /** Some hostile spells apply their beneficial rider to the caster. */ readonly recipient?: "ability-target" | "caster"; /** Party/raid area auras are applied to the player and every active party member. */ readonly scope?: "target" | "party"; readonly control?: AbilityAuraControl; readonly sourceEffectType: number; readonly sourceAuraType: number; readonly miscValue: number; readonly triggerSpellId: number; } | { readonly kind: "dispel"; readonly mode: "cleanse" | "purge" | "steal"; /** `target` lets a dual-purpose spell cleanse an ally or purge an enemy. */ readonly relationship: "friendly" | "hostile" | "target"; readonly categories: readonly DispelCategory[]; readonly maxCount: number; readonly scope?: "target" | "party"; } | { readonly kind: "interrupt"; readonly lockoutMs: number } | { readonly kind: "pull"; readonly toward: "caster" | "destination" } | { readonly kind: "execute"; readonly coefficient: number } | { readonly kind: "resource-drain"; readonly amount: number; readonly burn: boolean; readonly resource?: ResourceType; readonly percentage?: boolean } | { readonly kind: "threat"; readonly mode: "flat" | "percent" | "redirect"; readonly amount: number; readonly recipient?: "caster" | "ability-target"; } | { readonly kind: "movement"; readonly movement: "teleport" | "leap" | "charge" | "knockback" | "sanctuary"; readonly toward: "target" | "destination" | "away"; } | { readonly kind: "dispel-mechanic"; readonly mechanic: number; readonly maxCount: number } | { readonly kind: "pet"; readonly action: "summon" | "call" | "recall" | "dismiss" | "resurrect" | "feed" | "tame"; readonly creatureId?: number } | { readonly kind: "totem"; readonly action: "destroy-all" } | { readonly kind: "rune"; readonly action: "activate"; readonly count: number } | { readonly kind: "cooldown-reset"; readonly mode: "reset" | "reduce"; readonly abilityName?: string; readonly amountMs?: number; /** Fraction of the target ability's remaining cooldown removed at execution time. */ readonly amountPercent?: number; } | { readonly kind: "trigger-spell"; readonly spellId: number; readonly withValue?: number; readonly expanded?: boolean } | { /** A known combat-side DBC script hook that requires a spell-specific handler. */ readonly kind: "scripted"; readonly effectType: number; readonly auraType: number; readonly miscValue: number; readonly triggerSpellId: number; readonly label: string; } | { /** A deliberately noncombat DBC operation, retained for source fidelity. */ readonly kind: "unsupported"; readonly combat: false; readonly effectType: number; readonly auraType: number; readonly reason: string; } | { readonly kind: "utility"; readonly action?: "create-item" | "open-lock" | "portal" | "weapon-enchant" | "pickpocket" | "farsight" | "class-script"; readonly effectType: number; readonly auraType: number; readonly miscValue: number; readonly triggerSpellId: number; readonly durationMs: number; }; export interface AbilityCost { readonly resource: ResourceType; readonly amount: number; readonly percentage?: boolean; readonly percentageBasis?: "base-mana"; } export interface AbilityRankDefinition { readonly rank: number; readonly spellId: number; readonly level: number; readonly description: string; readonly castMode: AbilityCastMode; readonly castTimeMs: number; readonly cooldownMs: number; readonly gcdMs: number; readonly cost: AbilityCost; readonly costs?: readonly AbilityCost[]; readonly effects: readonly AbilityEffect[]; readonly targeting?: AbilityTargetingRules; readonly castRules?: AbilityCastRules; readonly runeCost?: AbilityRuneCost; } export interface AbilityDefinition { readonly id: string; readonly classId: ClassId; readonly secondaryClassId?: ClassId; readonly dbcSpellId: number; readonly name: string; readonly unlockLevel: number; readonly icon: string; readonly sigil: string; readonly description: string; readonly target: AbilityTarget; readonly range: AbilityRange; readonly radius?: number; readonly castMode: AbilityCastMode; readonly castTimeMs: number; readonly cooldownMs: number; readonly cooldownGroup?: string; readonly gcdMs: number; readonly cost: AbilityCost; readonly costs?: readonly AbilityCost[]; readonly effects: readonly AbilityEffect[]; readonly targeting?: AbilityTargetingRules; readonly castRules?: AbilityCastRules; readonly runeCost?: AbilityRuneCost; readonly ai?: AbilityAiHints; readonly passive?: boolean; readonly source?: "core" | "wow335-progression" | "ascension-progression" | "coa-starter" | "coa-progression" | "coa-tree" | "runewaker"; readonly romSkillGroup?: RomSkillGroup; /** The original client level before Healer Man's starter-kit remapping. */ readonly sourceUnlockLevel?: number; readonly ranks?: readonly AbilityRankDefinition[]; /** Runtime talent node that must be learned before this ability is available. */ readonly talentNodeId?: string; /** Legacy CoA source identifier; `abilityTalentNodeId` normalizes it. */ readonly talentEntryId?: number; } export interface ClassResourceProfile { readonly type: ResourceType; readonly name: string; readonly color: string; readonly maximum: number; readonly initial: number; readonly regenerationPerSecond: number; } const spellIcon = (basename: string) => `/assets/ui/spells/${basename.toLowerCase()}.png`; const melee: AbilityRange = { min: 0, max: 3 }; const friendly: AbilityRange = { min: 0, max: 40 }; const self: AbilityRange = { min: 0, max: 0 }; type AbilityDefinitionInput = Omit & { readonly castMode?: AbilityCastMode; readonly unlockLevel?: number; }; function defineAbility(definition: AbilityDefinitionInput): AbilityDefinition { return { ...definition, unlockLevel: definition.unlockLevel ?? 1, castMode: definition.castMode ?? (definition.castTimeMs > 0 ? "cast" : "instant"), }; } const classResources: Partial> = { // The prototype has no auto-attack swing timer yet, so a small passive Rage // gain stands in for white-hit generation and keeps the full kit usable. warrior: { type: "rage", name: "Rage", color: "#b74439", maximum: 100, initial: 0, regenerationPerSecond: 5 }, paladin: { type: "mana", name: "Mana", color: "#397dcc", maximum: 100, initial: 100, regenerationPerSecond: 4 }, hunter: { type: "mana", name: "Mana", color: "#397dcc", maximum: 100, initial: 100, regenerationPerSecond: 4 }, rogue: { type: "energy", name: "Energy", color: "#d8c43f", maximum: 100, initial: 100, regenerationPerSecond: 10 }, priest: { type: "mana", name: "Mana", color: "#397dcc", maximum: 100, initial: 100, regenerationPerSecond: 4 }, "death-knight": { type: "runic-power", name: "Runic Power", color: "#55b9c7", maximum: 100, initial: 0, regenerationPerSecond: 0 }, shaman: { type: "mana", name: "Mana", color: "#397dcc", maximum: 100, initial: 100, regenerationPerSecond: 4 }, mage: { type: "mana", name: "Mana", color: "#397dcc", maximum: 100, initial: 100, regenerationPerSecond: 4 }, warlock: { type: "mana", name: "Mana", color: "#397dcc", maximum: 100, initial: 100, regenerationPerSecond: 4 }, druid: { type: "mana", name: "Mana", color: "#397dcc", maximum: 100, initial: 100, regenerationPerSecond: 4 }, "rom-warrior": { type: "rage", name: "Rage", color: "#b74439", maximum: 100, initial: 0, regenerationPerSecond: 0 }, "rom-scout": { type: "focus", name: "Focus", color: "#55b995", maximum: 100, initial: 100, regenerationPerSecond: 6 }, "rom-rogue": { type: "energy", name: "Energy", color: "#d8c43f", maximum: 100, initial: 100, regenerationPerSecond: 10 }, "rom-mage": { type: "mana", name: "Mana", color: "#397dcc", maximum: 100, initial: 100, regenerationPerSecond: 4 }, "rom-priest": { type: "mana", name: "Mana", color: "#397dcc", maximum: 100, initial: 100, regenerationPerSecond: 4 }, "rom-knight": { type: "mana", name: "Mana", color: "#397dcc", maximum: 100, initial: 100, regenerationPerSecond: 4 }, "rom-warden": { type: "mana", name: "Mana", color: "#397dcc", maximum: 100, initial: 100, regenerationPerSecond: 4 }, "rom-druid": { type: "mana", name: "Mana", color: "#397dcc", maximum: 100, initial: 100, regenerationPerSecond: 4 }, "rom-warlock": { type: "focus", name: "Focus", color: "#8b72d8", maximum: 100, initial: 100, regenerationPerSecond: 6 }, "rom-champion": { type: "rage", name: "Rage", color: "#bb604c", maximum: 100, initial: 0, regenerationPerSecond: 0 }, }; /** Populated per mode/class by preloadAbilityCatalogsForClasses before gameplay mounts. */ export const CLASS_RESOURCES = classResources as Readonly>; const WARRIOR = [ defineAbility({ id: "warrior-heroic-strike", classId: "warrior", dbcSpellId: 78, name: "Heroic Strike", icon: spellIcon("Ability_Rogue_Ambush"), sigil: "HS", target: "hostile", range: melee, castTimeMs: 0, cooldownMs: 0, gcdMs: 1500, cost: { resource: "rage", amount: 15 }, description: "A forceful melee strike that deals heavy physical damage.", effects: [{ kind: "damage", coefficient: 1.1 }], }), defineAbility({ id: "warrior-rend", classId: "warrior", dbcSpellId: 772, name: "Rend", icon: spellIcon("Ability_Gouge"), sigil: "RE", target: "hostile", range: melee, castTimeMs: 0, cooldownMs: 0, gcdMs: 1500, cost: { resource: "rage", amount: 10 }, description: "Wounds the target, causing physical bleed damage over time.", effects: [{ kind: "dot", coefficient: 0.28, ticks: 5, intervalMs: 3000, tag: "rend" }], }), defineAbility({ id: "warrior-thunder-clap", classId: "warrior", dbcSpellId: 6343, name: "Thunder Clap", unlockLevel: 2, icon: spellIcon("Spell_Nature_ThunderClap"), sigil: "TC", target: "self", range: self, radius: 8, castTimeMs: 0, cooldownMs: 6000, gcdMs: 1500, cost: { resource: "rage", amount: 20 }, description: "Blasts nearby enemies and slows their attacks.", effects: [ { kind: "damage", coefficient: 0.75 }, { kind: "status", status: "attack-speed", durationMs: 10_000, magnitude: 0.2 }, ], }), defineAbility({ id: "warrior-charge", classId: "warrior", dbcSpellId: 100, name: "Charge", unlockLevel: 4, icon: spellIcon("Ability_Warrior_Charge"), sigil: "CH", target: "hostile", range: { min: 8, max: 25 }, castTimeMs: 0, cooldownMs: 15_000, gcdMs: 1500, cost: { resource: "rage", amount: 0 }, description: "Charges a distant enemy, briefly stunning it and generating Rage.", effects: [ { kind: "damage", coefficient: 0.25 }, { kind: "status", status: "stun", durationMs: 1000 }, { kind: "status", status: "pulled", durationMs: 500 }, { kind: "resource", amount: 15 }, ], }), defineAbility({ id: "warrior-taunt", classId: "warrior", dbcSpellId: 355, name: "Taunt", unlockLevel: 4, icon: spellIcon("Spell_Nature_Reincarnation"), sigil: "TA", target: "hostile", range: { min: 0, max: 30 }, castTimeMs: 0, cooldownMs: 8000, gcdMs: 0, cost: { resource: "rage", amount: 0 }, description: "Taunts the target to attack you and matches the highest threat against it.", effects: [{ kind: "taunt", durationMs: 3000 }], }), ] as const; const PALADIN = [ defineAbility({ id: "paladin-judgement-of-light", classId: "paladin", dbcSpellId: 20271, name: "Judgement of Light", icon: spellIcon("Spell_Holy_RighteousFury"), sigil: "JL", target: "hostile", range: { min: 0, max: 30 }, castTimeMs: 0, cooldownMs: 10_000, gcdMs: 1500, cost: { resource: "mana", amount: 5 }, description: "Deals Holy damage and marks the enemy with restorative Light.", effects: [ { kind: "damage", coefficient: 0.85 }, { kind: "status", status: "marked", durationMs: 10_000, magnitude: 0.02 }, ], }), defineAbility({ id: "paladin-holy-light", classId: "paladin", dbcSpellId: 635, name: "Holy Light", icon: spellIcon("Spell_Holy_HolyBolt"), sigil: "HL", target: "friendly", range: friendly, castTimeMs: 2500, cooldownMs: 0, gcdMs: 1500, cost: { resource: "mana", amount: 22 }, description: "Calls down the Light to restore a large amount of health.", effects: [{ kind: "heal", coefficient: 2.4 }], }), defineAbility({ id: "paladin-consecration", classId: "paladin", dbcSpellId: 26573, name: "Consecration", unlockLevel: 2, icon: spellIcon("Spell_Holy_InnerFire"), sigil: "CO", target: "self", range: self, radius: 8, castTimeMs: 0, cooldownMs: 8000, gcdMs: 1500, cost: { resource: "mana", amount: 22 }, description: "Consecrates the ground, damaging nearby enemies over time.", effects: [{ kind: "dot", coefficient: 0.25, ticks: 8, intervalMs: 1000, tag: "consecration" }], }), defineAbility({ id: "paladin-hammer-of-justice", classId: "paladin", dbcSpellId: 853, name: "Hammer of Justice", unlockLevel: 4, icon: spellIcon("Spell_Holy_SealOfMight"), sigil: "HJ", target: "hostile", range: { min: 0, max: 10 }, castTimeMs: 0, cooldownMs: 60_000, gcdMs: 1500, cost: { resource: "mana", amount: 3 }, description: "Stuns an enemy with the force of the Light.", effects: [{ kind: "status", status: "stun", durationMs: 6000 }], }), defineAbility({ id: "paladin-hand-of-reckoning", classId: "paladin", dbcSpellId: 62124, name: "Hand of Reckoning", unlockLevel: 4, icon: spellIcon("Spell_Holy_UnyieldingFaith"), sigil: "HR", target: "hostile", range: { min: 0, max: 30 }, castTimeMs: 0, cooldownMs: 8000, gcdMs: 0, cost: { resource: "mana", amount: 0 }, description: "Taunts the target to attack you and matches the highest threat against it.", effects: [{ kind: "taunt", durationMs: 3000 }], }), ] as const; const HUNTER = [ defineAbility({ id: "hunter-steady-shot", classId: "hunter", dbcSpellId: 56641, name: "Steady Shot", icon: spellIcon("Ability_Hunter_SteadyShot"), sigil: "SS", target: "hostile", range: { min: 5, max: 35 }, castTimeMs: 1500, cooldownMs: 0, gcdMs: 1500, cost: { resource: "mana", amount: 5 }, description: "A carefully aimed physical shot.", effects: [{ kind: "damage", coefficient: 1.15 }], }), defineAbility({ id: "hunter-arcane-shot", classId: "hunter", dbcSpellId: 3044, name: "Arcane Shot", icon: spellIcon("Ability_ImpalingBolt"), sigil: "AS", target: "hostile", range: { min: 5, max: 35 }, castTimeMs: 0, cooldownMs: 6000, gcdMs: 1500, cost: { resource: "mana", amount: 6 }, description: "An instant shot infused with arcane energy.", effects: [{ kind: "damage", coefficient: 1 }], }), defineAbility({ id: "hunter-serpent-sting", classId: "hunter", dbcSpellId: 1978, name: "Serpent Sting", unlockLevel: 2, icon: spellIcon("Ability_Hunter_Quickshot"), sigil: "ST", target: "hostile", range: { min: 5, max: 35 }, castTimeMs: 0, cooldownMs: 0, gcdMs: 1500, cost: { resource: "mana", amount: 9 }, description: "Poisons the target with recurring Nature damage.", effects: [{ kind: "dot", coefficient: 0.32, ticks: 5, intervalMs: 3000, tag: "serpent-sting" }], }), defineAbility({ id: "hunter-concussive-shot", classId: "hunter", dbcSpellId: 5116, name: "Concussive Shot", unlockLevel: 4, icon: spellIcon("Spell_Frost_Stun"), sigil: "CS", target: "hostile", range: { min: 5, max: 35 }, castTimeMs: 0, cooldownMs: 12_000, gcdMs: 1500, cost: { resource: "mana", amount: 0 }, description: "Dazes and greatly slows an enemy.", effects: [{ kind: "status", status: "slow", durationMs: 4000, magnitude: 0.5 }], }), ] as const; const ROGUE = [ defineAbility({ id: "rogue-instant-poison", classId: "rogue", dbcSpellId: 57965, name: "Instant Poison", unlockLevel: 20, icon: spellIcon("Ability_Poisons"), sigil: "IP", target: "self", range: self, castTimeMs: 0, cooldownMs: 0, gcdMs: 0, cost: { resource: "energy", amount: 0 }, description: "Coats the main-hand weapon with poison that can deal Nature damage on hit.", effects: [{ kind: "apply-aura", aura: { id: "rogue-instant-poison-main-hand", name: "Instant Poison", disposition: "buff", durationMs: 3_600_000, exclusiveGroup: "rogue-main-hand-poison", utilityTags: ["weapon-enchant", "rogue-instant-poison"], }, recipient: "caster", sourceEffectType: 6, sourceAuraType: 42, miscValue: 0, triggerSpellId: 0, }], }), defineAbility({ id: "rogue-deadly-poison", classId: "rogue", dbcSpellId: 57970, name: "Deadly Poison", unlockLevel: 30, icon: spellIcon("Ability_Rogue_DualWeild"), sigil: "DP", target: "self", range: self, castTimeMs: 0, cooldownMs: 0, gcdMs: 0, cost: { resource: "energy", amount: 0 }, description: "Coats the off-hand weapon with a stacking damage-over-time poison.", effects: [{ kind: "apply-aura", aura: { id: "rogue-deadly-poison-off-hand", name: "Deadly Poison", disposition: "buff", durationMs: 3_600_000, exclusiveGroup: "rogue-off-hand-poison", utilityTags: ["weapon-enchant", "rogue-deadly-poison-coating"], }, recipient: "caster", sourceEffectType: 6, sourceAuraType: 42, miscValue: 0, triggerSpellId: 0, }], }), defineAbility({ id: "rogue-sinister-strike", classId: "rogue", dbcSpellId: 1752, name: "Sinister Strike", icon: spellIcon("Spell_Shadow_RitualOfSacrifice"), sigil: "SS", target: "hostile", range: melee, castTimeMs: 0, cooldownMs: 0, gcdMs: 1000, cost: { resource: "energy", amount: 45 }, description: "A precise physical attack that awards a combo point.", effects: [{ kind: "damage", coefficient: 1 }, { kind: "combo", amount: 1 }], }), defineAbility({ id: "rogue-eviscerate", classId: "rogue", dbcSpellId: 2098, name: "Eviscerate", icon: spellIcon("Ability_Rogue_Eviscerate"), sigil: "EV", target: "hostile", range: melee, castTimeMs: 0, cooldownMs: 0, gcdMs: 1000, cost: { resource: "energy", amount: 35 }, description: "Consumes combo points for a powerful finishing attack.", effects: [{ kind: "finisher-damage", baseCoefficient: 0.6, perComboCoefficient: 0.55 }], }), defineAbility({ id: "rogue-gouge", classId: "rogue", dbcSpellId: 1776, name: "Gouge", unlockLevel: 2, icon: spellIcon("Ability_Gouge"), sigil: "GO", target: "hostile", range: melee, castTimeMs: 0, cooldownMs: 10_000, gcdMs: 1000, cost: { resource: "energy", amount: 45 }, description: "Incapacitating strike that awards a combo point.", effects: [ { kind: "damage", coefficient: 0.2 }, { kind: "status", status: "stun", durationMs: 4000 }, { kind: "combo", amount: 1 }, ], }), defineAbility({ id: "rogue-kick", classId: "rogue", dbcSpellId: 1766, name: "Kick", unlockLevel: 4, icon: spellIcon("Ability_Kick"), sigil: "KI", target: "hostile", range: melee, castTimeMs: 0, cooldownMs: 10_000, gcdMs: 1000, cost: { resource: "energy", amount: 25 }, description: "Interrupts spellcasting and briefly locks the target's school.", effects: [ { kind: "damage", coefficient: 0.15 }, { kind: "status", status: "interrupt", durationMs: 4000 }, ], }), ] as const; const PRIEST = [ defineAbility({ id: "priest-smite", classId: "priest", dbcSpellId: 585, name: "Smite", icon: spellIcon("Spell_Holy_HolySmite"), sigil: "SM", target: "hostile", range: { min: 0, max: 30 }, castTimeMs: 1500, cooldownMs: 0, gcdMs: 1500, cost: { resource: "mana", amount: 8 }, description: "Smites an enemy with Holy damage.", effects: [{ kind: "damage", coefficient: 1.15 }], }), defineAbility({ id: "priest-shadow-word-pain", classId: "priest", dbcSpellId: 589, name: "Shadow Word: Pain", unlockLevel: 2, icon: spellIcon("Spell_Shadow_ShadowWordPain"), sigil: "SP", target: "hostile", range: { min: 0, max: 30 }, castTimeMs: 0, cooldownMs: 0, gcdMs: 1500, cost: { resource: "mana", amount: 10 }, description: "A word of darkness that deals Shadow damage over time.", effects: [{ kind: "dot", coefficient: 0.28, ticks: 6, intervalMs: 3000, tag: "shadow-word-pain" }], }), defineAbility({ id: "priest-flash-heal", classId: "priest", dbcSpellId: 2061, name: "Flash Heal", icon: spellIcon("Spell_Holy_FlashHeal"), sigil: "FH", target: "friendly", range: friendly, castTimeMs: 1500, cooldownMs: 0, gcdMs: 1500, cost: { resource: "mana", amount: 18 }, description: "Rapidly restores health.", effects: [{ kind: "heal", coefficient: 1.7 }], }), defineAbility({ id: "priest-psychic-scream", classId: "priest", dbcSpellId: 8122, name: "Psychic Scream", unlockLevel: 4, icon: spellIcon("Spell_Shadow_PsychicScream"), sigil: "PS", target: "self", range: self, radius: 8, castTimeMs: 0, cooldownMs: 30_000, gcdMs: 1500, cost: { resource: "mana", amount: 12 }, description: "Frightens nearby non-boss enemies for 8 sec.", effects: [{ kind: "status", status: "fear", durationMs: 8000 }], }), ] as const; const DEATH_KNIGHT = [ defineAbility({ id: "death-knight-icy-touch", classId: "death-knight", dbcSpellId: 45477, name: "Icy Touch", icon: spellIcon("Spell_DeathKnight_IceTouch"), sigil: "IT", target: "hostile", range: { min: 0, max: 20 }, castTimeMs: 0, cooldownMs: 0, gcdMs: 1500, cost: { resource: "runic-power", amount: 0 }, description: "Chills an enemy and generates Runic Power.", effects: [ { kind: "damage", coefficient: 0.8 }, { kind: "dot", coefficient: 0.12, ticks: 5, intervalMs: 3000, tag: "frost-fever" }, { kind: "status", status: "attack-speed", durationMs: 15_000, magnitude: 0.2 }, { kind: "resource", amount: 10 }, ], }), defineAbility({ id: "death-knight-plague-strike", classId: "death-knight", dbcSpellId: 45462, name: "Plague Strike", icon: spellIcon("Spell_DeathKnight_EmpowerRuneBlade"), sigil: "PL", target: "hostile", range: melee, castTimeMs: 0, cooldownMs: 0, gcdMs: 1500, cost: { resource: "runic-power", amount: 0 }, description: "A diseased melee strike that generates Runic Power.", effects: [ { kind: "damage", coefficient: 0.8 }, { kind: "dot", coefficient: 0.18, ticks: 5, intervalMs: 3000, tag: "blood-plague" }, { kind: "resource", amount: 10 }, ], }), defineAbility({ id: "death-knight-death-strike", classId: "death-knight", dbcSpellId: 49998, name: "Death Strike", unlockLevel: 2, icon: spellIcon("Spell_DeathKnight_Butcher2"), sigil: "DS", target: "hostile", range: melee, castTimeMs: 0, cooldownMs: 0, gcdMs: 1500, cost: { resource: "runic-power", amount: 20 }, description: "Strikes an enemy and restores the death knight's health.", effects: [ { kind: "damage", coefficient: 1.4 }, { kind: "heal", coefficient: 0, percentMaxHealth: 0.1 }, ], }), defineAbility({ id: "death-knight-death-grip", classId: "death-knight", dbcSpellId: 49576, name: "Death Grip", unlockLevel: 4, icon: spellIcon("Spell_DeathKnight_Strangulate"), sigil: "DG", target: "hostile", range: { min: 0, max: 30 }, castTimeMs: 0, cooldownMs: 35_000, gcdMs: 1500, cost: { resource: "runic-power", amount: 0 }, description: "Pulls a non-boss target close and taunts it.", effects: [ { kind: "status", status: "pulled", durationMs: 750 }, { kind: "taunt", durationMs: 3000 }, ], }), defineAbility({ id: "death-knight-dark-command", classId: "death-knight", dbcSpellId: 56222, name: "Dark Command", unlockLevel: 4, icon: spellIcon("Spell_Nature_ShamanRage"), sigil: "DC", target: "hostile", range: { min: 0, max: 30 }, castTimeMs: 0, cooldownMs: 8000, gcdMs: 0, cost: { resource: "runic-power", amount: 0 }, description: "Commands the target to attack you and matches the highest threat against it.", effects: [{ kind: "taunt", durationMs: 3000 }], }), ] as const; const SHAMAN = [ defineAbility({ id: "shaman-lightning-bolt", classId: "shaman", dbcSpellId: 403, name: "Lightning Bolt", icon: spellIcon("Spell_Nature_Lightning"), sigil: "LB", target: "hostile", range: { min: 0, max: 30 }, castTimeMs: 1500, cooldownMs: 0, gcdMs: 1500, cost: { resource: "mana", amount: 8 }, description: "Hurls a bolt of lightning at an enemy.", effects: [{ kind: "damage", coefficient: 1.25 }], }), defineAbility({ id: "shaman-flame-shock", classId: "shaman", dbcSpellId: 8050, name: "Flame Shock", unlockLevel: 2, icon: spellIcon("Spell_Fire_FlameShock"), sigil: "FS", target: "hostile", range: { min: 0, max: 20 }, castTimeMs: 0, cooldownMs: 6000, cooldownGroup: "shaman-shock", gcdMs: 1500, cost: { resource: "mana", amount: 12 }, description: "Burns an enemy immediately and over time.", effects: [ { kind: "damage", coefficient: 0.5 }, { kind: "dot", coefficient: 0.25, ticks: 6, intervalMs: 3000, tag: "flame-shock" }, ], }), defineAbility({ id: "shaman-healing-wave", classId: "shaman", dbcSpellId: 331, name: "Healing Wave", icon: spellIcon("Spell_Nature_MagicImmunity"), sigil: "HW", target: "friendly", range: friendly, castTimeMs: 1500, cooldownMs: 0, gcdMs: 1500, cost: { resource: "mana", amount: 20 }, description: "Restores a large amount of health.", effects: [{ kind: "heal", coefficient: 2.2 }], }), defineAbility({ id: "shaman-earth-shock", classId: "shaman", dbcSpellId: 8042, name: "Earth Shock", unlockLevel: 4, icon: spellIcon("Spell_Nature_EarthShock"), sigil: "ES", target: "hostile", range: { min: 0, max: 20 }, castTimeMs: 0, cooldownMs: 6000, cooldownGroup: "shaman-shock", gcdMs: 1500, cost: { resource: "mana", amount: 10 }, description: "Deals Nature damage and interrupts spellcasting.", effects: [ { kind: "damage", coefficient: 0.75 }, { kind: "status", status: "interrupt", durationMs: 2000 }, ], }), ] as const; const MAGE = [ defineAbility({ id: "mage-frostbolt", classId: "mage", dbcSpellId: 116, name: "Frostbolt", icon: spellIcon("Spell_Frost_FrostBolt02"), sigil: "FB", target: "hostile", range: { min: 0, max: 30 }, castTimeMs: 1500, cooldownMs: 0, gcdMs: 1500, cost: { resource: "mana", amount: 8 }, description: "Deals Frost damage and slows the enemy.", effects: [ { kind: "damage", coefficient: 1 }, { kind: "status", status: "slow", durationMs: 8000, magnitude: 0.4 }, ], }), defineAbility({ id: "mage-fire-blast", classId: "mage", dbcSpellId: 2136, name: "Fire Blast", icon: spellIcon("Spell_Fire_Fireball"), sigil: "FI", target: "hostile", range: { min: 0, max: 20 }, castTimeMs: 0, cooldownMs: 8000, gcdMs: 1500, cost: { resource: "mana", amount: 12 }, description: "Blasts an enemy instantly with Fire damage.", effects: [{ kind: "damage", coefficient: 0.9 }], }), defineAbility({ id: "mage-frost-nova", classId: "mage", dbcSpellId: 122, name: "Frost Nova", unlockLevel: 2, icon: spellIcon("Spell_Frost_FrostNova"), sigil: "FN", target: "self", range: self, radius: 10, castTimeMs: 0, cooldownMs: 25_000, gcdMs: 1500, cost: { resource: "mana", amount: 10 }, description: "Damages and roots nearby enemies in ice.", effects: [ { kind: "damage", coefficient: 0.35 }, { kind: "status", status: "root", durationMs: 8000 }, ], }), defineAbility({ id: "mage-mana-shield", classId: "mage", dbcSpellId: 1463, name: "Mana Shield", unlockLevel: 4, icon: spellIcon("Spell_Shadow_DetectLesserInvisibility"), sigil: "MS", target: "self", range: self, castTimeMs: 0, cooldownMs: 12_000, gcdMs: 1500, cost: { resource: "mana", amount: 15 }, description: "Surrounds the mage with a temporary damage-absorbing shield.", effects: [{ kind: "shield", coefficient: 1.8, durationMs: 30_000 }], }), ] as const; const WARLOCK = [ defineAbility({ id: "warlock-shadow-bolt", classId: "warlock", dbcSpellId: 686, name: "Shadow Bolt", icon: spellIcon("Spell_Shadow_ShadowBolt"), sigil: "SB", target: "hostile", range: { min: 0, max: 30 }, castTimeMs: 1700, cooldownMs: 0, gcdMs: 1500, cost: { resource: "mana", amount: 8 }, description: "Launches a bolt of dark magic.", effects: [{ kind: "damage", coefficient: 1.3 }], }), defineAbility({ id: "warlock-corruption", classId: "warlock", dbcSpellId: 172, name: "Corruption", icon: spellIcon("Spell_Shadow_AbominationExplosion"), sigil: "CO", target: "hostile", range: { min: 0, max: 30 }, castTimeMs: 0, cooldownMs: 0, gcdMs: 1500, cost: { resource: "mana", amount: 10 }, description: "Corrupts an enemy with recurring Shadow damage.", effects: [{ kind: "dot", coefficient: 0.28, ticks: 6, intervalMs: 3000, tag: "corruption" }], }), defineAbility({ id: "warlock-drain-life", classId: "warlock", dbcSpellId: 689, name: "Drain Life", unlockLevel: 2, icon: spellIcon("Spell_Shadow_LifeDrain02"), sigil: "DL", target: "hostile", range: { min: 0, max: 30 }, castMode: "channel", castTimeMs: 5000, cooldownMs: 5000, gcdMs: 1500, cost: { resource: "mana", amount: 12 }, description: "Drains life from the enemy and transfers it to the warlock.", effects: [{ kind: "damage", coefficient: 1.4 }, { kind: "heal", coefficient: 1.4 }], }), defineAbility({ id: "warlock-fear", classId: "warlock", dbcSpellId: 5782, name: "Fear", unlockLevel: 4, icon: spellIcon("Spell_Shadow_Possession"), sigil: "FE", target: "hostile", range: { min: 0, max: 20 }, castTimeMs: 1500, cooldownMs: 15_000, gcdMs: 1500, cost: { resource: "mana", amount: 10 }, description: "Frightens a non-boss enemy for a short time.", effects: [{ kind: "status", status: "fear", durationMs: 6000 }], }), ] as const; const DRUID = [ defineAbility({ id: "druid-wrath", classId: "druid", dbcSpellId: 5176, name: "Wrath", icon: spellIcon("Spell_Nature_AbolishMagic"), sigil: "WR", target: "hostile", range: { min: 0, max: 30 }, castTimeMs: 1500, cooldownMs: 0, gcdMs: 1500, cost: { resource: "mana", amount: 8 }, description: "Calls down Nature damage on an enemy.", effects: [{ kind: "damage", coefficient: 1.15 }], }), defineAbility({ id: "druid-moonfire", classId: "druid", dbcSpellId: 8921, name: "Moonfire", unlockLevel: 2, icon: spellIcon("Spell_Nature_StarFall"), sigil: "MF", target: "hostile", range: { min: 0, max: 30 }, castTimeMs: 0, cooldownMs: 0, gcdMs: 1500, cost: { resource: "mana", amount: 10 }, description: "Burns an enemy with immediate and recurring lunar damage.", effects: [ { kind: "damage", coefficient: 0.45 }, { kind: "dot", coefficient: 0.24, ticks: 4, intervalMs: 3000, tag: "moonfire" }, ], }), defineAbility({ id: "druid-rejuvenation", classId: "druid", dbcSpellId: 774, name: "Rejuvenation", icon: spellIcon("Spell_Nature_Rejuvenation"), sigil: "RJ", target: "friendly", range: friendly, castTimeMs: 0, cooldownMs: 0, gcdMs: 1500, cost: { resource: "mana", amount: 14 }, description: "Restores health over time.", effects: [{ kind: "hot", coefficient: 0.35, ticks: 5, intervalMs: 3000 }], }), defineAbility({ id: "druid-entangling-roots", classId: "druid", dbcSpellId: 339, name: "Entangling Roots", unlockLevel: 4, icon: spellIcon("Spell_Nature_StrangleVines"), sigil: "ER", target: "hostile", range: { min: 0, max: 30 }, castTimeMs: 1500, cooldownMs: 12_000, gcdMs: 1500, cost: { resource: "mana", amount: 9 }, description: "Roots an enemy while thorns deal Nature damage.", effects: [ { kind: "dot", coefficient: 0.15, ticks: 4, intervalMs: 2000, tag: "entangling-roots" }, { kind: "status", status: "root", durationMs: 8000 }, ], }), defineAbility({ id: "druid-growl", classId: "druid", dbcSpellId: 6795, name: "Growl", unlockLevel: 4, icon: spellIcon("Ability_Physical_Taunt"), sigil: "GR", target: "hostile", range: { min: 0, max: 30 }, castTimeMs: 0, cooldownMs: 8000, gcdMs: 0, cost: { resource: "mana", amount: 0 }, description: "Taunts the target to attack you and matches the highest threat against it.", effects: [{ kind: "taunt", durationMs: 3000 }], }), ] as const; function mergeWow335ClassicAbilities( core: readonly AbilityDefinition[], wow335: readonly AbilityDefinition[], ): readonly AbilityDefinition[] { const mergeCoreEffects = ( importedEffects: readonly AbilityEffect[], coreEffects: readonly AbilityEffect[], ): readonly AbilityEffect[] => { let merged = [...importedEffects]; for (const effect of coreEffects) { if (effect.kind === "finisher-damage") { merged = merged.filter((candidate) => ( candidate.kind !== "damage" && candidate.kind !== "finisher-damage" )); merged.push(effect); continue; } const present = merged.some((candidate) => ( candidate.kind === effect.kind && (candidate.kind !== "status" || effect.kind !== "status" || candidate.status === effect.status) )); if (!present) merged.push(effect); } return merged; }; const remaining = [...wow335]; const stableCore = core.map((ability) => { const importedIndex = remaining.findIndex((candidate) => ( candidate.name.toLowerCase() === ability.name.toLowerCase() )); if (importedIndex < 0) { return ability.id === "death-knight-dark-command" ? { ...ability, unlockLevel: 1, sourceUnlockLevel: ability.unlockLevel } : ability; } const [updated] = remaining.splice(importedIndex, 1); const ranks = updated.ranks?.map((rank) => ({ ...rank, effects: mergeCoreEffects(rank.effects, ability.effects), })); return { ...updated, id: ability.id, sigil: ability.sigil, target: ability.target, range: ability.range, ...(ability.radius !== undefined ? { radius: ability.radius } : {}), ...(ability.cooldownGroup !== undefined ? { cooldownGroup: ability.cooldownGroup } : {}), effects: mergeCoreEffects(updated.effects, ability.effects), ranks, }; }); return [ ...stableCore, ...remaining.sort((left, right) => left.unlockLevel - right.unlockLevel || left.name.localeCompare(right.name)), ]; } const CLASSIC_CORE_ABILITIES: Readonly> = { warrior: WARRIOR, paladin: PALADIN, hunter: HUNTER, rogue: ROGUE, priest: PRIEST, "death-knight": DEATH_KNIGHT, shaman: SHAMAN, mage: MAGE, warlock: WARLOCK, druid: DRUID, }; const abilitiesByClass: Partial> = { ...CLASSIC_CORE_ABILITIES, }; const sourceAbilitiesByClass: Partial> = { ...CLASSIC_CORE_ABILITIES, }; const abilityCatalog: Record = {}; let romEliteAbilities: readonly AbilityDefinition[] = []; let romTriggeredAbilityResolver: ((spellId: number) => AbilityDefinition | null) | null = null; const coaTriggeredAbilities = new Map(); let classicLoad: Promise | null = null; let romLoad: Promise | null = null; const coaLoads = new Map>(); /** Live read-only views retained for catalog consumers and validation tests. */ export const ABILITIES_BY_CLASS = abilitiesByClass as Readonly>; export const ABILITY_CATALOG = abilityCatalog as Readonly>; function registerClassAbilities(classId: ClassId, abilities: readonly AbilityDefinition[]): void { for (const previous of abilitiesByClass[classId] ?? []) delete abilityCatalog[previous.id]; sourceAbilitiesByClass[classId] = abilities; const policy = currentBalanceTuningPolicy(); const tuned = abilities.map((ability) => applyAbilityBalanceTuning(ability, policy)); abilitiesByClass[classId] = tuned; for (const ability of tuned) abilityCatalog[ability.id] = ability; } /** Rebuilds the live catalog after a local balance policy is published. */ export function refreshAbilityBalanceTuning(): void { const policy = currentBalanceTuningPolicy(); for (const [classId, source] of Object.entries(sourceAbilitiesByClass)) { if (!source) continue; for (const previous of abilitiesByClass[classId as ClassId] ?? []) delete abilityCatalog[previous.id]; const tuned = source.map((ability) => applyAbilityBalanceTuning(ability, policy)); abilitiesByClass[classId as ClassId] = tuned; for (const ability of tuned) abilityCatalog[ability.id] = ability; } } /** Untuned source definitions used by the GM balance editor. */ export function sourceAbilitiesForClass(classId: ClassId): readonly AbilityDefinition[] { return sourceAbilitiesByClass[classId] ?? []; } for (const classId of BASE_CLASS_IDS) { registerClassAbilities(classId, CLASSIC_CORE_ABILITIES[classId]); } async function preloadClassicAbilityCatalog(): Promise { if (classicLoad) return classicLoad; classicLoad = import("./wow335AbilityCatalog").then(({ WOW335_ABILITIES_BY_CLASS }) => { for (const classId of BASE_CLASS_IDS) { registerClassAbilities( classId, mergeWow335ClassicAbilities(CLASSIC_CORE_ABILITIES[classId], WOW335_ABILITIES_BY_CLASS[classId]), ); } }).catch((error: unknown) => { classicLoad = null; throw error; }); return classicLoad; } async function preloadRomAbilityCatalog(): Promise { if (romLoad) return romLoad; romLoad = import("./romAbilityCatalog").then((catalog) => { for (const classId of ROM_CLASS_IDS) { registerClassAbilities(classId, catalog.ROM_ORDINARY_ABILITIES_BY_CLASS[classId]); } romEliteAbilities = catalog.ROM_ELITE_ABILITIES; romTriggeredAbilityResolver = catalog.romTriggeredAbilityBySpellId; for (const ability of catalog.ROM_ALL_ABILITIES) abilityCatalog[ability.id] = ability; }).catch((error: unknown) => { romLoad = null; throw error; }); return romLoad; } async function preloadCoaAbilityCatalog(classId: CoaClassId): Promise { const cached = coaLoads.get(classId); if (cached) return cached; const pending = loadCoaAbilityCatalogShard(classId).then((shard) => { registerClassAbilities(classId, shard.abilities); classResources[classId] = shard.resource; for (const [spellId, ability] of Object.entries(shard.triggeredAbilitiesBySpellId)) { coaTriggeredAbilities.set(Number(spellId), ability); } }).catch((error: unknown) => { coaLoads.delete(classId); throw error; }); coaLoads.set(classId, pending); return pending; } /** * Loads only the executable catalogs needed by the selected character and its * party mode. Callers gate gameplay mounting on this promise so combat APIs can * stay synchronous inside the simulation loop. */ export async function preloadAbilityCatalogsForClasses(classIds: readonly ClassId[]): Promise { const requested = new Set(classIds); const loads: Promise[] = []; if (BASE_CLASS_IDS.some((classId) => requested.has(classId))) loads.push(preloadClassicAbilityCatalog()); if (ROM_CLASS_IDS.some((classId) => requested.has(classId))) loads.push(preloadRomAbilityCatalog()); for (const classId of COA_CLASS_IDS) { if (requested.has(classId)) loads.push(preloadCoaAbilityCatalog(classId)); } await Promise.all(loads); } export function triggeredAbilityBySpellId( spellId: number, includeCoa = true, ): AbilityDefinition | null { return romTriggeredAbilityResolver?.(spellId) ?? (includeCoa ? coaTriggeredAbilities.get(spellId) : undefined) ?? null; } export function abilitiesForClass(classId: ClassId): readonly AbilityDefinition[] { return abilitiesByClass[classId] ?? []; } export function abilitiesForCharacter( classId: ClassId, secondaryClassId: ClassId | null | undefined, ): readonly AbilityDefinition[] { if (!classId.startsWith("rom-") || !secondaryClassId?.startsWith("rom-")) return abilitiesForClass(classId); const primary = abilitiesForClass(classId); const secondaryGeneral = abilitiesForClass(secondaryClassId).filter((ability) => ( ability.romSkillGroup === "general" || (ability.romSkillGroup === "passive" && ability.id.includes("-general-")) )); const elites = romEliteAbilities.filter((ability) => ability.classId === classId && ability.secondaryClassId === secondaryClassId); return [...primary, ...secondaryGeneral, ...elites]; } export function abilityRankForLevel( ability: AbilityDefinition, playerLevel: number, learnedTalentRank?: number, ): AbilityRankDefinition | null { if (!ability.ranks?.length) return null; if (ability.talentEntryId !== undefined) { const normalized = Math.max(1, Math.trunc(learnedTalentRank ?? 1)); return ability.ranks.find((rank) => rank.rank === normalized) ?? ability.ranks[Math.min(ability.ranks.length, normalized) - 1] ?? ability.ranks[0]; } const eligible = [...ability.ranks] .filter((rank) => rank.level <= playerLevel) .sort((left, right) => right.rank - left.rank); const highestRank = eligible[0]?.rank; if (highestRank === undefined) return ability.ranks[0]; const candidates = eligible.filter((rank) => rank.rank === highestRank); if (candidates.length === 1) return candidates[0]; // Several Wrath spell-name chains contain both the player-facing rank and a // same-rank triggered child (for example Lightning Shield's retaliation). // Preserve the structural effect family of the chain's root spell so a // triggered damage child cannot replace the executable aura at later ranks. const effectStructure = (effects: readonly AbilityEffect[]): string => effects.map((effect) => ( effect.kind === "apply-aura" ? [effect.kind, effect.sourceEffectType, effect.sourceAuraType].join(":") : effect.kind )).join("|"); const rootStructure = effectStructure(ability.effects); return candidates.find((rank) => effectStructure(rank.effects) === rootStructure) ?? candidates[0]; } export function abilityTalentNodeId(ability: AbilityDefinition): string | null { return ability.talentNodeId ?? (ability.talentEntryId === undefined ? null : `coa-entry-${ability.talentEntryId}`); } export function learnedAbilityRank( ability: AbilityDefinition, talentRanks: Readonly>, ): number | undefined { const nodeId = abilityTalentNodeId(ability); return nodeId ? Math.max(0, Math.trunc(talentRanks[nodeId] ?? 0)) : undefined; } export function learnedAbilityForBuild( ability: AbilityDefinition, playerLevel: number, talentRanks: Readonly>, ): AbilityDefinition | null { if (!isAbilityUnlocked(ability, playerLevel)) return null; const nodeId = abilityTalentNodeId(ability); const learnedRank = learnedAbilityRank(ability, talentRanks); if (nodeId && !learnedRank) return null; // CoA tree entries use the allocated talent rank as the spell rank. Classic // active talents continue to learn their later spell ranks by character level. return abilityAtLevel(ability, playerLevel, ability.talentEntryId === undefined ? undefined : learnedRank); } export function abilityAtLevel( ability: AbilityDefinition, playerLevel: number, learnedTalentRank?: number, ): AbilityDefinition { const rank = abilityRankForLevel(ability, playerLevel, learnedTalentRank); const effects = rank?.effects ?? ability.effects; const target = effects.length > 0 && effects.every((effect) => effect.kind === "dispel" && effect.relationship === "target" && effect.mode === "cleanse") ? "any" : ability.target; if (!rank) return target === ability.target ? ability : { ...ability, target }; return { ...ability, target, dbcSpellId: rank.spellId, description: rank.description || ability.description, castMode: rank.castMode, castTimeMs: rank.castTimeMs, cooldownMs: rank.cooldownMs, gcdMs: rank.gcdMs, cost: rank.cost, costs: rank.costs ?? [rank.cost], effects: rank.effects, targeting: rank.targeting ?? ability.targeting, castRules: rank.castRules ?? ability.castRules, runeCost: rank.runeCost ?? ability.runeCost, }; } export function isAbilityUnlocked(ability: AbilityDefinition, playerLevel: number): boolean { return playerLevel >= ability.unlockLevel; } export function abilitiesUnlockedBetweenLevels( classId: ClassId, previousLevel: number, currentLevel: number, ): readonly AbilityDefinition[] { return abilitiesForClass(classId).filter((ability) => ( ability.unlockLevel > previousLevel && ability.unlockLevel <= currentLevel )); } export function abilityById(id: string): AbilityDefinition | null { return ABILITY_CATALOG[id] ?? null; } registerAbilityAnimationLookup(abilityById); export function defaultActionBarForClass(classId: ClassId): readonly string[] { const actionPriority = (ability: AbilityDefinition) => ability.effects.some((effect) => ( effect.kind === "damage" || effect.kind === "finisher-damage" || effect.kind === "heal" || effect.kind === "shield" || effect.kind === "dot" || effect.kind === "hot" || effect.kind === "taunt" || effect.kind === "status" || effect.kind === "apply-aura" || effect.kind === "dispel" || effect.kind === "interrupt" || effect.kind === "pull" || effect.kind === "execute" || effect.kind === "resource-drain" || effect.kind === "threat" || effect.kind === "movement" || effect.kind === "dispel-mechanic" || effect.kind === "pet" || effect.kind === "totem" || effect.kind === "rune" || effect.kind === "trigger-spell" )) ? 0 : 1; const ordered = abilitiesForClass(classId) .map((ability, index) => ({ ability, index })) .sort((left, right) => ( left.ability.unlockLevel - right.ability.unlockLevel || actionPriority(left.ability) - actionPriority(right.ability) || left.index - right.index )) .map(({ ability }) => ability) .filter((ability) => ( !ability.passive && !abilityTalentNodeId(ability) && !ability.effects.every((effect) => effect.kind === "unsupported" || effect.kind === "scripted") )) .map((ability) => ability.id); const primaryTauntId = ({ warrior: "warrior-taunt", paladin: "paladin-hand-of-reckoning", "death-knight": "death-knight-dark-command", druid: "druid-growl", } as Partial>)[classId]; if (!primaryTauntId || !ordered.includes(primaryTauntId)) return ordered; const withoutTaunt = ordered.filter((abilityId) => abilityId !== primaryTauntId); return [ ...withoutTaunt.slice(0, 4), primaryTauntId, ...withoutTaunt.slice(4), ]; } export function defaultActionBarForCharacter(classId: ClassId, secondaryClassId?: ClassId | null, level = 80): readonly string[] { if (!secondaryClassId) return defaultActionBarForClass(classId); return abilitiesForCharacter(classId, secondaryClassId) .filter((ability) => ( !ability.passive && ability.unlockLevel <= level && !abilityTalentNodeId(ability) && !ability.effects.every((effect) => effect.kind === "unsupported" || effect.kind === "scripted") )) .sort((left, right) => left.unlockLevel - right.unlockLevel || left.name.localeCompare(right.name)) .map((ability) => ability.id); } export function resourceProfileForClass(classId: ClassId): ClassResourceProfile { const profile = classResources[classId]; if (!profile) throw new Error(`Ability catalog for ${classId} was used before it was loaded.`); return profile; } if (import.meta.env.MODE === "test" || import.meta.env.MODE === "shelved") { await preloadAbilityCatalogsForClasses([ ...BASE_CLASS_IDS, ...COA_CLASS_IDS, ...ROM_CLASS_IDS, ]); }