Update Healer Man server

This commit is contained in:
phenom committed 2026-10-04 13:13:35 -04:00
1 parent 39458d5de6
commit 6cc1b9217f
203 files changed
+133929 -31271

No files matched your search

+45 -6
View File
@@ -15,7 +15,13 @@ import type { PartyState } from "../partyStore";
import { usePartyStore } from "../partyStore";
import type { PartyRole } from "../partyRoles";
import type { BenchmarkScenarioDefinition } from "./benchmarkTypes";
import { COMBAT_ROTATION_VERSION, evaluateCombatRotation } from "../combatRotation";
import { nativeRotationAbilityAllowed, nativeRotationProfileFor } from "../romFiveRotation";
import {
COMBAT_ROTATION_VERSION,
evaluateCombatRotation,
nativeEarlyPriestHealReserveBlocksAbility,
type RotationContext,
} from "../combatRotation";
export const BENCHMARK_POLICY_ID = `rotation-v4:${COMBAT_ROTATION_VERSION}`;
@@ -193,19 +199,37 @@ export class BenchmarkController {
) return { made: false, cast: false, resourceBlocked: false, invalidFailures: 0 };
const targets = friendlyTargets(combat, party);
const availableAbilities = combat.abilities
const learnedAbilities = combat.abilities
.map((ability) => learnedAbility(ability, combat))
.filter((ability): ability is AbilityDefinition => ability !== null)
.filter((ability) => !moving || ability.castTimeMs <= 0 || canAbilityCastWhileMoving(ability));
.filter((ability): ability is AbilityDefinition => ability !== null);
const availableAbilities = learnedAbilities
.filter((ability) => !moving || ability.castTimeMs <= 0 || canAbilityCastWhileMoving(ability))
.filter((ability) => combat.statRuleset !== "rom" || (
(combat.cooldowns[ability.id] ?? 0) <= now
&& (ability.target !== "hostile" || (() => {
const position = combat.selectedTargetId ? getMobPosition(combat.selectedTargetId) : null;
if (!position) return false;
const distance = Math.hypot(...position.map((value, index) => value - combat.playerPosition[index]));
return distance >= ability.range.min && distance <= ability.range.max;
})())
));
const candidates = availableAbilities
.map((ability) => scoreAbility(ability, role, scenario, combat, targets))
.filter((candidate): candidate is ScoredAbility => candidate !== null)
.sort((left, right) => right.score - left.score || left.ability.id.localeCompare(right.ability.id));
const hostileTarget = combat.selectedTargetId ? combat.mobs[combat.selectedTargetId] : null;
const sharedDecision = evaluateCombatRotation({
const rotationContext: RotationContext = {
now,
canUseAbility: combat.statRuleset === "rom" ? (ability) =>
resolvedAbilityCosts(ability, combat.talentModifiers, combat).every((cost) =>
cost.amount <= (cost.resource === "health" ? combat.health : combat.resourcePools[cost.resource] ?? 0))
: undefined,
manaCostForAbility: (ability) => resolvedAbilityCosts(ability, combat.talentModifiers, combat)
.filter((cost) => cost.resource === "mana")
.reduce((total, cost) => total + cost.amount, 0),
classId: combat.classId,
secondaryClassId: combat.secondaryClassId,
statRuleset: combat.statRuleset,
specializationId: combat.specializationId,
role,
level: combat.level,
@@ -219,6 +243,7 @@ export class BenchmarkController {
comboTargetId: combat.comboTargetId,
runes: combat.deathKnightRunes,
abilities: availableAbilities,
learnedAbilities,
knownDamagingAbilityNames: new Set(availableAbilities.filter((ability) => {
const categories = aiHintsForAbility(ability).categories;
return categories.includes("damage") || categories.includes("execute");
@@ -248,6 +273,12 @@ export class BenchmarkController {
&& effect.aura.id === aura.definition.id))
.map((ability) => ability.id)),
]),
maintainedPeriodicEffects: combat.effects.map((effect) => ({
abilityId: effect.sourceAbilityId,
sourceActorId: effect.sourceActorId,
targetId: effect.targetId ?? PLAYER_AGGRO_ID,
remainingMs: Math.max(0, effect.nextTickAt + (effect.remainingTicks - 1) * effect.intervalMs - now),
})),
activeAuraExclusiveGroups: new Set(combat.auras
.filter((aura) => aura.sourceId === PLAYER_AGGRO_ID && (aura.expiresAt === null || aura.expiresAt > now))
.flatMap((aura) => aura.definition.exclusiveGroup ? [aura.definition.exclusiveGroup] : [])),
@@ -260,7 +291,15 @@ export class BenchmarkController {
return source?.effects.some((effect) => effect.kind === "pet"
&& ["summon", "call", "resurrect", "tame"].includes(effect.action)) ?? false;
}),
});
};
const sharedDecision = evaluateCombatRotation(rotationContext);
for (let index = candidates.length - 1; index >= 0; index -= 1) {
const ability = candidates[index].ability;
if (nativeEarlyPriestHealReserveBlocksAbility(ability, rotationContext)
|| (nativeRotationProfileFor(rotationContext) && !nativeRotationAbilityAllowed(ability, rotationContext))) {
candidates.splice(index, 1);
}
}
if (sharedDecision) {
const friendlyTarget = sharedDecision.targetId
? targets.find((target) => target.id === sharedDecision.targetId) ?? null
+122
View File
@@ -0,0 +1,122 @@
import { describe, expect, it } from "vitest";
import {
COA_FIRST_PARTY_MEMBERS,
coaFirstPartyBuildCatalog,
coaFirstPartySimulationConfig,
createCoaFirstPartyCompanions,
loadCoaFirstPartyBuilds,
} from "./coaFirstParty";
import { READY_COMPANION_SPECIALIZATION_IDS, readinessStatusFor } from "../companionSpecReadiness";
import { runtimeNormalizeTalentRanks } from "../talentRuntimeCatalog";
describe("CoA first-party diagnostic composition", () => {
it("defines the selected five as one tank, one healer, and three DPS seats", () => {
expect(COA_FIRST_PARTY_MEMBERS.map(({ classId, specializationId, specialization, role }) => (
[classId, specializationId, specialization, role]
))).toEqual([
["guardian", "guardian:vanguard", "Vanguard", "tank"],
["witch-doctor", "witch-doctor:brewing", "Brewing", "healer"],
["barbarian", "barbarian:brutality", "Brutality", "melee"],
["ranger", "ranger:archery", "Archery", "ranged"],
["pyromancer", "pyromancer:incineration", "Incineration", "caster"],
]);
expect(COA_FIRST_PARTY_MEMBERS.filter((member) => member.partyRole === "tank")).toHaveLength(1);
expect(COA_FIRST_PARTY_MEMBERS.filter((member) => member.partyRole === "healer")).toHaveLength(1);
expect(COA_FIRST_PARTY_MEMBERS.filter((member) => member.partyRole === "damage")).toHaveLength(3);
});
it("resolves only the five explicit build identities and preserves the Ready baseline", () => {
const catalog = coaFirstPartyBuildCatalog(60);
expect(catalog.map((build) => [build.classId, build.specializationId, build.orientation, build.role, build.source]))
.toEqual(COA_FIRST_PARTY_MEMBERS.map((member) => [
member.classId,
member.specializationId,
member.specialization,
member.partyRole,
"coa",
]));
expect(new Set(catalog.map((build) => build.id)).size).toBe(5);
expect(READY_COMPANION_SPECIALIZATION_IDS).toEqual([
"warrior:protection",
"priest:discipline",
"hunter:marksmanship",
"mage:fire",
"rogue:assassination",
]);
expect(COA_FIRST_PARTY_MEMBERS.map((member) => readinessStatusFor(member.specializationId)))
.toEqual(["needs-testing", "needs-testing", "needs-testing", "needs-testing", "needs-testing"]);
});
it("loads legal talent allocations for the exact five", async () => {
const builds = await loadCoaFirstPartyBuilds(20);
expect(builds.map((build) => [build.classId, build.orientation, build.role, build.source]))
.toEqual(COA_FIRST_PARTY_MEMBERS.map((member) => [
member.classId,
member.specialization,
member.partyRole,
"coa",
]));
for (const build of builds) {
expect(runtimeNormalizeTalentRanks(build.classId, build.level, build.talentRanks))
.toEqual(build.talentRanks);
}
}, 60_000);
it("keeps the same four configured companion builds for every active player seat", async () => {
const builds = await loadCoaFirstPartyBuilds(20);
for (let activeSlot = 0; activeSlot < 5; activeSlot += 1) {
const companions = createCoaFirstPartyCompanions(builds, activeSlot, "coa-first-party-test");
expect(companions).toHaveLength(4);
for (const [slot, build] of builds.entries()) {
if (slot === activeSlot) continue;
const companion = companions.find((candidate) => candidate.id.includes(`-${slot}-`));
expect(companion).toBeDefined();
expect(companion).toMatchObject({
classId: build.classId,
specialization: build.orientation,
specializationId: COA_FIRST_PARTY_MEMBERS[slot].specializationId,
role: build.role,
talentRanks: build.talentRanks,
});
}
}
}, 60_000);
it("rejects supplied builds with non-normalized talent rank entries", async () => {
const builds = await loadCoaFirstPartyBuilds(20);
for (const rank of [-1, 0, Number.NaN, Number.POSITIVE_INFINITY, 0.5]) {
const invalid = builds.map((build, slot) => slot === 0 ? {
...build,
talentRanks: { ...build.talentRanks, "invalid-node": rank },
} : build);
expect(() => createCoaFirstPartyCompanions(invalid, 1, "invalid-talents"))
.toThrow(/illegal or non-normalized talent ranks/);
}
}, 60_000);
it("builds a deterministic Balance Lab request with explicit bosses and a zero-based seat", () => {
const config = coaFirstPartySimulationConfig(60, ["deadmines-van-cleef", "shadowfang-archmage-arugal"]);
const catalog = coaFirstPartyBuildCatalog(60);
expect(config).toMatchObject({
schemaVersion: 1,
source: "coa",
level: 60,
durationSeconds: 30,
seeds: [1],
partyBuildIds: catalog.map((build) => build.id),
activeSlot: 1,
bossCategory: "wow",
bossKeys: ["deadmines-van-cleef", "shadowfang-archmage-arugal"],
});
expect(coaFirstPartySimulationConfig(60, ["boss"], 4).activeSlot).toBe(4);
});
it("rejects missing bosses, invalid levels, duplicate bosses, and invalid active seats", () => {
expect(() => coaFirstPartySimulationConfig(60, [])).toThrow(/explicit boss key/);
expect(() => coaFirstPartySimulationConfig(0, ["boss"])).toThrow(/level must be an integer/);
expect(() => coaFirstPartySimulationConfig(81, ["boss"])).toThrow(/level must be an integer/);
expect(() => coaFirstPartySimulationConfig(60, ["boss", "boss"])).toThrow(/must be unique/);
expect(() => coaFirstPartySimulationConfig(60, ["boss"], -1)).toThrow(/activeSlot/);
expect(() => coaFirstPartySimulationConfig(60, ["boss"], 5)).toThrow(/activeSlot/);
});
});
+198
View File
@@ -0,0 +1,198 @@
import { classById, type CoaClassId } from "../../app/characterCatalog";
import {
COA_FIRST_PARTY_CLASS_IDS,
COA_FIRST_PARTY_MEMBERS,
type CoaFirstPartyMemberDefinition,
} from "../coaFirstPartyRoster";
import { createConfiguredPartyMember, type PartyMember } from "../partyStore";
import { runtimeNormalizeTalentRanks } from "../talentRuntimeCatalog";
import {
loadSelectableBenchmarkBuilds,
selectableBenchmarkBuildCatalog,
type SelectableBenchmarkBuildCatalogEntry,
} from "./benchmarkBuilds";
import type { BenchmarkBuildDefinition } from "./benchmarkTypes";
export { COA_FIRST_PARTY_MEMBERS } from "../coaFirstPartyRoster";
function checkedLevel(level: number): number {
if (!Number.isInteger(level) || level < 1 || level > 80) {
throw new RangeError("CoA first-party level must be an integer from 1 through 80.");
}
return level;
}
function checkedActiveSlot(activeSlot: number): number {
if (!Number.isInteger(activeSlot) || activeSlot < 0 || activeSlot >= COA_FIRST_PARTY_MEMBERS.length) {
throw new RangeError("CoA first-party activeSlot must be an integer from 0 through 4.");
}
return activeSlot;
}
function sameTalentRanks(
left: Readonly<Record<string, number>>,
right: Readonly<Record<string, number>>,
): boolean {
const normalize = (ranks: Readonly<Record<string, number>>) => Object.entries(ranks)
.sort(([leftId], [rightId]) => leftId.localeCompare(rightId));
return JSON.stringify(normalize(left)) === JSON.stringify(normalize(right));
}
function expectedMemberForBuild(
build: Pick<BenchmarkBuildDefinition, "classId" | "orientation" | "role" | "source" | "level" | "id" | "talentRanks">,
index: number,
): CoaFirstPartyMemberDefinition {
const expected = COA_FIRST_PARTY_MEMBERS[index];
if (!expected) throw new Error(`Unexpected CoA first-party slot ${index + 1}.`);
if (build.source !== "coa"
|| build.classId !== expected.classId
|| build.orientation !== expected.specialization
|| build.role !== expected.partyRole) {
throw new Error(
`CoA first-party slot ${index + 1} must be ${expected.classId}:${expected.specializationId} (${expected.partyRole}).`,
);
}
if (!build.id) throw new Error(`CoA first-party slot ${index + 1} has no build ID.`);
return expected;
}
function validateBuilds(
builds: readonly BenchmarkBuildDefinition[],
): number {
if (builds.length !== COA_FIRST_PARTY_MEMBERS.length) {
throw new Error("A CoA first-party diagnostic requires exactly five builds in tank/healer/melee/ranged/caster order.");
}
const ids = new Set<string>();
let level: number | null = null;
let canonicalIds: ReadonlyMap<string, string> | null = null;
for (const [index, build] of builds.entries()) {
expectedMemberForBuild(build, index);
if (ids.has(build.id)) throw new Error(`Duplicate CoA first-party build ID: ${build.id}.`);
ids.add(build.id);
if (level === null) level = checkedLevel(build.level);
else if (build.level !== level) throw new Error("CoA first-party builds must use one shared level.");
}
canonicalIds = new Map(coaFirstPartyBuildCatalog(level!).map((entry) => [entry.specializationId ?? "", entry.id]));
for (const build of builds) {
const specialization = COA_FIRST_PARTY_MEMBERS.find((member) => (
member.classId === build.classId && member.specialization === build.orientation
));
if (!specialization || canonicalIds.get(specialization.specializationId) !== build.id) {
throw new Error(`CoA first-party build ${build.id} does not match the canonical selector for its class and specialization.`);
}
const normalized = selectableTalentRanks(specialization.classId, build.level, build.talentRanks);
if (!sameTalentRanks(build.talentRanks, normalized)) {
throw new Error(`CoA first-party build ${build.id} has illegal or non-normalized talent ranks.`);
}
}
return level!;
}
function selectableTalentRanks(
classId: CoaClassId,
level: number,
ranks: Readonly<Record<string, number>>,
): Readonly<Record<string, number>> {
// Keep the legality check in the same runtime allocator used by the build loader.
return runtimeNormalizeTalentRanks(classId, level, ranks);
}
function exactCatalogMember(
catalog: readonly SelectableBenchmarkBuildCatalogEntry[],
expected: CoaFirstPartyMemberDefinition,
): SelectableBenchmarkBuildCatalogEntry {
const matches = catalog.filter((build) => (
build.source === "coa"
&& build.classId === expected.classId
&& build.specializationId === expected.specializationId
&& build.orientation === expected.specialization
&& build.role === expected.partyRole
));
if (matches.length !== 1) {
throw new Error(
`Expected exactly one selectable CoA build for ${expected.classId}:${expected.specializationId}; found ${matches.length}.`,
);
}
return matches[0];
}
/** Resolves the five exact selectors without loading catalogs or substituting builds. */
export function coaFirstPartyBuildCatalog(level = 60): readonly SelectableBenchmarkBuildCatalogEntry[] {
const safeLevel = checkedLevel(level);
const catalog = selectableBenchmarkBuildCatalog(safeLevel, COA_FIRST_PARTY_CLASS_IDS);
return Object.freeze(COA_FIRST_PARTY_MEMBERS.map((member) => exactCatalogMember(catalog, member)));
}
/** Loads legal talent allocations for the five exact classes and specifications. */
export async function loadCoaFirstPartyBuilds(level = 60): Promise<readonly BenchmarkBuildDefinition[]> {
const safeLevel = checkedLevel(level);
const catalog = await loadSelectableBenchmarkBuilds(safeLevel, COA_FIRST_PARTY_CLASS_IDS);
const builds = COA_FIRST_PARTY_MEMBERS.map((member) => {
const matches = catalog.filter((build) => (
build.source === "coa"
&& build.classId === member.classId
&& build.orientation === member.specialization
&& build.role === member.partyRole
));
if (matches.length !== 1) {
throw new Error(
`Expected exactly one loaded CoA build for ${member.classId}:${member.specializationId}; found ${matches.length}.`,
);
}
return matches[0];
});
validateBuilds(builds);
return Object.freeze(builds);
}
/** Creates the four exact companion actors around whichever party seat is active. */
export function createCoaFirstPartyCompanions(
builds: readonly BenchmarkBuildDefinition[],
activeSlot: number,
seed: string,
): readonly PartyMember[] {
const active = checkedActiveSlot(activeSlot);
validateBuilds(builds);
return Object.freeze(builds.flatMap((build, slot) => {
if (slot === active) return [];
const expected = expectedMemberForBuild(build, slot);
return [createConfiguredPartyMember({
name: build.characterName ?? `${classById(expected.classId).name} ${expected.specialization}`,
classId: expected.classId,
raceId: build.raceId,
secondaryClassId: build.secondaryClassId,
specialization: expected.specialization,
specializationId: expected.specializationId,
talentTreeId: expected.specializationId,
talentRanks: build.talentRanks,
role: expected.partyRole,
}, build.level, slot, seed)];
}));
}
/** Produces the existing Balance Lab request shape with a mandatory boss allowlist. */
export function coaFirstPartySimulationConfig(
level: number,
bossKeys: readonly string[],
activeSlot = 1,
) {
const safeLevel = checkedLevel(level);
const safeActiveSlot = checkedActiveSlot(activeSlot);
if (!bossKeys.length || bossKeys.some((key) => typeof key !== "string" || key.trim().length === 0)) {
throw new Error("CoA first-party simulation requires at least one non-empty explicit boss key.");
}
if (new Set(bossKeys).size !== bossKeys.length) {
throw new Error("CoA first-party simulation boss keys must be unique.");
}
return Object.freeze({
schemaVersion: 1 as const,
source: "coa" as const,
level: safeLevel,
durationSeconds: 30,
seeds: Object.freeze([1]),
partyBuildIds: Object.freeze(coaFirstPartyBuildCatalog(safeLevel).map((build) => build.id)),
activeSlot: safeActiveSlot,
bossCategory: "wow" as const,
bossKeys: Object.freeze([...bossKeys]),
});
}