772 lines
29 KiB
TypeScript
772 lines
29 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 { playerCombatPower } from "./progression";
|
|
|
|
export const BASE_CLASS_STARTER_SOURCE_LEVEL = 10;
|
|
|
|
interface Wow335Effect {
|
|
readonly effectType: number;
|
|
readonly auraType: number;
|
|
readonly basePoints: number;
|
|
readonly dieSides: number;
|
|
readonly pointsPerLevel: number;
|
|
readonly implicitTargetA: number;
|
|
readonly implicitTargetB: number;
|
|
readonly radius: number;
|
|
readonly periodMs: number;
|
|
readonly miscValue: number;
|
|
readonly triggerSpellId: number;
|
|
readonly coefficient: number;
|
|
}
|
|
|
|
interface Wow335Rank {
|
|
readonly spellId: number;
|
|
readonly rank: number;
|
|
readonly level: number;
|
|
readonly sourceLevel: number;
|
|
readonly maxScalingLevel: number;
|
|
readonly description: string;
|
|
readonly castTimeMs: number;
|
|
readonly cooldownMs: number;
|
|
readonly gcdMs: number;
|
|
readonly durationMs: number;
|
|
readonly channelDurationMs: number;
|
|
readonly powerType: number;
|
|
readonly powerCost: number;
|
|
readonly powerCostPercentage: number;
|
|
readonly rangeIndex: number;
|
|
readonly rangeMin: number;
|
|
readonly rangeMax: number;
|
|
readonly equippedItemClass: number;
|
|
readonly equippedItemSubclassMask: number;
|
|
readonly effects: readonly Wow335Effect[];
|
|
}
|
|
|
|
interface Wow335Chain {
|
|
readonly id: string;
|
|
readonly name: string;
|
|
readonly iconBasename: string;
|
|
readonly sourceUnlockLevel: number;
|
|
readonly passive?: boolean;
|
|
readonly ranks: readonly Wow335Rank[];
|
|
}
|
|
|
|
const data = generatedData as {
|
|
readonly classes: Readonly<Record<BaseClassId, readonly Wow335Chain[]>>;
|
|
};
|
|
|
|
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 schools: readonly [number, DamageSchool][] = [
|
|
[1, "physical"],
|
|
[2, "holy"],
|
|
[4, "fire"],
|
|
[8, "nature"],
|
|
[16, "frost"],
|
|
[32, "shadow"],
|
|
[64, "arcane"],
|
|
];
|
|
const matches = schools.filter(([bit]) => (mask & bit) !== 0);
|
|
return matches.length === 1 ? matches[0]?.[1] : undefined;
|
|
}
|
|
|
|
function percentValue(effect: Wow335Effect): number {
|
|
return effect.basePoints / 100;
|
|
}
|
|
|
|
function auraModifiers(effect: Wow335Effect): readonly AuraModifier[] {
|
|
const flatStat = (stat: string): AuraModifier[] => [
|
|
{ kind: "stat", stat, operation: "flat", value: effect.basePoints },
|
|
];
|
|
const percentStat = (stat: string): AuraModifier[] => [
|
|
{ kind: "stat", stat, operation: "percent", value: percentValue(effect) },
|
|
];
|
|
const resistanceStat = `resistance:${schoolFromMask(effect.miscValue) ?? "all"}`;
|
|
switch (effect.auraType) {
|
|
case 9: return percentStat("attack-speed");
|
|
case 10:
|
|
case 103:
|
|
case 183:
|
|
case 221: return percentStat("threat");
|
|
case 13: return [{ kind: "damage", direction: "dealt", school: schoolFromMask(effect.miscValue), operation: "flat", value: effect.basePoints }];
|
|
case 14: return [{ kind: "damage", direction: "taken", school: schoolFromMask(effect.miscValue), operation: "flat", value: effect.basePoints }];
|
|
case 22:
|
|
case 83:
|
|
case 123:
|
|
case 143: return flatStat(resistanceStat);
|
|
case 29: return flatStat(STAT_BY_MISC_VALUE[effect.miscValue] ?? `stat:${effect.miscValue}`);
|
|
case 31:
|
|
case 32:
|
|
case 33:
|
|
case 58:
|
|
case 129:
|
|
case 171:
|
|
case 172:
|
|
case 191:
|
|
case 201:
|
|
case 206:
|
|
case 207:
|
|
case 208:
|
|
case 209:
|
|
case 210:
|
|
case 211:
|
|
case 252: return percentStat("movement-speed");
|
|
case 34:
|
|
case 250: return flatStat("maximum-health");
|
|
case 35: return [{ kind: "resource", resource: "power", property: "maximum", operation: "flat", value: effect.basePoints }];
|
|
case 47: return percentStat("parry");
|
|
case 49: return percentStat("dodge");
|
|
case 50: return percentStat("critical-healing");
|
|
case 51: return percentStat("block");
|
|
case 52:
|
|
case 187:
|
|
case 188:
|
|
case 197: return percentStat("critical-strike");
|
|
case 54:
|
|
case 184:
|
|
case 185: return percentStat("hit");
|
|
case 55:
|
|
case 186:
|
|
case 199: return percentStat("spell-hit");
|
|
case 57:
|
|
case 71:
|
|
case 179: return percentStat("spell-critical-strike");
|
|
case 59: return [{ kind: "damage", direction: "dealt", operation: "flat", value: effect.basePoints }];
|
|
case 65:
|
|
case 138:
|
|
case 140:
|
|
case 141:
|
|
case 192:
|
|
case 193:
|
|
case 216:
|
|
case 217:
|
|
case 218: return percentStat("haste");
|
|
case 72: return [{ kind: "resource", resource: "power", property: "cost", operation: "percent", value: percentValue(effect) }];
|
|
case 73: return [{ kind: "resource", resource: "power", property: "cost", operation: "flat", value: effect.basePoints }];
|
|
case 79:
|
|
case 168: return [{ kind: "damage", direction: "dealt", school: schoolFromMask(effect.miscValue), operation: "percent", value: percentValue(effect) }];
|
|
case 80:
|
|
case 137: return percentStat(STAT_BY_MISC_VALUE[effect.miscValue] ?? "all-primary");
|
|
case 84:
|
|
case 88:
|
|
case 116:
|
|
case 161: return effect.auraType === 84 || effect.auraType === 116 || effect.auraType === 161
|
|
? flatStat("health-regeneration")
|
|
: percentStat("health-regeneration");
|
|
case 85: return [{ kind: "resource", resource: "power", property: "regeneration", operation: "flat", value: effect.basePoints }];
|
|
case 87:
|
|
case 229:
|
|
case 255: return [{ kind: "damage", direction: "taken", school: schoolFromMask(effect.miscValue), operation: "percent", value: percentValue(effect) }];
|
|
case 99:
|
|
case 165: return flatStat("attack-power");
|
|
case 101:
|
|
case 142: return percentStat(resistanceStat);
|
|
case 110:
|
|
case 134:
|
|
case 219: return [{ kind: "resource", resource: "mana", property: "regeneration", operation: "percent", value: percentValue(effect) }];
|
|
case 113: return [{ kind: "damage", direction: "taken", attackKind: "ranged", operation: "flat", value: effect.basePoints }];
|
|
case 114: return [{ kind: "damage", direction: "taken", attackKind: "ranged", operation: "percent", value: percentValue(effect) }];
|
|
case 115: return [{ kind: "healing", direction: "taken", operation: "flat", value: effect.basePoints }];
|
|
case 118: return [{ kind: "healing", direction: "taken", operation: "percent", value: percentValue(effect) }];
|
|
case 122: return [{ kind: "damage", direction: "dealt", attackKind: "melee", operation: "percent", value: percentValue(effect) }];
|
|
case 124:
|
|
case 127: return flatStat("ranged-attack-power");
|
|
case 125: return [{ kind: "damage", direction: "taken", attackKind: "melee", operation: "flat", value: effect.basePoints }];
|
|
case 126: return [{ kind: "damage", direction: "taken", attackKind: "melee", operation: "percent", value: percentValue(effect) }];
|
|
case 132: return [{ kind: "resource", resource: "power", property: "maximum", operation: "percent", value: percentValue(effect) }];
|
|
case 133: return percentStat("maximum-health");
|
|
case 135: return [{ kind: "healing", direction: "done", operation: "flat", value: effect.basePoints }];
|
|
case 136:
|
|
case 175: return [{ kind: "healing", direction: "done", operation: "percent", value: percentValue(effect) }];
|
|
case 150: return percentStat("block-value");
|
|
case 158: return flatStat("block-value");
|
|
case 163:
|
|
case 203:
|
|
case 204: return percentStat("critical-damage");
|
|
case 166: return percentStat("attack-power");
|
|
case 167:
|
|
case 212: return percentStat("ranged-attack-power");
|
|
case 169: return percentStat("critical-strike");
|
|
case 174:
|
|
case 237: return percentStat("spell-power");
|
|
case 189:
|
|
case 220: return flatStat(`combat-rating:${effect.miscValue}`);
|
|
case 240: return flatStat("expertise");
|
|
case 248: return percentStat("combat-result");
|
|
case 251: return percentStat("enemy-dodge");
|
|
case 253: return percentStat("block-critical-strike");
|
|
default: return [];
|
|
}
|
|
}
|
|
|
|
function controlFromAura(auraType: number): AbilityAuraControl | undefined {
|
|
switch (auraType) {
|
|
case 5: return { kind: "confuse" };
|
|
case 6:
|
|
case 177: return { kind: "charm" };
|
|
case 7: return { kind: "fear" };
|
|
case 12: return { kind: "stun" };
|
|
case 25: return { kind: "pacify" };
|
|
case 26: return { kind: "root" };
|
|
case 27: return { kind: "silence" };
|
|
case 60: return { kind: "silence" };
|
|
case 67:
|
|
case 254: return { kind: "disarm" };
|
|
default: return undefined;
|
|
}
|
|
}
|
|
|
|
function inferredProcTriggers(description: string, auraType: number): readonly ProcTrigger[] {
|
|
if (auraType === 23 || auraType === 48 || auraType === 226 || auraType === 227) return ["periodic"];
|
|
const triggers: ProcTrigger[] = [];
|
|
if (/\bcritical|critically\b/i.test(description)) triggers.push("crit");
|
|
if (/\bdodge/i.test(description)) triggers.push("dodge");
|
|
if (/\bparr(?:y|ied)/i.test(description)) triggers.push("parry");
|
|
if (/\bblock/i.test(description)) triggers.push("block");
|
|
if (/\bcast(?:ing|s)?\b/i.test(description)) triggers.push("cast");
|
|
if (/\bheal(?:ing|s|ed)?\b/i.test(description)) triggers.push("heal");
|
|
if (/\bkill(?:ing|s|ed)?\b/i.test(description)) triggers.push("kill");
|
|
if (/\bdamage taken|when struck|takes? damage/i.test(description)) triggers.push("damage-taken");
|
|
if (/\bhit(?:s|ting)?\b|attack/i.test(description)) triggers.push("hit");
|
|
return triggers.length ? [...new Set(triggers)] : ["hit"];
|
|
}
|
|
|
|
function procChance(description: string): number {
|
|
const match = description.match(/(\d+(?:\.\d+)?)% chance/i);
|
|
return match ? Math.min(1, Math.max(0, Number(match[1]) / 100)) : 1;
|
|
}
|
|
|
|
function procInternalCooldown(description: string): number {
|
|
const match = description.match(/(?:once every|once per|only once (?:every|per))\s+(\d+(?:\.\d+)?)\s*sec/i);
|
|
return match ? Number(match[1]) * 1_000 : 0;
|
|
}
|
|
|
|
function procForAura(
|
|
effect: Wow335Effect,
|
|
rank: Wow335Rank,
|
|
description: string,
|
|
): AuraProcDefinition | undefined {
|
|
const triggerAura = [23, 42, 43, 48, 109, 111, 223, 225, 227, 231].includes(effect.auraType);
|
|
const damageShield = effect.auraType === 15;
|
|
if (!triggerAura && !damageShield) return undefined;
|
|
if (!damageShield && effect.triggerSpellId <= 0) return undefined;
|
|
return {
|
|
id: `wow335:${rank.spellId}:proc:${effect.auraType}:${effect.triggerSpellId || "damage"}`,
|
|
triggers: damageShield ? "damage-taken" : inferredProcTriggers(description, effect.auraType),
|
|
chance: procChance(description),
|
|
...(procInternalCooldown(description) > 0
|
|
? { internalCooldownMs: procInternalCooldown(description) }
|
|
: {}),
|
|
action: damageShield
|
|
? {
|
|
kind: "damage",
|
|
amount: Math.max(0, effect.basePoints),
|
|
school: schoolFromMask(effect.miscValue) ?? "physical",
|
|
target: "event-other",
|
|
}
|
|
: {
|
|
kind: "custom",
|
|
id: "wow335-trigger-spell",
|
|
data: { spellId: effect.triggerSpellId },
|
|
},
|
|
};
|
|
}
|
|
|
|
function dispelCategoryForAura(
|
|
abilityName: string,
|
|
description: string,
|
|
durationMs: number,
|
|
): DispelCategory | undefined {
|
|
const text = `${abilityName} ${description}`;
|
|
if (/\bcurse\b/i.test(text)) return "curse";
|
|
if (/\bpoison|venom\b/i.test(text)) return "poison";
|
|
if (/\bdisease|plague\b/i.test(text)) return "disease";
|
|
if (/\benrage|frenzy|berserk/i.test(text)) return "enrage";
|
|
if (/\bbleed|bleeding|wound|rend\b/i.test(text)) return undefined;
|
|
return durationMs > 0 ? "magic" : undefined;
|
|
}
|
|
|
|
function namedAuraForEffect(
|
|
effect: Wow335Effect,
|
|
rank: Wow335Rank,
|
|
abilityName: string,
|
|
effectIndex: number,
|
|
): AuraDefinition {
|
|
const durationMs = rank.durationMs > 0 ? rank.durationMs : null;
|
|
const proc = procForAura(effect, rank, rank.description);
|
|
const school = schoolFromMask(effect.miscValue);
|
|
return {
|
|
id: `wow335:${rank.spellId}:effect:${effectIndex}:aura:${effect.auraType}`,
|
|
name: `${abilityName} (DBC ${rank.spellId}, Aura ${effect.auraType})`,
|
|
disposition: effectTargetsHostile(effect) ? "debuff" : "buff",
|
|
...([3, 8].includes(effect.auraType) && effect.periodMs > 0
|
|
? { hideFromAuraStrip: true }
|
|
: {}),
|
|
durationMs,
|
|
maxStacks: 1,
|
|
stackBehavior: "refresh",
|
|
...(dispelCategoryForAura(abilityName, rank.description, rank.durationMs) !== undefined
|
|
? { dispelCategory: dispelCategoryForAura(abilityName, rank.description, rank.durationMs) }
|
|
: {}),
|
|
...(auraModifiers(effect).length ? { modifiers: auraModifiers(effect) } : {}),
|
|
...([69, 97].includes(effect.auraType)
|
|
? {
|
|
absorbs: [{
|
|
id: `wow335:${rank.spellId}:absorb:${effectIndex}`,
|
|
amount: Math.max(0, effect.basePoints),
|
|
...(school !== undefined ? { school } : {}),
|
|
perStack: false,
|
|
}],
|
|
}
|
|
: {}),
|
|
...(proc ? { procs: [proc] } : {}),
|
|
};
|
|
}
|
|
|
|
function categoriesForDispel(miscValue: number): readonly DispelCategory[] {
|
|
switch (miscValue) {
|
|
case 1: return ["magic"];
|
|
case 2: return ["curse"];
|
|
case 3: return ["disease"];
|
|
case 4: return ["poison"];
|
|
case 9: return ["enrage"];
|
|
case 7: return ALL_DISPEL_CATEGORIES;
|
|
default: return [];
|
|
}
|
|
}
|
|
|
|
function gameplaySourceLevel(classId: BaseClassId, sourceLevel: number): number {
|
|
// Wrath death knights began at level 55. Healer Man starts every class at
|
|
// one, so translate their 55..80 spell track to 1..26 before applying the
|
|
// same starter-kit policy used by the other base classes.
|
|
return classId === "death-knight"
|
|
? Math.max(1, sourceLevel - 54)
|
|
: Math.max(1, sourceLevel);
|
|
}
|
|
|
|
function starterUnlockLevel(classId: BaseClassId, sourceLevel: number, abilityName: string): number {
|
|
const translated = gameplaySourceLevel(classId, sourceLevel);
|
|
// A five-player tank must have a taunt even where the original trainer put
|
|
// it slightly beyond level ten (Hand of Reckoning was level 16 in Wrath).
|
|
const roleEssentialStarter = classId === "paladin" && abilityName === "Hand of Reckoning";
|
|
return translated <= BASE_CLASS_STARTER_SOURCE_LEVEL || roleEssentialStarter ? 1 : translated;
|
|
}
|
|
|
|
function amountScaling(effect: Wow335Effect, rank: Wow335Rank): AbilityAmountScaling {
|
|
return {
|
|
basePoints: Math.abs(effect.basePoints),
|
|
dieSides: Math.abs(effect.dieSides),
|
|
pointsPerLevel: Math.max(0, effect.pointsPerLevel),
|
|
bonusPowerCoefficient: Math.max(0, effect.coefficient),
|
|
sourceLevel: Math.max(1, rank.sourceLevel || rank.level),
|
|
...(rank.maxScalingLevel > 0 ? { maxScalingLevel: rank.maxScalingLevel } : {}),
|
|
};
|
|
}
|
|
|
|
function fallbackCoefficient(effect: Wow335Effect, rank: Wow335Rank): number {
|
|
if (effect.coefficient > 0) return effect.coefficient;
|
|
const average = Math.abs(effect.basePoints) + Math.max(0, Math.abs(effect.dieSides) - 1) / 2;
|
|
return Math.max(0.08, Math.min(4, average / playerCombatPower(Math.max(1, rank.sourceLevel || rank.level))));
|
|
}
|
|
|
|
function effectsForRank(rank: Wow335Rank, abilityName: string): readonly AbilityEffect[] {
|
|
const effects: AbilityEffect[] = [];
|
|
for (const [effectIndex, effect] of rank.effects.entries()) {
|
|
const coefficient = fallbackCoefficient(effect, rank);
|
|
const scaling = amountScaling(effect, rank);
|
|
if (APPLY_AURA_EFFECTS.has(effect.effectType) && effect.auraType > 0) {
|
|
const aura = namedAuraForEffect(effect, rank, abilityName, effectIndex);
|
|
effects.push({
|
|
kind: "apply-aura",
|
|
aura,
|
|
...(controlFromAura(effect.auraType) ? { control: controlFromAura(effect.auraType) } : {}),
|
|
sourceEffectType: effect.effectType,
|
|
sourceAuraType: effect.auraType,
|
|
miscValue: effect.miscValue,
|
|
triggerSpellId: effect.triggerSpellId,
|
|
});
|
|
if (effect.auraType === 3 && effect.periodMs > 0) {
|
|
const ticks = Math.max(1, Math.round(Math.max(effect.periodMs, rank.durationMs) / effect.periodMs));
|
|
effects.push({ kind: "dot", coefficient, ...scaling, ticks, intervalMs: effect.periodMs, tag: `spell-${rank.spellId}` });
|
|
} else if (effect.auraType === 8 && effect.periodMs > 0) {
|
|
const ticks = Math.max(1, Math.round(Math.max(effect.periodMs, rank.durationMs) / effect.periodMs));
|
|
effects.push({ kind: "hot", coefficient, ...scaling, ticks, intervalMs: effect.periodMs });
|
|
} else if (effect.auraType === 11) {
|
|
effects.push({ kind: "taunt", durationMs: Math.max(3_000, rank.durationMs) });
|
|
}
|
|
continue;
|
|
}
|
|
if (effect.effectType === 1) {
|
|
effects.push({ kind: "execute", coefficient: Math.max(1, coefficient) });
|
|
continue;
|
|
}
|
|
if (effect.effectType === 2) {
|
|
effects.push({ kind: "damage", coefficient, ...scaling });
|
|
continue;
|
|
}
|
|
if (effect.effectType === 8 || effect.effectType === 62) {
|
|
effects.push({
|
|
kind: "resource-drain",
|
|
amount: Math.max(0, Math.abs(effect.basePoints)),
|
|
burn: effect.effectType === 62,
|
|
});
|
|
continue;
|
|
}
|
|
if (effect.effectType === 9) {
|
|
effects.push({ kind: "damage", coefficient, ...scaling });
|
|
effects.push({ kind: "heal", coefficient, ...scaling });
|
|
continue;
|
|
}
|
|
if (WEAPON_DAMAGE_EFFECTS.has(effect.effectType)) {
|
|
const weaponCoefficient = effect.effectType === 31
|
|
? Math.max(0.1, Math.abs(effect.basePoints) / 100)
|
|
: 1;
|
|
effects.push({ kind: "damage", coefficient: weaponCoefficient });
|
|
continue;
|
|
}
|
|
if (effect.effectType === 10 || effect.effectType === 67 || effect.effectType === 136) {
|
|
effects.push({ kind: "heal", coefficient, ...scaling });
|
|
continue;
|
|
}
|
|
if (effect.effectType === 30) {
|
|
effects.push({ kind: "resource", amount: Math.abs(effect.basePoints) });
|
|
continue;
|
|
}
|
|
if (effect.effectType === 137) {
|
|
effects.push({ kind: "resource", amount: Math.abs(effect.basePoints) });
|
|
continue;
|
|
}
|
|
if (effect.effectType === 63 || effect.effectType === 91 || effect.effectType === 125 || effect.effectType === 130) {
|
|
effects.push({
|
|
kind: "threat",
|
|
mode: effect.effectType === 130 ? "redirect" : effect.effectType === 125 ? "percent" : "flat",
|
|
amount: effect.basePoints,
|
|
});
|
|
continue;
|
|
}
|
|
if (effect.effectType === 114 || effect.auraType === 11) {
|
|
effects.push({ kind: "taunt", durationMs: 3_000 });
|
|
continue;
|
|
}
|
|
if (effect.effectType === 38) {
|
|
const anyTarget = targetsForEffect(effect).includes(25);
|
|
const relationship = anyTarget ? "target" : effectTargetsHostile(effect) ? "hostile" : "friendly";
|
|
effects.push({
|
|
kind: "dispel",
|
|
mode: relationship === "hostile" ? "purge" : "cleanse",
|
|
relationship,
|
|
categories: categoriesForDispel(effect.miscValue),
|
|
maxCount: Math.max(1, Math.abs(effect.basePoints)),
|
|
});
|
|
continue;
|
|
}
|
|
if (effect.effectType === 126) {
|
|
effects.push({
|
|
kind: "dispel",
|
|
mode: "steal",
|
|
relationship: "hostile",
|
|
categories: ["magic"],
|
|
maxCount: Math.max(1, Math.abs(effect.basePoints)),
|
|
});
|
|
continue;
|
|
}
|
|
if (effect.effectType === 68) {
|
|
effects.push({ kind: "interrupt", lockoutMs: Math.max(1_000, rank.durationMs) });
|
|
continue;
|
|
}
|
|
if (effect.effectType === 80) {
|
|
effects.push({ kind: "combo", amount: Math.max(1, Math.abs(effect.basePoints)) });
|
|
continue;
|
|
}
|
|
if (effect.effectType === 70 || effect.effectType === 124 || effect.effectType === 145) {
|
|
effects.push({ kind: "pull", toward: effect.effectType === 145 ? "destination" : "caster" });
|
|
continue;
|
|
}
|
|
if ([5, 29, 41, 42, 43, 79, 96, 98, 138, 144, 149].includes(effect.effectType)) {
|
|
const movement = effect.effectType === 5 || effect.effectType === 43
|
|
? "teleport"
|
|
: effect.effectType === 96 || effect.effectType === 149
|
|
? "charge"
|
|
: effect.effectType === 98 || effect.effectType === 144
|
|
? "knockback"
|
|
: effect.effectType === 79
|
|
? "sanctuary"
|
|
: "leap";
|
|
effects.push({
|
|
kind: "movement",
|
|
movement,
|
|
toward: movement === "knockback" || effect.effectType === 138
|
|
? "away"
|
|
: [42, 43, 144, 149].includes(effect.effectType)
|
|
? "destination"
|
|
: "target",
|
|
});
|
|
continue;
|
|
}
|
|
if (effect.effectType === 108) {
|
|
effects.push({
|
|
kind: "dispel-mechanic",
|
|
mechanic: effect.miscValue,
|
|
maxCount: Math.max(1, Math.abs(effect.basePoints)),
|
|
});
|
|
continue;
|
|
}
|
|
if (TRIGGER_SPELL_EFFECTS.has(effect.effectType) && effect.triggerSpellId > 0) {
|
|
effects.push({
|
|
kind: "trigger-spell",
|
|
spellId: effect.triggerSpellId,
|
|
...([141, 142].includes(effect.effectType) ? { withValue: effect.basePoints } : {}),
|
|
});
|
|
continue;
|
|
}
|
|
if ([18, 94, 113, 117].includes(effect.effectType)) {
|
|
effects.push({ kind: "resurrection", percentMaxHealth: effect.effectType === 94 ? 1 : 0.35 });
|
|
continue;
|
|
}
|
|
if (effect.effectType === 69) {
|
|
effects.push({ kind: "status", status: "confuse", durationMs: Math.max(1_000, rank.durationMs) });
|
|
continue;
|
|
}
|
|
if ([55, 56, 101, 102, 109, 135, 153].includes(effect.effectType)) {
|
|
const action: Extract<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: "unsupported",
|
|
combat: false,
|
|
effectType: effect.effectType,
|
|
auraType: effect.auraType,
|
|
reason: `Noncombat DBC effect ${effect.effectType}`,
|
|
});
|
|
continue;
|
|
}
|
|
effects.push({
|
|
kind: "scripted",
|
|
effectType: effect.effectType,
|
|
auraType: effect.auraType,
|
|
miscValue: effect.miscValue,
|
|
triggerSpellId: effect.triggerSpellId,
|
|
label: `DBC combat effect ${effect.effectType}${effect.auraType ? ` / aura ${effect.auraType}` : ""}`,
|
|
});
|
|
}
|
|
if (!effects.some((effect) => ["damage", "heal", "dot", "hot", "shield"].includes(effect.kind))) {
|
|
if (/\b(?:heals?|restore|mend|rejuvenat)\w*\b/i.test(rank.description)) {
|
|
effects.unshift({ kind: "heal", coefficient: 1 });
|
|
} else if (/\b(?:deals?|causes?|inflicts?)\b[^.]*\bdamage\b/i.test(rank.description)) {
|
|
effects.unshift({ kind: "damage", coefficient: 1 });
|
|
}
|
|
}
|
|
return effects.length
|
|
? effects
|
|
: [{
|
|
kind: "scripted",
|
|
effectType: 0,
|
|
auraType: 0,
|
|
miscValue: 0,
|
|
triggerSpellId: 0,
|
|
label: "Description-backed spell script",
|
|
}];
|
|
}
|
|
|
|
function targetForRank(rank: Wow335Rank, effects: readonly AbilityEffect[]): AbilityTarget {
|
|
const targets = rank.effects.flatMap((effect) => [effect.implicitTargetA, effect.implicitTargetB]);
|
|
if (targets.some((target) => HOSTILE_TARGETS.has(target))) return "hostile";
|
|
if (targets.some((target) => FRIENDLY_TARGETS.has(target))) return "friendly";
|
|
if (effects.some((effect) => ["damage", "dot", "taunt", "interrupt", "pull"].includes(effect.kind))) return "hostile";
|
|
if (effects.some((effect) => effect.kind === "dispel" && effect.relationship === "hostile")) return "hostile";
|
|
if (effects.some((effect) => effect.kind === "apply-aura" && effect.aura.disposition === "debuff")) return "hostile";
|
|
if (effects.some((effect) => ["heal", "hot"].includes(effect.kind)) && targets.some((target) => target !== 1)) {
|
|
return "friendly";
|
|
}
|
|
return "self";
|
|
}
|
|
|
|
function costForRank(resource: ResourceType, rank: Wow335Rank): number {
|
|
if (rank.powerCostPercentage > 0) return rank.powerCostPercentage;
|
|
if (resource === "rage" || resource === "runic-power") return Math.round(rank.powerCost / 10);
|
|
return rank.powerCost;
|
|
}
|
|
|
|
function rankDefinition(
|
|
classId: BaseClassId,
|
|
resource: ResourceType,
|
|
rank: Wow335Rank,
|
|
index: number,
|
|
abilityName: string,
|
|
): AbilityRankDefinition {
|
|
const effects = effectsForRank(rank, abilityName);
|
|
const channeling = rank.channelDurationMs > 0;
|
|
return {
|
|
rank: rank.rank || index + 1,
|
|
spellId: rank.spellId,
|
|
level: gameplaySourceLevel(classId, rank.level),
|
|
description: rank.description,
|
|
castMode: channeling ? "channel" : rank.castTimeMs > 0 ? "cast" : "instant",
|
|
castTimeMs: channeling ? Math.max(rank.castTimeMs, rank.channelDurationMs) : rank.castTimeMs,
|
|
cooldownMs: rank.cooldownMs,
|
|
gcdMs: rank.gcdMs,
|
|
cost: { resource, amount: costForRank(resource, rank) },
|
|
effects,
|
|
};
|
|
}
|
|
|
|
function sigil(name: string): string {
|
|
return name
|
|
.split(/[\s:-]+/)
|
|
.filter(Boolean)
|
|
.slice(0, 2)
|
|
.map((part) => part[0]?.toUpperCase() ?? "")
|
|
.join("")
|
|
.slice(0, 2) || "SP";
|
|
}
|
|
|
|
function abilityDefinition(classId: BaseClassId, chain: Wow335Chain): AbilityDefinition | null {
|
|
const resource = RESOURCE_BY_CLASS[classId];
|
|
const ranks = chain.ranks.map((rank, index) => rankDefinition(classId, resource, rank, index, chain.name));
|
|
const first = ranks[0];
|
|
const firstRaw = chain.ranks[0];
|
|
if (!first || !firstRaw) return null;
|
|
const target = targetForRank(firstRaw, first.effects);
|
|
const radius = Math.max(0, ...firstRaw.effects.map((effect) => effect.radius));
|
|
const range = target === "self"
|
|
? { min: 0, max: 0 }
|
|
: {
|
|
min: Math.max(0, firstRaw.rangeMin),
|
|
max: firstRaw.rangeMax > 0 ? firstRaw.rangeMax : firstRaw.equippedItemClass === 2 ? 30 : 30,
|
|
};
|
|
return {
|
|
id: chain.id,
|
|
classId,
|
|
dbcSpellId: first.spellId,
|
|
name: chain.name,
|
|
unlockLevel: starterUnlockLevel(classId, chain.sourceUnlockLevel, chain.name),
|
|
sourceUnlockLevel: chain.sourceUnlockLevel,
|
|
icon: `/assets/ui/spells/${chain.iconBasename}.png`,
|
|
sigil: sigil(chain.name),
|
|
description: first.description,
|
|
target,
|
|
range,
|
|
...(radius > 0 ? { radius } : {}),
|
|
castMode: first.castMode,
|
|
castTimeMs: first.castTimeMs,
|
|
cooldownMs: first.cooldownMs,
|
|
gcdMs: first.gcdMs,
|
|
cost: first.cost,
|
|
effects: first.effects,
|
|
passive: chain.passive,
|
|
source: "wow335-progression",
|
|
ranks,
|
|
};
|
|
}
|
|
|
|
export const WOW335_ABILITIES_BY_CLASS: Readonly<Record<BaseClassId, readonly AbilityDefinition[]>> =
|
|
Object.freeze(Object.fromEntries(BASE_CLASS_IDS.map((classId) => [
|
|
classId,
|
|
data.classes[classId]
|
|
.map((chain) => abilityDefinition(classId, chain))
|
|
.filter((ability): ability is AbilityDefinition => Boolean(ability))
|
|
.sort((left, right) => left.unlockLevel - right.unlockLevel || left.name.localeCompare(right.name)),
|
|
]))) as unknown as Readonly<Record<BaseClassId, readonly AbilityDefinition[]>>;
|