forked from phenom/healer-man
257 lines
11 KiB
TypeScript
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 });
|
|
});
|
|
});
|