128 lines
5.4 KiB
TypeScript
128 lines
5.4 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,
|
|
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", "Marksmanship", "hunter-marksmanship"],
|
|
["hunter", "Survival", "hunter-survival"],
|
|
["rogue", "Combat", "rogue-combat"],
|
|
["rogue", "Assassination", "rogue-assassination"],
|
|
["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"],
|
|
["druid", "Balance", "druid-balance"],
|
|
["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);
|
|
}
|
|
}
|
|
});
|
|
|
|
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([]);
|
|
}
|
|
});
|
|
});
|