Release Healer Man 0.1.6

This commit is contained in:
phenom committed 2026-08-20 14:05:50 -04:00
1 parent 55cfd43d66
commit b12fb84859
96 files changed
+10874 -609

No files matched your search

+560 -27
View File
@@ -125,6 +125,11 @@ function effectTargetsHostile(effect: Wow335Effect): boolean {
}
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"],
@@ -134,8 +139,7 @@ function schoolFromMask(mask: number): DamageSchool | undefined {
[32, "shadow"],
[64, "arcane"],
];
const matches = schools.filter(([bit]) => (mask & bit) !== 0);
return matches.length === 1 ? matches[0]?.[1] : undefined;
return schools.filter(([bit]) => (mask & bit) !== 0).map(([, school]) => school);
}
function percentValue(effect: Wow335Effect): number {
@@ -158,6 +162,7 @@ function auraModifiers(effect: Wow335Effect): readonly AuraModifier[] {
case 221: return percentStat("threat");
case 13: return [{ kind: "damage", direction: "dealt", school: schoolFromMask(effect.miscValue), operation: "flat", value: effect.basePoints }];
case 14: return [{ kind: "damage", direction: "taken", school: schoolFromMask(effect.miscValue), operation: "flat", value: effect.basePoints }];
case 20: return flatStat("health-regeneration");
case 22:
case 83:
case 123:
@@ -182,6 +187,17 @@ function auraModifiers(effect: Wow335Effect): readonly AuraModifier[] {
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");
@@ -200,6 +216,7 @@ function auraModifiers(effect: Wow335Effect): readonly AuraModifier[] {
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:
@@ -260,6 +277,9 @@ function auraModifiers(effect: Wow335Effect): readonly AuraModifier[] {
case 189:
case 220: return flatStat(`combat-rating:${effect.miscValue}`);
case 240: return flatStat("expertise");
case 230: return flatStat("maximum-health");
case 271: return [{ kind: "damage", direction: "taken", operation: "percent", value: percentValue(effect) }];
case 280: return percentStat("armor-penetration");
case 248: return percentStat("combat-result");
case 251: return percentStat("enemy-dodge");
case 253: return percentStat("block-critical-strike");
@@ -279,11 +299,56 @@ function controlFromAura(auraType: number): AbilityAuraControl | undefined {
case 27: return { kind: "silence" };
case 60: return { kind: "silence" };
case 67:
case 254: return { kind: "disarm" };
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 (/\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 (/ 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[] = [];
@@ -355,16 +420,176 @@ function dispelCategoryForAura(
}
function namedAuraForEffect(
classId: BaseClassId,
effect: Wow335Effect,
rank: Wow335Rank,
abilityName: string,
effectIndex: number,
): AuraDefinition {
const durationMs = rank.durationMs > 0 ? rank.durationMs : null;
const proc = procForAura(effect, rank, rank.description);
const hiddenProcOverrides = new Set([
"Seal of Justice", "Lightning Shield", "Water Shield", "Frost Armor", "Ice Armor", "Molten Armor", "Nature's Grasp",
]);
const proc = hiddenProcOverrides.has(abilityName) ? undefined : procForAura(effect, rank, rank.description);
const school = schoolFromMask(effect.miscValue);
const auraId = `wow335:${rank.spellId}:effect:${effectIndex}:aura:${effect.auraType}`;
const modifiers = [...auraModifiers(effect)];
if (abilityName === "Greater Blessing of Sanctuary" && effect.auraType === 4) {
modifiers.push(
{ kind: "damage", direction: "taken", operation: "percent", value: -0.03 },
{ kind: "stat", stat: "stamina", operation: "percent", value: 0.1 },
);
}
if (abilityName === "Ice Block" && effect.auraType === 39) {
modifiers.push({ kind: "damage", direction: "taken", school: "physical", operation: "percent", value: -1 });
}
if (effect.auraType === 36 && ["Bear Form", "Dire Bear Form"].includes(abilityName)) {
modifiers.push(
{ kind: "stat", stat: "armor", operation: "percent", value: abilityName === "Dire Bear Form" ? 3.7 : 1.8 },
{ kind: "stat", stat: "stamina", operation: "percent", value: abilityName === "Dire Bear Form" ? 0.25 : 0.1 },
);
}
if (effect.auraType === 36 && abilityName === "Cat Form") {
modifiers.push(
{ kind: "stat", stat: "attack-power", operation: "percent", value: 0.1 },
{ kind: "stat", stat: "movement-speed", operation: "percent", value: 0.3 },
);
}
if (effect.auraType === 36 && ["Travel Form", "Ghost Wolf"].includes(abilityName)) {
modifiers.push({ kind: "stat", stat: "movement-speed", operation: "percent", value: 0.4 });
}
if (effect.auraType === 36 && ["Flight Form", "Swift Flight Form"].includes(abilityName)) {
modifiers.push({ kind: "stat", stat: "movement-speed", operation: "percent", value: abilityName === "Swift Flight Form" ? 2.8 : 0.6 });
}
if (effect.auraType === 36 && abilityName === "Tree of Life") {
modifiers.push({ kind: "healing", direction: "done", operation: "percent", value: 0.06 });
}
if (effect.auraType === 36 && abilityName === "Defensive Stance") {
modifiers.push(
{ kind: "damage", direction: "taken", operation: "percent", value: -0.1 },
{ kind: "stat", stat: "threat", operation: "percent", value: 0.3 },
);
}
if (effect.auraType === 36 && abilityName === "Berserker Stance") {
modifiers.push(
{ kind: "stat", stat: "critical-strike", operation: "percent", value: 0.03 },
{ kind: "damage", direction: "taken", operation: "percent", value: 0.05 },
);
}
if (effect.auraType === 21 && abilityName === "Aspect of the Viper") {
modifiers.push({
kind: "resource",
resource: "mana",
property: "regeneration",
operation: "flat",
value: Math.max(1, Math.abs(effect.basePoints)),
});
}
const specialProcs: AuraProcDefinition[] = [];
const procAmount = Math.max(1, Math.round(playerCombatPower(Math.max(1, rank.sourceLevel)) * 0.12));
if (["Seal of Righteousness", "Seal of Vengeance", "Seal of Corruption"].includes(abilityName) && effectIndex === 0) {
specialProcs.push({
id: `${auraId}:seal-damage`,
triggers: "hit",
chance: 1,
action: { kind: "damage", amount: procAmount, school: "holy", target: "event-other" },
});
} else if (abilityName === "Seal of Light" && effectIndex === 0) {
specialProcs.push({
id: `${auraId}:seal-heal`,
triggers: "hit",
chance: 1,
action: { kind: "heal", amount: procAmount, target: "aura-target" },
});
} else if (abilityName === "Seal of Wisdom" && effectIndex === 0) {
specialProcs.push({
id: `${auraId}:seal-mana`,
triggers: "hit",
chance: 1,
action: { kind: "resource", resource: "mana", amount: Math.max(1, Math.round(procAmount / 3)), target: "aura-target" },
});
}
if (abilityName === "Holy Shield" && effect.auraType === 43) {
specialProcs.push({
id: `${auraId}:holy-shield`,
triggers: "block",
chance: 1,
action: { kind: "damage", amount: procAmount, school: "holy", target: "event-other" },
});
}
if (abilityName === "Retaliation" && effect.auraType === 4) {
specialProcs.push({
id: `${auraId}:retaliation`, triggers: "damage-taken", chance: 1, charges: 30,
action: { kind: "damage", amount: Math.max(1, Math.round(procAmount * 1.5)), school: "physical", target: "event-other" },
});
}
if (abilityName === "Judgement of Light" && effect.auraType === 4) {
specialProcs.push({
id: `${auraId}:judgement-light`, triggers: "damage-taken", chance: 1,
action: { kind: "heal", amount: procAmount, target: "event-other" },
});
}
if (abilityName === "Judgement of Wisdom" && effect.auraType === 4) {
specialProcs.push({
id: `${auraId}:judgement-wisdom`, triggers: "damage-taken", chance: 1,
action: { kind: "resource", resource: "mana", amount: Math.max(1, Math.round(procAmount / 3)), target: "event-other" },
});
}
if (abilityName === "Seal of Justice" && effect.auraType === 42) {
specialProcs.push({
id: `${auraId}:seal-justice`, triggers: "hit", chance: 0.2,
action: { kind: "custom", id: "classic-status", data: { status: "stun", durationMs: 2_000 } },
});
}
if (abilityName === "Lightning Shield" && effect.auraType === 42) {
specialProcs.push({
id: `${auraId}:lightning-shield`, triggers: "damage-taken", chance: 1, charges: 3,
action: { kind: "damage", amount: Math.max(1, effect.basePoints), school: "nature", target: "event-other" },
});
}
if (abilityName === "Water Shield" && effect.auraType === 42) {
specialProcs.push({
id: `${auraId}:water-shield`, triggers: "damage-taken", chance: 1, charges: 3,
action: { kind: "resource", resource: "mana", amount: Math.max(1, effect.basePoints), target: "aura-target" },
});
}
if (["Frost Armor", "Ice Armor"].includes(abilityName) && effect.auraType === 42) {
specialProcs.push({
id: `${auraId}:frost-slow`, triggers: "damage-taken", chance: 1,
action: { kind: "custom", id: "classic-status", data: { status: "slow", durationMs: 5_000, magnitude: 0.3 } },
});
}
if (abilityName === "Molten Armor" && effect.auraType === 42) {
specialProcs.push({
id: `${auraId}:molten-retaliation`, triggers: "damage-taken", chance: 1,
action: { kind: "damage", amount: procAmount, school: "fire", target: "event-other" },
});
}
if (abilityName === "Nature's Grasp" && effect.auraType === 42) {
specialProcs.push({
id: `${auraId}:natures-grasp`, triggers: "damage-taken", chance: 1, charges: 1,
action: { kind: "custom", id: "classic-status", data: { status: "root", durationMs: 10_000 } },
});
}
if (abilityName === "Recklessness" && effect.auraType === 107) {
modifiers.push({ kind: "stat", stat: "critical-strike", operation: "percent", value: 1 });
}
const sacredShield = abilityName === "Sacred Shield" && effect.auraType === 4;
if (sacredShield) {
specialProcs.push({
id: `${auraId}:sacred-shield-refresh`,
triggers: "damage-taken",
chance: 1,
internalCooldownMs: 6_000,
action: { kind: "apply-aura", auraId, target: "aura-target" },
});
}
const utilityTags = [
...utilityTagsForAura(effect.auraType, abilityName),
...(abilityName === "Light's Beacon" ? ["beacon-of-light"] : []),
];
return {
id: `wow335:${rank.spellId}:effect:${effectIndex}:aura:${effect.auraType}`,
id: auraId,
name: `${abilityName} (DBC ${rank.spellId}, Aura ${effect.auraType})`,
disposition: effectTargetsHostile(effect) ? "debuff" : "buff",
...([3, 8].includes(effect.auraType) && effect.periodMs > 0
@@ -373,21 +598,37 @@ function namedAuraForEffect(
durationMs,
maxStacks: 1,
stackBehavior: "refresh",
...(exclusiveGroupForAbility(classId, abilityName) || abilityName === "Light's Beacon"
? { exclusiveGroup: abilityName === "Light's Beacon" ? "paladin:beacon" : exclusiveGroupForAbility(classId, abilityName) }
: {}),
...(utilityTags.length ? { utilityTags } : {}),
...(controlImmunitiesForAura(effect.auraType, effect.miscValue)
? { controlImmunities: controlImmunitiesForAura(effect.auraType, effect.miscValue) }
: {}),
...(effect.auraType === 28
? { reflectSpellChance: Math.min(1, Math.max(0, Math.abs(effect.basePoints) / 100)) }
: {}),
...(effect.auraType === 74
? { reflectSpellChance: Math.min(1, Math.max(0, Math.abs(effect.basePoints) / 100)) }
: {}),
...(effect.auraType === 81
? { redirectDamagePercent: Math.min(1, Math.max(0, Math.abs(effect.basePoints) / 100)) }
: {}),
...(dispelCategoryForAura(abilityName, rank.description, rank.durationMs) !== undefined
? { dispelCategory: dispelCategoryForAura(abilityName, rank.description, rank.durationMs) }
: {}),
...(auraModifiers(effect).length ? { modifiers: auraModifiers(effect) } : {}),
...([69, 97].includes(effect.auraType)
...(modifiers.length ? { modifiers } : {}),
...([69, 97].includes(effect.auraType) || sacredShield
? {
absorbs: [{
id: `wow335:${rank.spellId}:absorb:${effectIndex}`,
amount: Math.max(0, effect.basePoints),
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 ? { procs: [proc] } : {}),
...(proc || specialProcs.length ? { procs: [...(proc ? [proc] : []), ...specialProcs] } : {}),
};
}
@@ -437,16 +678,183 @@ function fallbackCoefficient(effect: Wow335Effect, rank: Wow335Rank): number {
return Math.max(0.08, Math.min(4, average / playerCombatPower(Math.max(1, rank.sourceLevel || rank.level))));
}
function effectsForRank(rank: Wow335Rank, abilityName: string): readonly AbilityEffect[] {
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";
}
function classScriptEffects(
classId: BaseClassId,
abilityName: string,
rank: Wow335Rank,
): readonly AbilityEffect[] {
const power = playerCombatPower(Math.max(1, rank.sourceLevel || rank.level));
const aura = (
id: string,
modifiers: readonly AuraModifier[],
procs: readonly AuraProcDefinition[] = [],
): AbilityEffect => ({
kind: "apply-aura",
aura: {
id: `wow335:${rank.spellId}:${id}`,
name: abilityName,
disposition: "buff",
durationMs: rank.durationMs > 0 ? rank.durationMs : null,
maxStacks: 1,
stackBehavior: "refresh",
exclusiveGroup: exclusiveGroupForAbility(classId, abilityName),
...(modifiers.length ? { modifiers } : {}),
...(procs.length ? { procs } : {}),
utilityTags: ["weapon-enchant"],
},
sourceEffectType: 3,
sourceAuraType: 4,
miscValue: 0,
triggerSpellId: 0,
});
switch (`${classId}:${abilityName}`) {
case "warrior:Slam": return [{ kind: "damage", coefficient: 1.5 }];
case "warrior:Charge": return [{ kind: "status", status: "stun", durationMs: 1_500 }];
case "warrior:Intercept": return [{ kind: "status", status: "stun", durationMs: 3_000 }];
case "warrior:Sunder Armor": return [{
kind: "apply-aura",
aura: {
id: `wow335:${rank.spellId}:sunder-armor`, name: abilityName, disposition: "debuff", durationMs: 30_000,
maxStacks: 5, stackBehavior: "add",
modifiers: [{ kind: "damage", direction: "taken", school: "physical", operation: "percent", value: 0.04, perStack: true }],
},
sourceEffectType: 6, sourceAuraType: 4, miscValue: 0, triggerSpellId: 0,
}];
case "warrior:Shattering Throw": return [
{ kind: "damage", coefficient: 1 },
{ kind: "status", status: "marked", durationMs: 10_000, magnitude: 0.2 },
];
case "paladin:Judgement of Justice": return [{ kind: "status", status: "root", durationMs: 10_000 }];
case "paladin:Righteous Defense": return [{ kind: "taunt", durationMs: 3_000, maxTargets: 3 }];
case "paladin:Hand of Salvation": return [{ kind: "threat", mode: "percent", amount: -20, recipient: "ability-target" }];
case "paladin:Divine Intervention": return [{
kind: "apply-aura",
aura: {
id: `wow335:${rank.spellId}:divine-intervention`, name: abilityName, disposition: "buff", durationMs: 180_000,
modifiers: schoolsFromMask(127).map((school) => ({
kind: "damage" as const, direction: "taken" as const, school, operation: "percent" as const, value: -1,
})),
},
sourceEffectType: 64, sourceAuraType: 39, miscValue: 127, triggerSpellId: 0,
}, {
kind: "utility", action: "class-script", effectType: 77, auraType: 0, miscValue: 0, triggerSpellId: 0, durationMs: 0,
}];
case "hunter:Disengage": return [{ kind: "movement", movement: "leap", toward: "away" }];
case "hunter:Freezing Arrow": return [{ kind: "status", status: "freeze", durationMs: 10_000 }];
case "hunter:Feign Death": return [{ kind: "threat", mode: "percent", amount: -100 }];
case "rogue:Shiv": return [{ kind: "damage", coefficient: 1 }];
case "rogue:Garrote": return [{ kind: "status", status: "silence", durationMs: 3_000 }];
case "rogue:Cloak of Shadows": return [{
kind: "dispel", mode: "cleanse", relationship: "friendly", categories: ["magic"], maxCount: 10,
}];
case "priest:Shadowfiend": return [{ kind: "summon", creatureId: 19668, coefficient: 0.5, durationMs: 15_000 }];
case "priest:Prayer of Mending": return [{
kind: "apply-aura",
aura: {
id: `wow335:${rank.spellId}:prayer-of-mending`,
name: abilityName,
disposition: "buff",
durationMs: 30_000,
maxStacks: 1,
stackBehavior: "refresh",
procs: [{
id: `wow335:${rank.spellId}:prayer-of-mending-proc`,
triggers: "damage-taken",
chance: 1,
charges: 5,
action: { kind: "heal", amount: Math.max(1, Math.round(power * 0.8)), target: "aura-target" },
}],
},
sourceEffectType: 64,
sourceAuraType: 42,
miscValue: 0,
triggerSpellId: 0,
}];
case "death-knight:Death Grip": return [{ kind: "pull", toward: "caster" }];
case "death-knight:Blood Tap": return [{ kind: "rune", action: "activate", count: 1 }];
case "death-knight:Chains of Ice": return [
{ kind: "status", status: "root", durationMs: 3_000 },
{ kind: "status", status: "slow", durationMs: 10_000, magnitude: 0.95 },
];
case "death-knight:Icy Touch": return [{ kind: "dot", coefficient: 0.22, ticks: 5, intervalMs: 3_000, tag: "frost-fever" }];
case "death-knight:Plague Strike": return [{ kind: "dot", coefficient: 0.22, ticks: 5, intervalMs: 3_000, tag: "blood-plague" }];
case "death-knight:Pestilence": return [{ kind: "damage", coefficient: 0.35 }];
case "death-knight:Raise Dead": return [{ kind: "summon", creatureId: 26125, coefficient: 0.45, durationMs: 60_000 }];
case "death-knight:Army of the Dead": return Array.from({ length: 8 }, () => ({
kind: "summon" as const, creatureId: 24207, coefficient: 0.2, durationMs: 40_000,
}));
case "shaman:Rockbiter Weapon": return [aura("rockbiter", [
{ kind: "damage", direction: "dealt", school: "physical", operation: "flat", value: Math.max(1, Math.round(power * 0.12)) },
])];
case "shaman:Frostbrand Weapon": return [aura("frostbrand", [], [{
id: `wow335:${rank.spellId}:frostbrand-proc`, triggers: "hit", chance: 0.2,
action: { kind: "damage", amount: Math.max(1, Math.round(power * 0.3)), school: "frost", target: "event-other" },
}])];
case "shaman:Flametongue Weapon": return [aura("flametongue", [], [{
id: `wow335:${rank.spellId}:flametongue-proc`, triggers: "hit", chance: 1,
action: { kind: "damage", amount: Math.max(1, Math.round(power * 0.18)), school: "fire", target: "event-other" },
}])];
case "shaman:Windfury Weapon": return [aura("windfury", [], [{
id: `wow335:${rank.spellId}:windfury-proc`, triggers: "hit", chance: 0.2,
action: { kind: "damage", amount: Math.max(1, Math.round(power * 0.75)), school: "physical", target: "event-other" },
}])];
case "warlock:Soulshatter": return [{ kind: "threat", mode: "percent", amount: -90 }];
case "warlock:Demonic Circle: Summon": return [{ kind: "summon", creatureId: 19175, coefficient: 0, durationMs: 360_000 }];
case "warlock:Demonic Circle: Teleport": return [{ kind: "movement", movement: "teleport", toward: "away" }];
case "hunter:Misdirection": return [{ kind: "threat", mode: "redirect", amount: 100 }];
case "rogue:Tricks of the Trade": return [{ kind: "threat", mode: "redirect", amount: 100 }];
case "mage:Blink": return [{ kind: "movement", movement: "leap", toward: "away" }];
case "mage:Arcane Blast": return [{
kind: "apply-aura",
recipient: "caster",
aura: {
id: "wow335:arcane-blast-stack",
name: "Arcane Blast",
disposition: "debuff",
durationMs: 6_000,
maxStacks: 4,
stackBehavior: "add",
modifiers: [{ kind: "resource", resource: "mana", property: "cost", operation: "percent", value: 0.15 }],
},
sourceEffectType: 64,
sourceAuraType: 108,
miscValue: 0,
triggerSpellId: 0,
}];
case "warlock:Inferno": return [{ kind: "summon", creatureId: 89, coefficient: 0.8, durationMs: 60_000 }];
case "druid:Pounce": return [{ kind: "dot", coefficient: 0.18, ticks: 6, intervalMs: 3_000, tag: "pounce" }];
default: return [];
}
}
const COMBO_FINISHERS = new Set([
"Eviscerate", "Slice and Dice", "Expose Armor", "Rupture", "Kidney Shot", "Envenom", "Deadly Throw",
"Rip", "Ferocious Bite", "Maim", "Savage Roar",
]);
const DIRECT_COMBO_FINISHERS = new Set(["Eviscerate", "Envenom", "Deadly Throw", "Ferocious Bite"]);
function effectsForRank(classId: BaseClassId, rank: Wow335Rank, abilityName: string): readonly AbilityEffect[] {
const effects: AbilityEffect[] = [];
for (const [effectIndex, effect] of rank.effects.entries()) {
const coefficient = fallbackCoefficient(effect, rank);
const scaling = amountScaling(effect, rank);
if (APPLY_AURA_EFFECTS.has(effect.effectType) && effect.auraType > 0) {
const aura = namedAuraForEffect(effect, rank, abilityName, effectIndex);
const aura = namedAuraForEffect(classId, effect, rank, abilityName, effectIndex);
effects.push({
kind: "apply-aura",
aura,
...([35, 65].includes(effect.effectType) ? { scope: "party" as const } : {}),
...(controlFromAura(effect.auraType) ? { control: controlFromAura(effect.auraType) } : {}),
sourceEffectType: effect.effectType,
sourceAuraType: effect.auraType,
@@ -461,6 +869,42 @@ function effectsForRank(rank: Wow335Rank, abilityName: string): readonly Ability
effects.push({ kind: "hot", coefficient, ...scaling, ticks, intervalMs: effect.periodMs });
} else if (effect.auraType === 11) {
effects.push({ kind: "taunt", durationMs: Math.max(3_000, rank.durationMs) });
} else if (effect.auraType === 20 && effect.periodMs > 0) {
const ticks = Math.max(1, Math.round(Math.max(effect.periodMs, rank.durationMs) / effect.periodMs));
effects.push({ kind: "hot", coefficient, ...scaling, ticks, intervalMs: effect.periodMs });
} else if (effect.auraType === 53 && effect.periodMs > 0) {
const ticks = Math.max(1, Math.round(Math.max(effect.periodMs, rank.durationMs) / effect.periodMs));
effects.push({ kind: "dot", coefficient, ...scaling, ticks, intervalMs: effect.periodMs, tag: `spell-${rank.spellId}` });
effects.push({
kind: "hot",
coefficient: abilityName === "Devouring Plague" ? coefficient * 0.15 : coefficient,
...scaling,
ticks,
intervalMs: effect.periodMs,
});
} else if (effect.auraType === 64 && effect.periodMs > 0) {
const ticks = Math.max(1, Math.round(Math.max(effect.periodMs, rank.durationMs) / effect.periodMs));
effects.push({ kind: "resource-drain", amount: Math.abs(effect.basePoints) * ticks, burn: false, resource: "mana", percentage: true });
} else if (effect.auraType === 24) {
const ticks = effect.periodMs > 0
? Math.max(1, Math.round(Math.max(effect.periodMs, rank.durationMs) / effect.periodMs))
: 1;
effects.push({
kind: "resource",
resource: abilityName === "Enrage" ? "rage" : "mana",
amount: abilityName === "Innervate" ? 100 : Math.abs(effect.basePoints) * ticks,
...(abilityName === "Innervate" ? { percentage: true } : {}),
});
} else if (effect.auraType === 21 && abilityName === "Divine Plea") {
effects.push({ kind: "resource", resource: "mana", amount: 25, percentage: true });
} else if (effect.auraType === 226 && abilityName === "Death and Decay" && effect.periodMs > 0) {
const ticks = Math.max(1, Math.round(Math.max(effect.periodMs, rank.durationMs) / effect.periodMs));
effects.push({ kind: "dot", coefficient, ...scaling, ticks, intervalMs: effect.periodMs, tag: `spell-${rank.spellId}` });
} else if (effect.auraType === 226 && abilityName === "Frenzied Regeneration" && effect.periodMs > 0) {
const ticks = Math.max(1, Math.round(Math.max(effect.periodMs, rank.durationMs) / effect.periodMs));
effects.push({ kind: "hot", coefficient: 0.3, ticks, intervalMs: effect.periodMs });
} else if (effect.auraType === 226 && abilityName === "Mirror Image") {
effects.push({ kind: "summon", creatureId: effect.triggerSpellId || 58836, coefficient: 0.25, durationMs: Math.max(1_000, rank.durationMs) });
}
continue;
}
@@ -492,8 +936,18 @@ function effectsForRank(rank: Wow335Rank, abilityName: string): readonly Ability
effects.push({ kind: "damage", coefficient: weaponCoefficient });
continue;
}
if (effect.effectType === 10 || effect.effectType === 67 || effect.effectType === 136) {
effects.push({ kind: "heal", coefficient, ...scaling });
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) {
@@ -501,7 +955,7 @@ function effectsForRank(rank: Wow335Rank, abilityName: string): readonly Ability
continue;
}
if (effect.effectType === 137) {
effects.push({ kind: "resource", amount: Math.abs(effect.basePoints) });
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) {
@@ -634,11 +1088,13 @@ function effectsForRank(rank: Wow335Rank, abilityName: string): readonly Ability
if (effect.effectType === 0 && effect.auraType === 0) continue;
if (NONCOMBAT_EFFECTS.has(effect.effectType)) {
effects.push({
kind: "unsupported",
combat: false,
kind: "utility",
action: utilityAction(effect.effectType),
effectType: effect.effectType,
auraType: effect.auraType,
reason: `Noncombat DBC effect ${effect.effectType}`,
miscValue: effect.miscValue,
triggerSpellId: effect.triggerSpellId,
durationMs: rank.durationMs,
});
continue;
}
@@ -658,15 +1114,40 @@ function effectsForRank(rank: Wow335Rank, abilityName: string): readonly Ability
effects.unshift({ kind: "damage", coefficient: 1 });
}
}
return effects.length
? effects
const scriptedFallbacks = classScriptEffects(classId, abilityName, rank);
const executable = effects
.filter((effect) => effect.kind !== "scripted" && effect.kind !== "unsupported")
.map((effect) => effect.kind === "utility" ? effect : effect);
const result = scriptedFallbacks.length
? [...executable, ...scriptedFallbacks]
: executable;
let finalized = result;
if (DIRECT_COMBO_FINISHERS.has(abilityName)) {
finalized = [
...result.filter((effect) => effect.kind !== "damage" && effect.kind !== "finisher-damage"),
{ kind: "finisher-damage", baseCoefficient: 0.55, perComboCoefficient: 0.45 },
];
}
if (COMBO_FINISHERS.has(abilityName) && !finalized.some((effect) => effect.kind === "finisher-damage")) {
finalized = [...finalized, { kind: "combo", amount: -5 }];
}
if (
classId === "death-knight"
&& rank.powerType === 5
&& finalized.some((effect) => ["damage", "dot", "status", "pull", "taunt"].includes(effect.kind))
) {
finalized = [...finalized, { kind: "resource", resource: "runic-power", amount: 10 }];
}
return finalized.length
? finalized
: [{
kind: "scripted",
kind: "utility",
action: "class-script",
effectType: 0,
auraType: 0,
miscValue: 0,
triggerSpellId: 0,
label: "Description-backed spell script",
durationMs: rank.durationMs,
}];
}
@@ -689,6 +1170,20 @@ function costForRank(resource: ResourceType, rank: Wow335Rank): number {
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,
@@ -696,8 +1191,9 @@ function rankDefinition(
index: number,
abilityName: string,
): AbilityRankDefinition {
const effects = effectsForRank(rank, abilityName);
const effects = effectsForRank(classId, rank, abilityName);
const channeling = rank.channelDurationMs > 0;
const rankResource = resourceForRank(resource, rank);
return {
rank: rank.rank || index + 1,
spellId: rank.spellId,
@@ -707,7 +1203,11 @@ function rankDefinition(
castTimeMs: channeling ? Math.max(rank.castTimeMs, rank.channelDurationMs) : rank.castTimeMs,
cooldownMs: rank.cooldownMs,
gcdMs: rank.gcdMs,
cost: { resource, amount: costForRank(resource, rank) },
cost: {
resource: rankResource,
amount: costForRank(rankResource, rank),
...(rank.powerCostPercentage > 0 ? { percentage: true } : {}),
},
effects,
};
}
@@ -724,12 +1224,22 @@ function sigil(name: string): string {
function abilityDefinition(classId: BaseClassId, chain: Wow335Chain): AbilityDefinition | null {
const resource = RESOURCE_BY_CLASS[classId];
const ranks = chain.ranks.map((rank, index) => rankDefinition(classId, resource, rank, index, chain.name));
// Some SpellChain rows include rank-zero triggered children or cosmetic
// variants after the actual learnable ranks (Execute's damage child,
// Divine Hymn's tick, Death Coil's split target spells, polymorph models).
// They are executable sub-spells, not a higher player rank.
const sourceRanks = chain.ranks.some((rank) => rank.rank > 0)
? chain.ranks.filter((rank) => rank.rank > 0)
: chain.ranks;
const ranks = sourceRanks.map((rank, index) => rankDefinition(classId, resource, rank, index, chain.name));
const first = ranks[0];
const firstRaw = chain.ranks[0];
const firstRaw = sourceRanks[0];
if (!first || !firstRaw) return null;
const target = targetForRank(firstRaw, first.effects);
const radius = Math.max(0, ...firstRaw.effects.map((effect) => effect.radius));
const target = abilityNameTarget(chain.name) ?? targetForRank(firstRaw, first.effects);
const radius = Math.max(
abilityNameRadius(chain.name),
...firstRaw.effects.map((effect) => effect.radius),
);
const range = target === "self"
? { min: 0, max: 0 }
: {
@@ -752,6 +1262,9 @@ function abilityDefinition(classId: BaseClassId, chain: Wow335Chain): AbilityDef
castMode: first.castMode,
castTimeMs: first.castTimeMs,
cooldownMs: first.cooldownMs,
...(classicCooldownGroup(classId, chain.name)
? { cooldownGroup: classicCooldownGroup(classId, chain.name) }
: {}),
gcdMs: first.gcdMs,
cost: first.cost,
effects: first.effects,
@@ -761,6 +1274,26 @@ function abilityDefinition(classId: BaseClassId, chain: Wow335Chain): AbilityDef
};
}
function abilityNameRadius(abilityName: string): number {
if (abilityName === "Hellfire") return 10;
if (abilityName === "Mind Sear") return 10;
if (abilityName === "Pestilence") return 10;
if (abilityName === "Divine Hymn") return 40;
return 0;
}
function abilityNameTarget(abilityName: string): AbilityTarget | undefined {
if (["Divine Hymn", "Tranquility", "Holy Nova", "Hellfire", "Army of the Dead"].includes(abilityName)) return "self";
return undefined;
}
function classicCooldownGroup(classId: BaseClassId, abilityName: string): string | undefined {
if (classId === "shaman" && / Shock$/i.test(abilityName)) return "shaman-shocks";
if (classId === "hunter" && / Trap$/i.test(abilityName)) return "hunter-traps";
if (classId === "mage" && /^(?:Fire|Frost) Ward$/i.test(abilityName)) return "mage-wards";
return undefined;
}
export const WOW335_ABILITIES_BY_CLASS: Readonly<Record<BaseClassId, readonly AbilityDefinition[]>> =
Object.freeze(Object.fromEntries(BASE_CLASS_IDS.map((classId) => [
classId,