Release Healer Man 0.1.46

This commit is contained in:
phenom committed 2026-09-16 14:30:58 -04:00
1 parent f82daf1c13
commit 9ff574ac0b
73 files changed
+33486 -174

No files matched your search

@@ -0,0 +1,69 @@
import { describe, expect, it } from "vitest";
import { createConfiguredPartyMember } from "../partyStore";
import { runtimeNormalizeTalentRanks } from "../talentRuntimeCatalog";
import {
loadSelectableBenchmarkBuilds,
selectableBenchmarkBuildCatalog,
} from "./benchmarkBuilds";
describe("balance-lab selectable builds", () => {
it("lists every authored WoW specialization instead of the representative cohort", () => {
const catalog = selectableBenchmarkBuildCatalog(60, [
"warrior", "paladin", "hunter", "rogue", "priest", "death-knight",
"shaman", "mage", "warlock", "druid",
]);
expect(catalog).toHaveLength(31);
expect(catalog).toEqual(expect.arrayContaining([
expect.objectContaining({ classId: "hunter", orientation: "Beast Mastery", role: "damage" }),
expect.objectContaining({ classId: "priest", orientation: "Discipline", role: "healer" }),
expect.objectContaining({ classId: "warlock", orientation: "Destruction", role: "damage" }),
expect.objectContaining({ classId: "druid", orientation: "Feral (Bear)", role: "tank" }),
]));
expect(new Set(catalog.map((build) => build.id)).size).toBe(catalog.length);
});
it("legally allocates a previously omitted specialization", async () => {
const builds = await loadSelectableBenchmarkBuilds(20, ["hunter"]);
const beastMastery = builds.find((build) => build.orientation === "Beast Mastery");
expect(beastMastery?.role).toBe("damage");
expect(Object.keys(beastMastery?.talentRanks ?? {})).not.toHaveLength(0);
});
it("keeps each CoA specialization tree selectable", async () => {
const builds = await loadSelectableBenchmarkBuilds(20, ["witch-doctor"]);
expect(builds.map((build) => `${build.orientation}:${build.role}`)).toEqual([
"Shadowhunting:damage",
"Voodoo:damage",
"Brewing:healer",
]);
expect(builds.every((build) => Object.keys(build.talentRanks).length > 0)).toBe(true);
});
it("constructs a legal build for every selectable WoW, CoA, and RoM entry", async () => {
const catalog = selectableBenchmarkBuildCatalog(20);
const builds = await loadSelectableBenchmarkBuilds(20);
expect(builds.map((build) => build.id)).toEqual(catalog.map((build) => build.id));
for (const build of builds) {
expect(runtimeNormalizeTalentRanks(build.classId, build.level, build.talentRanks))
.toEqual(build.talentRanks);
}
}, 60_000);
it("constructs an exact companion without replacing selected talents", () => {
const talents = Object.freeze({ "hunter-beast-mastery-0": 3 });
const member = createConfiguredPartyMember({
name: "Balance Hunter",
classId: "hunter",
specialization: "Beast Mastery",
specializationId: "hunter:beast-mastery",
talentTreeId: "hunter-beast-mastery",
talentRanks: talents,
role: "damage",
}, 20, 2, "balance-test");
expect(member.name).toBe("Balance Hunter");
expect(member.specialization).toBe("Beast Mastery");
expect(member.specializationId).toBe("hunter:beast-mastery");
expect(member.talentRanks).toBe(talents);
expect(member.id).toContain("party-damage-2-hunter");
});
});
+206
View File
@@ -3,6 +3,7 @@ import {
CLASSES,
COA_CLASS_IDS,
ROM_CLASS_IDS,
classById,
contentCategoryForClass,
type BaseClassId,
type ClassId,
@@ -20,6 +21,10 @@ import type { EquipmentAssignments } from "../equipment";
import { partyRolesForClass, type PartyRole } from "../partyRoles";
import { runtimePartyTalentRanksForLevel } from "../partyTalentRuntime";
import { clampPlayerLevel } from "../progression";
import {
specializationsForClass,
type SpecializationDefinition,
} from "../specializationCatalog";
import {
runtimeAllocateTalentPoint,
runtimeNormalizeTalentRanks,
@@ -153,6 +158,16 @@ function benchmarkBuildId(classId: ClassId, role: PartyRole, level: number): str
return `canonical-${contentCategoryForClass(classId)}-${classId}-${role}-level-${level}`;
}
function benchmarkVariantBuildId(
classId: ClassId,
role: PartyRole,
orientation: string,
level: number,
): string {
const variant = orientation.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
return `balance-${contentCategoryForClass(classId)}-${classId}-${role}-${variant}-level-${level}`;
}
function commonBuildFields(classId: ClassId, role: PartyRole, level: number) {
return {
id: benchmarkBuildId(classId, role, level),
@@ -365,6 +380,197 @@ function romBuild(
});
}
function specializationTree(
catalog: TalentUiCatalog,
specialization: SpecializationDefinition,
): TalentUiTree {
const normalized = (value: string) => value.toLowerCase().replace(/[^a-z0-9]+/g, "");
const names = new Set([
normalized(specialization.talentTree),
normalized(specialization.displayName),
]);
const tree = catalog.trees.find((candidate) => names.has(normalized(candidate.name)));
if (!tree) {
throw new Error(
`No talent tree matches ${classById(specialization.classId).name} ${specialization.displayName}.`,
);
}
return tree;
}
function allocateSingleTreeTalentRanks(
classId: BaseClassId,
catalog: TalentUiCatalog,
tree: TalentUiTree,
level: number,
): RuntimeTalentRanks {
const candidates = catalog.nodes
.filter((node) => node.treeId === tree.id)
.sort((left, right) => (
left.row - right.row
|| left.column - right.column
|| left.index - right.index
|| left.id.localeCompare(right.id)
));
const successfulRequests: Record<string, number> = {};
let ranks: RuntimeTalentRanks = EMPTY_TALENT_RANKS;
while (true) {
let allocated = false;
for (const node of candidates) {
if ((successfulRequests[node.id] ?? 0) >= node.maxRank) continue;
const result = runtimeAllocateTalentPoint(ranks, classId, node.id, level);
if (!result.ok) continue;
successfulRequests[node.id] = (successfulRequests[node.id] ?? 0) + 1;
ranks = result.ranks;
allocated = true;
break;
}
if (!allocated) break;
}
return Object.freeze({ ...runtimeNormalizeTalentRanks(classId, level, ranks) });
}
function selectableClassicBuild(
specialization: SpecializationDefinition,
level: number,
catalog: TalentUiCatalog | null,
): BenchmarkBuildDefinition {
const classId = specialization.classId as BaseClassId;
let talentRanks: RuntimeTalentRanks;
try {
talentRanks = runtimePartyTalentRanksForLevel(classId, specialization.displayName, level);
} catch {
if (!catalog) throw new Error(`No talent catalog was loaded for ${classId}.`);
talentRanks = allocateSingleTreeTalentRanks(
classId,
catalog,
specializationTree(catalog, specialization),
level,
);
}
return Object.freeze({
...commonBuildFields(classId, specialization.role, level),
id: benchmarkVariantBuildId(classId, specialization.role, specialization.displayName, level),
orientation: specialization.displayName,
talentRanks,
});
}
function selectableCoaBuild(
specialization: SpecializationDefinition,
level: number,
catalog: TalentUiCatalog,
): BenchmarkBuildDefinition {
const classId = specialization.classId as CoaClassId;
const tree = specializationTree(catalog, specialization);
const abilitiesByNode = coaNodeAbilities(classId);
return Object.freeze({
...commonBuildFields(classId, specialization.role, level),
id: benchmarkVariantBuildId(classId, specialization.role, specialization.displayName, level),
orientation: specialization.displayName,
talentRanks: allocateCoaTalentRanks(
classId,
catalog,
tree,
specialization.role,
level,
abilitiesByNode,
),
});
}
export interface SelectableBenchmarkBuildCatalogEntry {
readonly id: string;
readonly source: ReturnType<typeof contentCategoryForClass>;
readonly classId: ClassId;
readonly role: PartyRole;
readonly orientation: string;
readonly level: number;
}
/** Cheap metadata-only roster used by interactive tools before a build is selected. */
export function selectableBenchmarkBuildCatalog(
level = CANONICAL_BENCHMARK_LEVEL,
classIds?: readonly ClassId[],
): readonly SelectableBenchmarkBuildCatalogEntry[] {
const safeLevel = clampPlayerLevel(level);
const requested = classIds ? new Set(classIds) : null;
return CLASSES
.map((definition) => definition.id)
.filter((classId) => !requested || requested.has(classId))
.flatMap((classId): readonly SelectableBenchmarkBuildCatalogEntry[] => {
if (isRomClassId(classId)) return partyRolesForClass(classId).map((role) => ({
id: benchmarkBuildId(classId, role, safeLevel),
source: contentCategoryForClass(classId),
classId,
role,
orientation: "Primary",
level: safeLevel,
}));
return specializationsForClass(classId).map((specialization) => ({
id: benchmarkVariantBuildId(classId, specialization.role, specialization.displayName, safeLevel),
source: contentCategoryForClass(classId),
classId,
role: specialization.role,
orientation: specialization.displayName,
level: safeLevel,
}));
});
}
/**
* Builds every user-selectable class/spec/role variant for the balance lab.
* This is intentionally broader than the canonical benchmark cohort above,
* which keeps one representative orientation per class-role pair.
*/
export async function loadSelectableBenchmarkBuilds(
level = CANONICAL_BENCHMARK_LEVEL,
classIds?: readonly ClassId[],
): Promise<BenchmarkBuildDefinition[]> {
const safeLevel = clampPlayerLevel(level);
const requested = classIds ? new Set(classIds) : null;
const selectedClassIds = CLASSES
.map((definition) => definition.id)
.filter((classId) => !requested || requested.has(classId));
const catalogClassIds = selectedClassIds.filter((classId) => (
isCoaClassId(classId)
|| (isBaseClassId(classId) && specializationsForClass(classId).some((specialization) => {
try {
runtimePartyTalentRanksForLevel(classId, specialization.displayName, safeLevel);
return false;
} catch {
return true;
}
}))
));
await preloadAbilityCatalogsForClasses(selectedClassIds);
const catalogs = new Map<ClassId, TalentUiCatalog>(await Promise.all(
catalogClassIds.map(async (classId) => [
classId,
await loadTalentUiCatalog(classId),
] as const),
));
const builds: BenchmarkBuildDefinition[] = [];
for (const classId of selectedClassIds) {
if (isRomClassId(classId)) {
for (const role of partyRolesForClass(classId)) builds.push(romBuild(classId, role, safeLevel));
continue;
}
for (const specialization of specializationsForClass(classId)) {
if (isBaseClassId(classId)) {
builds.push(selectableClassicBuild(specialization, safeLevel, catalogs.get(classId) ?? null));
} else if (isCoaClassId(classId)) {
const catalog = catalogs.get(classId);
if (!catalog) throw new Error(`No loaded CoA talent catalog exists for ${classId}.`);
builds.push(selectableCoaBuild(specialization, safeLevel, catalog));
}
}
}
return builds;
}
/**
* Creates one stable, legal benchmark build for every declared class-role pair.
* A filter changes only membership; output always follows the canonical CLASSES