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

148 lines
6.3 KiB
TypeScript

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<Record<string, number>>,
after: Readonly<Record<string, number>>,
): 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([]);
}
});
});