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"; 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 sourceUnlockLevel: number; readonly passive?: boolean; 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 schools: readonly [number, DamageSchool][] = [ [1, "physical"], [2, "holy"], [4, "fire"], [8, "nature"], [16, "frost"], [32, "shadow"], [64, "arcane"], ]; const matches = schools.filter(([bit]) => (mask & bit) !== 0); return matches.length === 1 ? matches[0]?.[1] : undefined; } 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 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 }]; 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 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 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: return { kind: "disarm" }; default: 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( effect: Wow335Effect, rank: Wow335Rank, abilityName: string, effectIndex: number, ): AuraDefinition { const durationMs = rank.durationMs > 0 ? rank.durationMs : null; const proc = procForAura(effect, rank, rank.description); const school = schoolFromMask(effect.miscValue); return { id: `wow335:${rank.spellId}:effect:${effectIndex}:aura:${effect.auraType}`, 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", ...(dispelCategoryForAura(abilityName, rank.description, rank.durationMs) !== undefined ? { dispelCategory: dispelCategoryForAura(abilityName, rank.description, rank.durationMs) } : {}), ...(auraModifiers(effect).length ? { modifiers: auraModifiers(effect) } : {}), ...([69, 97].includes(effect.auraType) ? { absorbs: [{ id: `wow335:${rank.spellId}:absorb:${effectIndex}`, amount: Math.max(0, effect.basePoints), ...(school !== undefined ? { school } : {}), perStack: false, }], } : {}), ...(proc ? { procs: [proc] } : {}), }; } 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 effectsForRank(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(effect, rank, abilityName, effectIndex); effects.push({ kind: "apply-aura", aura, ...(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) }); } 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 === 10 || effect.effectType === 67 || effect.effectType === 136) { effects.push({ 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) }); 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: "unsupported", combat: false, effectType: effect.effectType, auraType: effect.auraType, reason: `Noncombat DBC effect ${effect.effectType}`, }); 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}` : ""}`, }); } if (!effects.some((effect) => ["damage", "heal", "dot", "hot", "shield"].includes(effect.kind))) { 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 }); } } return effects.length ? effects : [{ kind: "scripted", effectType: 0, auraType: 0, miscValue: 0, triggerSpellId: 0, label: "Description-backed spell script", }]; } 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(resource: ResourceType, rank: Wow335Rank): number { if (rank.powerCostPercentage > 0) return rank.powerCostPercentage; if (resource === "rage" || resource === "runic-power") return Math.round(rank.powerCost / 10); return rank.powerCost; } function rankDefinition( classId: BaseClassId, resource: ResourceType, rank: Wow335Rank, index: number, abilityName: string, ): AbilityRankDefinition { const effects = effectsForRank(rank, abilityName); const channeling = rank.channelDurationMs > 0; 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, amount: costForRank(resource, rank) }, 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]; const ranks = chain.ranks.map((rank, index) => rankDefinition(classId, resource, rank, index, chain.name)); const first = ranks[0]; const firstRaw = chain.ranks[0]; if (!first || !firstRaw) return null; const target = targetForRank(firstRaw, first.effects); const radius = Math.max(0, ...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: `/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, gcdMs: first.gcdMs, cost: first.cost, effects: first.effects, passive: chain.passive, source: "wow335-progression", ranks, }; } 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>;