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(); 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> = { 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 }); }); });