Update Healer Man server
This commit is contained in:
1 parent
f8314e7de1
commit
1fedb9368d
76 files changed
+20556
-2523
No files matched your search
@@ -1,5 +1,8 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { abilitiesForClass } from "../abilityCatalog";
|
||||
import { rotationProfileFor } from "../combatRotation";
|
||||
import { createConfiguredPartyMember } from "../partyStore";
|
||||
import { specializationsForClass } from "../specializationCatalog";
|
||||
import { runtimeNormalizeTalentRanks } from "../talentRuntimeCatalog";
|
||||
import {
|
||||
loadSelectableBenchmarkBuilds,
|
||||
@@ -12,7 +15,15 @@ describe("balance-lab selectable builds", () => {
|
||||
"warrior", "paladin", "hunter", "rogue", "priest", "death-knight",
|
||||
"shaman", "mage", "warlock", "druid",
|
||||
]);
|
||||
expect(catalog).toHaveLength(31);
|
||||
const specializations = new Map<string, typeof catalog>();
|
||||
for (const build of catalog) {
|
||||
const key = `${build.classId}:${build.orientation}:${build.role}`;
|
||||
specializations.set(key, [...(specializations.get(key) ?? []), build]);
|
||||
}
|
||||
expect(specializations.size).toBe(31);
|
||||
expect([...specializations.values()].every((profiles) => profiles.length >= 2)).toBe(true);
|
||||
expect(catalog.filter((build) => build.defaultTalentBuild)).toHaveLength(31);
|
||||
expect(catalog.every((build) => build.talentBuildId && build.talentBuildName && build.characterName)).toBe(true);
|
||||
expect(catalog).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ classId: "hunter", orientation: "Beast Mastery", role: "damage" }),
|
||||
expect.objectContaining({ classId: "priest", orientation: "Discipline", role: "healer" }),
|
||||
@@ -29,6 +40,27 @@ describe("balance-lab selectable builds", () => {
|
||||
expect(Object.keys(beastMastery?.talentRanks ?? {})).not.toHaveLength(0);
|
||||
});
|
||||
|
||||
it("gives every selectable WoW specialization an executable priority rotation", () => {
|
||||
const catalog = selectableBenchmarkBuildCatalog(80, [
|
||||
"warrior", "paladin", "hunter", "rogue", "priest", "death-knight",
|
||||
"shaman", "mage", "warlock", "druid",
|
||||
]);
|
||||
const checked = new Set<string>();
|
||||
for (const build of catalog) {
|
||||
const key = `${build.classId}:${build.orientation}`;
|
||||
if (checked.has(key)) continue;
|
||||
checked.add(key);
|
||||
const specialization = specializationsForClass(build.classId)
|
||||
.find((candidate) => candidate.displayName === build.orientation);
|
||||
const profile = rotationProfileFor(build.classId, specialization?.id, build.orientation);
|
||||
expect(profile, key).not.toBeNull();
|
||||
expect(profile!.abilityPriority.length, key).toBeGreaterThan(0);
|
||||
const abilityNames = new Set(abilitiesForClass(build.classId).map((ability) => ability.name));
|
||||
expect(profile!.abilityPriority.some((name) => abilityNames.has(name)), key).toBe(true);
|
||||
}
|
||||
expect(checked.size).toBe(31);
|
||||
});
|
||||
|
||||
it("keeps each CoA specialization tree selectable", async () => {
|
||||
const builds = await loadSelectableBenchmarkBuilds(20, ["witch-doctor"]);
|
||||
expect(builds.map((build) => `${build.orientation}:${build.role}`)).toEqual([
|
||||
|
||||
@@ -2,7 +2,11 @@ import { describe, expect, it } from "vitest";
|
||||
import { CLASSES } from "../../app/characterCatalog";
|
||||
import { partyRolesForClass } from "../partyRoles";
|
||||
import { runtimeNormalizeTalentRanks } from "../talentRuntimeCatalog";
|
||||
import { loadCanonicalBenchmarkBuilds } from "./benchmarkBuilds";
|
||||
import {
|
||||
loadCanonicalBenchmarkBuilds,
|
||||
loadSelectableBenchmarkBuilds,
|
||||
selectableBenchmarkBuildCatalog,
|
||||
} from "./benchmarkBuilds";
|
||||
|
||||
describe("canonical benchmark builds", () => {
|
||||
it("covers every declared class-role pair exactly once with legal ranks", async () => {
|
||||
@@ -17,7 +21,7 @@ describe("canonical benchmark builds", () => {
|
||||
expect(new Set(actualPairs)).toEqual(new Set(expectedPairs));
|
||||
for (const build of builds) {
|
||||
expect(build.level).toBe(60);
|
||||
expect(build.gearPresetId).toBe("coefficient-empty");
|
||||
expect(build.gearPresetId).toBe("virtual-heirloom");
|
||||
expect(build.inventory).toEqual([]);
|
||||
expect(build.equipment).toEqual({});
|
||||
expect(runtimeNormalizeTalentRanks(build.classId, build.level, build.talentRanks)).toEqual(build.talentRanks);
|
||||
@@ -33,4 +37,19 @@ describe("canonical benchmark builds", () => {
|
||||
"rom-priest:damage",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps both named classic talent builds and marks Playerbots as the default", async () => {
|
||||
const catalog = selectableBenchmarkBuildCatalog(60, ["mage"]);
|
||||
const builds = await loadSelectableBenchmarkBuilds(60, ["mage"]);
|
||||
expect(catalog).toHaveLength(6);
|
||||
expect(builds).toHaveLength(6);
|
||||
expect(new Set(builds.map((build) => build.characterName)).size).toBe(6);
|
||||
for (const specialization of ["Arcane", "Fire", "Frost"]) {
|
||||
const variants = builds.filter((build) => build.orientation === specialization);
|
||||
expect(variants).toHaveLength(2);
|
||||
expect(variants.filter((build) => build.defaultTalentBuild)).toHaveLength(1);
|
||||
expect(variants.find((build) => build.defaultTalentBuild)?.talentBuildSource)
|
||||
.toBe("azerothcore-playerbots@531282e");
|
||||
}
|
||||
}, 30_000);
|
||||
});
|
||||
@@ -20,7 +20,13 @@ import {
|
||||
import { aiHintsForAbility } from "../abilityRules";
|
||||
import type { EquipmentAssignments } from "../equipment";
|
||||
import { partyRolesForClass, type PartyRole } from "../partyRoles";
|
||||
import { runtimePartyTalentRanksForLevel } from "../partyTalentRuntime";
|
||||
import {
|
||||
runtimeDefaultPartyTalentProfile,
|
||||
runtimePartyTalentProvenanceForLevel,
|
||||
runtimePartyTalentProfiles,
|
||||
runtimePartyTalentRanksForLevel,
|
||||
type PartyTalentRuntimeProfile,
|
||||
} from "../partyTalentRuntime";
|
||||
import { clampPlayerLevel } from "../progression";
|
||||
import {
|
||||
specializationsForClass,
|
||||
@@ -38,9 +44,13 @@ import {
|
||||
type TalentUiTree,
|
||||
} from "../talentUiCatalog";
|
||||
import type { BenchmarkBuildDefinition } from "./benchmarkTypes";
|
||||
import {
|
||||
readinessStatusFor,
|
||||
type CompanionSpecReadinessStatus,
|
||||
} from "../companionSpecReadiness";
|
||||
|
||||
export const CANONICAL_BENCHMARK_LEVEL = 60;
|
||||
export const CANONICAL_BENCHMARK_GEAR_PRESET_ID = "coefficient-empty";
|
||||
export const CANONICAL_BENCHMARK_GEAR_PRESET_ID = "virtual-heirloom";
|
||||
|
||||
const EMPTY_INVENTORY: readonly never[] = Object.freeze([]);
|
||||
const EMPTY_EQUIPMENT: EquipmentAssignments = Object.freeze({});
|
||||
@@ -164,9 +174,13 @@ function benchmarkVariantBuildId(
|
||||
role: PartyRole,
|
||||
orientation: string,
|
||||
level: number,
|
||||
talentProfile?: PartyTalentRuntimeProfile,
|
||||
): string {
|
||||
const variant = orientation.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
||||
return `balance-${contentCategoryForClass(classId)}-${classId}-${role}-${variant}-level-${level}`;
|
||||
const base = `balance-${contentCategoryForClass(classId)}-${classId}-${role}-${variant}-level-${level}`;
|
||||
if (!talentProfile || talentProfile.defaultForSpecialization) return base;
|
||||
const profile = talentProfile.id.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
||||
return `${base}--${profile}`;
|
||||
}
|
||||
|
||||
function commonBuildFields(classId: ClassId, role: PartyRole, level: number) {
|
||||
@@ -192,10 +206,20 @@ function wowBuild(
|
||||
if (!orientation) {
|
||||
throw new Error(`No canonical WoW talent orientation exists for ${classId}:${role}.`);
|
||||
}
|
||||
const talentProfile = runtimeDefaultPartyTalentProfile(classId, orientation);
|
||||
const talentProvenance = runtimePartyTalentProvenanceForLevel(talentProfile, level);
|
||||
return Object.freeze({
|
||||
...commonBuildFields(classId, role, level),
|
||||
orientation,
|
||||
talentRanks: runtimePartyTalentRanksForLevel(classId, orientation, level),
|
||||
characterName: talentProfile.characterName,
|
||||
talentBuildId: talentProfile.id,
|
||||
talentBuildName: talentProfile.name,
|
||||
talentBuildSource: `${talentProfile.source}@${talentProfile.sourceVersion}`,
|
||||
talentBuildSourcePointCount: talentProvenance.sourcePointCount,
|
||||
talentBuildSourcePointsRetained: talentProvenance.sourcePointsRetained,
|
||||
talentBuildFallbackPointCount: talentProvenance.fallbackPointCount,
|
||||
defaultTalentBuild: talentProfile.defaultForSpecialization,
|
||||
talentRanks: runtimePartyTalentRanksForLevel(classId, orientation, level, talentProfile.id),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -435,11 +459,18 @@ function selectableClassicBuild(
|
||||
specialization: SpecializationDefinition,
|
||||
level: number,
|
||||
catalog: TalentUiCatalog | null,
|
||||
talentProfile: PartyTalentRuntimeProfile,
|
||||
): BenchmarkBuildDefinition {
|
||||
const classId = specialization.classId as BaseClassId;
|
||||
const talentProvenance = runtimePartyTalentProvenanceForLevel(talentProfile, level);
|
||||
let talentRanks: RuntimeTalentRanks;
|
||||
try {
|
||||
talentRanks = runtimePartyTalentRanksForLevel(classId, specialization.displayName, level);
|
||||
talentRanks = runtimePartyTalentRanksForLevel(
|
||||
classId,
|
||||
specialization.displayName,
|
||||
level,
|
||||
talentProfile.id,
|
||||
);
|
||||
} catch {
|
||||
if (!catalog) throw new Error(`No talent catalog was loaded for ${classId}.`);
|
||||
talentRanks = allocateSingleTreeTalentRanks(
|
||||
@@ -451,8 +482,22 @@ function selectableClassicBuild(
|
||||
}
|
||||
return Object.freeze({
|
||||
...commonBuildFields(classId, specialization.role, level),
|
||||
id: benchmarkVariantBuildId(classId, specialization.role, specialization.displayName, level),
|
||||
id: benchmarkVariantBuildId(
|
||||
classId,
|
||||
specialization.role,
|
||||
specialization.displayName,
|
||||
level,
|
||||
talentProfile,
|
||||
),
|
||||
orientation: specialization.displayName,
|
||||
characterName: talentProfile.characterName,
|
||||
talentBuildId: talentProfile.id,
|
||||
talentBuildName: talentProfile.name,
|
||||
talentBuildSource: `${talentProfile.source}@${talentProfile.sourceVersion}`,
|
||||
talentBuildSourcePointCount: talentProvenance.sourcePointCount,
|
||||
talentBuildSourcePointsRetained: talentProvenance.sourcePointsRetained,
|
||||
talentBuildFallbackPointCount: talentProvenance.fallbackPointCount,
|
||||
defaultTalentBuild: talentProfile.defaultForSpecialization,
|
||||
talentRanks,
|
||||
});
|
||||
}
|
||||
@@ -486,7 +531,17 @@ export interface SelectableBenchmarkBuildCatalogEntry {
|
||||
readonly classId: ClassId;
|
||||
readonly role: PartyRole;
|
||||
readonly orientation: string;
|
||||
readonly specializationId?: string;
|
||||
readonly readinessStatus?: CompanionSpecReadinessStatus;
|
||||
readonly level: number;
|
||||
readonly characterName?: string;
|
||||
readonly talentBuildId?: string;
|
||||
readonly talentBuildName?: string;
|
||||
readonly talentBuildSource?: string;
|
||||
readonly talentBuildSourcePointCount?: number | null;
|
||||
readonly talentBuildSourcePointsRetained?: number | null;
|
||||
readonly talentBuildFallbackPointCount?: number;
|
||||
readonly defaultTalentBuild?: boolean;
|
||||
}
|
||||
|
||||
/** Cheap metadata-only roster used by interactive tools before a build is selected. */
|
||||
@@ -508,14 +563,42 @@ export function selectableBenchmarkBuildCatalog(
|
||||
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,
|
||||
}));
|
||||
return specializationsForClass(classId).flatMap((specialization) => {
|
||||
const profiles = isBaseClassId(classId)
|
||||
? runtimePartyTalentProfiles(classId, specialization.displayName)
|
||||
: [undefined];
|
||||
return profiles.map((talentProfile) => {
|
||||
const talentProvenance = talentProfile
|
||||
? runtimePartyTalentProvenanceForLevel(talentProfile, safeLevel)
|
||||
: null;
|
||||
return {
|
||||
id: benchmarkVariantBuildId(
|
||||
classId,
|
||||
specialization.role,
|
||||
specialization.displayName,
|
||||
safeLevel,
|
||||
talentProfile,
|
||||
),
|
||||
source: contentCategoryForClass(classId),
|
||||
classId,
|
||||
role: specialization.role,
|
||||
orientation: specialization.displayName,
|
||||
specializationId: isBaseClassId(classId) ? specialization.id : undefined,
|
||||
readinessStatus: isBaseClassId(classId) ? readinessStatusFor(specialization.id) : undefined,
|
||||
level: safeLevel,
|
||||
characterName: talentProfile?.characterName,
|
||||
talentBuildId: talentProfile?.id,
|
||||
talentBuildName: talentProfile?.name,
|
||||
talentBuildSource: talentProfile
|
||||
? `${talentProfile.source}@${talentProfile.sourceVersion}`
|
||||
: undefined,
|
||||
talentBuildSourcePointCount: talentProvenance?.sourcePointCount,
|
||||
talentBuildSourcePointsRetained: talentProvenance?.sourcePointsRetained,
|
||||
talentBuildFallbackPointCount: talentProvenance?.fallbackPointCount,
|
||||
defaultTalentBuild: talentProfile?.defaultForSpecialization,
|
||||
};
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -561,7 +644,14 @@ export async function loadSelectableBenchmarkBuilds(
|
||||
}
|
||||
for (const specialization of specializationsForClass(classId)) {
|
||||
if (isBaseClassId(classId)) {
|
||||
builds.push(selectableClassicBuild(specialization, safeLevel, catalogs.get(classId) ?? null));
|
||||
for (const talentProfile of runtimePartyTalentProfiles(classId, specialization.displayName)) {
|
||||
builds.push(selectableClassicBuild(
|
||||
specialization,
|
||||
safeLevel,
|
||||
catalogs.get(classId) ?? null,
|
||||
talentProfile,
|
||||
));
|
||||
}
|
||||
} else if (isCoaClassId(classId)) {
|
||||
const catalog = catalogs.get(classId);
|
||||
if (!catalog) throw new Error(`No loaded CoA talent catalog exists for ${classId}.`);
|
||||
|
||||
@@ -231,6 +231,7 @@ export class BenchmarkController {
|
||||
health: member.health,
|
||||
maxHealth: member.maxHealth,
|
||||
fallen: member.health <= 0,
|
||||
role: member.role,
|
||||
})),
|
||||
],
|
||||
hostileCount: scenario.hostileTargets,
|
||||
|
||||
@@ -51,6 +51,14 @@ export interface BenchmarkBuildDefinition {
|
||||
readonly role: PartyRole;
|
||||
readonly level: number;
|
||||
readonly orientation: string;
|
||||
readonly characterName?: string;
|
||||
readonly talentBuildId?: string;
|
||||
readonly talentBuildName?: string;
|
||||
readonly talentBuildSource?: string;
|
||||
readonly talentBuildSourcePointCount?: number | null;
|
||||
readonly talentBuildSourcePointsRetained?: number | null;
|
||||
readonly talentBuildFallbackPointCount?: number;
|
||||
readonly defaultTalentBuild?: boolean;
|
||||
readonly talentRanks: Readonly<Record<string, number>>;
|
||||
readonly inventory: readonly never[];
|
||||
readonly equipment: EquipmentAssignments;
|
||||
|
||||
Reference in new issue
Block a user