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