Files
healer-man/src/game/abilityCatalog.ts
T
2026-10-07 13:05:04 -04:00

1383 lines
61 KiB
TypeScript

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";
import { preloadRuntimeTalentCatalog } from "./talentRuntimeCatalog";
import { applyGeneratedBeastWithinTalent } from "./wow335HunterRuntime";
import { romBloodArrowAbilityForOwner } from "./romBloodArrow";
import type { EnemyNpcSpellDefenseMetadata } from "./enemyBlocking";
export interface WowWeaponRequirementMetadata {
readonly equippedItemClass: number;
readonly equippedItemSubclassMask: number;
readonly equippedItemInventoryMask?: number;
}
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" | "line" | "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 =
| "slow"
| "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 {
/** Raw RuneWaker component identity; native formulas never infer this from a WoW coefficient. */
readonly romNativeSource?: {
readonly skillId: number;
readonly componentId: number;
readonly kind: "damage" | "heal" | "dot" | "hot";
};
/**
* 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 rangedAttackPowerCoefficient?: number;
/** Add a fraction of the source character's Intelligence to the resolved amount. */
readonly intellectCoefficient?: number;
readonly spiritCoefficient?: number;
/** Add a fraction of the source character's Stamina to the resolved amount. */
readonly staminaCoefficient?: number;
/** Add main-hand weapon DPS multiplied by this value to the resolved amount. */
readonly weaponDpsCoefficient?: number;
readonly sourceLevel?: number;
readonly maxScalingLevel?: number;
}
export interface AbilityHealingRecipients {
readonly recipient?: "caster" | "ability-target" | "pet";
readonly scope?: "party";
readonly center?: "caster" | "ability-target";
readonly radius?: number;
readonly maximumTargets?: number;
readonly falloffPerJump?: number;
readonly selection?: "random" | "lowest-max-health";
readonly excludeCaster?: boolean;
}
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 school?: DamageSchool; readonly weaponHand?: "main-hand" | "off-hand"; readonly falloffPerJump?: 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; readonly percentOfDamage?: number; readonly requiresLandedHit?: boolean } & AbilityAmountScaling & AbilityHealingRecipients)
| ({ readonly kind: "shield"; readonly coefficient: number; readonly durationMs: number } & AbilityAmountScaling & AbilityHealingRecipients)
| ({ readonly kind: "dot"; readonly coefficient: number; readonly school?: DamageSchool; 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; readonly percentMaxHealth?: number } & AbilityAmountScaling & AbilityHealingRecipients)
| { readonly kind: "resurrection"; readonly percentMaxHealth: number; readonly flatHealth?: number }
| {
readonly kind: "summon";
readonly creatureId: number;
readonly coefficient: number;
readonly durationMs: number;
/** Source-defined pulse for stationary healing totems. */
readonly healingPulse?: { readonly amount: number; readonly intervalMs: number; readonly radius: number };
/** Source-defined party absorb area carried by a stationary Death Knight zone. */
readonly antiMagicZone?: { readonly radius: number; readonly capacityBase: number; readonly capacityPerAttackPower: number; readonly absorbFraction: number; readonly intervalMs: number };
}
| {
readonly kind: "resource";
readonly amount: number;
readonly resource?: ResourceType;
readonly percentage?: boolean;
/** Party-scoped resource effects use the ability's radius and targeting shape. */
readonly scope?: "target" | "party" | "caster";
}
| { 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" | "pet";
/** Party/raid area auras are applied to the player and every active party member. */
readonly scope?: "target" | "party";
/** Optional range for a party aura sourced by a nearby-target proc. */
readonly radius?: number;
/** Some party effects benefit allies but exclude the caster. */
readonly excludeCaster?: boolean;
readonly control?: AbilityAuraControl;
readonly sourceEffectType: number;
readonly sourceAuraType: number;
/** Unadjusted DBC scalars, retained when source-sensitive immunity needs exact proof. */
readonly sourceBasePoints?: number;
readonly sourceDieSides?: number;
readonly sourcePointsPerLevel?: number;
readonly miscValue: number;
readonly triggerSpellId: number;
/** Resolve this rider only if the cast landed at least one direct hit. */
readonly requiresLandedHit?: boolean;
/** Resolve this rider only if the cast landed at least one direct critical hit. */
readonly requiresCriticalHit?: boolean;
}
| {
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 recipient?: "caster" }
| {
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";
/** Source row movement distance in yards when the spell supplies one. */
readonly distance?: number;
}
| { 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 runeType?: "blood" | "frost" | "unholy";
readonly convertToDeathMs?: number;
}
| {
readonly kind: "cooldown-reset";
readonly mode: "reset" | "reduce";
readonly abilityName?: string;
readonly excludeAbilityNames?: readonly 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 wowSpellDefense?: EnemyNpcSpellDefenseMetadata;
/** Spell.dbc equipped-item requirement; absent when the source class is -1. */
readonly wowWeaponRequirements?: WowWeaponRequirementMetadata;
readonly level: number;
readonly description: string;
readonly castMode: AbilityCastMode;
readonly castTimeMs: number;
readonly channelTickMs?: number;
readonly channelPulses?: number;
readonly channelStartsWithPulse?: boolean;
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;
/** Source rank's damage class, school and defense exceptions; unknown stays absent. */
readonly wowSpellDefense?: EnemyNpcSpellDefenseMetadata;
/** First source rank's item requirement; the selected rank owns its own copy. */
readonly wowWeaponRequirements?: WowWeaponRequirementMetadata;
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 channelTickMs?: number;
readonly channelPulses?: number;
readonly channelStartsWithPulse?: boolean;
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";
/** Source combat delivery metadata independent of a native skill's damage school. */
readonly sourceAttackKind?: "melee" | "ranged" | "spell";
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;
/** A pair-only upgrade applied after its source passive unlock level. */
readonly buildUpgrade?: AbilityBuildUpgrade;
}
export interface AbilityBuildUpgrade {
readonly unlockLevel: number;
readonly values: Partial<Pick<AbilityDefinition,
"description" | "target" | "castMode" | "castTimeMs" | "cooldownMs" | "cost" | "costs" | "effects"
>>;
}
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<AbilityDefinition, "castMode" | "unlockLevel"> & {
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<Record<ClassId, ClassResourceProfile>> = {
// 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<Record<ClassId, ClassResourceProfile>>;
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: "status", status: "stun", durationMs: 1000 },
],
}),
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);
// The selected source Kick is pure interruption. The legacy prototype's
// damage and second interrupt cannot become effects of that sourced row.
const sourceOnlyKick = ability.id === "rogue-kick" && updated.id === "wow335-rogue-kick"
&& updated.classId === "rogue" && updated.source === "wow335-progression"
&& updated.dbcSpellId === 1766;
const ranks = updated.ranks?.map((rank) => ({
...rank,
effects: sourceOnlyKick ? 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: sourceOnlyKick ? updated.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<Record<BaseClassId, readonly AbilityDefinition[]>> = {
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<Record<ClassId, readonly AbilityDefinition[]>> = {
...CLASSIC_CORE_ABILITIES,
};
const sourceAbilitiesByClass: Partial<Record<ClassId, readonly AbilityDefinition[]>> = {
...CLASSIC_CORE_ABILITIES,
};
const abilityCatalog: Record<string, AbilityDefinition> = {};
let romEliteAbilities: readonly AbilityDefinition[] = [];
let romTriggeredAbilityResolver: ((spellId: number) => AbilityDefinition | null) | null = null;
const coaTriggeredAbilities = new Map<number, AbilityDefinition>();
const classicTriggeredAbilities = new Map<number, AbilityDefinition>();
let classicLoad: Promise<void> | null = null;
let romLoad: Promise<void> | null = null;
const coaLoads = new Map<CoaClassId, Promise<void>>();
/** Live read-only views retained for catalog consumers and validation tests. */
export const ABILITIES_BY_CLASS = abilitiesByClass as Readonly<Record<ClassId, readonly AbilityDefinition[]>>;
export const ABILITY_CATALOG = abilityCatalog as Readonly<Record<string, AbilityDefinition>>;
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<void> {
if (classicLoad) return classicLoad;
classicLoad = import("./wow335AbilityCatalog").then(({
WOW335_ABILITIES_BY_CLASS,
WOW335_TRIGGERED_ABILITIES_BY_SPELL_ID,
}) => {
for (const classId of BASE_CLASS_IDS) {
const abilities = mergeWow335ClassicAbilities(CLASSIC_CORE_ABILITIES[classId], WOW335_ABILITIES_BY_CLASS[classId]);
registerClassAbilities(
classId,
classId === "hunter" ? abilities.map(applyGeneratedBeastWithinTalent) : abilities,
);
}
for (const [spellId, ability] of Object.entries(WOW335_TRIGGERED_ABILITIES_BY_SPELL_ID)) {
classicTriggeredAbilities.set(Number(spellId), ability);
}
}).catch((error: unknown) => {
classicLoad = null;
throw error;
});
return classicLoad;
}
async function preloadRomAbilityCatalog(): Promise<void> {
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<void> {
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<void> {
const requested = new Set(classIds);
if (BASE_CLASS_IDS.some((classId) => requested.has(classId))
|| COA_CLASS_IDS.some((classId) => requested.has(classId))) {
await preloadRuntimeTalentCatalog();
}
const loads: Promise<void>[] = [];
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)
?? classicTriggeredAbilities.get(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);
const characterAbilities = [...primary, ...secondaryGeneral, ...elites].map((ability) => {
const bloodArrow = romBloodArrowAbilityForOwner(ability, { classId, secondaryClassId });
return bloodArrow === ability ? ability : {
...bloodArrow,
description: classId === "rom-knight" && secondaryClassId === "rom-scout"
? "Activates Blood Arrow and increases ranged weapon damage. Each offensive ranged weapon skill costs 2.0% HP and 1.0% MP. Right-click the buff icon or use the skill again to deactivate; the skill enters a 10-second cooldown after deactivation."
: classId === "rom-scout" && secondaryClassId === "rom-rogue"
? "Activates Blood Arrow and increases physical ranged weapon damage. Ranged attacks cost 2.0% HP each. Right-click the buff icon or use the skill again to deactivate; the skill enters a 10-second cooldown after deactivation."
: "Activates Blood Arrow and increases physical ranged weapon damage. Right-click the buff icon or use the skill again to deactivate; the skill enters a 10-second cooldown after deactivation.",
};
});
if (classId !== "rom-warlock" || secondaryClassId !== "rom-priest") return characterAbilities;
return characterAbilities.map((ability) => {
if (ability.classId === "rom-priest" && ability.dbcSpellId === 490256) {
return {
...ability,
buildUpgrade: {
unlockLevel: 30,
values: {
castMode: "instant",
castTimeMs: 0,
cooldownMs: 5_000,
description: "Instant cast with a 5-second cooldown. Restores 1 Psi. A critical hit activates Healing Fortune. The simulator cannot redirect an ally-targeted cast to that ally's hostile target.",
},
},
};
}
if (ability.classId === "rom-warlock" && ability.dbcSpellId === 497958) {
return {
...ability,
buildUpgrade: {
unlockLevel: 15,
values: {
target: "any",
description: "Can target a hostile enemy to deal damage or a friendly ally to heal them when Healing Fortune is learned. Ally-target redirection is not modeled.",
},
},
};
}
return ability;
});
}
/** Return the RuneWaker Nature Power cap granted by the learned Brilliance passive. */
export function naturePowerMaximumForCharacter(
classId: ClassId,
secondaryClassId: ClassId | null | undefined,
level: number,
): number {
const hasBrillianceOfNature = abilitiesForCharacter(classId, secondaryClassId).some((ability) => (
ability.source === "runewaker"
&& ability.dbcSpellId === 494931
&& ability.passive
&& level >= ability.unlockLevel
));
return hasBrillianceOfNature ? 15 : 10;
}
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<Record<string, number>>,
): 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<Record<string, number>>,
): 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);
}
function applyBuildUpgradeAtLevel(ability: AbilityDefinition, playerLevel: number): AbilityDefinition {
return ability.buildUpgrade && playerLevel >= ability.buildUpgrade.unlockLevel
? { ...ability, ...ability.buildUpgrade.values }
: ability;
}
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 applyBuildUpgradeAtLevel(target === ability.target ? ability : { ...ability, target }, playerLevel);
return applyBuildUpgradeAtLevel({
...ability,
target,
dbcSpellId: rank.spellId,
wowSpellDefense: rank.wowSpellDefense,
wowWeaponRequirements: rank.wowWeaponRequirements,
description: rank.description || ability.description,
castMode: rank.castMode,
castTimeMs: rank.castTimeMs,
channelTickMs: rank.channelTickMs,
channelPulses: rank.channelPulses,
channelStartsWithPulse: rank.channelStartsWithPulse,
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,
}, playerLevel);
}
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<Record<ClassId, string>>)[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,
]);
}