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 { playerCombatPower } from "./progression"; import { baseResourcesFor } from "./wotlkStats"; export const BASE_CLASS_STARTER_SOURCE_LEVEL = 10; interface Wow335Effect { readonly effectType: number; readonly auraType: number; readonly basePoints: number; readonly dieSides: number; readonly pointsPerLevel: 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 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 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 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: 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: case 187: case 188: case 197: return percentStat("critical-strike"); case 54: case 184: case 185: return percentStat("hit"); case 55: case 186: case 199: return percentStat("spell-hit"); case 57: 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) }]; case 280: return percentStat("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"; 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", "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 === "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 === "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, 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 (["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" ? ["beacon-of-light"] : []), ]; 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: 1, stackBehavior: "refresh", ...(exclusiveGroupForAbility(classId, abilityName) || abilityName === "Light's Beacon" ? { exclusiveGroup: abilityName === "Light's Beacon" ? "paladin:beacon" : exclusiveGroupForAbility(classId, abilityName) } : {}), ...(utilityTags.length ? { utilityTags } : {}), ...(controlImmunitiesForAura(effect.auraType, effect.miscValue) ? { controlImmunities: controlImmunitiesForAura(effect.auraType, effect.miscValue) } : {}), ...(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)) } : {}), ...(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 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"; } 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 "warrior:Slam": return [{ kind: "damage", coefficient: 1.5 }]; case "warrior:Charge": return [{ kind: "status", status: "stun", durationMs: 1_500 }]; case "warrior:Intercept": return [{ kind: "status", status: "stun", durationMs: 3_000 }]; case "warrior:Sunder Armor": return [{ kind: "apply-aura", aura: { id: `wow335:${rank.spellId}: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" }]; 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:Freezing Arrow": return [{ kind: "status", status: "freeze", durationMs: 10_000 }]; case "hunter:Feign Death": return [{ kind: "threat", mode: "percent", amount: -100 }]; case "rogue:Shiv": return [{ kind: "damage", coefficient: 1 }]; case "rogue:Garrote": return [{ kind: "status", status: "silence", durationMs: 3_000 }]; case "rogue:Cloak of Shadows": return [{ kind: "dispel", mode: "cleanse", relationship: "friendly", categories: ["magic"], maxCount: 10, }]; // 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: "damage", coefficient: 0.8 }]; case "priest:Shadowfiend": return [{ kind: "summon", creatureId: 19668, coefficient: 0.5, durationMs: 15_000 }]; case "priest:Prayer of Mending": return [{ kind: "apply-aura", aura: { id: `wow335:${rank.spellId}:prayer-of-mending`, name: abilityName, disposition: "buff", 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: "heal", amount: Math.max(1, Math.round(power * 0.8)), target: "aura-target" }, }], }, sourceEffectType: 64, sourceAuraType: 42, miscValue: 0, triggerSpellId: 0, }]; case "death-knight:Death Grip": return [{ kind: "pull", toward: "caster" }]; case "death-knight:Blood Tap": return [{ kind: "rune", action: "activate", count: 1 }]; case "death-knight:Chains of Ice": return [ { kind: "status", status: "root", durationMs: 3_000 }, { 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: "damage", coefficient: 0.35 }]; 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 "warlock:Soulshatter": return [{ kind: "threat", mode: "percent", amount: -90 }]; 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 "rogue:Tricks of the Trade": return [{ kind: "threat", mode: "redirect", amount: 100 }]; case "mage:Blink": return [{ kind: "movement", movement: "leap", toward: "away" }]; // 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: "debuff", durationMs: 6_000, maxStacks: 4, stackBehavior: "add", modifiers: [{ kind: "resource", resource: "mana", property: "cost", operation: "percent", value: 0.15 }], }, sourceEffectType: 64, sourceAuraType: 108, miscValue: 0, triggerSpellId: 0, }]; case "warlock:Inferno": return [{ kind: "summon", creatureId: 89, coefficient: 0.8, durationMs: 60_000 }]; 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"]); 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, ...(effect.implicitTargetA === 1 ? { recipient: "caster" as const } : {}), ...([35, 65].includes(effect.effectType) ? { scope: "party" as const } : {}), ...(controlFromAura(effect.auraType) ? { control: controlFromAura(effect.auraType) } : {}), sourceEffectType: effect.effectType, sourceAuraType: effect.auraType, 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 }); } 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) { 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 (!auraOnlyStance && !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 (/\b(?:heals?|restore|mend|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") .map((effect) => effect.kind === "utility" ? effect : effect); const result = scriptedFallbacks.length ? [...executable, ...scriptedFallbacks] : executable; let finalized = result; if (DIRECT_COMBO_FINISHERS.has(abilityName)) { finalized = [ ...result.filter((effect) => effect.kind !== "damage" && effect.kind !== "finisher-damage"), { kind: "finisher-damage", baseCoefficient: 0.55, perComboCoefficient: 0.45 }, ]; } 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) => ["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); const channeling = rank.channelDurationMs > 0; const rankResource = resourceForRank(resource, rank); return { rank: rank.rank || index + 1, spellId: rank.spellId, level: gameplaySourceLevel(classId, rank.level), description: rank.description, castMode: channeling ? "channel" : rank.castTimeMs > 0 ? "cast" : "instant", castTimeMs: channeling ? Math.max(rank.castTimeMs, rank.channelDurationMs) : rank.castTimeMs, cooldownMs: rank.cooldownMs, gcdMs: rank.gcdMs, cost: { resource: rankResource, amount: 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 = chain.ranks.some((rank) => rank.rank > 0) ? chain.ranks.filter((rank) => rank.rank > 0) : chain.ranks; const ranks = sourceRanks.map((rank, index) => rankDefinition(classId, resource, rank, index, chain.name)); const first = ranks[0]; const firstRaw = sourceRanks[0]; if (!first || !firstRaw) return null; const target = abilityNameTarget(chain.name) ?? targetForRank(firstRaw, first.effects); const radius = 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, name: chain.name, unlockLevel: starterUnlockLevel(classId, chain.sourceUnlockLevel, chain.name), sourceUnlockLevel: chain.sourceUnlockLevel, icon: chain.iconPath ?? `/assets/ui/spells/${chain.iconBasename}.png`, sigil: sigil(chain.name), description: first.description, target, range, ...(radius > 0 ? { radius } : {}), castMode: first.castMode, castTimeMs: first.castTimeMs, cooldownMs: first.cooldownMs, ...(classicCooldownGroup(classId, chain.name) ? { cooldownGroup: classicCooldownGroup(classId, chain.name) } : {}), gcdMs: first.gcdMs, cost: first.cost, effects: first.effects, passive: chain.passive, source: "wow335-progression", talentNodeId: chain.talentNodeId, ranks, }; } function abilityNameRadius(abilityName: string): number { 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(abilityName: string): AbilityTarget | undefined { if (["Divine Hymn", "Tranquility", "Holy Nova", "Hellfire", "Army of the Dead"].includes(abilityName)) return "self"; if (abilityName === "Death Coil") return "hostile"; return undefined; } function classicCooldownGroup(classId: BaseClassId, abilityName: string): string | undefined { if (classId === "shaman" && / Shock$/i.test(abilityName)) return "shaman-shocks"; 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) => [ classId, data.classes[classId] .map((chain) => abilityDefinition(classId, chain)) .filter((ability): ability is AbilityDefinition => Boolean(ability)) .sort((left, right) => left.unlockLevel - right.unlockLevel || left.name.localeCompare(right.name)), ]))) as unknown as Readonly>;