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>; }; 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> = { 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> = { [-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 | 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["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> = { 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> = { 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> = { 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["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 = 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> = 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>; 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> = 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 = {}; 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> = Object.freeze(hunterPreventionTypesBySpellId); const HUNTER_SOURCE_RANK_IDENTITIES = new Map(); const DUPLICATE_HUNTER_SOURCE_RANK_IDS = new Set(); 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, ): 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> = 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;