Files
healer-man/src/game/abilityCatalog.test.ts
T
2026-09-17 09:13:15 -04:00

257 lines
11 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { existsSync, statSync } from "node:fs";
import path from "node:path";
import { CLASSES, COA_CLASS_IDS } from "../app/characterCatalog";
import { COA_LIVE_COUNTS } from "./coaLiveCatalog";
import {
ABILITY_CATALOG,
abilitiesForClass,
abilityAtLevel,
abilityById,
defaultActionBarForClass,
resourceProfileForClass,
} from "./abilityCatalog";
describe("baseline ability catalog", () => {
it("provides the classic kits and the complete live CoA spell catalogs", () => {
const wowClasses = CLASSES.filter((characterClass) => characterClass.categoryId !== "rom");
expect(wowClasses).toHaveLength(31);
const allIds = new Set<string>();
for (const characterClass of wowClasses) {
const abilities = abilitiesForClass(characterClass.id);
if (characterClass.mode === "classic") {
expect(abilities.length, characterClass.name).toBeGreaterThanOrEqual(15);
expect(
abilities.some((ability) => ability.source === "wow335-progression"),
characterClass.name,
).toBe(true);
} else {
expect(abilities.length, characterClass.name).toBeGreaterThanOrEqual(20);
expect(abilities.some((ability) => ability.source === "coa-progression"), characterClass.name).toBe(true);
expect(abilities.some((ability) => ability.source === "coa-tree"), characterClass.name).toBe(true);
}
const resource = resourceProfileForClass(characterClass.id);
for (const ability of abilities) {
expect(ability.classId).toBe(characterClass.id);
expect(ability.dbcSpellId).toBeGreaterThan(0);
expect(ability.icon).toMatch(/^\/assets\/ui\/(?:spells|talents\/icons)\/[a-z0-9_()' -]+\.png$/);
const iconPath = path.join(process.cwd(), "public", ability.icon.slice(1));
expect(existsSync(iconPath), `${ability.name} icon should exist at ${iconPath}`).toBe(true);
expect(statSync(iconPath).size, `${ability.name} icon should not be empty`).toBeGreaterThan(0);
if (characterClass.mode !== "classic" || ability.cost.amount > 0) {
const classicResources: Partial<Record<typeof characterClass.id, readonly string[]>> = {
warrior: ["rage", "health"],
rogue: ["energy"],
"death-knight": ["runic-power", "health"],
druid: ["mana", "rage", "energy"],
warlock: ["mana", "health"],
};
expect(
characterClass.mode === "classic"
? classicResources[characterClass.id] ?? [resource.type]
: ["mana", "rage", "energy", "runic-power", "focus", "health"],
).toContain(ability.cost.resource);
}
expect(ability.cost.amount).toBeGreaterThanOrEqual(0);
expect(ability.effects.length).toBeGreaterThan(0);
expect(allIds.has(ability.id)).toBe(false);
allIds.add(ability.id);
}
}
expect(allIds.size).toBeGreaterThan(COA_LIVE_COUNTS.coaProgressionChains);
expect(Object.values(ABILITY_CATALOG).filter((ability) => ability.source !== "runewaker")).toHaveLength(allIds.size);
});
it("resolves stable ids without silently substituting another ability", () => {
expect(abilityById("priest-flash-heal")?.dbcSpellId).toBe(2061);
expect(abilityById("missing")).toBeNull();
});
it("gives every class a visible early-game ability progression", () => {
for (const characterClass of CLASSES.filter((entry) => entry.categoryId !== "rom")) {
const unlockLevels = abilitiesForClass(characterClass.id).map((ability) => ability.unlockLevel);
if (characterClass.mode === "classic") {
expect(unlockLevels.filter((level) => level === 1).length, characterClass.name).toBeGreaterThanOrEqual(2);
expect(
abilitiesForClass(characterClass.id).some((ability) => (ability.ranks?.length ?? 0) > 1),
characterClass.name,
).toBe(true);
} else {
expect(unlockLevels.filter((level) => level === 1).length, characterClass.name).toBeGreaterThanOrEqual(2);
expect(abilitiesForClass(characterClass.id).some((ability) => (ability.ranks?.length ?? 0) > 1), characterClass.name).toBe(true);
}
}
expect(COA_CLASS_IDS).toHaveLength(21);
});
it("separates the rank number from live percentage costs", () => {
const stormbringer = abilitiesForClass("stormbringer");
const megawatt = stormbringer.find((ability) => ability.name === "Megawatt Missile")!;
const volt = stormbringer.find((ability) => ability.name === "Volt")!;
expect(abilityAtLevel(megawatt, 1).cost.amount).toBe(19);
expect(abilityAtLevel(volt, 1).cost.amount).toBe(15);
});
it("upgrades a CoA spell rank without changing its logical action-bar id", () => {
const aeroblast = abilitiesForClass("stormbringer")
.find((ability) => ability.name === "Aeroblast")!;
const rankOne = abilityAtLevel(aeroblast, 13);
const rankTwo = abilityAtLevel(aeroblast, 20);
expect(rankOne.id).toBe(aeroblast.id);
expect(rankTwo.id).toBe(aeroblast.id);
expect(rankOne.dbcSpellId).toBe(801839);
expect(rankTwo.dbcSpellId).toBe(501450);
});
it("upgrades a WoW 3.3.5a default-class spell without changing its logical action-bar id", () => {
const heroicStrike = abilityById("warrior-heroic-strike")!;
const firstRank = heroicStrike.ranks![0];
const lastRank = heroicStrike.ranks!.at(-1)!;
const early = abilityAtLevel(heroicStrike, firstRank.level);
const late = abilityAtLevel(heroicStrike, lastRank.level);
expect(heroicStrike.source).toBe("wow335-progression");
expect(heroicStrike.ranks!.length).toBeGreaterThan(1);
expect(early.id).toBe("warrior-heroic-strike");
expect(late.id).toBe("warrior-heroic-strike");
expect(early.dbcSpellId).toBe(firstRank.spellId);
expect(late.dbcSpellId).toBe(lastRank.spellId);
});
it("keeps every imported default-class spell on a concrete local-client icon", () => {
for (const characterClass of CLASSES.filter((definition) => definition.mode === "classic")) {
for (const ability of abilitiesForClass(characterClass.id)) {
if (ability.source !== "wow335-progression") continue;
expect(path.basename(ability.icon, ".png"), `${characterClass.name}: ${ability.name}`)
.not.toBe("inv_misc_questionmark");
}
}
});
it("defines every primary class taunt and Death Grip with an explicit three-second taunt effect", () => {
for (const [abilityId, dbcSpellId] of [
["warrior-taunt", 355],
["paladin-hand-of-reckoning", 62124],
["death-knight-dark-command", 56222],
["druid-growl", 6795],
] as const) {
expect(abilityById(abilityId)).toMatchObject({
dbcSpellId,
target: "hostile",
range: { min: 0, max: 30 },
cooldownMs: 8000,
effects: expect.arrayContaining([{ kind: "taunt", durationMs: 3000 }]),
});
}
expect(abilityById("death-knight-death-grip")?.effects).toEqual(expect.arrayContaining([
{ kind: "taunt", durationMs: 3000 },
]));
});
it("uses Wrath's eight-second Psychic Scream at its original level gate", () => {
expect(abilityById("priest-psychic-scream")).toMatchObject({
unlockLevel: 14,
target: "self",
radius: 8,
effects: expect.arrayContaining([{ kind: "status", status: "fear", durationMs: 8000 }]),
});
});
it("classifies triggered passive-talent effects as passive abilities", () => {
for (const [classId, abilityId, name] of [
["paladin", "wow335-paladin-eye-for-an-eye", "Eye for an Eye"],
["hunter", "wow335-hunter-the-beast-within", "The Beast Within"],
["priest", "wow335-priest-holy-concentration", "Holy Concentration"],
["mage", "wow335-mage-blazing-speed", "Blazing Speed"],
["mage", "wow335-mage-burnout", "Burnout"],
] as const) {
expect(abilityById(abilityId)).toMatchObject({ classId, name, passive: true });
expect(defaultActionBarForClass(classId)).not.toContain(abilityId);
}
expect(abilityById("wow335-druid-enrage")).toMatchObject({ classId: "druid", name: "Enrage" });
expect(abilityById("wow335-druid-enrage")?.passive).not.toBe(true);
});
it("marks direct heals and Rejuvenation as forty-yard friendly abilities", () => {
for (const abilityId of [
"paladin-holy-light",
"priest-flash-heal",
"shaman-healing-wave",
"druid-rejuvenation",
]) {
expect(abilityById(abilityId)).toMatchObject({
target: "friendly",
range: { min: 0, max: 40 },
});
}
expect(abilityById("mage-mana-shield")?.target).toBe("self");
expect(abilityById("death-knight-death-strike")?.target).toBe("hostile");
});
it("uses the WoW 3.3.5a cast timings and internally consistent cast modes", () => {
expect(abilityById("hunter-steady-shot")).toMatchObject({ castTimeMs: 1500, castMode: "cast" });
const timedAbilities = [
"hunter-steady-shot",
"paladin-holy-light",
"priest-flash-heal",
"priest-smite",
"shaman-healing-wave",
"shaman-lightning-bolt",
"mage-frostbolt",
"warlock-shadow-bolt",
"warlock-drain-life",
"warlock-fear",
"druid-entangling-roots",
"druid-wrath",
] as const;
for (const abilityId of timedAbilities) {
const ability = abilityById(abilityId)!;
expect(["cast", "channel"]).toContain(ability.castMode);
expect(ability.castTimeMs).toBeGreaterThan(0);
}
for (const ability of Object.values(ABILITY_CATALOG)) {
if (ability.castMode === "instant") {
expect(ability.castTimeMs).toBe(0);
} else {
expect(ability.castTimeMs).toBeGreaterThan(0);
}
}
});
it("grants the level-ten base toolkit at level one without granting later spell families", () => {
for (const characterClass of CLASSES.filter((definition) => definition.mode === "classic")) {
for (const ability of abilitiesForClass(characterClass.id)) {
if (ability.source !== "wow335-progression") continue;
const translatedSourceLevel = characterClass.id === "death-knight"
? Math.max(1, (ability.sourceUnlockLevel ?? 55) - 54)
: ability.sourceUnlockLevel ?? ability.unlockLevel;
if (
translatedSourceLevel <= 10
|| (characterClass.id === "paladin" && ability.name === "Hand of Reckoning")
) expect(ability.unlockLevel, ability.name).toBe(1);
else expect(ability.unlockLevel, ability.name).toBe(translatedSourceLevel);
}
}
});
it("restores the stock priest healing families and their exact rank chains", () => {
const byName = Object.fromEntries(abilitiesForClass("priest").map((ability) => [ability.name, ability]));
expect(byName["Lesser Heal"]).toMatchObject({
unlockLevel: 1,
sourceUnlockLevel: 1,
ranks: [
expect.objectContaining({ spellId: 2050, level: 1 }),
expect.objectContaining({ spellId: 2052, level: 4 }),
expect.objectContaining({ spellId: 2053, level: 10 }),
],
});
expect(byName.Heal).toMatchObject({ unlockLevel: 16, dbcSpellId: 2054 });
expect(byName["Flash Heal"]).toMatchObject({ unlockLevel: 20, dbcSpellId: 2061 });
expect(byName["Greater Heal"]).toMatchObject({ unlockLevel: 40, dbcSpellId: 2060 });
});
});