Files
healer-man/src/game/talentCatalog.test.ts
T
2026-09-18 16:09:25 -04:00

188 lines
8.4 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { BASE_CLASS_IDS, COA_CLASS_IDS } from "../app/characterCatalog";
import { COA_LIVE_COUNTS } from "./coaLiveCatalog";
import {
TALENT_NODES,
TALENT_TREES,
activeTalentRankEffects,
allocateTalentPoint,
coaEssenceBudgetForLevel,
normalizeTalentRanks,
spentCoaEssence,
spentTalentPoints,
talentNodesForTree,
talentDescriptionForRank,
talentNodeById,
talentPointsForLevel,
talentTreesForClass,
unspentTalentPoints,
} from "./talentCatalog";
const firstNode = (treeId: string) => talentNodesForTree(treeId)[0];
describe("Ascension-synchronized classic talent trees", () => {
it("contains the 30 classic trees plus every live Vol'Jin CoA tree and node", () => {
expect(TALENT_TREES.filter((tree) => tree.system === "classic")).toHaveLength(30);
expect(TALENT_NODES.filter((node) => node.system === "classic")).toHaveLength(826);
expect(TALENT_TREES.filter((tree) => tree.system === "coa")).toHaveLength(COA_LIVE_COUNTS.tabs);
expect(TALENT_NODES.filter((node) => node.system === "coa")).toHaveLength(COA_LIVE_COUNTS.entries);
for (const classId of BASE_CLASS_IDS) {
const trees = talentTreesForClass(classId);
expect(trees).toHaveLength(3);
expect(new Set(trees.map((tree) => tree.dbcTalentTabId)).size).toBe(3);
for (const tree of trees) expect(talentNodesForTree(tree.id).length).toBeGreaterThanOrEqual(25);
}
for (const classId of COA_CLASS_IDS) {
const trees = talentTreesForClass(classId);
expect(trees.length, classId).toBeGreaterThanOrEqual(4);
expect(trees.every((tree) => tree.system === "coa")).toBe(true);
}
});
it("uses Ascension's modified stock talent ranks without mixing in its alternate talent systems", () => {
expect(talentNodeById("talent-28")?.rankSpellIds).toEqual([11100, 12353, 812352]);
expect(talentNodeById("talent-1666")).toBeNull();
expect(talentNodeById("talent-1714")).toBeNull();
expect(talentNodeById("talent-2249")).toBeNull();
expect(TALENT_NODES.some((node) => node.dbcTalentId >= 100_000 && node.system === "classic")).toBe(false);
});
it("ships concrete, rank-aware client descriptions for every visible talent", () => {
for (const node of TALENT_NODES) {
expect(node.name).not.toMatch(/^Talent \d+$/);
expect(node.description).not.toMatch(/talent rank from|this is a talent/i);
expect(node.rankDescriptions).toHaveLength(node.maxRank);
expect(node.rankEffects).toHaveLength(node.maxRank);
for (const description of node.rankDescriptions) {
expect(description).not.toMatch(/talent rank from|this is a talent|\$[A-Za-z0-9]|@ext:|:ext@/i);
expect(description.length).toBeGreaterThan(8);
}
}
const circleOfHealing = TALENT_NODES.find((node) => node.name === "Circle of Healing")!;
expect(circleOfHealing.description).toContain("within 15 yards");
});
it("resolves the selected rank and exact backing Spell.dbc effect row", () => {
const twinDisciplines = TALENT_NODES.find((node) => node.name === "Twin Disciplines")!;
expect(talentDescriptionForRank(twinDisciplines, 1)).toContain("by 1%");
expect(talentDescriptionForRank(twinDisciplines, 5)).toContain("by 5%");
expect(twinDisciplines.rankEffects.map((rank) => rank.effects[0]?.basePoints)).toEqual([1, 2, 3, 4, 5]);
const active = activeTalentRankEffects({ [twinDisciplines.id]: 3 }, "priest");
expect(active).toHaveLength(1);
expect(active[0]).toMatchObject({
talentId: twinDisciplines.id,
talentName: "Twin Disciplines",
rank: 3,
spellId: twinDisciplines.rankSpellIds[2],
});
expect(active[0].description).toContain("by 3%");
});
it("uses the WotLK level 10 through 80 talent-point curve", () => {
expect(talentPointsForLevel(1)).toBe(0);
expect(talentPointsForLevel(9)).toBe(0);
expect(talentPointsForLevel(10)).toBe(1);
expect(talentPointsForLevel(80)).toBe(71);
});
it("uses separate live Ability Essence and Talent Essence budgets for CoA", () => {
expect(coaEssenceBudgetForLevel("stormbringer", 1)).toMatchObject({ ability: 0, talent: 0 });
expect(coaEssenceBudgetForLevel("stormbringer", 10)).toMatchObject({ ability: 1, talent: 0 });
expect(coaEssenceBudgetForLevel("stormbringer", 11)).toMatchObject({ ability: 1, talent: 1 });
expect(coaEssenceBudgetForLevel("stormbringer", 12)).toMatchObject({ ability: 2, talent: 1 });
expect(coaEssenceBudgetForLevel("stormbringer", 60)).toMatchObject({
ability: 26,
talent: 25,
maximumAbility: 26,
maximumTalent: 25,
});
});
it("allocates CoA class and specialization nodes on the alternating live schedule", () => {
const trees = talentTreesForClass("stormbringer");
const nodes = trees.flatMap((tree) => talentNodesForTree(tree.id));
const classNode = nodes.find((node) => (
node.abilityEssenceCost === 1
&& node.talentEssenceCost === 0
&& node.prerequisites.length === 0
&& (node.requiredClassPoints ?? 0) === 0
))!;
const specNode = nodes.find((node) => (
node.talentEssenceCost === 1
&& node.abilityEssenceCost === 0
&& node.prerequisites.length === 0
&& (node.requiredSpecPoints ?? 0) === 0
))!;
expect(allocateTalentPoint({}, "stormbringer", classNode.id, 9).reason).toBe("ability-essence");
const classResult = allocateTalentPoint({}, "stormbringer", classNode.id, 10);
expect(classResult.ok).toBe(true);
expect(spentCoaEssence(classResult.ranks, "stormbringer")).toEqual({ ability: 1, talent: 0 });
expect(allocateTalentPoint(classResult.ranks, "stormbringer", specNode.id, 10).reason).toBe("talent-essence");
const specResult = allocateTalentPoint(classResult.ranks, "stormbringer", specNode.id, 11);
expect(specResult.ok).toBe(true);
expect(spentCoaEssence(specResult.ranks, "stormbringer")).toEqual({ ability: 1, talent: 1 });
});
it("allocates node ranks only to the active class", () => {
const holy = firstNode("priest-holy");
const shadow = firstNode("priest-shadow");
const fire = firstNode("mage-fire");
const first = allocateTalentPoint({}, "priest", holy.id, 10);
expect(first).toMatchObject({ ok: true, ranks: { [holy.id]: 1 } });
expect(unspentTalentPoints(10, first.ranks, "priest")).toBe(0);
expect(allocateTalentPoint(first.ranks, "priest", shadow.id, 10).reason).toBe("no-points");
expect(allocateTalentPoint({}, "priest", fire.id, 10).reason).toBe("wrong-class");
});
it("enforces maximum ranks and tier gates", () => {
const tierNode = talentNodesForTree("priest-holy").find((node) => node.row > 1)!;
expect(allocateTalentPoint({}, "priest", tierNode.id, 80).reason).toBe("tier-locked");
expect(TALENT_NODES.filter((node) => node.system === "classic").some(
(node) => node.prerequisites.length > 0,
)).toBe(true);
let maxed = {};
const rankNode = firstNode("priest-holy");
for (let rank = 0; rank < rankNode.maxRank; rank += 1) {
maxed = allocateTalentPoint(maxed, "priest", rankNode.id, 80).ranks;
}
expect(allocateTalentPoint(maxed, "priest", rankNode.id, 80).reason).toBe("max-rank");
});
it("clamps stale stock prerequisite ranks to the patched talent maximum", () => {
for (const node of TALENT_NODES.filter((candidate) => candidate.system === "classic")) {
for (const prerequisite of node.prerequisites) {
expect(talentNodeById(prerequisite.talentId), `${node.name} prerequisite`).toBeDefined();
expect(
prerequisite.requiredRank,
`${node.name} requires ${prerequisite.talentId}`,
).toBeLessThanOrEqual(talentNodeById(prerequisite.talentId)?.maxRank ?? 0);
}
}
expect(talentNodeById("talent-1917")?.prerequisites).toContainEqual({
talentId: "talent-1791",
requiredRank: 1,
});
expect(talentNodeById("talent-1791")?.prerequisites).toContainEqual({
talentId: "talent-1789",
requiredRank: 3,
});
});
it("deterministically trims invalid, legacy, and over-budget persisted allocations", () => {
const arcane = firstNode("mage-arcane");
const fire = firstNode("mage-fire");
const priest = firstNode("priest-holy");
const ranks = normalizeTalentRanks("mage", 12, {
[arcane.id]: 9,
[fire.id]: 2,
[priest.id]: 99,
"priest-holy-1": 99,
"mage-arcane": 99,
});
expect(ranks).toEqual({ [arcane.id]: arcane.maxRank });
expect(spentTalentPoints(ranks, "mage")).toBe(3);
});
});