1191 lines
51 KiB
TypeScript
1191 lines
51 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";
|
|
|
|
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 } & 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<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: "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<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 abilityCatalog: Record<string, AbilityDefinition> = {};
|
|
let romEliteAbilities: readonly AbilityDefinition[] = [];
|
|
let romTriggeredAbilityResolver: ((spellId: number) => AbilityDefinition | null) | null = null;
|
|
const coaTriggeredAbilities = 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];
|
|
abilitiesByClass[classId] = abilities;
|
|
for (const ability of abilities) abilityCatalog[ability.id] = ability;
|
|
}
|
|
|
|
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 }) => {
|
|
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<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);
|
|
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)
|
|
?? (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<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);
|
|
}
|
|
|
|
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<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,
|
|
]);
|
|
}
|