import { describe, expect, it } from "vitest"; import type { ClassId } from "../app/characterCatalog"; import { PARTY_TALENT_SPECIALIZATIONS, learnedPartyTalentSelections, partyTalentAllocationPlan, partyTalentPrimaryTreeId, partyTalentRanksForLevel, upcomingPartyTalentSelections, } from "./partyTalents"; import { normalizeTalentRanks, spentTalentPoints, talentNodeById, talentNodesForTree, talentPointsForLevel, talentTreeById, } from "./talentCatalog"; const EXPECTED_SPECIALIZATIONS = [ ["warrior", "Protection", "warrior-protection"], ["death-knight", "Blood", "death-knight-blood"], ["paladin", "Protection", "paladin-protection"], ["priest", "Holy", "priest-holy"], ["paladin", "Holy", "paladin-holy"], ["druid", "Restoration", "druid-restoration"], ["shaman", "Restoration", "shaman-restoration"], ["warrior", "Arms", "warrior-arms"], ["warrior", "Fury", "warrior-fury"], ["paladin", "Retribution", "paladin-retribution"], ["hunter", "Beast Mastery", "hunter-beast-mastery"], ["hunter", "Marksmanship", "hunter-marksmanship"], ["hunter", "Survival", "hunter-survival"], ["rogue", "Combat", "rogue-combat"], ["rogue", "Assassination", "rogue-assassination"], ["rogue", "Subtlety", "rogue-subtlety"], ["priest", "Discipline", "priest-discipline"], ["priest", "Shadow", "priest-shadow"], ["death-knight", "Frost", "death-knight-frost"], ["death-knight", "Unholy", "death-knight-unholy"], ["shaman", "Enhancement", "shaman-enhancement"], ["shaman", "Elemental", "shaman-elemental"], ["mage", "Arcane", "mage-arcane"], ["mage", "Fire", "mage-fire"], ["mage", "Frost", "mage-frost"], ["warlock", "Affliction", "warlock-affliction"], ["warlock", "Demonology", "warlock-demonology"], ["warlock", "Destruction", "warlock-destruction"], ["druid", "Balance", "druid-balance"], ["druid", "Feral (Bear)", "druid-feral-combat"], ["druid", "Feral (Cat)", "druid-feral-combat"], ] as const satisfies readonly (readonly [ClassId, string, string])[]; function changedRank( before: Readonly>, after: Readonly>, ): readonly [string, number] | null { const changed = Object.entries(after).filter(([nodeId, rank]) => rank !== (before[nodeId] ?? 0)); return changed.length === 1 ? changed[0] : null; } describe("party talent specialization plans", () => { it("maps every current party template specialization to an explicit primary tree", () => { expect(PARTY_TALENT_SPECIALIZATIONS).toHaveLength(EXPECTED_SPECIALIZATIONS.length); expect(PARTY_TALENT_SPECIALIZATIONS.map(({ classId, specialization, primaryTreeId }) => [ classId, specialization, primaryTreeId, ])).toEqual(EXPECTED_SPECIALIZATIONS); for (const [classId, specialization, treeId] of EXPECTED_SPECIALIZATIONS) { expect(partyTalentPrimaryTreeId(classId, specialization)).toBe(treeId); expect(talentTreeById(treeId)?.classId).toBe(classId); } expect(partyTalentPrimaryTreeId("druid", "Feral (Cat)")).toBe("druid-feral-combat"); }); it("builds a deterministic, legal 71-point plan for every supported specialization", () => { for (const { classId, specialization } of PARTY_TALENT_SPECIALIZATIONS) { const first = partyTalentAllocationPlan(classId, specialization); const second = partyTalentAllocationPlan(classId, specialization); expect(second).toBe(first); expect(first).toHaveLength(71); expect(first.map((selection) => selection.level)).toEqual( Array.from({ length: 71 }, (_, index) => index + 10), ); expect(first.map((selection) => selection.point)).toEqual( Array.from({ length: 71 }, (_, index) => index + 1), ); for (const selection of first) { expect(talentNodeById(selection.nodeId)?.treeId).toBe(selection.treeId); expect(talentTreeById(selection.treeId)?.classId).toBe(classId); expect(selection.rank).toBeGreaterThan(0); expect(selection.rank).toBeLessThanOrEqual(selection.maxRank); } } }, 15_000); it("reaches every primary-tree capstone before branching into a secondary tree", () => { for (const { classId, specialization, primaryTreeId } of PARTY_TALENT_SPECIALIZATIONS) { const level60 = learnedPartyTalentSelections(classId, specialization, 60); const primary = level60.filter((selection) => selection.inPrimaryTree); const deepestRow = Math.max(...talentNodesForTree(primaryTreeId).map((node) => node.row)); expect(primary).toHaveLength(51); expect( Math.max(...primary.map((selection) => talentNodeById(selection.nodeId)?.row ?? 0)), `${classId}:${specialization}`, ) .toBe(deepestRow); } }); it("spends exactly the points available at every level from 1 through 80", () => { for (const { classId, specialization } of PARTY_TALENT_SPECIALIZATIONS) { for (let level = 1; level <= 80; level += 1) { const ranks = partyTalentRanksForLevel(classId, specialization, level); const available = talentPointsForLevel(level); expect(spentTalentPoints(ranks, classId)).toBe(available); expect(normalizeTalentRanks(classId, level, ranks)).toEqual(ranks); expect(learnedPartyTalentSelections(classId, specialization, level)).toHaveLength(available); } } }); it("reports the exact next-level rank delta and future level for every plan", () => { for (const { classId, specialization } of PARTY_TALENT_SPECIALIZATIONS) { for (let level = 1; level < 80; level += 1) { const current = partyTalentRanksForLevel(classId, specialization, level); const next = partyTalentRanksForLevel(classId, specialization, level + 1); const pointDelta = talentPointsForLevel(level + 1) - talentPointsForLevel(level); const delta = changedRank(current, next); if (pointDelta === 0) { expect(delta).toBeNull(); continue; } const upcoming = upcomingPartyTalentSelections(classId, specialization, level, 1); expect(upcoming).toHaveLength(1); expect(upcoming[0].level).toBe(level + 1); expect(delta).toEqual([upcoming[0].nodeId, upcoming[0].rank]); } expect(upcomingPartyTalentSelections(classId, specialization, 1, 1)[0]?.level).toBe(10); expect(upcomingPartyTalentSelections(classId, specialization, 80)).toEqual([]); } }); });