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); }); });