Update Healer Man server
This commit is contained in:
1 parent
39458d5de6
commit
6cc1b9217f
203 files changed
+133929
-31271
No files matched your search
@@ -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
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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]),
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user