forked from phenom/healer-man
Release Healer Man 0.1.6
This commit is contained in:
1 parent
55cfd43d66
commit
b12fb84859
96 files changed
+10874
-609
No files matched your search
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user