Files
healer-man/src/game/wow335AbilityCatalog.ts
T
warren 3687c423e2 Correct minion sacrifice and passive talent semantics (refs #19)
Coordinator Root /root, session 01a12399-cfab-7c72-8455-e0e4cb7e156c.
Independently reviewed by /root/combat_source_finish_luna (GPT-6 Luna xhigh).
31 focused DK tests, required release check and complete source type check pass.
2026-10-15 09:09:45 -04:00

2897 lines
124 KiB
TypeScript

import {
BASE_CLASS_IDS,
type BaseClassId,
} from "../app/characterCatalog";
import type {
AbilityAmountScaling,
AbilityAuraControl,
AbilityDefinition,
AbilityEffect,
AbilityRankDefinition,
AbilityTarget,
ResourceType,
} from "./abilityCatalog";
import type {
AuraDefinition,
AuraModifier,
AuraProcDefinition,
DamageSchool,
DispelCategory,
ProcTrigger,
} from "./combatAuras";
import generatedData from "./wow335AbilityData.generated.json";
import { WOW335_SHIELD_BASH_DAZE } from "./wow335ShieldBashDaze";
import { playerCombatPower } from "./progression";
import {
WOW335_HUNTER_PET_KILL_COMMAND_COOLDOWN_MS,
WOW335_HUNTER_PET_KILL_COMMAND_DURATION_MS,
WOW335_HUNTER_PET_KILL_COMMAND_SPECIAL_CHARGES,
WOW335_HUNTER_PET_KILL_COMMAND_TAG,
} from "./wow335HunterPetRuntime";
import {
ROGUE_DEADLY_POISON_DOSE_TAG,
ROGUE_ENVENOM_BUFF_TAG,
} from "./roguePoison";
import { runtimeTalentNodeById } from "./talentRuntimeCatalog";
import { baseResourcesFor } from "./wotlkStats";
import {
WOW335_WARLOCK_METAMORPHOSIS_FORM_ABILITIES,
wow335WarlockMetamorphosisAbility,
} from "./wow335WarlockMetamorphosisRuntime";
import { WOW335_WARLOCK_SOUL_LINK_TAG } from "./wow335WarlockSoulLinkRuntime";
import { WOW335_TALENT_NODES } from "./wow335Talents.generated";
import { WOW335_HUNTER_DETERRENCE_PARENT_AURA_EFFECTS } from "./wow335DeterrenceRuntime";
import { WOW335_DETERRENCE_SOURCE_SIGNATURES } from "./wow335Deterrence";
export const BASE_CLASS_STARTER_SOURCE_LEVEL = 10;
interface Wow335Effect {
readonly index: number;
readonly effectType: number;
readonly auraType: number;
readonly basePoints: number;
readonly dieSides: number;
readonly pointsPerLevel: number;
readonly mechanic?: number;
readonly implicitTargetA: number;
readonly implicitTargetB: number;
readonly radius: number;
readonly periodMs: number;
readonly miscValue: number;
readonly triggerSpellId: number;
readonly coefficient: number;
}
interface Wow335Rank {
readonly spellId: number;
readonly wowSpellDefense?: AbilityRankDefinition["wowSpellDefense"];
readonly wowWeaponRequirements?: AbilityRankDefinition["wowWeaponRequirements"];
readonly rank: number;
readonly level: number;
readonly sourceLevel: number;
readonly maxScalingLevel: number;
readonly description: string;
readonly castTimeMs: number;
readonly cooldownMs: number;
readonly gcdMs: number;
readonly durationMs: number;
readonly channelDurationMs: number;
readonly powerType: number;
readonly powerCost: number;
readonly powerCostPercentage: number;
readonly rangeIndex: number;
readonly rangeMin: number;
readonly rangeMax: number;
readonly equippedItemClass: number;
readonly equippedItemSubclassMask: number;
readonly equippedItemInventoryMask?: number;
readonly effects: readonly Wow335Effect[];
}
interface Wow335Chain {
readonly id: string;
readonly name: string;
readonly iconBasename: string;
readonly iconPath?: string;
readonly sourceUnlockLevel: number;
readonly passive?: boolean;
readonly talentNodeId?: string;
readonly ranks: readonly Wow335Rank[];
}
const data = generatedData as {
readonly classes: Readonly<Record<BaseClassId, readonly Wow335Chain[]>>;
};
const WOW335_HUNTER_DETERRENCE_CHAIN: Wow335Chain = Object.freeze({
id: "wow335-hunter-deterrence",
name: "Deterrence",
iconBasename: "ability_whirlwind",
iconPath: "/assets/ui/spells/ability_whirlwind.png",
sourceUnlockLevel: 60,
ranks: Object.freeze([Object.freeze({
spellId: 19263,
rank: 0,
level: 60,
sourceLevel: 60,
maxScalingLevel: 0,
description: "When activated, increases parry chance by 100% and grants a 100% chance to deflect spells. While Deterrence is active, you cannot attack. Lasts 5 sec.",
castTimeMs: 0,
cooldownMs: 90_000,
gcdMs: 0,
durationMs: 5_000,
channelDurationMs: 0,
powerType: 0,
powerCost: 0,
powerCostPercentage: 0,
rangeIndex: 1,
rangeMin: 0,
rangeMax: 0,
equippedItemClass: 2,
equippedItemSubclassMask: 173_555,
equippedItemInventoryMask: 0,
wowSpellDefense: Object.freeze({
damageClass: 0,
schoolMask: 1,
attributes0: 327_696,
attributes1: 16,
attributes3: 0,
attributes5: 0,
directDamageFromEffects: false,
sourceProfile: "wow335" as const,
}),
effects: Object.freeze(WOW335_DETERRENCE_SOURCE_SIGNATURES.selectedParent.effects.map((effect) => ({
...effect,
coefficient: [1, 1, 0][effect.index] ?? 0,
}))),
})]),
});
const WOW335_HUNTER_DETERRENCE_DESCRIPTION = WOW335_HUNTER_DETERRENCE_CHAIN.ranks[0]!.description;
function isExactHunterDeterrenceChain(chain: Wow335Chain): boolean {
const [rank] = chain.ranks;
const source = WOW335_DETERRENCE_SOURCE_SIGNATURES.selectedParent;
if (chain.id !== "wow335-hunter-deterrence"
|| chain.name !== "Deterrence"
|| chain.iconBasename !== "ability_whirlwind"
|| chain.iconPath !== "/assets/ui/spells/ability_whirlwind.png"
|| chain.sourceUnlockLevel !== 60
|| chain.ranks.length !== 1
|| !rank
|| rank.spellId !== source.spellId
|| rank.rank !== 0
|| rank.level !== 60
|| rank.sourceLevel !== 60
|| rank.maxScalingLevel !== 0
|| rank.description !== WOW335_HUNTER_DETERRENCE_DESCRIPTION
|| rank.castTimeMs !== 0
|| rank.cooldownMs !== 90_000
|| rank.gcdMs !== 0
|| rank.durationMs !== 5_000
|| rank.channelDurationMs !== 0
|| rank.powerType !== 0
|| rank.powerCost !== 0
|| rank.powerCostPercentage !== 0
|| rank.rangeIndex !== 1
|| rank.rangeMin !== 0
|| rank.rangeMax !== 0
|| rank.equippedItemClass !== 2
|| rank.equippedItemSubclassMask !== 173_555
|| rank.equippedItemInventoryMask !== 0) return false;
const defense = rank.wowSpellDefense;
if (!defense
|| defense.damageClass !== 0
|| defense.schoolMask !== 1
|| defense.attributes0 !== 327_696
|| defense.attributes1 !== 16
|| defense.attributes3 !== 0
|| defense.attributes5 !== 0
|| defense.directDamageFromEffects !== false
|| defense.sourceProfile !== "wow335") return false;
return rank.effects.length === source.effects.length
&& rank.effects.every((effect, index) => {
const expected = source.effects[index];
return expected !== undefined
&& effect.index === expected.index
&& effect.effectType === expected.effectType
&& effect.auraType === expected.auraType
&& effect.basePoints === expected.basePoints
&& effect.dieSides === expected.dieSides
&& effect.pointsPerLevel === expected.pointsPerLevel
&& (effect.mechanic ?? 0) === expected.mechanic
&& effect.implicitTargetA === expected.implicitTargetA
&& effect.implicitTargetB === expected.implicitTargetB
&& effect.radius === expected.radius
&& effect.periodMs === expected.periodMs
&& effect.miscValue === expected.miscValue
&& effect.triggerSpellId === expected.triggerSpellId
&& effect.coefficient === [1, 1, 0][index];
});
}
function hunterChainsWithSelectedDeterrence(): readonly Wow335Chain[] {
const chains = data.classes.hunter;
const candidates = chains.filter((chain) => chain.name === "Deterrence"
|| chain.ranks.some((rank) => [19_263, 65_871, 67_801].includes(rank.spellId)));
if (candidates.length === 0) return [...chains, WOW335_HUNTER_DETERRENCE_CHAIN];
if (candidates.length === 1 && isExactHunterDeterrenceChain(candidates[0]!)) return chains;
// A conflicting same-name/linked-ID import is source drift. Drop the whole
// candidate group and fail closed rather than substitute 65871 or 67801.
return chains.filter((chain) => !candidates.includes(chain));
}
const RESOURCE_BY_CLASS: Readonly<Record<BaseClassId, ResourceType>> = {
warrior: "rage",
paladin: "mana",
hunter: "mana",
rogue: "energy",
priest: "mana",
"death-knight": "runic-power",
shaman: "mana",
mage: "mana",
warlock: "mana",
druid: "mana",
};
const HOSTILE_TARGETS = new Set([6, 15, 22, 25, 53, 77, 87]);
const FRIENDLY_TARGETS = new Set([21, 35, 37, 45, 57]);
const WEAPON_DAMAGE_EFFECTS = new Set([17, 31, 58, 121]);
const APPLY_AURA_EFFECTS = new Set([6, 27, 35, 65, 119, 128, 129, 143]);
const TRIGGER_SPELL_EFFECTS = new Set([32, 64, 140, 141, 142, 148, 151, 160]);
const NONCOMBAT_EFFECTS = new Set([
4, 11, 12, 13, 14, 15, 16, 24, 33, 34, 36, 39, 44, 45, 47, 50, 53, 54,
57, 59, 60, 66, 71, 72, 73, 74, 81, 82, 83, 86, 90, 95, 99, 103, 111, 115, 116,
118, 120, 123, 127, 131, 132, 133, 134, 139, 147, 150, 152, 154, 156,
157, 158, 159, 161, 162,
]);
const ALL_DISPEL_CATEGORIES: readonly DispelCategory[] = [
"magic",
"curse",
"poison",
"disease",
"enrage",
];
const STAT_BY_MISC_VALUE: Readonly<Record<number, string>> = {
[-1]: "all-primary",
0: "strength",
1: "agility",
2: "stamina",
3: "intellect",
4: "spirit",
};
function targetsForEffect(effect: Wow335Effect): readonly number[] {
return [effect.implicitTargetA, effect.implicitTargetB];
}
function effectTargetsHostile(effect: Wow335Effect): boolean {
return targetsForEffect(effect).some((target) => HOSTILE_TARGETS.has(target));
}
function schoolFromMask(mask: number): DamageSchool | undefined {
const matches = schoolsFromMask(mask);
return matches.length === 1 ? matches[0] : undefined;
}
function schoolsFromMask(mask: number): readonly DamageSchool[] {
const schools: readonly [number, DamageSchool][] = [
[1, "physical"],
[2, "holy"],
[4, "fire"],
[8, "nature"],
[16, "frost"],
[32, "shadow"],
[64, "arcane"],
];
return schools.filter(([bit]) => (mask & bit) !== 0).map(([, school]) => school);
}
function percentValue(effect: Wow335Effect): number {
return effect.basePoints / 100;
}
function auraModifiers(effect: Wow335Effect): readonly AuraModifier[] {
const flatStat = (stat: string): AuraModifier[] => [
{ kind: "stat", stat, operation: "flat", value: effect.basePoints },
];
const percentStat = (stat: string): AuraModifier[] => [
{ kind: "stat", stat, operation: "percent", value: percentValue(effect) },
];
const resistanceStat = `resistance:${schoolFromMask(effect.miscValue) ?? "all"}`;
switch (effect.auraType) {
case 9: return percentStat("attack-speed");
case 10:
case 103:
case 183:
case 221: return percentStat("threat");
case 13: return [{ kind: "damage", direction: "dealt", school: schoolFromMask(effect.miscValue), operation: "flat", value: effect.basePoints }];
case 14: return [{ kind: "damage", direction: "taken", school: schoolFromMask(effect.miscValue), operation: "flat", value: effect.basePoints }];
case 20: return flatStat("health-regeneration");
case 22: return effect.miscValue === 1 ? flatStat("armor") : flatStat(resistanceStat);
case 83:
case 123:
case 143: return flatStat(resistanceStat);
case 29: return flatStat(STAT_BY_MISC_VALUE[effect.miscValue] ?? `stat:${effect.miscValue}`);
case 31:
case 32:
case 33:
case 58:
case 129:
case 171:
case 172:
case 191:
case 201:
case 206:
case 207:
case 208:
case 209:
case 210:
case 211:
case 252: return percentStat("movement-speed");
case 34:
case 250: return flatStat("maximum-health");
case 35: return [{ kind: "resource", resource: "power", property: "maximum", operation: "flat", value: effect.basePoints }];
// SPELL_AURA_SCHOOL_IMMUNITY. Multi-school masks are common (Divine
// Shield, Ice Block, Cyclone); retain every bit instead of dropping the
// aura unless exactly one school was selected.
case 39:
case 267: return schoolsFromMask(effect.miscValue).map((school) => ({
kind: "damage" as const,
direction: "taken" as const,
school,
operation: "percent" as const,
value: -1,
}));
case 47: return percentStat("parry");
case 49: return percentStat("dodge");
case 50: return percentStat("critical-healing");
case 51: return percentStat("block");
case 52: return percentStat("critical-strike");
case 187: return [{ kind: "stat", stat: "incoming-melee-critical-strike", operation: "flat", value: effect.basePoints }];
case 188: return [{ kind: "stat", stat: "incoming-ranged-critical-strike", operation: "flat", value: effect.basePoints }];
case 197: return [{ kind: "stat", stat: "incoming-critical-strike", operation: "flat", value: effect.basePoints }];
case 54: return percentStat("hit");
case 184: return [{ kind: "stat", stat: "incoming-melee-hit", operation: "flat", value: effect.basePoints }];
case 185: return [{ kind: "stat", stat: "incoming-ranged-hit", operation: "flat", value: effect.basePoints }];
case 55:
case 186:
case 199: return percentStat("spell-hit");
case 57: return flatStat("spell-critical-strike");
case 71:
case 179: return percentStat("spell-critical-strike");
case 59: return [{ kind: "damage", direction: "dealt", operation: "flat", value: effect.basePoints }];
case 61: return percentStat("haste");
case 65:
case 138:
case 140:
case 141:
case 192:
case 193:
case 216:
case 217:
case 218: return percentStat("haste");
case 72: return [{ kind: "resource", resource: "power", property: "cost", operation: "percent", value: percentValue(effect) }];
case 73: return [{ kind: "resource", resource: "power", property: "cost", operation: "flat", value: effect.basePoints }];
case 79:
case 168: return [{ kind: "damage", direction: "dealt", school: schoolFromMask(effect.miscValue), operation: "percent", value: percentValue(effect) }];
case 80:
case 137: return percentStat(STAT_BY_MISC_VALUE[effect.miscValue] ?? "all-primary");
case 84:
case 88:
case 116:
case 161: return effect.auraType === 84 || effect.auraType === 116 || effect.auraType === 161
? flatStat("health-regeneration")
: percentStat("health-regeneration");
case 85: return [{ kind: "resource", resource: "power", property: "regeneration", operation: "flat", value: effect.basePoints }];
case 87:
case 229:
case 255: return [{ kind: "damage", direction: "taken", school: schoolFromMask(effect.miscValue), operation: "percent", value: percentValue(effect) }];
case 99:
case 165: return flatStat("attack-power");
case 101:
case 142: return percentStat(resistanceStat);
case 110:
case 134:
case 219: return [{ kind: "resource", resource: "mana", property: "regeneration", operation: "percent", value: percentValue(effect) }];
case 113: return [{ kind: "damage", direction: "taken", attackKind: "ranged", operation: "flat", value: effect.basePoints }];
case 114: return [{ kind: "damage", direction: "taken", attackKind: "ranged", operation: "percent", value: percentValue(effect) }];
case 115: return [{ kind: "healing", direction: "taken", operation: "flat", value: effect.basePoints }];
case 118: return [{ kind: "healing", direction: "taken", operation: "percent", value: percentValue(effect) }];
case 122: return [{ kind: "damage", direction: "dealt", attackKind: "melee", operation: "percent", value: percentValue(effect) }];
case 124:
case 127: return flatStat("ranged-attack-power");
case 125: return [{ kind: "damage", direction: "taken", attackKind: "melee", operation: "flat", value: effect.basePoints }];
case 126: return [{ kind: "damage", direction: "taken", attackKind: "melee", operation: "percent", value: percentValue(effect) }];
case 132: return [{ kind: "resource", resource: "power", property: "maximum", operation: "percent", value: percentValue(effect) }];
case 133: return percentStat("maximum-health");
case 135: return [{ kind: "healing", direction: "done", operation: "flat", value: effect.basePoints }];
case 136:
case 175: return [{ kind: "healing", direction: "done", operation: "percent", value: percentValue(effect) }];
case 150: return percentStat("block-value");
case 158: return flatStat("block-value");
case 163:
case 203:
case 204: return percentStat("critical-damage");
case 166: return percentStat("attack-power");
case 167:
case 212: return percentStat("ranged-attack-power");
case 169: return percentStat("critical-strike");
case 174:
case 237: return percentStat("spell-power");
case 189:
case 220: return flatStat(`combat-rating:${effect.miscValue}`);
case 240: return flatStat("expertise");
case 230: return flatStat("maximum-health");
case 271: return [{ kind: "damage", direction: "taken", operation: "percent", value: percentValue(effect) }];
// DBC MOD_ARMOR_PENETRATION_PERCENT stores percentage points here (for
// example Battle Stance's 10 means ignore 10% armor). This stat is already
// expressed as a percent in CharacterStats, so keep it additive.
case 280: return flatStat("armor-penetration");
case 248: return percentStat("combat-result");
case 251: return percentStat("enemy-dodge");
case 253: return percentStat("block-critical-strike");
default: return [];
}
}
function controlFromAura(auraType: number): AbilityAuraControl | undefined {
switch (auraType) {
case 5: return { kind: "confuse" };
case 6:
case 177: return { kind: "charm" };
case 7: return { kind: "fear" };
case 12: return { kind: "stun" };
case 25: return { kind: "pacify" };
case 26: return { kind: "root" };
case 27: return { kind: "silence" };
case 60: return { kind: "silence" };
case 67:
case 254:
case 278: return { kind: "disarm" };
case 56: return { kind: "confuse" };
case 92: return { kind: "root" };
default: return undefined;
}
}
function exclusiveGroupForAbility(classId: BaseClassId, abilityName: string): string | undefined {
if (["Arcane Intellect", "Arcane Brilliance"].includes(abilityName)) return "mage:intellect";
if (/\bStance$/i.test(abilityName)) return `${classId}:stance`;
if (/^Seal of\b/i.test(abilityName)) return "paladin:seal";
if (/\bAura$/i.test(abilityName)) return `${classId}:aura`;
if (/^(?:Greater )?Blessing of\b/i.test(abilityName)) return "paladin:blessing";
if (/^Aspect of\b/i.test(abilityName)) return "hunter:aspect";
if (/^Track\b|^Sense (?:Undead|Demons)$/i.test(abilityName)) return `${classId}:tracking`;
if (/\b(?:Bear|Cat|Travel|Aquatic|Flight|Tree of Life) Form$/i.test(abilityName)) return "druid:form";
if (/\bArmor$/i.test(abilityName)) return `${classId}:armor`;
if (/\bPresence$/i.test(abilityName)) return "death-knight:presence";
if (["Lightning Shield", "Water Shield"].includes(abilityName)) return "shaman:elemental-shield";
// Earth Shield is maintained on an ally while the Shaman keeps a personal
// Lightning/Water Shield, so it cannot share their caster-global group.
if (abilityName === "Earth Shield") return "shaman:earth-shield";
if (/ Weapon$/i.test(abilityName)) return "shaman:weapon-enchant";
return undefined;
}
function utilityTagsForAura(auraType: number, abilityName: string): readonly string[] {
const tags: string[] = [];
if (auraType === 16 || /\bStealth|Prowl\b/i.test(abilityName)) tags.push("stealth");
if ([17, 19, 44, 151].includes(auraType)) tags.push("tracking");
if (auraType === 36) tags.push("shapeshift");
if (auraType === 66) tags.push("feign-death");
if (auraType === 78) tags.push("mounted");
if (auraType === 82) tags.push("water-breathing");
if (auraType === 104) tags.push("water-walking");
if (auraType === 105) tags.push("slow-fall");
if ([1, 4, 68, 121, 128, 226, 292].includes(auraType)) tags.push(`classic-aura:${auraType}`);
if (auraType === 120) tags.push("untrackable");
if (auraType === 149) tags.push("pushback-resistance");
if (auraType === 25) tags.push("pacified");
if (auraType === 41) tags.push("reveal-stealth");
if (auraType === 91) tags.push("reduced-aggro-radius");
return tags;
}
function controlImmunitiesForAura(auraType: number, miscValue: number): AuraDefinition["controlImmunities"] {
if (auraType !== 77) return undefined;
if (miscValue === 5) return ["fear"];
if (miscValue === 7 || miscValue === 11) return ["root"];
if (miscValue === 10) return ["sleep"];
if (miscValue === 12) return ["stun"];
return undefined;
}
function inferredProcTriggers(description: string, auraType: number): readonly ProcTrigger[] {
if (auraType === 23 || auraType === 48 || auraType === 226 || auraType === 227) return ["periodic"];
const triggers: ProcTrigger[] = [];
if (/\bcritical|critically\b/i.test(description)) triggers.push("crit");
if (/\bdodge/i.test(description)) triggers.push("dodge");
if (/\bparr(?:y|ied)/i.test(description)) triggers.push("parry");
if (/\bblock/i.test(description)) triggers.push("block");
if (/\bcast(?:ing|s)?\b/i.test(description)) triggers.push("cast");
if (/\bheal(?:ing|s|ed)?\b/i.test(description)) triggers.push("heal");
if (/\bkill(?:ing|s|ed)?\b/i.test(description)) triggers.push("kill");
if (/\bdamage taken|when struck|takes? damage/i.test(description)) triggers.push("damage-taken");
if (/\bhit(?:s|ting)?\b|attack/i.test(description)) triggers.push("hit");
return triggers.length ? [...new Set(triggers)] : ["hit"];
}
function procChance(description: string): number {
const match = description.match(/(\d+(?:\.\d+)?)% chance/i);
return match ? Math.min(1, Math.max(0, Number(match[1]) / 100)) : 1;
}
function procInternalCooldown(description: string): number {
const match = description.match(/(?:once every|once per|only once (?:every|per))\s+(\d+(?:\.\d+)?)\s*sec/i);
return match ? Number(match[1]) * 1_000 : 0;
}
function procForAura(
effect: Wow335Effect,
rank: Wow335Rank,
description: string,
): AuraProcDefinition | undefined {
const triggerAura = [23, 42, 43, 48, 109, 111, 223, 225, 227, 231].includes(effect.auraType);
const damageShield = effect.auraType === 15;
if (!triggerAura && !damageShield) return undefined;
if (!damageShield && effect.triggerSpellId <= 0) return undefined;
return {
id: `wow335:${rank.spellId}:proc:${effect.auraType}:${effect.triggerSpellId || "damage"}`,
triggers: damageShield ? "damage-taken" : inferredProcTriggers(description, effect.auraType),
chance: procChance(description),
...(procInternalCooldown(description) > 0
? { internalCooldownMs: procInternalCooldown(description) }
: {}),
action: damageShield
? {
kind: "damage",
amount: Math.max(0, effect.basePoints),
school: schoolFromMask(effect.miscValue) ?? "physical",
target: "event-other",
}
: {
kind: "custom",
id: "wow335-trigger-spell",
data: { spellId: effect.triggerSpellId },
},
};
}
function dispelCategoryForAura(
abilityName: string,
description: string,
durationMs: number,
): DispelCategory | undefined {
const text = `${abilityName} ${description}`;
if (/\bcurse\b/i.test(text)) return "curse";
if (/\bpoison|venom\b/i.test(text)) return "poison";
if (/\bdisease|plague\b/i.test(text)) return "disease";
if (/\benrage|frenzy|berserk/i.test(text)) return "enrage";
if (/\bbleed|bleeding|wound|rend\b/i.test(text)) return undefined;
return durationMs > 0 ? "magic" : undefined;
}
function namedAuraForEffect(
classId: BaseClassId,
effect: Wow335Effect,
rank: Wow335Rank,
abilityName: string,
effectIndex: number,
): AuraDefinition {
const durationMs = rank.durationMs > 0 ? rank.durationMs : null;
const hiddenProcOverrides = new Set([
"Seal of Justice", "Lightning Shield", "Water Shield", "Shamanistic Rage", "Frost Armor", "Ice Armor", "Molten Armor", "Nature's Grasp",
]);
const proc = hiddenProcOverrides.has(abilityName) ? undefined : procForAura(effect, rank, rank.description);
const school = schoolFromMask(effect.miscValue);
const auraId = `wow335:${rank.spellId}:effect:${effectIndex}:aura:${effect.auraType}`;
const modifiers = [...auraModifiers(effect)];
if (abilityName === "Inner Fire") {
for (const [index, modifier] of modifiers.entries()) modifiers[index] = { ...modifier, perStack: false };
}
if (abilityName === "Molten Armor" && effect.auraType === 220) {
modifiers.splice(0, modifiers.length, {
kind: "stat", stat: "spirit-to-spell-crit-rating", operation: "flat", value: effect.basePoints / 100,
});
}
if (abilityName === "Greater Blessing of Sanctuary" && effect.auraType === 4) {
modifiers.push(
{ kind: "damage", direction: "taken", operation: "percent", value: -0.03 },
{ kind: "stat", stat: "stamina", operation: "percent", value: 0.1 },
);
}
if (abilityName === "Aura Mastery") {
if (effect.miscValue === 3) modifiers.push({ kind: "stat", stat: "silence-immunity", operation: "flat", value: 1 });
if (effect.miscValue === 12) modifiers.push({ kind: "stat", stat: "interrupt-immunity", operation: "flat", value: 1 });
}
if (abilityName === "Ice Block" && effect.auraType === 39) {
modifiers.push({ kind: "damage", direction: "taken", school: "physical", operation: "percent", value: -1 });
}
if (effect.auraType === 36 && ["Bear Form", "Dire Bear Form"].includes(abilityName)) {
modifiers.push(
{ kind: "stat", stat: "armor", operation: "percent", value: abilityName === "Dire Bear Form" ? 3.7 : 1.8 },
{ kind: "stat", stat: "stamina", operation: "percent", value: abilityName === "Dire Bear Form" ? 0.25 : 0.1 },
);
}
if (effect.auraType === 36 && abilityName === "Cat Form") {
modifiers.push(
{ kind: "stat", stat: "attack-power", operation: "percent", value: 0.1 },
{ kind: "stat", stat: "movement-speed", operation: "percent", value: 0.3 },
);
}
if (effect.auraType === 36 && ["Travel Form", "Ghost Wolf"].includes(abilityName)) {
modifiers.push({ kind: "stat", stat: "movement-speed", operation: "percent", value: 0.4 });
}
if (effect.auraType === 36 && ["Flight Form", "Swift Flight Form"].includes(abilityName)) {
modifiers.push({ kind: "stat", stat: "movement-speed", operation: "percent", value: abilityName === "Swift Flight Form" ? 2.8 : 0.6 });
}
if (effect.auraType === 36 && abilityName === "Tree of Life") {
modifiers.push({ kind: "healing", direction: "done", operation: "percent", value: 0.06 });
}
if (effect.auraType === 36 && abilityName === "Defensive Stance") {
modifiers.push(
{ kind: "damage", direction: "taken", operation: "percent", value: -0.1 },
{ kind: "stat", stat: "threat", operation: "percent", value: 0.3 },
);
}
if (effect.auraType === 36 && abilityName === "Berserker Stance") {
modifiers.push(
{ kind: "stat", stat: "critical-strike", operation: "percent", value: 0.03 },
{ kind: "damage", direction: "taken", operation: "percent", value: 0.05 },
);
}
if (effect.auraType === 21 && abilityName === "Aspect of the Viper") {
modifiers.push({
kind: "resource",
resource: "mana",
property: "regeneration",
operation: "flat",
value: Math.max(1, Math.abs(effect.basePoints)),
});
}
const specialProcs: AuraProcDefinition[] = [];
const procAmount = Math.max(1, Math.round(playerCombatPower(Math.max(1, rank.sourceLevel)) * 0.12));
if (["Seal of Righteousness", "Seal of Vengeance", "Seal of Corruption"].includes(abilityName) && effectIndex === 0) {
specialProcs.push({
id: `${auraId}:seal-damage`,
triggers: "hit",
chance: 1,
action: { kind: "damage", amount: procAmount, school: "holy", target: "event-other" },
});
} else if (abilityName === "Seal of Light" && effectIndex === 0) {
specialProcs.push({
id: `${auraId}:seal-heal`,
triggers: "hit",
chance: 1,
action: { kind: "heal", amount: procAmount, target: "aura-target" },
});
} else if (abilityName === "Seal of Wisdom" && effectIndex === 0) {
specialProcs.push({
id: `${auraId}:seal-mana`,
triggers: "hit",
chance: 1,
action: { kind: "resource", resource: "mana", amount: Math.max(1, Math.round(procAmount / 3)), target: "aura-target" },
});
}
if (abilityName === "Holy Shield" && effect.auraType === 43) {
specialProcs.push({
id: `${auraId}:holy-shield`,
triggers: "block",
chance: 1,
// Holy Shield ranks 20925/20927/20928 carry procCharges=8 in local Spell.dbc.
charges: 8,
action: { kind: "damage", amount: procAmount, school: "holy", target: "event-other" },
});
}
if (abilityName === "Retaliation" && effect.auraType === 4) {
specialProcs.push({
id: `${auraId}:retaliation`, triggers: "damage-taken", chance: 1, charges: 30,
action: { kind: "damage", amount: Math.max(1, Math.round(procAmount * 1.5)), school: "physical", target: "event-other" },
});
}
if (abilityName === "Judgement of Light" && effect.auraType === 4) {
specialProcs.push({
id: `${auraId}:judgement-light`, triggers: "damage-taken", chance: 1,
action: { kind: "heal", amount: procAmount, target: "event-other" },
});
}
if (abilityName === "Judgement of Wisdom" && effect.auraType === 4) {
specialProcs.push({
id: `${auraId}:judgement-wisdom`, triggers: "damage-taken", chance: 1,
action: { kind: "resource", resource: "mana", amount: Math.max(1, Math.round(procAmount / 3)), target: "event-other" },
});
}
if (abilityName === "Seal of Justice" && effect.auraType === 42) {
specialProcs.push({
id: `${auraId}:seal-justice`, triggers: "hit", chance: 0.2,
action: { kind: "custom", id: "classic-status", data: { status: "stun", durationMs: 2_000 } },
});
}
if (abilityName === "Lightning Shield" && effect.auraType === 42) {
specialProcs.push({
id: `${auraId}:lightning-shield`, triggers: "damage-taken", chance: 1, charges: 3,
action: { kind: "damage", amount: Math.max(1, effect.basePoints), school: "nature", target: "event-other" },
});
}
if (abilityName === "Water Shield" && effect.auraType === 42) {
specialProcs.push({
id: `${auraId}:water-shield`, triggers: "damage-taken", chance: 1, charges: 3,
action: { kind: "resource", resource: "mana", amount: Math.max(1, effect.basePoints), target: "aura-target" },
});
}
if (classId === "shaman" && abilityName === "Shamanistic Rage" && effectIndex === 0) {
specialProcs.push({
id: `${auraId}:shamanistic-rage-mana`,
triggers: ["hit", "crit"],
attackKinds: ["melee"],
chance: 1,
action: {
kind: "custom",
id: "wow335-shamanistic-rage-mana",
data: { attackPowerPercent: Math.max(0, effect.basePoints) },
},
});
}
if (abilityName === "Earth Shield" && effectIndex === 0) {
specialProcs.push({
id: `${auraId}:earth-shield`, triggers: "damage-taken", chance: 1,
internalCooldownMs: 3_000, charges: 9,
action: {
kind: "heal",
amount: Math.max(1, Math.round(
effect.basePoints
+ playerCombatPower(Math.max(1, rank.sourceLevel || rank.level)) * Math.max(0, effect.coefficient),
)),
target: "aura-target",
},
});
}
if (["Frost Armor", "Ice Armor"].includes(abilityName) && effect.auraType === 42) {
specialProcs.push({
id: `${auraId}:frost-slow`, triggers: "damage-taken", chance: 1,
action: { kind: "custom", id: "classic-status", data: { status: "slow", durationMs: 5_000, magnitude: 0.3 } },
});
}
if (abilityName === "Molten Armor" && effect.auraType === 42) {
specialProcs.push({
id: `${auraId}:molten-retaliation`, triggers: "damage-taken", chance: 1,
action: { kind: "damage", amount: procAmount, school: "fire", target: "event-other" },
});
}
if (abilityName === "Nature's Grasp" && effect.auraType === 42) {
specialProcs.push({
id: `${auraId}:natures-grasp`, triggers: "damage-taken", chance: 1, charges: 1,
action: { kind: "custom", id: "classic-status", data: { status: "root", durationMs: 10_000 } },
});
}
if (abilityName === "Recklessness" && effect.auraType === 107) {
modifiers.push({ kind: "stat", stat: "critical-strike", operation: "percent", value: 1 });
}
const sacredShield = abilityName === "Sacred Shield" && effect.auraType === 4;
if (sacredShield) {
specialProcs.push({
id: `${auraId}:sacred-shield-refresh`,
triggers: "damage-taken",
chance: 1,
internalCooldownMs: 6_000,
action: { kind: "apply-aura", auraId, target: "aura-target" },
});
}
const utilityTags = [
...utilityTagsForAura(effect.auraType, abilityName),
...(abilityName === "Light's Beacon" || abilityName === "Beacon of Light" ? ["beacon-of-light"] : []),
...(classId === "paladin" && abilityName === "Divine Sacrifice" ? ["wow335-paladin-divine-sacrifice"] : []),
...(abilityName === "Divine Favor" ? ["wow335-divine-favor-next-holy-heal"] : []),
...(abilityName === "Aura Mastery" ? ["wow335-aura-mastery-silence-interrupt", "wow335-aura-mastery-aura-effectiveness"] : []),
];
const controlImmunities = classId === "paladin" && abilityName === "Aura Mastery"
? effect.miscValue === 3 ? ["silence"] as const
: effect.miscValue === 12 ? ["interrupt"] as const
: undefined
: rank.spellId === 46924 && rank.wowSpellDefense?.sourceProfile === "wow335"
&& classId === "warrior" && rank.wowSpellDefense.damageClass === 2
&& rank.wowSpellDefense.schoolMask === 1 && rank.wowSpellDefense.directDamageFromEffects === false
&& rank.wowSpellDefense.attributes0 === 327696 && rank.wowSpellDefense.attributes1 === 32784
&& rank.wowSpellDefense.attributes3 === 1024 && rank.wowSpellDefense.attributes5 === 512
&& effect.effectType === 6 && effect.auraType === 147 && effect.miscValue === 1733
&& effect.basePoints === 0 && effect.dieSides === 1 && effect.pointsPerLevel === 0
? ["fear", "stun"] as const
: controlImmunitiesForAura(effect.auraType, effect.miscValue);
return {
id: auraId,
name: `${abilityName} (DBC ${rank.spellId}, Aura ${effect.auraType})`,
disposition: effectTargetsHostile(effect) ? "debuff" : "buff",
...([3, 8].includes(effect.auraType) && effect.periodMs > 0
? { hideFromAuraStrip: true }
: {}),
durationMs,
maxStacks: abilityName === "Inner Fire" ? 20 : 1,
...(abilityName === "Inner Fire" ? { initialStacks: 20 } : {}),
stackBehavior: abilityName === "Inner Fire" ? "replace" : "refresh",
...(exclusiveGroupForAbility(classId, abilityName) || abilityName === "Light's Beacon"
? { exclusiveGroup: abilityName === "Light's Beacon" ? "paladin:beacon" : exclusiveGroupForAbility(classId, abilityName) }
: {}),
...(utilityTags.length ? { utilityTags } : {}),
...(controlImmunities ? { controlImmunities } : {}),
...(effect.auraType === 28
? { reflectSpellChance: Math.min(1, Math.max(0, Math.abs(effect.basePoints) / 100)) }
: {}),
...(effect.auraType === 74
? { reflectSpellChance: Math.min(1, Math.max(0, Math.abs(effect.basePoints) / 100)) }
: {}),
...(effect.auraType === 81
? { redirectDamagePercent: Math.min(1, Math.max(0, Math.abs(effect.basePoints) / 100)) }
: {}),
...(classId === "paladin" && abilityName === "Divine Sacrifice" && effect.auraType === 81
? {
redirectDamageCapPercent: Math.max(0, rank.effects.find((row) => row.effectType === 0)?.basePoints ?? 0) / 100,
}
: {}),
...(dispelCategoryForAura(abilityName, rank.description, rank.durationMs) !== undefined
? { dispelCategory: dispelCategoryForAura(abilityName, rank.description, rank.durationMs) }
: {}),
...(modifiers.length ? { modifiers } : {}),
...([69, 97].includes(effect.auraType) || sacredShield
? {
absorbs: [{
id: `wow335:${rank.spellId}:absorb:${effectIndex}`,
amount: sacredShield ? Math.max(1, Math.round(playerCombatPower(Math.max(1, rank.sourceLevel)) * 0.75)) : Math.max(0, effect.basePoints),
...(school !== undefined ? { school } : {}),
perStack: false,
}],
}
: {}),
...(proc || specialProcs.length ? { procs: [...(proc ? [proc] : []), ...specialProcs] } : {}),
};
}
function categoriesForDispel(miscValue: number): readonly DispelCategory[] {
switch (miscValue) {
case 1: return ["magic"];
case 2: return ["curse"];
case 3: return ["disease"];
case 4: return ["poison"];
case 9: return ["enrage"];
case 7: return ALL_DISPEL_CATEGORIES;
default: return [];
}
}
function gameplaySourceLevel(classId: BaseClassId, sourceLevel: number): number {
// Wrath death knights began at level 55. Healer Man starts every class at
// one, so translate their 55..80 spell track to 1..26 before applying the
// same starter-kit policy used by the other base classes.
return classId === "death-knight"
? Math.max(1, sourceLevel - 54)
: Math.max(1, sourceLevel);
}
function starterUnlockLevel(classId: BaseClassId, sourceLevel: number, abilityName: string): number {
const translated = gameplaySourceLevel(classId, sourceLevel);
// A five-player tank must have a taunt even where the original trainer put
// it slightly beyond level ten (Hand of Reckoning was level 16 in Wrath).
const roleEssentialStarter = classId === "paladin" && abilityName === "Hand of Reckoning";
return translated <= BASE_CLASS_STARTER_SOURCE_LEVEL || roleEssentialStarter ? 1 : translated;
}
function amountScaling(effect: Wow335Effect, rank: Wow335Rank): AbilityAmountScaling {
return {
basePoints: Math.abs(effect.basePoints),
dieSides: Math.abs(effect.dieSides),
pointsPerLevel: Math.max(0, effect.pointsPerLevel),
bonusPowerCoefficient: Math.max(0, effect.coefficient),
sourceLevel: Math.max(1, rank.sourceLevel || rank.level),
...(rank.maxScalingLevel > 0 ? { maxScalingLevel: rank.maxScalingLevel } : {}),
};
}
function fallbackCoefficient(effect: Wow335Effect, rank: Wow335Rank): number {
if (effect.coefficient > 0) return effect.coefficient;
const average = Math.abs(effect.basePoints) + Math.max(0, Math.abs(effect.dieSides) - 1) / 2;
return Math.max(0.08, Math.min(4, average / playerCombatPower(Math.max(1, rank.sourceLevel || rank.level))));
}
function stormstrikeWeaponHitForTrigger(
spellId: number,
): Extract<AbilityEffect, { kind: "damage" }> | null {
const weaponHand = spellId === 32175
? "main-hand"
: spellId === 32176
? "off-hand"
: undefined;
if (!weaponHand) return null;
// Stormstrike's wrapper invokes these two DBC child spells. Their local
// generated SpellEffect rows contain the weapon-damage coefficient.
const childRank = data.classes.shaman
.find((chain) => chain.name === "Stormstrike")
?.ranks.find((candidate) => candidate.spellId === spellId);
const weaponEffect = childRank?.effects.find((effect) => WEAPON_DAMAGE_EFFECTS.has(effect.effectType));
if (!weaponEffect) return null;
const coefficient = weaponEffect.coefficient > 0
? weaponEffect.coefficient
: weaponEffect.effectType === 31
? Math.max(0.1, Math.abs(weaponEffect.basePoints) / 100)
: 1;
return { kind: "damage", coefficient, school: "physical", weaponHand };
}
function utilityAction(effectType: number): Extract<AbilityEffect, { kind: "utility" }>["action"] {
if (effectType === 24) return "create-item";
if (effectType === 33) return "open-lock";
if (effectType === 50) return "portal";
if (effectType === 54) return "weapon-enchant";
if (effectType === 71) return "pickpocket";
if (effectType === 72) return "farsight";
return "class-script";
}
const SHAMAN_HEALING_STREAM_AMOUNT_BY_RANK: Readonly<Record<number, number>> = {
5394: 6,
6375: 8,
6377: 10,
10462: 12,
10463: 14,
25567: 18,
58755: 20,
58756: 23,
58757: 25,
};
function classScriptEffects(
classId: BaseClassId,
abilityName: string,
rank: Wow335Rank,
): readonly AbilityEffect[] {
const power = playerCombatPower(Math.max(1, rank.sourceLevel || rank.level));
const aura = (
id: string,
modifiers: readonly AuraModifier[],
procs: readonly AuraProcDefinition[] = [],
): AbilityEffect => ({
kind: "apply-aura",
aura: {
id: `wow335:${rank.spellId}:${id}`,
name: abilityName,
disposition: "buff",
durationMs: rank.durationMs > 0 ? rank.durationMs : null,
maxStacks: 1,
stackBehavior: "refresh",
exclusiveGroup: exclusiveGroupForAbility(classId, abilityName),
...(modifiers.length ? { modifiers } : {}),
...(procs.length ? { procs } : {}),
utilityTags: ["weapon-enchant"],
},
sourceEffectType: 3,
sourceAuraType: 4,
miscValue: 0,
triggerSpellId: 0,
});
switch (`${classId}:${abilityName}`) {
case "mage:Blink": return [{
kind: "movement",
movement: "teleport",
toward: "destination",
distance: rank.effects.find((effect) => effect.effectType === 29)?.radius ?? 0,
}];
case "paladin:Redemption": return [{
kind: "resurrection",
percentMaxHealth: 0,
flatHealth: Math.max(1, Math.abs(rank.effects.find((effect) => effect.effectType === 113)?.basePoints ?? 1)),
}];
case "paladin:Divine Storm": return [
{ kind: "damage", coefficient: 1.1, school: "physical" },
{
kind: "heal", coefficient: 0, percentOfDamage: 0.25, scope: "party",
radius: 40, maximumTargets: 3,
},
];
case "warrior:Slam": return [{ kind: "damage", coefficient: 1.5 }];
// Bloodthirst's player row (23881) owns the hit and cost. The 8-second
// child aura (23885) owns its three next-hit self-heals through 23880.
case "warrior:Bloodthirst": {
const child = data.classes.warrior.flatMap((chain) => chain.ranks)
.find((candidate) => candidate.spellId === 23885);
const effect = child?.effects.find((candidate) => candidate.effectType === 6 && candidate.auraType === 42);
if (!child || !effect) return [];
const definition = namedAuraForEffect(classId, effect, child, abilityName, 0);
return [{
kind: "apply-aura",
recipient: "caster",
aura: {
...definition,
procs: definition.procs?.map((proc) => ({
...proc,
triggers: ["hit", "crit"],
attackKinds: ["melee"],
charges: 3,
})),
},
sourceEffectType: effect.effectType,
sourceAuraType: effect.auraType,
sourceBasePoints: effect.basePoints - 1,
sourceDieSides: effect.dieSides,
sourcePointsPerLevel: effect.pointsPerLevel,
miscValue: effect.miscValue,
triggerSpellId: effect.triggerSpellId,
}];
}
// The active Wrath talent grants five melee hits an extra nearby strike.
case "warrior:Sweeping Strikes": return [{
kind: "apply-aura",
recipient: "caster",
aura: {
id: "wow335:warrior:sweeping-strikes",
name: abilityName,
disposition: "buff",
durationMs: rank.durationMs,
maxStacks: 1,
stackBehavior: "refresh",
utilityTags: ["wow335-sweeping-strikes"],
procs: [{
id: "wow335:warrior:sweeping-strikes-secondary-hit",
triggers: ["hit", "crit"],
attackKinds: ["melee"],
chance: 1,
charges: 5,
action: { kind: "custom", id: "wow335-sweeping-strikes-secondary-hit" },
}],
},
sourceEffectType: 6,
sourceAuraType: 4,
miscValue: 0,
triggerSpellId: 0,
}];
// The player-facing aura delegates each weapon strike to server-only child
// spells. Model the six one-second swings as a periodic damage payload.
case "warrior:Bladestorm": return [{
kind: "dot", coefficient: 0.6, ticks: 6, intervalMs: 1_000, tag: "bladestorm",
}];
case "warrior:Bloodrage": return [{ kind: "resource", resource: "rage", amount: 20 }];
case "warrior:Execute": {
const source = rank.effects.find((effect) => effect.effectType === 3);
return source ? [{
kind: "damage",
coefficient: source.coefficient,
basePoints: source.basePoints,
dieSides: source.dieSides,
pointsPerLevel: source.pointsPerLevel,
sourceLevel: rank.sourceLevel,
maxScalingLevel: rank.maxScalingLevel,
}] : [];
}
case "warrior:Charge": return [
{ kind: "movement", movement: "charge", toward: "target" },
{ kind: "status", status: "stun", durationMs: 1_500 },
];
case "warrior:Intercept": return [{ kind: "status", status: "stun", durationMs: 3_000 }];
case "warrior:Devastate": return [
{
kind: "damage",
coefficient: 1,
bonusPerSunderStack: rank.effects.find((effect) => effect.effectType === 121)?.basePoints ?? 0,
},
{
kind: "apply-aura",
aura: {
id: "wow335:warrior:sunder-armor", name: "Sunder Armor", disposition: "debuff", durationMs: 30_000,
maxStacks: 5, stackBehavior: "add",
modifiers: [{ kind: "damage", direction: "taken", school: "physical", operation: "percent", value: 0.04, perStack: true }],
},
sourceEffectType: 6, sourceAuraType: 4, miscValue: 0, triggerSpellId: 0,
},
];
case "warrior:Last Stand": return [{
kind: "apply-aura",
recipient: "caster",
aura: {
id: "wow335:warrior:last-stand", name: abilityName, disposition: "buff",
durationMs: 20_000, maxStacks: 1, stackBehavior: "refresh",
modifiers: [{ kind: "stat", stat: "maximum-health", operation: "percent", value: 0.3 }],
},
sourceEffectType: 6, sourceAuraType: 133, miscValue: 0, triggerSpellId: 0,
}];
case "warrior:Sunder Armor": return [{
kind: "apply-aura",
aura: {
id: "wow335:warrior:sunder-armor", name: abilityName, disposition: "debuff", durationMs: 30_000,
maxStacks: 5, stackBehavior: "add",
modifiers: [{ kind: "damage", direction: "taken", school: "physical", operation: "percent", value: 0.04, perStack: true }],
},
sourceEffectType: 6, sourceAuraType: 4, miscValue: 0, triggerSpellId: 0,
}];
case "warrior:Shattering Throw": return [
{ kind: "damage", coefficient: 1 },
{ kind: "status", status: "marked", durationMs: 10_000, magnitude: 0.2 },
];
case "paladin:Judgement of Justice": return [{ kind: "status", status: "root", durationMs: 10_000 }];
case "paladin:Righteous Defense": return [{ kind: "taunt", durationMs: 3_000, maxTargets: 3 }];
case "paladin:Hand of Salvation": return [{ kind: "threat", mode: "percent", amount: -20, recipient: "ability-target" }];
// The DBC parent carries a one-point trigger marker; its weapon hit lives in
// a child spell that is not present in the player-facing progression rows.
case "paladin:Hammer of the Righteous": return [{ kind: "damage", coefficient: 1.2 }];
case "paladin:Divine Intervention": return [{
kind: "apply-aura",
aura: {
id: `wow335:${rank.spellId}:divine-intervention`, name: abilityName, disposition: "buff", durationMs: 180_000,
modifiers: schoolsFromMask(127).map((school) => ({
kind: "damage" as const, direction: "taken" as const, school, operation: "percent" as const, value: -1,
})),
},
sourceEffectType: 64, sourceAuraType: 39, miscValue: 127, triggerSpellId: 0,
}, {
kind: "utility", action: "class-script", effectType: 77, auraType: 0, miscValue: 0, triggerSpellId: 0, durationMs: 0,
}];
case "hunter:Disengage": return [{ kind: "movement", movement: "leap", toward: "away" }];
case "hunter:Intimidation": return [{
kind: "apply-aura",
recipient: "pet",
aura: {
id: `wow335:${rank.spellId}:intimidation`,
name: abilityName,
description: "Your pet's next successful melee attack stuns its target for 3 seconds and adds 580 threat.",
disposition: "buff",
durationMs: rank.durationMs,
maxStacks: 1,
stackBehavior: "replace",
procs: [{
id: `wow335:${rank.spellId}:intimidation-next-melee`,
triggers: "hit",
attackKinds: ["melee"],
chance: 1,
charges: 1,
action: { kind: "custom", id: "wow335-hunter-intimidation-hit" },
}],
},
sourceEffectType: 6,
sourceAuraType: 42,
miscValue: 0,
triggerSpellId: 24394,
}];
case "hunter:Kill Command": return [{
kind: "apply-aura",
recipient: "pet",
aura: {
id: `wow335:${rank.spellId}:kill-command`,
name: abilityName,
description: "Increases your pet's damage from special attacks by 60% for 30 seconds. Each special attack reduces the bonus by 20%.",
disposition: "buff",
durationMs: WOW335_HUNTER_PET_KILL_COMMAND_DURATION_MS,
maxStacks: WOW335_HUNTER_PET_KILL_COMMAND_SPECIAL_CHARGES,
initialStacks: WOW335_HUNTER_PET_KILL_COMMAND_SPECIAL_CHARGES,
stackBehavior: "replace",
utilityTags: [WOW335_HUNTER_PET_KILL_COMMAND_TAG],
},
sourceEffectType: 6,
sourceAuraType: 4,
miscValue: 0,
triggerSpellId: 58914,
}];
case "hunter:Freezing Arrow": return [{
kind: "utility",
action: "class-script",
effectType: 32,
auraType: 0,
miscValue: 0,
triggerSpellId: 60202,
durationMs: 30_000,
}];
case "hunter:Freezing Trap":
case "hunter:Frost Trap": return [{
kind: "utility",
action: "class-script",
effectType: 104,
auraType: 0,
miscValue: rank.effects.find((effect) => effect.effectType === 104)?.miscValue ?? 0,
triggerSpellId: 0,
durationMs: rank.durationMs,
}];
case "hunter:Immolation Trap": {
const child = HUNTER_IMMOLATION_TRAP_CHILD_BY_RANK[rank.spellId];
return [
{
kind: "utility",
action: "class-script",
effectType: 105,
auraType: 0,
miscValue: rank.effects.find((effect) => effect.effectType === 105)?.miscValue ?? 0,
triggerSpellId: 0,
durationMs: rank.durationMs,
},
...(child ? [{
kind: "dot" as const,
coefficient: 0,
basePoints: child.basePoints,
dieSides: child.dieSides,
rangedAttackPowerCoefficient: 0.02,
sourceLevel: Math.max(1, rank.sourceLevel || rank.level),
...(rank.maxScalingLevel > 0 ? { maxScalingLevel: rank.maxScalingLevel } : {}),
school: "fire" as const,
ticks: 5,
intervalMs: 3_000,
tag: "hunter-immolation-trap",
}] : []),
];
}
case "hunter:Explosive Trap": {
const child = HUNTER_EXPLOSIVE_TRAP_CHILD_BY_RANK[rank.spellId];
return child ? [{
kind: "dot",
coefficient: 0,
basePoints: child.periodicBasePoints,
dieSides: child.periodicDieSides,
sourceLevel: Math.max(1, rank.sourceLevel || rank.level),
...(rank.maxScalingLevel > 0 ? { maxScalingLevel: rank.maxScalingLevel } : {}),
school: "fire",
ticks: 10,
intervalMs: 2_000,
tag: "hunter-explosive-trap",
}] : [];
}
case "hunter:Feign Death": return [{ kind: "threat", mode: "percent", amount: -100 }];
case "hunter:Readiness": return [{
kind: "cooldown-reset", mode: "reset", excludeAbilityNames: ["Bestial Wrath"],
}];
case "hunter:Chimera Shot": return [{ kind: "damage", coefficient: 1.25 }];
case "hunter:Kill Shot": return [{
kind: "damage", coefficient: 2,
bonusFlat: rank.effects.find((effect) => effect.effectType === 17)?.basePoints ?? 0,
}];
// Volley's player-facing spell is a six-second channel aura whose Arcane
// pulses live in same-rank triggered child rows. Keep the channel/cost but
// make the six ground-area pulses executable by players and companions.
case "hunter:Volley": {
const pulse = rank.effects.find((effect) => effect.effectType === 27);
return pulse ? [{
kind: "dot",
coefficient: fallbackCoefficient(pulse, rank),
...amountScaling(pulse, rank),
ticks: 6,
intervalMs: 1_000,
tag: "volley",
}] : [];
}
case "rogue:Shiv": return [{ kind: "damage", coefficient: 1 }, { kind: "combo", amount: 1 }];
// Spell 51690 is an aura/periodic-dummy target selector, not a direct hit.
// Its source-linked children and five weapon assaults run in the Rogue class script.
case "rogue:Killing Spree": return [{
kind: "utility", action: "class-script", effectType: 0, auraType: 0,
miscValue: 0, triggerSpellId: 0, durationMs: Math.max(500, rank.durationMs),
}];
// Mutilate triggers separate main-hand and off-hand child attacks that are
// not player-facing SkillLine records. The DBC combo award remains intact.
case "rogue:Mutilate": return [{ kind: "damage", coefficient: 2 }];
case "rogue:Envenom": return [{
kind: "apply-aura",
recipient: "caster",
aura: {
id: "wow335:rogue:envenom-poison-buff",
name: abilityName,
description: "Increases Deadly Poison application chance by 15% and Instant Poison application frequency by 75%.",
disposition: "buff",
// Runtime shortens this to one second plus one second per combo point.
durationMs: rank.durationMs > 0 ? rank.durationMs : 6_000,
maxStacks: 1,
stackBehavior: "refresh",
utilityTags: [ROGUE_ENVENOM_BUFF_TAG],
},
sourceEffectType: 6,
sourceAuraType: 108,
miscValue: 26,
triggerSpellId: 0,
}];
case "rogue:Garrote": return [{ kind: "status", status: "silence", durationMs: 3_000 }];
case "rogue:Shadow Dance": return [{
kind: "apply-aura",
recipient: "caster",
aura: {
id: `wow335:${rank.spellId}:shadow-dance`,
name: abilityName,
description: "Allows Sap, Garrote, Ambush, Cheap Shot, Premeditation, Pick Pocket, and Disarm Trap without stealth.",
disposition: "buff",
durationMs: rank.durationMs || 6_000,
maxStacks: 1,
stackBehavior: "refresh",
utilityTags: ["wow335-rogue-shadow-dance"],
},
sourceEffectType: 6,
sourceAuraType: 275,
miscValue: 0,
triggerSpellId: 0,
}];
case "rogue:Tricks of the Trade": return [{
kind: "utility", action: "class-script", effectType: 0, auraType: 0,
miscValue: 0, triggerSpellId: 0, durationMs: Math.max(6_000, rank.durationMs),
}];
case "rogue:Shadowstep": return [{
kind: "utility", action: "class-script", effectType: 0, auraType: 0,
miscValue: 0, triggerSpellId: 0, durationMs: Math.max(3_000, rank.durationMs),
}];
case "rogue:Cold Blood": return [{
kind: "apply-aura",
aura: {
id: `wow335:${rank.spellId}:cold-blood`,
name: abilityName,
disposition: "buff",
durationMs: rank.durationMs > 0 ? rank.durationMs : 20_000,
maxStacks: 1,
stackBehavior: "refresh",
modifiers: [{ kind: "stat", stat: "critical-strike", operation: "flat", value: 100 }],
utilityTags: ["next-offensive-critical"],
},
recipient: "caster",
sourceEffectType: 6,
sourceAuraType: 4,
miscValue: 0,
triggerSpellId: 0,
}];
case "rogue:Cloak of Shadows": return [
{
kind: "dispel", mode: "cleanse", relationship: "friendly", categories: ["magic"], maxCount: 10,
},
{
kind: "apply-aura",
aura: {
id: `wow335:${rank.spellId}:cloak-of-shadows`,
name: abilityName,
disposition: "buff",
durationMs: rank.durationMs > 0 ? rank.durationMs : 5_000,
maxStacks: 1,
stackBehavior: "refresh",
modifiers: schoolsFromMask(126).map((school) => ({
kind: "damage" as const,
direction: "taken" as const,
school,
operation: "percent" as const,
value: -0.9,
})),
},
recipient: "caster",
sourceEffectType: 6,
sourceAuraType: 4,
miscValue: 126,
triggerSpellId: 0,
},
];
case "rogue:Preparation": return [
"Evasion",
"Sprint",
"Vanish",
"Cold Blood",
"Shadowstep",
].map((abilityName) => ({
kind: "cooldown-reset" as const,
mode: "reset" as const,
abilityName,
}));
case "rogue:Vanish": return [
{ kind: "threat", mode: "percent", amount: -100 },
{
kind: "apply-aura",
recipient: "caster",
aura: {
id: `wow335:${rank.spellId}:vanish-stealth`,
name: abilityName,
description: "Enters stealth and breaks movement impairing effects.",
disposition: "buff",
durationMs: null,
maxStacks: 1,
stackBehavior: "refresh",
utilityTags: ["stealth"],
},
sourceEffectType: 6,
sourceAuraType: 16,
miscValue: 0,
triggerSpellId: 0,
},
];
// Both spells place a channel aura whose actual pulses live in non-player
// trigger spells. Keep the imported cast/cost/rank data and supply only the
// missing executable payload.
case "priest:Mind Flay": return [{ kind: "damage", coefficient: 0.9 }];
case "priest:Penance": return [
{ kind: "heal", coefficient: 0.6 },
{ kind: "damage", coefficient: 0.6 },
];
case "priest:Divine Hymn": return [{
kind: "heal", coefficient: 1, scope: "party", center: "caster", radius: 40, maximumTargets: 3,
}];
case "priest:Shadowfiend": return [
{ kind: "summon", creatureId: 19668, coefficient: 0.5, durationMs: 15_000 },
];
// The client rank is a generic summon row. Its description owns the
// Lightwell duration; the click limit is enforced by the summon runtime.
// The child heal payload is absent from the imported spell rows.
case "priest:Lightwell": return [{
kind: "summon",
creatureId: rank.effects.find((effect) => effect.effectType === 28)?.miscValue ?? 0,
coefficient: 0,
durationMs: rank.durationMs || 180_000,
}];
case "priest:Pain Suppression": return [{
kind: "threat", mode: "percent", amount: -40, recipient: "ability-target",
}];
case "priest:Inner Focus": return [{
kind: "apply-aura",
recipient: "caster",
aura: {
id: "wow335:priest:inner-focus",
name: abilityName,
disposition: "buff",
durationMs: 30_000,
maxStacks: 1,
stackBehavior: "refresh",
utilityTags: ["next-heal-free-critical"],
},
sourceEffectType: 6,
sourceAuraType: 107,
miscValue: 0,
triggerSpellId: 0,
}];
case "shaman:Nature's Swiftness":
case "druid:Nature's Swiftness": return [{
kind: "apply-aura",
recipient: "caster",
aura: {
id: `wow335:${classId}:natures-swiftness`,
name: abilityName,
description: "Your next Nature spell with a base cast time under 10 seconds becomes instant.",
disposition: "buff",
durationMs: null,
maxStacks: 1,
stackBehavior: "replace",
utilityTags: ["wow335-next-nature-instant"],
},
sourceEffectType: 6,
sourceAuraType: 108,
miscValue: 10,
triggerSpellId: 0,
}];
// The player spell summons a 5-health totem. Its hidden child aura pulses
// a rank-specific group heal every two seconds in a 10-yard radius.
case "shaman:Healing Stream Totem": {
const amount = SHAMAN_HEALING_STREAM_AMOUNT_BY_RANK[rank.spellId];
const creatureId = rank.effects.find((effect) => effect.effectType === 28)?.miscValue ?? 0;
return amount === undefined || creatureId <= 0 ? [] : [{
kind: "summon",
creatureId,
coefficient: 0,
durationMs: rank.durationMs,
healingPulse: { amount, intervalMs: 2_000, radius: 10 },
}];
}
// Tidal Force applies its 60% healing-crit window as three 20% charges.
// The runtime consumes one charge for each critical heal and scopes the
// bonus to Healing Wave, Lesser Healing Wave, and Chain Heal.
case "shaman:Tidal Force": return [{
kind: "apply-aura",
recipient: "caster",
aura: {
id: "wow335:shaman:tidal-force",
name: abilityName,
description: "Adds 20% healing critical chance per remaining charge for 2 seconds.",
disposition: "buff",
durationMs: rank.durationMs,
maxStacks: 3,
initialStacks: 3,
stackBehavior: "replace",
utilityTags: ["wow335-tidal-force"],
},
sourceEffectType: 6,
sourceAuraType: 4,
miscValue: 7,
triggerSpellId: 0,
}];
case "priest:Prayer of Mending": {
const healAmount = rank.effects.find((effect) => effect.effectType === 142)?.basePoints ?? 0;
return healAmount > 0 ? [{
kind: "apply-aura",
aura: {
id: `wow335:${rank.spellId}:prayer-of-mending`,
name: abilityName,
disposition: "buff",
description: "Heals its target for this rank's listed amount the next time they take damage, then jumps to an injured party member within 40 yards. It has five damage-triggered activations total.",
durationMs: 30_000,
maxStacks: 1,
stackBehavior: "refresh",
procs: [{
id: `wow335:${rank.spellId}:prayer-of-mending-proc`,
triggers: "damage-taken",
chance: 1,
charges: 5,
action: {
kind: "custom",
id: "wow335-prayer-of-mending",
data: { healAmount, maxRange: rank.rangeMax || 40 },
},
}],
},
sourceEffectType: 64,
sourceAuraType: 42,
miscValue: 0,
triggerSpellId: 0,
}] : [];
}
case "death-knight:Death Grip": return [
{ kind: "pull", toward: "caster" },
{ kind: "taunt", durationMs: 3_000 },
];
case "death-knight:Death Coil": return [{ kind: "heal", coefficient: 1 }];
case "death-knight:Death Strike": return [{
kind: "heal",
coefficient: 0,
percentMaxHealth: 0.05,
}];
case "death-knight:Anti-Magic Shell": return [{
kind: "apply-aura",
recipient: "caster",
aura: {
id: `wow335:${rank.spellId}:anti-magic-shell`,
name: abilityName,
disposition: "buff",
durationMs: rank.durationMs,
maxStacks: 1,
stackBehavior: "replace",
utilityTags: ["wow335-death-knight-anti-magic-shell"],
// Amount is multiplied by max health when the aura is applied.
absorbs: [{
id: `wow335:${rank.spellId}:anti-magic-shell-absorb`,
amount: 0.5,
schools: ["holy", "fire", "nature", "frost", "shadow", "arcane"],
fraction: 0.75,
}],
},
sourceEffectType: 6,
sourceAuraType: 69,
miscValue: 126,
triggerSpellId: 0,
}];
case "death-knight:Anti-Magic Zone": return [{
kind: "summon",
creatureId: rank.effects.find((effect) => effect.effectType === 28)?.miscValue ?? 28306,
coefficient: 0,
durationMs: rank.durationMs,
antiMagicZone: {
radius: 10,
capacityBase: 10_000,
capacityPerAttackPower: 2,
absorbFraction: 0.75,
intervalMs: 250,
},
}];
case "death-knight:Bone Shield": return [{
kind: "apply-aura",
recipient: "caster",
aura: {
id: `wow335:${rank.spellId}:bone-shield`,
name: abilityName,
disposition: "buff",
durationMs: rank.durationMs,
maxStacks: 4,
initialStacks: 4,
stackBehavior: "replace",
modifiers: [
{ kind: "damage", direction: "taken", operation: "percent", value: -0.2, perStack: false },
{ kind: "damage", direction: "dealt", operation: "percent", value: 0.02, perStack: false },
],
utilityTags: ["wow335-death-knight-bone-shield"],
procs: [{
id: "wow335-death-knight-bone-shield-charge",
triggers: "damage-taken",
chance: 1,
charges: 4,
action: { kind: "custom", id: "wow335-death-knight-bone-shield-charge" },
}],
},
sourceEffectType: 6,
sourceAuraType: 87,
miscValue: 127,
triggerSpellId: 0,
}];
case "death-knight:Deathchill": return [{
kind: "apply-aura",
recipient: "caster",
aura: {
id: `wow335:${rank.spellId}:deathchill`,
name: abilityName,
disposition: "buff",
durationMs: rank.durationMs,
maxStacks: 1,
stackBehavior: "replace",
utilityTags: ["next-offensive-critical", "wow335-deathchill-next-critical"],
},
sourceEffectType: 6,
sourceAuraType: 107,
miscValue: 7,
triggerSpellId: 0,
}];
case "death-knight:Blood Tap": return [{
kind: "rune", action: "activate", count: 1, runeType: "blood", convertToDeathMs: rank.durationMs,
}];
// The DBC stores two rune activations with a count of two each, but the
// player-facing spell restores every rune. Replace those rows below.
case "death-knight:Empower Rune Weapon": return [
{ kind: "rune", action: "activate", count: 6 },
{ kind: "resource", resource: "runic-power", amount: 25 },
];
case "death-knight:Chains of Ice": return [
{ kind: "status", status: "slow", durationMs: 10_000, magnitude: 0.95 },
];
case "death-knight:Icy Touch": return [{ kind: "dot", coefficient: 0.22, ticks: 5, intervalMs: 3_000, tag: "frost-fever" }];
case "death-knight:Plague Strike": return [{ kind: "dot", coefficient: 0.22, ticks: 5, intervalMs: 3_000, tag: "blood-plague" }];
case "death-knight:Pestilence": return [{
kind: "utility",
action: "class-script",
effectType: 77,
auraType: 0,
miscValue: 0,
triggerSpellId: 0,
durationMs: 0,
}];
case "death-knight:Death Pact": return [{
kind: "sacrifice-minion",
// These are the undead ghoul entries already modeled by Raise Dead and
// Army of the Dead. Unknown summon families are never assumed undead.
creatureIds: [26125, 24207],
radius: rank.effects.find((effect) => effect.effectType === 1)?.radius ?? 0,
percentMaxHealth: Math.abs(rank.effects.find((effect) => effect.effectType === 10)?.basePoints ?? 0) / 100,
}];
case "death-knight:Raise Dead": return [{ kind: "summon", creatureId: 26125, coefficient: 0.45, durationMs: 60_000 }];
case "death-knight:Army of the Dead": return Array.from({ length: 8 }, () => ({
kind: "summon" as const, creatureId: 24207, coefficient: 0.2, durationMs: 40_000,
}));
case "shaman:Rockbiter Weapon": return [aura("rockbiter", [
{ kind: "damage", direction: "dealt", school: "physical", operation: "flat", value: Math.max(1, Math.round(power * 0.12)) },
])];
case "shaman:Frostbrand Weapon": return [aura("frostbrand", [], [{
id: `wow335:${rank.spellId}:frostbrand-proc`, triggers: "hit", chance: 0.2,
action: { kind: "damage", amount: Math.max(1, Math.round(power * 0.3)), school: "frost", target: "event-other" },
}])];
case "shaman:Flametongue Weapon": return [aura("flametongue", [], [{
id: `wow335:${rank.spellId}:flametongue-proc`, triggers: "hit", chance: 1,
action: { kind: "damage", amount: Math.max(1, Math.round(power * 0.18)), school: "fire", target: "event-other" },
}])];
case "shaman:Windfury Weapon": return [aura("windfury", [], [{
id: `wow335:${rank.spellId}:windfury-proc`, triggers: "hit", chance: 0.2,
action: { kind: "damage", amount: Math.max(1, Math.round(power * 0.75)), school: "physical", target: "event-other" },
}])];
case "shaman:Feral Spirit": return Array.from({ length: 2 }, () => ({
kind: "summon" as const, creatureId: 29264, coefficient: 0.35, durationMs: 45_000,
}));
case "warlock:Soulshatter": return [{
kind: "threat",
mode: "percent",
amount: rank.effects.find((effect) => effect.effectType === 3)?.basePoints ?? -50,
}];
// Soul Link's populated talent row provides the gameplay percentage and
// demon restrictions; its active DBC spell row is only a dummy marker.
case "warlock:Soul Link": return [{
kind: "apply-aura",
recipient: "caster",
aura: {
id: "wow335:warlock:soul-link",
name: abilityName,
description: "Shares 6% of incoming damage with your active, controlled demon within 30 yards.",
disposition: "buff",
durationMs: null,
maxStacks: 1,
stackBehavior: "replace",
utilityTags: [WOW335_WARLOCK_SOUL_LINK_TAG],
},
sourceEffectType: 3,
sourceAuraType: 0,
miscValue: 0,
triggerSpellId: 0,
}];
case "warlock:Life Tap": return [{
kind: "resource",
resource: "mana",
amount: Math.max(1, Math.abs(rank.effects.find((effect) => effect.effectType === 3)?.basePoints ?? 1)),
}];
// The parent row's one-point marker is not its damage. The runtime derives
// Conflagrate's payload from the consumed Immolate or Shadowflame effect.
case "warlock:Conflagrate": return [{ kind: "damage", coefficient: 0, school: "fire" }];
// The generated player chain omits Shadowflame's periodic child spells.
// These children are 8-second Fire DoTs with the imported per-tick bonus.
case "warlock:Shadowflame": return [
{ kind: "dot", coefficient: 0.0667, ticks: 4, intervalMs: 2_000, school: "fire", tag: "wow335-warlock-shadowflame" },
];
case "warlock:Demonic Circle: Summon": return [{ kind: "summon", creatureId: 19175, coefficient: 0, durationMs: 360_000 }];
case "warlock:Demonic Circle: Teleport": return [{ kind: "movement", movement: "teleport", toward: "away" }];
case "hunter:Misdirection": return [{ kind: "threat", mode: "redirect", amount: 100 }];
case "mage:Presence of Mind": return [{
kind: "apply-aura",
recipient: "caster",
aura: {
id: "wow335:mage:presence-of-mind",
name: abilityName,
description: "Your next Mage spell with a cast time under 10 seconds becomes instant.",
disposition: "buff",
durationMs: rank.durationMs > 0 ? rank.durationMs : null,
maxStacks: 1,
stackBehavior: "refresh",
utilityTags: ["wow335-next-mage-instant"],
},
sourceEffectType: 6,
sourceAuraType: 108,
miscValue: 10,
triggerSpellId: 0,
}];
case "mage:Arcane Power": {
const powerPercent = Math.abs(rank.effects.find((effect) => effect.auraType === 108)?.basePoints ?? 20) / 100;
return [{
kind: "apply-aura",
recipient: "caster",
aura: {
id: `wow335:${rank.spellId}:arcane-power`,
name: abilityName,
disposition: "buff",
durationMs: rank.durationMs,
modifiers: [
{ kind: "damage", direction: "dealt", operation: "percent", value: powerPercent },
{ kind: "resource", resource: "mana", property: "cost", operation: "percent", value: powerPercent },
],
},
sourceEffectType: 6,
sourceAuraType: 108,
miscValue: 0,
triggerSpellId: 0,
}];
}
case "mage:Combustion": return [{
kind: "apply-aura", recipient: "caster",
aura: {
id: "wow335:mage:combustion", name: abilityName, disposition: "buff",
durationMs: null, maxStacks: 100, stackBehavior: "replace",
utilityTags: ["fire-crit-stacking"],
},
sourceEffectType: 6, sourceAuraType: 4, miscValue: 0, triggerSpellId: 0,
}];
case "mage:Mirror Image": return Array.from({ length: 3 }, () => ({
kind: "summon" as const, creatureId: 58836, coefficient: 0.25, durationMs: 30_000,
}));
// Arcane Missiles stores its per-tick payload in a server-triggered child spell
// that is not a player-facing SkillLine entry. Model the channel payload here so
// both players and shared-rotation consumers execute the imported spell instead
// of completing an inert channel.
case "mage:Arcane Missiles": return [{ kind: "damage", coefficient: 1.2 }];
case "mage:Arcane Blast": return [{
kind: "apply-aura",
recipient: "caster",
aura: {
id: "wow335:arcane-blast-stack",
name: "Arcane Blast",
disposition: "buff",
description: "Each Arcane Blast adds 15% Arcane spell damage and 200% Arcane Blast mana cost for 10 seconds, stacking up to 3 times.",
durationMs: 10_000,
maxStacks: 3,
stackBehavior: "add",
utilityTags: ["wow335-arcane-blast-stack"],
modifiers: [
{ kind: "damage", direction: "dealt", school: "arcane", operation: "percent", value: 0.15, perStack: true },
{ kind: "resource", resource: "mana", property: "cost", operation: "percent", value: 2, perStack: true },
],
},
sourceEffectType: 64,
sourceAuraType: 108,
miscValue: 0,
triggerSpellId: 0,
}];
// Cold Snap is a DBC dummy effect whose gameplay operation is a school
// cooldown reset. Resolve the Frost spell names explicitly because the
// shared cooldown-reset effect currently selects candidates by name.
case "mage:Cold Snap": return [
"Frost Nova",
"Frost Ward",
"Fire Ward", // Frost Ward shares its cooldown with Fire Ward.
"Icy Veins",
"Cone of Cold",
"Ice Block",
"Ice Barrier",
"Summon Water Elemental",
"Deep Freeze",
].map((abilityName) => ({ kind: "cooldown-reset" as const, mode: "reset" as const, abilityName }));
case "warlock:Inferno": return [{ kind: "summon", creatureId: 89, coefficient: 0.8, durationMs: 60_000 }];
// Swiftmend's DBC row exposes only a one-point marker. The cast runtime
// supplies its direct heal and resolves the consumed Rejuvenation/Regrowth.
case "druid:Swiftmend": return [{ kind: "heal", coefficient: 2.2 }];
case "druid:Pounce": return [{ kind: "dot", coefficient: 0.18, ticks: 6, intervalMs: 3_000, tag: "pounce" }];
default: return [];
}
}
const COMBO_FINISHERS = new Set([
"Eviscerate", "Slice and Dice", "Expose Armor", "Rupture", "Kidney Shot", "Envenom", "Deadly Throw",
"Rip", "Ferocious Bite", "Maim", "Savage Roar",
]);
const DIRECT_COMBO_FINISHERS = new Set(["Eviscerate", "Envenom", "Deadly Throw", "Ferocious Bite"]);
const HUNTER_EXPLOSIVE_TRAP_CHILD_BY_RANK: Readonly<Record<number, {
directBasePoints: number;
directDieSides: number;
periodicBasePoints: number;
periodicDieSides: number;
sourceLevel: number;
maxScalingLevel: number;
}>> = {
13813: { directBasePoints: 100, directDieSides: 31, periodicBasePoints: 15, periodicDieSides: 1, sourceLevel: 34, maxScalingLevel: 40 },
14316: { directBasePoints: 139, directDieSides: 49, periodicBasePoints: 24, periodicDieSides: 1, sourceLevel: 44, maxScalingLevel: 50 },
14317: { directBasePoints: 201, directDieSides: 57, periodicBasePoints: 33, periodicDieSides: 1, sourceLevel: 54, maxScalingLevel: 60 },
27025: { directBasePoints: 263, directDieSides: 75, periodicBasePoints: 45, periodicDieSides: 1, sourceLevel: 61, maxScalingLevel: 67 },
49066: { directBasePoints: 434, directDieSides: 123, periodicBasePoints: 74, periodicDieSides: 1, sourceLevel: 71, maxScalingLevel: 75 },
49067: { directBasePoints: 523, directDieSides: 149, periodicBasePoints: 90, periodicDieSides: 1, sourceLevel: 77, maxScalingLevel: 81 },
};
const HUNTER_IMMOLATION_TRAP_CHILD_BY_RANK: Readonly<Record<number, {
basePoints: number;
dieSides: number;
}>> = {
13795: { basePoints: 21, dieSides: 1 },
14302: { basePoints: 43, dieSides: 1 },
14303: { basePoints: 68, dieSides: 1 },
14304: { basePoints: 102, dieSides: 1 },
14305: { basePoints: 138, dieSides: 1 },
27023: { basePoints: 197, dieSides: 1 },
49055: { basePoints: 308, dieSides: 1 },
49056: { basePoints: 377, dieSides: 1 },
};
export function hunterExplosiveTrapDirectScaling(
spellId: number,
sourceLevel = 0,
maxScalingLevel = 0,
): AbilityAmountScaling & { readonly coefficient: number } | null {
const child = HUNTER_EXPLOSIVE_TRAP_CHILD_BY_RANK[spellId];
if (!child) return null;
return {
coefficient: 0,
basePoints: child.directBasePoints,
dieSides: child.directDieSides,
attackPowerCoefficient: 0.1,
sourceLevel: sourceLevel > 0 ? sourceLevel : child.sourceLevel,
maxScalingLevel: maxScalingLevel > 0 ? maxScalingLevel : child.maxScalingLevel,
};
}
function learnOnlyRank(rank: Wow335Rank): boolean {
const effects = rank.effects.filter((effect) => effect.effectType !== 0);
return effects.length > 0 && effects.every((effect) => effect.effectType === 36);
}
function effectsForRank(classId: BaseClassId, rank: Wow335Rank, abilityName: string): readonly AbilityEffect[] {
const effects: AbilityEffect[] = [];
for (const [effectIndex, effect] of rank.effects.entries()) {
const coefficient = fallbackCoefficient(effect, rank);
const scaling = amountScaling(effect, rank);
if (APPLY_AURA_EFFECTS.has(effect.effectType) && effect.auraType > 0) {
const aura = namedAuraForEffect(classId, effect, rank, abilityName, effectIndex);
effects.push({
kind: "apply-aura",
aura,
...(classId === "hunter" && abilityName === "Bestial Wrath" && effect.implicitTargetA === 5
? { recipient: "pet" as const }
: {}),
...(classId === "hunter" && abilityName === "Mend Pet"
? { recipient: "pet" as const }
: {}),
...(effect.implicitTargetA === 1 ? { recipient: "caster" as const } : {}),
...([35, 65].includes(effect.effectType)
|| (classId === "warrior"
&& ["Battle Shout", "Commanding Shout"].includes(abilityName)
&& effect.implicitTargetA === 56)
|| (classId === "paladin" && abilityName.startsWith("Greater Blessing of "))
|| (classId === "paladin" && abilityName === "Aura Mastery")
|| (classId === "priest"
&& ["Prayer of Fortitude", "Prayer of Shadow Protection", "Prayer of Spirit"].includes(abilityName)
&& effect.implicitTargetA === 56)
? { scope: "party" as const }
: {}),
...(controlFromAura(effect.auraType) ? { control: controlFromAura(effect.auraType) } : {}),
sourceEffectType: effect.effectType,
sourceAuraType: effect.auraType,
sourceBasePoints: effect.basePoints - 1,
sourceDieSides: effect.dieSides,
sourcePointsPerLevel: effect.pointsPerLevel,
miscValue: effect.miscValue,
triggerSpellId: effect.triggerSpellId,
});
if (effect.auraType === 3 && effect.periodMs > 0) {
const ticks = Math.max(1, Math.round(Math.max(effect.periodMs, rank.durationMs) / effect.periodMs));
effects.push({ kind: "dot", coefficient, ...scaling, ticks, intervalMs: effect.periodMs, tag: `spell-${rank.spellId}` });
} else if (effect.auraType === 8 && effect.periodMs > 0) {
const ticks = Math.max(1, Math.round(Math.max(effect.periodMs, rank.durationMs) / effect.periodMs));
effects.push({
kind: "hot",
coefficient,
...scaling,
ticks,
intervalMs: effect.periodMs,
...(classId === "hunter" && abilityName === "Mend Pet" ? { recipient: "pet" as const } : {}),
});
} else if (effect.auraType === 11) {
effects.push({ kind: "taunt", durationMs: Math.max(3_000, rank.durationMs) });
} else if (effect.auraType === 20 && effect.periodMs > 0) {
const ticks = Math.max(1, Math.round(Math.max(effect.periodMs, rank.durationMs) / effect.periodMs));
effects.push({ kind: "hot", coefficient, ...scaling, ticks, intervalMs: effect.periodMs });
} else if (effect.auraType === 53 && effect.periodMs > 0) {
const ticks = Math.max(1, Math.round(Math.max(effect.periodMs, rank.durationMs) / effect.periodMs));
effects.push({ kind: "dot", coefficient, ...scaling, ticks, intervalMs: effect.periodMs, tag: `spell-${rank.spellId}` });
effects.push({
kind: "hot",
coefficient: abilityName === "Devouring Plague" ? coefficient * 0.15 : coefficient,
...scaling,
ticks,
intervalMs: effect.periodMs,
});
} else if (effect.auraType === 64 && effect.periodMs > 0) {
const ticks = Math.max(1, Math.round(Math.max(effect.periodMs, rank.durationMs) / effect.periodMs));
effects.push({ kind: "resource-drain", amount: Math.abs(effect.basePoints) * ticks, burn: false, resource: "mana", percentage: true });
} else if (effect.auraType === 24) {
const ticks = effect.periodMs > 0
? Math.max(1, Math.round(Math.max(effect.periodMs, rank.durationMs) / effect.periodMs))
: 1;
effects.push({
kind: "resource",
resource: abilityName === "Enrage" ? "rage" : "mana",
amount: abilityName === "Innervate" ? 100 : Math.abs(effect.basePoints) * ticks,
...(abilityName === "Innervate" ? { percentage: true } : {}),
});
} else if (effect.auraType === 21 && abilityName === "Divine Plea") {
effects.push({ kind: "resource", resource: "mana", amount: 25, percentage: true });
} else if (effect.auraType === 226 && abilityName === "Death and Decay" && effect.periodMs > 0) {
const ticks = Math.max(1, Math.round(Math.max(effect.periodMs, rank.durationMs) / effect.periodMs));
effects.push({ kind: "dot", coefficient, ...scaling, ticks, intervalMs: effect.periodMs, tag: `spell-${rank.spellId}` });
} else if (effect.auraType === 226 && abilityName === "Frenzied Regeneration" && effect.periodMs > 0) {
const ticks = Math.max(1, Math.round(Math.max(effect.periodMs, rank.durationMs) / effect.periodMs));
effects.push({ kind: "hot", coefficient: 0.3, ticks, intervalMs: effect.periodMs });
} else if (effect.auraType === 226 && abilityName === "Mirror Image") {
effects.push({ kind: "summon", creatureId: effect.triggerSpellId || 58836, coefficient: 0.25, durationMs: Math.max(1_000, rank.durationMs) });
}
continue;
}
if (effect.effectType === 1) {
effects.push({ kind: "execute", coefficient: Math.max(1, coefficient) });
continue;
}
if (effect.effectType === 2) {
effects.push({ kind: "damage", coefficient, ...scaling });
continue;
}
if (effect.effectType === 8 || effect.effectType === 62) {
effects.push({
kind: "resource-drain",
amount: Math.max(0, Math.abs(effect.basePoints)),
burn: effect.effectType === 62,
});
continue;
}
if (effect.effectType === 9) {
effects.push({ kind: "damage", coefficient, ...scaling });
effects.push({ kind: "heal", coefficient, ...scaling });
continue;
}
if (WEAPON_DAMAGE_EFFECTS.has(effect.effectType)) {
const weaponCoefficient = effect.effectType === 31
? Math.max(0.1, Math.abs(effect.basePoints) / 100)
: 1;
effects.push({ kind: "damage", coefficient: weaponCoefficient });
continue;
}
if (effect.effectType === 67) {
// SPELL_EFFECT_HEAL_MAX_HEALTH is used by Lay on Hands. Its DBC amount
// fields are intentionally zero because the target's maximum health is
// the amount; treating it as an ordinary coefficient heal makes the
// spell appear to do nothing.
effects.push({ kind: "heal", coefficient: 0, percentMaxHealth: 1, ...scaling });
continue;
}
if (effect.effectType === 10 || effect.effectType === 136) {
effects.push(effect.effectType === 136
? { kind: "heal", coefficient: 0, percentMaxHealth: Math.max(0, Math.abs(effect.basePoints) / 100), ...scaling }
: { kind: "heal", coefficient, ...scaling });
continue;
}
if (effect.effectType === 30) {
effects.push({ kind: "resource", amount: Math.abs(effect.basePoints) });
continue;
}
if (effect.effectType === 137) {
effects.push({ kind: "resource", amount: Math.abs(effect.basePoints), percentage: true });
continue;
}
if (effect.effectType === 63 || effect.effectType === 91 || effect.effectType === 125 || effect.effectType === 130) {
effects.push({
kind: "threat",
mode: effect.effectType === 130 ? "redirect" : effect.effectType === 125 ? "percent" : "flat",
amount: effect.basePoints,
});
continue;
}
if (effect.effectType === 114 || effect.auraType === 11) {
effects.push({ kind: "taunt", durationMs: 3_000 });
continue;
}
if (effect.effectType === 38) {
const anyTarget = targetsForEffect(effect).includes(25);
const relationship = anyTarget ? "target" : effectTargetsHostile(effect) ? "hostile" : "friendly";
effects.push({
kind: "dispel",
mode: relationship === "hostile" ? "purge" : "cleanse",
relationship,
categories: categoriesForDispel(effect.miscValue),
maxCount: Math.max(1, Math.abs(effect.basePoints)),
});
continue;
}
if (effect.effectType === 126) {
effects.push({
kind: "dispel",
mode: "steal",
relationship: "hostile",
categories: ["magic"],
maxCount: Math.max(1, Math.abs(effect.basePoints)),
});
continue;
}
if (effect.effectType === 68) {
effects.push({ kind: "interrupt", lockoutMs: Math.max(1_000, rank.durationMs) });
continue;
}
if (effect.effectType === 80) {
effects.push({ kind: "combo", amount: Math.max(1, Math.abs(effect.basePoints)) });
continue;
}
if (effect.effectType === 70 || effect.effectType === 124 || effect.effectType === 145) {
effects.push({ kind: "pull", toward: effect.effectType === 145 ? "destination" : "caster" });
continue;
}
if ([5, 29, 41, 42, 43, 79, 96, 98, 138, 144, 149].includes(effect.effectType)) {
const movement = effect.effectType === 5 || effect.effectType === 43
? "teleport"
: effect.effectType === 96 || effect.effectType === 149
? "charge"
: effect.effectType === 98 || effect.effectType === 144
? "knockback"
: effect.effectType === 79
? "sanctuary"
: "leap";
effects.push({
kind: "movement",
movement,
toward: movement === "knockback" || effect.effectType === 138
? "away"
: [42, 43, 144, 149].includes(effect.effectType)
? "destination"
: "target",
});
continue;
}
if (effect.effectType === 108) {
effects.push({
kind: "dispel-mechanic",
mechanic: effect.miscValue,
maxCount: Math.max(1, Math.abs(effect.basePoints)),
});
continue;
}
if (TRIGGER_SPELL_EFFECTS.has(effect.effectType) && effect.triggerSpellId > 0) {
if (classId === "shaman" && abilityName === "Stormstrike" && rank.spellId === 17364) {
const weaponHit = stormstrikeWeaponHitForTrigger(effect.triggerSpellId);
if (weaponHit) {
effects.push(weaponHit);
continue;
}
}
effects.push({
kind: "trigger-spell",
spellId: effect.triggerSpellId,
...([141, 142].includes(effect.effectType) ? { withValue: effect.basePoints } : {}),
});
continue;
}
if ([18, 94, 113, 117].includes(effect.effectType)) {
effects.push({ kind: "resurrection", percentMaxHealth: effect.effectType === 94 ? 1 : 0.35 });
continue;
}
if (effect.effectType === 69) {
effects.push({ kind: "status", status: "confuse", durationMs: Math.max(1_000, rank.durationMs) });
continue;
}
if ([55, 56, 101, 102, 109, 135, 153].includes(effect.effectType)) {
const action: Extract<AbilityEffect, { kind: "pet" }>["action"] = effect.effectType === 101
? "feed"
: effect.effectType === 102
? "dismiss"
: effect.effectType === 109
? "resurrect"
: effect.effectType === 135
? "call"
: effect.effectType === 55
? "tame"
: "summon";
effects.push({
kind: "pet",
action,
...(effect.miscValue > 0 ? { creatureId: effect.miscValue } : {}),
});
continue;
}
if ([28, 76, 104, 105, 106, 107].includes(effect.effectType)) {
effects.push({
kind: "summon",
creatureId: Math.max(0, effect.miscValue),
coefficient,
durationMs: Math.max(1_000, rank.durationMs),
});
continue;
}
if (effect.effectType === 110) {
effects.push({ kind: "totem", action: "destroy-all" });
continue;
}
if (effect.effectType === 146) {
effects.push({ kind: "rune", action: "activate", count: Math.max(1, Math.abs(effect.basePoints)) });
continue;
}
if (effect.effectType === 0 && effect.auraType === 0) continue;
if (NONCOMBAT_EFFECTS.has(effect.effectType)) {
effects.push({
kind: "utility",
action: utilityAction(effect.effectType),
effectType: effect.effectType,
auraType: effect.auraType,
miscValue: effect.miscValue,
triggerSpellId: effect.triggerSpellId,
durationMs: rank.durationMs,
});
continue;
}
effects.push({
kind: "scripted",
effectType: effect.effectType,
auraType: effect.auraType,
miscValue: effect.miscValue,
triggerSpellId: effect.triggerSpellId,
label: `DBC combat effect ${effect.effectType}${effect.auraType ? ` / aura ${effect.auraType}` : ""}`,
});
}
const auraOnlyStance = classId === "death-knight"
&& ["Blood Presence", "Frost Presence", "Unholy Presence"].includes(abilityName);
if (!learnOnlyRank(rank) && !auraOnlyStance && !(classId === "warrior" && abilityName === "Last Stand")
&& !(classId === "mage" && abilityName === "Molten Armor")
&& !effects.some((effect) => ["damage", "heal", "dot", "hot", "shield"].includes(effect.kind))) {
if (classId === "death-knight" && abilityName === "Death Coil") {
effects.unshift({ kind: "damage", coefficient: 1 });
} else if (!/^Create\b/i.test(abilityName)
&& /\b(?:heal(?:s|ed|ing)?|restore(?:s|d)?|mend(?:s|ed)?|rejuvenat\w*)\b/i.test(rank.description)) {
effects.unshift({ kind: "heal", coefficient: 1 });
} else if (/\b(?:deals?|causes?|inflicts?)\b[^.]*\bdamage\b/i.test(rank.description)) {
effects.unshift({ kind: "damage", coefficient: 1 });
}
}
const scriptedFallbacks = classScriptEffects(classId, abilityName, rank);
const executable = effects
.filter((effect) => effect.kind !== "scripted" && effect.kind !== "unsupported")
.filter((effect) => !(
(classId === "paladin" && abilityName === "Redemption" && effect.kind === "resurrection")
|| (classId === "death-knight" && abilityName === "Death Pact" && ["execute", "heal"].includes(effect.kind))
|| (classId === "paladin" && abilityName === "Divine Storm"
&& (effect.kind === "damage" || effect.kind === "heal"))
|| (classId === "paladin" && abilityName === "Holy Shield" && effect.kind === "damage")
|| (classId === "warlock" && abilityName === "Conflagrate" && effect.kind === "damage")
|| (classId === "warrior" && abilityName === "Execute"
&& (effect.kind === "damage" || effect.kind === "heal"))
|| (classId === "priest" && abilityName === "Penance"
&& (effect.kind === "damage" || effect.kind === "heal"))
|| (classId === "priest" && abilityName === "Divine Hymn" && effect.kind === "heal")
|| (classId === "priest" && abilityName === "Prayer of Mending"
&& (effect.kind === "heal" || effect.kind === "trigger-spell"))
|| (classId === "rogue" && abilityName === "Killing Spree"
&& ["damage", "dot", "apply-aura", "trigger-spell"].includes(effect.kind))
|| (classId === "rogue" && abilityName === "Tricks of the Trade"
&& ["threat", "apply-aura"].includes(effect.kind))
|| (classId === "warrior" && ["Devastate", "Last Stand"].includes(abilityName)
&& (effect.kind === "damage" || effect.kind === "heal"))
|| (classId === "hunter" && abilityName === "Chimera Shot" && effect.kind === "damage")
|| (classId === "hunter" && abilityName === "Kill Shot" && effect.kind === "damage")
|| (classId === "hunter" && abilityName === "Intimidation" && effect.kind === "apply-aura")
|| (classId === "hunter" && abilityName === "Kill Command" && effect.kind === "apply-aura")
|| (classId === "hunter" && abilityName === "Freezing Arrow"
&& (effect.kind === "trigger-spell" || effect.kind === "status"))
|| (classId === "hunter" && abilityName === "Explosive Trap" && effect.kind === "summon")
|| (classId === "priest" && abilityName === "Shadowfiend"
&& (effect.kind === "summon" || effect.kind === "trigger-spell"))
|| (classId === "priest" && abilityName === "Lightwell" && effect.kind === "summon")
|| (classId === "mage" && abilityName === "Combustion"
&& (effect.kind === "damage" || effect.kind === "apply-aura" || effect.kind === "trigger-spell"))
|| (classId === "mage" && abilityName === "Arcane Power" && effect.kind === "apply-aura")
|| (classId === "mage" && abilityName === "Presence of Mind" && effect.kind === "apply-aura")
|| (classId === "mage" && abilityName === "Mirror Image"
&& (effect.kind === "summon" || effect.kind === "trigger-spell"))
|| (classId === "rogue" && abilityName === "Cold Blood" && effect.kind === "apply-aura")
|| (classId === "shaman" && abilityName === "Feral Spirit" && effect.kind === "summon")
|| (classId === "warrior" && abilityName === "Bladestorm" && effect.kind === "dot")
|| (classId === "paladin" && abilityName === "Hammer of the Righteous" && effect.kind === "damage")
|| (classId === "druid" && abilityName === "Swiftmend" && effect.kind === "heal")
|| (classId === "warrior" && abilityName === "Bloodrage"
&& (effect.kind === "resource" || effect.kind === "heal"))
|| (classId === "warrior" && abilityName === "Sweeping Strikes" && effect.kind === "apply-aura")
|| (classId === "priest" && abilityName === "Inner Focus" && effect.kind === "apply-aura")
|| (classId === "shaman" && abilityName === "Nature's Swiftness" && effect.kind === "apply-aura")
|| (classId === "druid" && abilityName === "Nature's Swiftness" && effect.kind === "apply-aura")
|| (classId === "shaman" && abilityName === "Tidal Force" && effect.kind === "apply-aura")
|| (classId === "shaman" && abilityName === "Healing Stream Totem" && effect.kind === "summon")
|| (classId === "hunter" && abilityName === "Volley" && effect.kind === "apply-aura")
|| (classId === "rogue" && abilityName === "Envenom" && effect.kind === "apply-aura")
|| (classId === "rogue" && abilityName === "Vanish" && effect.kind === "movement")
|| (classId === "death-knight" && abilityName === "Empower Rune Weapon" && effect.kind === "rune")
|| (classId === "death-knight" && abilityName === "Blood Tap" && effect.kind === "rune")
|| (classId === "death-knight" && abilityName === "Anti-Magic Zone" && effect.kind === "summon")
|| (classId === "death-knight" && ["Anti-Magic Shell", "Bone Shield", "Deathchill"].includes(abilityName)
&& effect.kind === "apply-aura")
// Its DBC effect is converted into an explicit class fallback below so
// the source row's forward movement distance stays attached to the spell.
|| (classId === "mage" && abilityName === "Blink" && effect.kind === "movement")
))
.map((effect) => effect.kind === "utility" ? effect : effect);
const result = scriptedFallbacks.length
? [...executable, ...scriptedFallbacks]
: executable;
let finalized = result;
if (DIRECT_COMBO_FINISHERS.has(abilityName)) {
const directDamage = rank.effects.find((effect) => effect.effectType === 2);
const finisher: Extract<AbilityEffect, { kind: "finisher-damage" }> = abilityName === "Envenom"
? {
kind: "finisher-damage",
baseCoefficient: 0,
perComboCoefficient: 0,
poison: {
doseAuraTag: ROGUE_DEADLY_POISON_DOSE_TAG,
maximumDoses: 5,
basePointsPerDose: Math.max(1, directDamage?.basePoints ?? 1),
attackPowerCoefficientPerDose: 0.09,
},
}
: { kind: "finisher-damage", baseCoefficient: 0.55, perComboCoefficient: 0.45 };
finalized = [
...result.filter((effect) => effect.kind !== "damage" && effect.kind !== "finisher-damage"),
finisher,
];
}
if (COMBO_FINISHERS.has(abilityName) && !finalized.some((effect) => effect.kind === "finisher-damage")) {
finalized = [...finalized, { kind: "combo", amount: -5 }];
}
if (
classId === "death-knight"
&& rank.powerType === 5
&& finalized.some((effect) => ["damage", "dot", "status", "pull", "taunt"].includes(effect.kind))
) {
finalized = [...finalized, { kind: "resource", resource: "runic-power", amount: 10 }];
}
return finalized.length
? finalized
: [{
kind: "utility",
action: "class-script",
effectType: 0,
auraType: 0,
miscValue: 0,
triggerSpellId: 0,
durationMs: rank.durationMs,
}];
}
function targetForRank(rank: Wow335Rank, effects: readonly AbilityEffect[]): AbilityTarget {
const targets = rank.effects.flatMap((effect) => [effect.implicitTargetA, effect.implicitTargetB]);
if (targets.some((target) => HOSTILE_TARGETS.has(target))) return "hostile";
if (targets.some((target) => FRIENDLY_TARGETS.has(target))) return "friendly";
if (effects.some((effect) => effect.kind === "resurrection")) return "friendly";
if (effects.some((effect) => ["damage", "dot", "taunt", "interrupt", "pull"].includes(effect.kind))) return "hostile";
if (effects.some((effect) => effect.kind === "dispel" && effect.relationship === "hostile")) return "hostile";
if (effects.some((effect) => effect.kind === "apply-aura" && effect.aura.disposition === "debuff")) return "hostile";
if (effects.some((effect) => ["heal", "hot"].includes(effect.kind)) && targets.some((target) => target !== 1)) {
return "friendly";
}
return "self";
}
function costForRank(classId: BaseClassId, resource: ResourceType, rank: Wow335Rank): number {
if (rank.powerCostPercentage > 0) {
if (resource === "mana") {
// Freeze DBC percent-of-base-mana costs at the level where this rank is
// learned. Advancing levels or equipping Intellect must never make an
// already-known rank more expensive; learning a later rank still can.
const sourceLevel = Math.max(1, rank.sourceLevel || rank.level);
return Math.max(0, Math.round(
baseResourcesFor(classId, sourceLevel).mana * rank.powerCostPercentage / 100,
));
}
return rank.powerCostPercentage;
}
if (resource === "rage" || resource === "runic-power") return Math.round(rank.powerCost / 10);
return rank.powerCost;
}
function resourceForRank(fallback: ResourceType, rank: Wow335Rank): ResourceType {
switch (rank.powerType) {
case -2: return "health";
case 0: return "mana";
case 1: return "rage";
case 2: return "focus";
case 3: return "energy";
case 6: return "runic-power";
// Runes (power type 5) are represented by the existing rune effects;
// their DBC powerCost is zero, so keep the class pool for display.
default: return fallback;
}
}
function rankDefinition(
classId: BaseClassId,
resource: ResourceType,
rank: Wow335Rank,
index: number,
abilityName: string,
): AbilityRankDefinition {
const effects = effectsForRank(classId, rank, abilityName);
// Penance's learnable spell is a dummy wrapper; its triggered heal/damage
// children own the channel in the client DBC.
const penance = classId === "priest" && abilityName === "Penance";
const channeling = rank.channelDurationMs > 0 || penance;
const channelDurationMs = penance ? 2_000 : rank.channelDurationMs;
const channelTickMs = penance ? 1_000 : rank.effects
.map((effect) => effect.periodMs)
.filter((period) => period > 0)
.sort((left, right) => left - right)[0];
const castTimeMs = channeling ? Math.max(rank.castTimeMs, channelDurationMs) : rank.castTimeMs;
const rankResource = resourceForRank(resource, rank);
const lifeTapCost = classId === "warlock" && abilityName === "Life Tap"
? Math.max(1, Math.abs(rank.effects.find((effect) => effect.effectType === 3)?.basePoints ?? 1))
: null;
return {
rank: rank.rank || index + 1,
spellId: rank.spellId,
wowSpellDefense: rank.wowSpellDefense,
...(rank.equippedItemClass === 2 ? { wowWeaponRequirements: {
equippedItemClass: rank.equippedItemClass,
equippedItemSubclassMask: rank.equippedItemSubclassMask,
equippedItemInventoryMask: rank.equippedItemInventoryMask,
} } : {}),
level: gameplaySourceLevel(classId, rank.level),
description: classId === "death-knight" && abilityName === "Anti-Magic Zone"
? "Creates a stationary 10-yard zone for 10 seconds. Party members inside it absorb 75% of incoming magic damage, up to 10000 plus twice your attack power."
: classId === "hunter" && abilityName === "Kill Command"
? "Give the command to kill, increasing your pet's damage from special attacks by 60% for 30 seconds. Each special attack done by the pet reduces the damage bonus by 20%."
: rank.description,
castMode: channeling ? "channel" : rank.castTimeMs > 0 ? "cast" : "instant",
castTimeMs,
...(channeling && channelTickMs ? { channelTickMs } : {}),
...(channeling && channelTickMs
? { channelPulses: Math.max(1, Math.round(castTimeMs / channelTickMs) + (penance ? 1 : 0)) }
: {}),
...(penance ? { channelStartsWithPulse: true } : {}),
cooldownMs: classId === "hunter" && abilityName === "Kill Command"
? WOW335_HUNTER_PET_KILL_COMMAND_COOLDOWN_MS
: classId === "shaman" && abilityName === "Nature's Swiftness"
? Math.max(180_000, rank.cooldownMs)
: rank.cooldownMs,
gcdMs: rank.gcdMs,
cost: {
resource: rankResource,
amount: lifeTapCost ?? costForRank(classId, rankResource, rank),
...(rank.powerCostPercentage > 0 && rankResource !== "mana" ? { percentage: true } : {}),
},
effects,
};
}
function sigil(name: string): string {
return name
.split(/[\s:-]+/)
.filter(Boolean)
.slice(0, 2)
.map((part) => part[0]?.toUpperCase() ?? "")
.join("")
.slice(0, 2) || "SP";
}
function abilityDefinition(classId: BaseClassId, chain: Wow335Chain): AbilityDefinition | null {
const resource = RESOURCE_BY_CLASS[classId];
// Some SpellChain rows include rank-zero triggered children or cosmetic
// variants after the actual learnable ranks (Execute's damage child,
// Divine Hymn's tick, Death Coil's split target spells, polymorph models).
// They are executable sub-spells, not a higher player rank.
const sourceRanks = classId === "warlock" && chain.name === "Seed of Corruption"
? chain.ranks.filter((rank) => [27243, 47835, 47836].includes(rank.spellId))
: classId === "shaman" && chain.name === "Shamanistic Rage"
? chain.ranks.filter((rank) => rank.spellId === 30823)
: classId === "shaman" && chain.name === "Stormstrike"
? chain.ranks.filter((rank) => rank.spellId === 17364)
: classId === "warrior" && chain.name === "Bloodthirst"
? chain.ranks.filter((rank) => rank.spellId === 23881)
: classId === "warrior" && chain.name === "Sweeping Strikes"
? chain.ranks.filter((rank) => rank.spellId === 12328)
: chain.ranks.some((rank) => rank.rank > 0)
? chain.ranks.filter((rank) => rank.rank > 0)
: chain.ranks;
const channelRanks = sourceRanks[0]?.channelDurationMs
? sourceRanks.filter((rank) => rank.channelDurationMs > 0)
: sourceRanks;
// The imported progression includes Hurricane's rank-five damage child
// (48466), but omits its player-cast wrapper (48467). Preserve the previous
// wrapper's channel/cost fields and point its periodic trigger at that child.
const hurricaneRankFive = classId === "druid" && chain.name === "Hurricane"
? sourceRanks.find((rank) => rank.rank === 5 && rank.spellId === 48466)
: undefined;
const previousHurricaneRank = channelRanks.at(-1);
const playerRanks = hurricaneRankFive && previousHurricaneRank?.rank === 4
? [...channelRanks, {
...previousHurricaneRank,
spellId: 48467,
rank: 5,
level: hurricaneRankFive.level,
sourceLevel: hurricaneRankFive.sourceLevel,
maxScalingLevel: hurricaneRankFive.maxScalingLevel,
description: hurricaneRankFive.description,
effects: previousHurricaneRank.effects.map((effect) => effect.auraType === 23
? { ...effect, triggerSpellId: hurricaneRankFive.spellId }
: effect),
}]
: channelRanks;
// Holy Nova and Holy Shock store damage and healing as separate same-rank
// spells. Holy Shock also has a player-cast wrapper that owns its cost,
// cooldown, and range; retain that wrapper as the selectable spell while
// combining both payloads into its executable rank.
const castRanks = classId === "paladin" && chain.name === "Holy Shock"
? playerRanks.filter((rank) => rank.effects.some((effect) => effect.effectType === 3))
.map((rank) => {
const damage = sourceRanks.find((candidate) => candidate.rank === rank.rank
&& candidate.effects.some((effect) => effect.effectType === 2));
const healing = sourceRanks.find((candidate) => candidate.rank === rank.rank
&& candidate.effects.some((effect) => effect.effectType === 10));
return damage && healing
? { ...rank, effects: [...damage.effects, ...healing.effects] }
: rank;
})
: classId === "priest" && chain.name === "Holy Nova"
? playerRanks.filter((rank) => rank.effects.some((effect) => effect.effectType === 2))
.map((rank) => {
const healing = sourceRanks.find((candidate) => candidate.rank === rank.rank
&& candidate.effects.some((effect) => effect.effectType === 10));
return healing ? { ...rank, effects: [...rank.effects, ...healing.effects] } : rank;
})
: playerRanks;
const soulLinkTalentTooltip = classId === "warlock" && chain.name === "Soul Link"
? WOW335_TALENT_NODES.find((node) => node.id === chain.talentNodeId)?.rankDescriptions[0]
: undefined;
const focusMagicTalent = classId === "mage" && chain.name === "Focus Magic"
? WOW335_TALENT_NODES.find((node) => node.id === chain.talentNodeId)
: undefined;
const hysteriaTalent = classId === "death-knight" && chain.name === "Hysteria"
? WOW335_TALENT_NODES.find((node) => node.id === chain.talentNodeId)
: undefined;
const auraMasteryTalent = classId === "paladin" && chain.name === "Aura Mastery"
? WOW335_TALENT_NODES.find((node) => (node.rankSpellIds as readonly number[]).includes(31821))
: undefined;
const focusMagicRankSpellIds = (focusMagicTalent?.rankSpellIds ?? []) as readonly number[];
const focusMagicTalentEffect = focusMagicTalent?.rankEffects[0]?.effects.find((effect) => (
effect.index === 0 && effect.auraType === 57
));
const focusMagicTalentTooltip = focusMagicTalent?.rankDescriptions[0];
const hysteriaTalentTooltip = hysteriaTalent?.rankDescriptions[0];
const auraMasteryTalentTooltip = auraMasteryTalent?.rankDescriptions[0];
const ranks = castRanks.map((rank, index) => {
// Focus Magic's current Area-52 talent row is generated from patch-D and
// gives the recipient +1% spell crit. The separate enUS Spell.dbc import
// conflicts at +3%, so use the current talent DBC effect and tooltip for
// the runtime ability.
const auraMasteryRank = auraMasteryTalentTooltip && rank.spellId === 31821
? { ...rank, durationMs: 6_000, description: auraMasteryTalentTooltip }
: rank;
const hysteriaTalentRank = hysteriaTalent?.rankEffects.find((talentRank) => talentRank.spellId === rank.spellId);
const resolvedRank = focusMagicTalentEffect
&& focusMagicRankSpellIds.includes(rank.spellId)
? {
...auraMasteryRank,
effects: auraMasteryRank.effects.map((effect) => effect.effectType === focusMagicTalentEffect.effectType
&& effect.auraType === focusMagicTalentEffect.auraType
? { ...effect, basePoints: focusMagicTalentEffect.basePoints }
: effect),
}
// Hysteria's active ability row is also a talent. Keep it on the current
// Area-52 talent DBC's 15% value instead of the separate stock enUS row.
: hysteriaTalentRank
? {
...auraMasteryRank,
effects: auraMasteryRank.effects.map((effect) => {
const talentEffect = hysteriaTalentRank.effects.find((candidate) => candidate.index === effect.index);
return talentEffect ? { ...effect, basePoints: talentEffect.basePoints } : effect;
}),
}
: auraMasteryRank;
return rankDefinition(classId, resource, resolvedRank, index, chain.name);
}).map((rank) => {
const talentTooltip = soulLinkTalentTooltip
?? focusMagicTalentTooltip
?? hysteriaTalentTooltip
?? auraMasteryTalentTooltip;
return talentTooltip ? { ...rank, description: talentTooltip } : rank;
});
const selectedDeterrence = classId === "hunter"
&& chain.id === "wow335-hunter-deterrence"
&& chain.name === "Deterrence"
&& castRanks.length === 1
&& castRanks[0]?.spellId === 19_263;
const mappedRanks = selectedDeterrence
? ranks.map((rank) => ({ ...rank, effects: WOW335_HUNTER_DETERRENCE_PARENT_AURA_EFFECTS }))
: ranks;
const first = mappedRanks[0];
const firstRaw = castRanks[0];
if (!first || !firstRaw) return null;
const effectiveSourceUnlockLevel = classId === "warrior" && chain.name === "Sweeping Strikes"
? Math.max(1, firstRaw.sourceLevel || firstRaw.level)
: chain.sourceUnlockLevel;
const target = first.effects.some((effect) => effect.kind === "sacrifice-minion")
? "self" : abilityNameTarget(classId, chain.name) ?? targetForRank(firstRaw, first.effects);
const radius = first.effects.some((effect) => effect.kind === "sacrifice-minion") ? 0 : Math.max(
abilityNameRadius(chain.name),
...firstRaw.effects.map((effect) => effect.radius),
);
const range = target === "self"
? { min: 0, max: 0 }
: {
min: Math.max(0, firstRaw.rangeMin),
max: firstRaw.rangeMax > 0 ? firstRaw.rangeMax : firstRaw.equippedItemClass === 2 ? 30 : 30,
};
return {
id: chain.id,
classId,
dbcSpellId: first.spellId,
wowSpellDefense: first.wowSpellDefense,
wowWeaponRequirements: first.wowWeaponRequirements,
name: chain.name,
unlockLevel: starterUnlockLevel(classId, effectiveSourceUnlockLevel, chain.name),
sourceUnlockLevel: effectiveSourceUnlockLevel,
icon: chain.iconPath ?? `/assets/ui/spells/${chain.iconBasename}.png`,
sigil: sigil(chain.name),
description: first.description,
target,
range,
...(radius > 0 ? { radius } : {}),
...(classId === "paladin" && chain.name === "Avenger's Shield"
? { targeting: { shape: "chain" as const, maximumTargets: 3, jumpRange: 10 } }
: {}),
...(classId === "hunter" && ["Freezing Arrow", "Explosive Trap"].includes(chain.name)
? {
targeting: {
shape: "ground" as const,
radius: 2,
groundRange: chain.name === "Freezing Arrow" ? Math.max(40, firstRaw.rangeMax) : 0,
},
}
: {}),
...(classId === "death-knight" && chain.name === "Anti-Magic Zone"
? { targeting: { shape: "ground" as const, radius: 10, groundRange: 30 } }
: {}),
...(classId === "death-knight" && chain.name === "Anti-Magic Zone"
? { description: "Creates a stationary 10-yard zone for 10 seconds. Party members inside it absorb 75% of incoming magic damage, up to 10000 plus twice your attack power." }
: {}),
castMode: first.castMode,
castTimeMs: first.castTimeMs,
channelTickMs: first.channelTickMs,
channelPulses: first.channelPulses,
channelStartsWithPulse: first.channelStartsWithPulse,
cooldownMs: first.cooldownMs,
...(classicCooldownGroup(classId, chain.name)
? { cooldownGroup: classicCooldownGroup(classId, chain.name) }
: {}),
gcdMs: first.gcdMs,
cost: first.cost,
effects: first.effects,
passive: chain.passive || (Boolean(chain.talentNodeId) && castRanks.every((rank) => learnOnlyRank(rank) && rank.castTimeMs === 0
&& rank.cooldownMs === 0 && rank.powerCost === 0 && rank.powerCostPercentage === 0)),
source: "wow335-progression",
// Some stock WotLK active talents remain in Spell.dbc after Ascension's
// patched Talent.dbc removes their nodes (Devastate, Shadowstep, Lava
// Lash). They cannot be allocated by any legal local build, so expose
// those retained class actions at their source level instead of making
// them permanently unlearnable behind a dangling talent ID.
talentNodeId: chain.talentNodeId && runtimeTalentNodeById(chain.talentNodeId)
? chain.talentNodeId
: undefined,
ranks: mappedRanks,
};
}
function abilityNameRadius(abilityName: string): number {
if (["Bladestorm", "Divine Storm"].includes(abilityName)) return 8;
if (abilityName === "Hellfire") return 10;
if (abilityName === "Mind Sear") return 10;
if (abilityName === "Pestilence") return 10;
if (abilityName === "Divine Hymn") return 40;
return 0;
}
function abilityNameTarget(classId: BaseClassId, abilityName: string): AbilityTarget | undefined {
if (abilityName === "Redemption" || abilityName.startsWith("Greater Blessing of ")) return "friendly";
if (classId === "rogue" && abilityName === "Tricks of the Trade") return "friendly";
if (classId === "rogue" && abilityName === "Killing Spree") return "hostile";
if (["Bladestorm", "Divine Hymn", "Tranquility", "Holy Nova", "Hellfire", "Army of the Dead", "Fan of Knives", "Anti-Magic Zone"].includes(abilityName)) return "self";
if (abilityName === "Soulshatter") return "self";
if (abilityName === "Desperate Prayer") return "self";
if (abilityName === "Lightwell") return "self";
if (classId === "warlock" && abilityName === "Soul Link") return "self";
if (abilityName === "Dispel Magic") return "any";
if (abilityName === "Holy Shock") return "any";
if (abilityName === "Volley") return "hostile";
if (abilityName === "Penance") return "any";
if (["Last Stand", "Enraged Regeneration", "Molten Armor", "Combustion", "Life Tap", "Bone Shield"].includes(abilityName)) return "self";
if (abilityName === "Death Coil") return classId === "death-knight" ? "any" : "hostile";
return undefined;
}
function classicCooldownGroup(classId: BaseClassId, abilityName: string): string | undefined {
if (classId === "shaman" && / Shock$/i.test(abilityName)) return "shaman-shocks";
if (classId === "shaman" && ["Nature's Swiftness", "Elemental Mastery"].includes(abilityName)) return "shaman-nature-swiftness";
if (classId === "hunter" && / Trap$/i.test(abilityName)) return "hunter-traps";
if (classId === "mage" && /^(?:Fire|Frost) Ward$/i.test(abilityName)) return "mage-wards";
return undefined;
}
export const WOW335_ABILITIES_BY_CLASS: Readonly<Record<BaseClassId, readonly AbilityDefinition[]>> =
Object.freeze(Object.fromEntries(BASE_CLASS_IDS.map((classId) => {
const sourceChains = classId === "hunter" ? hunterChainsWithSelectedDeterrence() : data.classes[classId];
const generated = sourceChains
.map((chain) => abilityDefinition(classId, chain))
.filter((ability): ability is AbilityDefinition => Boolean(ability));
const specialized = classId === "warlock"
? [
...generated.filter((ability) => ability.id !== "wow335-warlock-metamorphosis"),
wow335WarlockMetamorphosisAbility(),
...WOW335_WARLOCK_METAMORPHOSIS_FORM_ABILITIES,
]
: generated;
return [classId, specialized.sort((left, right) => (
left.unlockLevel - right.unlockLevel || left.name.localeCompare(right.name)
))];
}))) as unknown as Readonly<Record<BaseClassId, readonly AbilityDefinition[]>>;
export type Wow335HunterPreventionType = 0 | 1 | 2;
// Spell.dbc field 214 in the pinned 234-field layout (field 220 in the
// selected 239-field archive) is kept locally until the generator has a
// source-profile-aware way to carry this cast gate. Every value below was
// read from the pinned Spell.dbc row; missing IDs intentionally remain absent.
const WOW335_HUNTER_PREVENTION_SPELL_IDS_BY_TYPE: Readonly<Record<Wow335HunterPreventionType, readonly number[]>> = Object.freeze({
0: Object.freeze([
23_989, 1_494, 1_130, 14_323, 14_324, 14_325, 53_338, 883, 2_641, 6_991,
1_515, 19_883, 19_884, 781, 1_462, 19_885, 3_045, 19_880, 5_384,
19_878, 19_574, 19_882, 24_131, 24_134, 24_135, 27_069, 49_009, 49_010,
34_471, 19_879, 62_757, 19_263,
]),
1: Object.freeze([
13_163, 13_165, 14_318, 14_319, 14_320, 14_321, 14_322, 25_296, 27_044,
982, 136, 3_111, 3_661, 3_662, 13_542, 13_543, 13_544, 27_046, 48_989,
48_990, 6_197, 1_002, 1_513, 14_326, 14_327, 5_118, 13_795, 13_797, 14_298,
14_302, 14_299, 14_303, 14_300, 14_304, 14_301, 14_305, 27_023, 27_024,
49_053, 49_055, 49_054, 49_056, 34_074, 1_499, 14_310, 14_311, 13_809,
13_161, 19_577, 1_543, 13_813, 14_316, 14_317, 27_025, 49_066, 49_067,
13_159, 19_506, 20_043, 20_190, 27_045,
49_071, 34_026, 34_600, 61_846, 61_847, 60_192,
]),
2: Object.freeze([
75, 2_973, 14_260, 14_261, 14_262, 14_263, 14_264, 14_265, 14_266, 27_014,
48_995, 48_996, 1_978, 13_549, 13_550, 13_551, 13_552, 13_553, 13_554,
13_555, 25_295, 27_016, 49_000, 49_001, 3_044, 14_281, 14_282, 14_283,
14_284, 14_285, 14_286, 14_287, 27_019, 49_044, 49_045, 5_116, 20_736,
2_974, 19_503, 1_495, 14_269, 14_270, 14_271, 36_916, 53_339, 2_643,
14_288, 14_289, 14_290, 25_294, 27_021, 49_047, 49_048, 19_434, 27_065,
49_049, 49_050, 3_043, 19_306, 20_909, 20_910, 27_067, 48_998, 48_999,
34_490, 3_034, 1_510, 14_294, 14_295, 27_022, 19_386, 24_132, 24_133,
27_068, 49_011, 49_012, 3_674, 63_668, 63_669, 63_670, 63_671, 63_672,
56_641, 49_051, 49_052, 53_209, 53_301, 19_801, 34_477, 61_005, 61_006,
]),
});
const hunterPreventionTypesBySpellId: Record<number, Wow335HunterPreventionType> = {};
for (const type of [0, 1, 2] as const) {
for (const spellId of WOW335_HUNTER_PREVENTION_SPELL_IDS_BY_TYPE[type]) {
hunterPreventionTypesBySpellId[spellId] = type;
}
}
export const WOW335_HUNTER_PREVENTION_TYPES_BY_SPELL_ID: Readonly<Record<number, Wow335HunterPreventionType>> =
Object.freeze(hunterPreventionTypesBySpellId);
const HUNTER_SOURCE_RANK_IDENTITIES = new Map<number, { readonly id: string; readonly name: string }>();
const DUPLICATE_HUNTER_SOURCE_RANK_IDS = new Set<number>();
for (const ability of WOW335_ABILITIES_BY_CLASS.hunter) {
for (const rank of ability.ranks ?? []) {
if (HUNTER_SOURCE_RANK_IDENTITIES.has(rank.spellId)) {
HUNTER_SOURCE_RANK_IDENTITIES.delete(rank.spellId);
DUPLICATE_HUNTER_SOURCE_RANK_IDS.add(rank.spellId);
continue;
}
if (!DUPLICATE_HUNTER_SOURCE_RANK_IDS.has(rank.spellId)) {
HUNTER_SOURCE_RANK_IDENTITIES.set(rank.spellId, { id: ability.id, name: ability.name });
}
}
}
/** Returns the pinned source PreventionType only for a selected Hunter row. */
export function wow335HunterPreventionTypeForAbility(
ability: Pick<AbilityDefinition, "id" | "classId" | "source" | "name" | "dbcSpellId">,
): Wow335HunterPreventionType | undefined {
if (ability.classId !== "hunter" || ability.source !== "wow335-progression") return undefined;
const preventionType = WOW335_HUNTER_PREVENTION_TYPES_BY_SPELL_ID[ability.dbcSpellId];
const identity = HUNTER_SOURCE_RANK_IDENTITIES.get(ability.dbcSpellId);
if (preventionType === undefined || !identity || DUPLICATE_HUNTER_SOURCE_RANK_IDS.has(ability.dbcSpellId)) {
return undefined;
}
const publicAlias = identity.id.startsWith("wow335-") ? identity.id.slice("wow335-".length) : undefined;
if (ability.name !== identity.name || (ability.id !== identity.id && ability.id !== publicAlias)) return undefined;
return preventionType;
}
// These ground channels store their actual damage in same-name triggered
// child spells. Resolve the child once so each channel pulse can use its
// rank-specific DBC amount rather than creating a delayed area effect.
const GROUND_CHANNEL_CHILD_NAMES = new Set(["Blizzard", "Rain of Fire", "Hurricane"]);
const wow335ChannelTriggeredAbilitiesBySpellId = Object.fromEntries(BASE_CLASS_IDS.flatMap((classId) => data.classes[classId]
.filter((chain) => GROUND_CHANNEL_CHILD_NAMES.has(chain.name))
.flatMap((chain) => {
const ranksById = new Map(chain.ranks.map((rank) => [rank.spellId, rank]));
const childIds = new Set(chain.ranks.filter((rank) => rank.channelDurationMs === 0
&& rank.effects.some((effect) => effect.effectType === 2))
.map((rank) => rank.spellId));
return [...childIds].flatMap((spellId): Array<[number, AbilityDefinition]> => {
const child = ranksById.get(spellId);
if (!child) return [];
const effects = effectsForRank(classId, child, chain.name)
.filter((effect) => effect.kind === "damage" || effect.kind === "heal");
if (!effects.length) return [];
return [[spellId, {
id: `wow335-trigger-${spellId}`,
classId,
dbcSpellId: spellId,
name: `${chain.name} pulse`,
unlockLevel: 1,
icon: chain.iconPath ?? `/assets/ui/spells/${chain.iconBasename}.png`,
sigil: sigil(chain.name),
description: child.description,
target: "hostile",
range: { min: 0, max: 40 },
castMode: "instant",
castTimeMs: 0,
cooldownMs: 0,
gcdMs: 0,
cost: { resource: RESOURCE_BY_CLASS[classId], amount: 0 },
effects,
}]];
});
})));
const wow335SeedExplosionAbilitiesBySpellId = Object.fromEntries(
[27285, 47833, 47834].flatMap((spellId) => {
const chain = data.classes.warlock.find((candidate) => candidate.name === "Seed of Corruption");
const rank = chain?.ranks.find((candidate) => candidate.spellId === spellId);
if (!rank) return [];
const effects = effectsForRank("warlock", rank, "Seed of Corruption")
.filter((effect) => effect.kind === "damage");
if (!effects.length) return [];
return [[spellId, {
id: `wow335-trigger-${spellId}`,
classId: "warlock" as const,
dbcSpellId: spellId,
name: "Seed of Corruption explosion",
unlockLevel: 1,
icon: chain?.iconPath ?? "/assets/ui/spells/warlock_seedofcorruption.png",
sigil: "SC",
description: "Deals the rank-specific Shadow damage when Seed of Corruption detonates.",
target: "hostile" as const,
range: { min: 0, max: 15 },
castMode: "instant" as const,
castTimeMs: 0,
cooldownMs: 0,
gcdMs: 0,
cost: { resource: "mana" as const, amount: 0 },
effects,
} satisfies AbilityDefinition]];
}),
);
const bloodthirstHealTriggeredAbility: AbilityDefinition = {
id: "wow335-trigger-23880",
classId: "warrior",
dbcSpellId: 23880,
name: "Bloodthirst heal",
unlockLevel: 1,
icon: "/assets/ui/spells/ability_warrior_bloodthirst.png",
sigil: "BH",
description: "A successful melee hit heals the warrior for 1% of maximum health.",
target: "self",
range: { min: 0, max: 0 },
castMode: "instant",
castTimeMs: 0,
cooldownMs: 0,
gcdMs: 0,
cost: { resource: "rage", amount: 0 },
effects: [{ kind: "heal", coefficient: 0, percentMaxHealth: 0.01 }],
};
const rogueShadowstepPositionChild: AbilityDefinition = {
id: "wow335-trigger-36563",
classId: "rogue",
dbcSpellId: 36563,
name: "Shadowstep behind target",
unlockLevel: 1,
icon: "/assets/ui/spells/ability_rogue_shadowstep.png",
sigil: "SS",
description: "Reappear behind the selected enemy.",
target: "hostile",
range: { min: 0, max: 25 },
castMode: "instant",
castTimeMs: 0,
cooldownMs: 0,
gcdMs: 0,
cost: { resource: "energy", amount: 0 },
effects: [],
};
const rogueShadowstepRiderChild: AbilityDefinition = {
id: "wow335-trigger-44373",
classId: "rogue",
dbcSpellId: 44373,
name: "Shadowstep next-ability rider",
unlockLevel: 1,
icon: "/assets/ui/spells/ability_rogue_shadowstep.png",
sigil: "SS",
description: "Increases damage by 1% and reduces threat by 1% for 3 sec.",
target: "self",
range: { min: 0, max: 0 },
castMode: "instant",
castTimeMs: 0,
cooldownMs: 0,
gcdMs: 0,
cost: { resource: "energy", amount: 0 },
effects: [{
kind: "apply-aura",
recipient: "caster",
aura: {
id: "wow335:rogue:shadowstep-rider",
name: "Shadowstep",
disposition: "buff",
durationMs: 3_000,
maxStacks: 1,
stackBehavior: "refresh",
modifiers: [
{ kind: "damage", direction: "dealt", operation: "percent", value: 0.01 },
{ kind: "stat", stat: "threat", operation: "percent", value: -0.01 },
],
utilityTags: ["wow335-rogue-shadowstep-rider"],
},
sourceEffectType: 6,
sourceAuraType: 4,
miscValue: 0,
triggerSpellId: 0,
}],
};
export const WOW335_TRIGGERED_ABILITIES_BY_SPELL_ID: Readonly<Record<number, AbilityDefinition>> =
Object.freeze({
...wow335ChannelTriggeredAbilitiesBySpellId,
...wow335SeedExplosionAbilitiesBySpellId,
36563: rogueShadowstepPositionChild,
44373: rogueShadowstepRiderChild,
23880: bloodthirstHealTriggeredAbility,
29703: WOW335_SHIELD_BASH_DAZE,
});
/** @deprecated Use WOW335_TRIGGERED_ABILITIES_BY_SPELL_ID. */
export const WOW335_CHANNEL_TRIGGERED_ABILITIES_BY_SPELL_ID = WOW335_TRIGGERED_ABILITIES_BY_SPELL_ID;