188 lines
8.4 KiB
TypeScript
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);
|
|
});
|
|
});
|