Files
healer-man/src/game/benchmark/benchmarkRunner.ts
T
2026-09-23 16:14:19 -04:00

443 lines
16 KiB
TypeScript

import { preloadAbilityCatalogsForClasses, type MobStatusKind } from "../abilityCatalog";
import { PLAYER_AGGRO_ID, type ThreatSource } from "../aggro";
import type { AzerothCoreMechanic } from "../azerothCoreReference";
import { installCombatClock } from "../combatClock";
import { useCombatStore } from "../combatStore";
import { resetCombatSpatialProvider } from "../combatSpatial";
import type { EnemyAttackDefinition } from "../enemyAttacks";
import { clearMobRuntimeRegistry, getMobPosition, setMobPosition } from "../mobRuntimeRegistry";
import {
clearPartyRuntimeRegistry,
getPartyRuntimePosition,
registerPartyRuntimePosition,
} from "../partyRuntimeRegistry";
import { resetPartyRuntimeClock } from "../partyRuntime";
import { usePartyStore } from "../partyStore";
import type { PartyRole } from "../partyRoles";
import type { CombatPosition } from "../mobRuntimeRegistry";
import { BenchmarkController } from "./benchmarkController";
import { BenchmarkRecorder } from "./benchmarkRecorder";
import type {
BenchmarkBuildDefinition,
BenchmarkRunOptions,
BenchmarkRunReport,
BenchmarkScenarioDefinition,
} from "./benchmarkTypes";
import { specializationsForClass } from "../specializationCatalog";
const BENCHMARK_CLOCK_ORIGIN = 1_000_000;
const BENCHMARK_MOB_HEALTH = 1_000_000_000;
const BENCHMARK_BOSS_CONTROL_IMMUNITIES: readonly AzerothCoreMechanic[] = Object.freeze([
"charm",
"disarm",
"disorient",
"fear",
"freeze",
"grip",
"horror",
"knockout",
"pacify",
"polymorph",
"root",
"sap",
"shackle",
"silence",
"sleep",
"snare",
"stun",
"turn",
]);
const BENCHMARK_CONTROL_STATUS_KINDS: ReadonlySet<MobStatusKind> = new Set([
"blind",
"charm",
"confuse",
"disarm",
"fear",
"freeze",
"knockdown",
"pacify",
"silence",
"sleep",
"stun",
]);
function scaledDuration(scenario: BenchmarkScenarioDefinition, scale: number): number {
const requested = scenario.durationMs * Math.max(0.001, scale);
return Math.max(scenario.tickMs, Math.round(requested / scenario.tickMs) * scenario.tickMs);
}
function partyPositions(layout: BenchmarkScenarioDefinition["partyLayout"], count: number): readonly CombatPosition[] {
if (layout === "spread") {
const spread: readonly CombatPosition[] = [
[0, 0, 1],
[-12, 0, -3],
[12, 0, -3],
[0, 0, -12],
];
return spread.slice(0, count);
}
if (layout === "stacked") {
const stacked: readonly CombatPosition[] = [
[-1, 0, 0.5],
[1, 0, 0.5],
[-0.5, 0, -1],
[0.5, 0, -1],
];
return stacked.slice(0, count);
}
const melee: readonly CombatPosition[] = [
[-1.5, 0, 1],
[1.5, 0, 1],
[-2.5, 0, -1],
[2.5, 0, -1],
];
return melee.slice(0, count);
}
function mobPosition(index: number, count: number, ringRadius = 2): CombatPosition {
if (index === 0 || count <= 1) return [0, 0, 2.5];
const angle = (index - 1) / Math.max(1, count - 1) * Math.PI * 2;
return [Math.sin(angle) * ringRadius, 0, 2.5 + Math.cos(angle) * ringRadius];
}
function benchmarkMobRingRadius(scenario: BenchmarkScenarioDefinition): number {
return scenario.id === "damage-aoe-eight" ? 6 : 2;
}
/** Keep scenario geometry fixed so pulls and knockbacks cannot change the pressure schedule. */
function restoreBenchmarkMobPositions(
scenario: BenchmarkScenarioDefinition,
mobIds: readonly string[],
): void {
const ringRadius = benchmarkMobRingRadius(scenario);
mobIds.forEach((id, index) => setMobPosition(
id,
mobPosition(index, mobIds.length, ringRadius),
));
}
function referenceAttackDamage(level: number): number {
return 12 + level * 2;
}
function enemyAttacks(
scenario: BenchmarkScenarioDefinition,
level: number,
referenceHealth: number,
): readonly EnemyAttackDefinition[] {
const baseDamage = referenceAttackDamage(level);
return scenario.attacks.map((profile) => ({
id: profile.id,
name: profile.name,
delivery: profile.target === "primary" && profile.school === "physical" ? "melee" : "area",
target: profile.target,
school: profile.school,
animation: profile.school === "physical" ? "attack" : "cast",
range: profile.target === "primary" ? 5.5 : 45,
cooldownMs: profile.intervalMs,
initialCooldownMs: [profile.intervalMs, profile.intervalMs],
repeatCooldownMs: [profile.intervalMs, profile.intervalMs],
damageMultiplier: profile.damageMultiplier * referenceHealth / Math.max(1, baseDamage),
...(profile.radius === undefined ? {} : { radius: profile.radius }),
}));
}
function movementPosition(
scenario: BenchmarkScenarioDefinition,
elapsedMs: number,
): { readonly position: CombatPosition; readonly moving: boolean } {
const movement = scenario.movement;
if (!movement || elapsedMs < movement.startsAtMs) return { position: [0, 0, 0], moving: false };
const cycle = (elapsedMs - movement.startsAtMs) % movement.periodMs;
if (cycle < movement.outboundMs) {
return {
position: [movement.distance * cycle / Math.max(1, movement.outboundMs), 0, 0],
moving: true,
};
}
const returnStart = movement.outboundMs;
if (cycle < returnStart + movement.returnMs) {
const progress = (cycle - returnStart) / Math.max(1, movement.returnMs);
return { position: [movement.distance * (1 - progress), 0, 0], moving: true };
}
return { position: [0, 0, 0], moving: false };
}
function distance(left: CombatPosition, right: CombatPosition): number {
return Math.hypot(left[0] - right[0], left[1] - right[1], left[2] - right[2]);
}
function stackCompliant(scenario: BenchmarkScenarioDefinition): boolean | null {
if (scenario.partyLayout !== "stacked") return null;
const combat = useCombatStore.getState();
return usePartyStore.getState().members
.filter((member) => member.health > 0)
.every((member) => {
const position = getPartyRuntimePosition(member.id);
return position !== null && distance(combat.playerPosition, position) <= 5;
});
}
function primaryThreatSource(role: PartyRole): ThreatSource {
if (role === "tank") return { actorId: PLAYER_AGGRO_ID, role: "tank", abilityId: "benchmark-pull" };
const tank = usePartyStore.getState().members.find((member) => member.role === "tank");
return {
actorId: tank?.id ?? usePartyStore.getState().members[0]?.id ?? PLAYER_AGGRO_ID,
role: "tank",
abilityId: "benchmark-pull",
};
}
function seedThreat(mobIds: readonly string[], role: PartyRole, now: number): void {
const source = primaryThreatSource(role);
for (const id of mobIds) {
useCombatStore.getState().damageMob(id, 1, now, source, "physical");
}
}
function applyTankThreatCompetition(mobIds: readonly string[], now: number): void {
const damageMembers = usePartyStore.getState().members.filter((member) => member.role === "damage").slice(0, 2);
for (const member of damageMembers) {
for (const id of mobIds) {
useCombatStore.getState().damageMob(
id,
Math.max(1, Math.round(member.level * 0.9)),
now,
{ actorId: member.id, role: "damage", abilityId: "benchmark-reference-dps" },
"physical",
);
}
}
}
function enforceExecuteWindow(
scenario: BenchmarkScenarioDefinition,
priorityTargetId: string,
elapsedMs: number,
durationMs: number,
): void {
if (scenario.id !== "damage-single-sustained" || elapsedMs < durationMs * 0.8) return;
useCombatStore.setState((state) => {
const target = state.mobs[priorityTargetId];
if (!target || target.dead || target.health / target.maxHealth <= 0.15) return state;
return {
mobs: {
...state.mobs,
[priorityTargetId]: { ...target, health: Math.max(1, Math.round(target.maxHealth * 0.15)) },
},
};
});
}
/** Reference bosses ignore player hard control so every tank receives the same attack schedule. */
function clearBenchmarkControlStatuses(): void {
useCombatStore.setState((state) => {
let changed = false;
const mobs = Object.fromEntries(Object.entries(state.mobs).map(([id, mob]) => {
const statuses = mob.statuses.filter((status) => !BENCHMARK_CONTROL_STATUS_KINDS.has(status.kind));
if (statuses.length !== mob.statuses.length) changed = true;
return [id, statuses.length === mob.statuses.length ? mob : { ...mob, statuses }];
}));
return changed ? { mobs } : state;
});
}
function anyInjuredBelow(percent: number): boolean {
const combat = useCombatStore.getState();
if (combat.health > 0 && combat.health / Math.max(1, combat.maxHealth) < percent) return true;
return usePartyStore.getState().members.some((member) => (
member.health > 0 && member.health / Math.max(1, member.maxHealth) < percent
));
}
/** Runs one build/scenario pair against the real authoritative player combat path. */
export async function runCombatBenchmark(
build: BenchmarkBuildDefinition,
scenario: BenchmarkScenarioDefinition,
options: BenchmarkRunOptions = {},
): Promise<BenchmarkRunReport> {
if (!scenario.roles.includes(build.role)) {
throw new Error(`Scenario ${scenario.id} does not support the ${build.role} role.`);
}
const seed = Math.trunc(options.seed ?? 1);
const durationMs = scaledDuration(scenario, options.durationScale ?? 1);
const classIds = build.secondaryClassId ? [build.classId, build.secondaryClassId] : [build.classId];
await preloadAbilityCatalogsForClasses(classIds);
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
resetPartyRuntimeClock();
resetCombatSpatialProvider();
let now = BENCHMARK_CLOCK_ORIGIN;
const restoreClock = installCombatClock(() => now);
let recorder: BenchmarkRecorder | null = null;
let simulatedDurationMs = 0;
let completed = true;
try {
const combat = useCombatStore.getState();
const specializationId = specializationsForClass(build.classId)
.find((specialization) => specialization.displayName === build.orientation)?.id;
combat.initializeCharacter({
classId: build.classId,
raceId: build.raceId,
secondaryClassId: build.secondaryClassId,
specializationId,
level: build.level,
talentRanks: build.talentRanks,
inventory: build.inventory,
equipment: build.equipment,
});
combat.setPlayerPosition([0, 0, 0]);
const initializedCombat = useCombatStore.getState();
usePartyStore.getState().initializeParty(
`benchmark:${build.id}:${scenario.id}:${seed}`,
build.level,
initializedCombat.maxHealth,
build.role,
5,
build.source,
);
const party = usePartyStore.getState();
const positions = partyPositions(scenario.partyLayout, party.members.length);
party.members.forEach((member, index) => registerPartyRuntimePosition(member.id, positions[index] ?? [0, 0, 0]));
useCombatStore.getState().synchronizeActors();
useCombatStore.getState().applyDefaultPartyBuffs(now);
useCombatStore.getState().applyPrecombatPreparation(now);
const referenceTank = build.role === "tank"
? { maxHealth: initializedCombat.maxHealth }
: party.members.find((member) => member.role === "tank") ?? { maxHealth: initializedCombat.maxHealth };
const attacks = enemyAttacks(scenario, build.level, referenceTank.maxHealth);
const mobIds = Array.from({ length: scenario.hostileTargets }, (_, index) => `benchmark-${scenario.id}-${seed}-${index + 1}`);
const ringRadius = benchmarkMobRingRadius(scenario);
mobIds.forEach((id, index) => {
setMobPosition(id, mobPosition(index, mobIds.length, ringRadius));
useCombatStore.getState().registerMob(id, {
name: index === 0 ? "Benchmark Boss" : `Benchmark Target ${index + 1}`,
boss: index === 0,
level: build.level,
maxHealth: BENCHMARK_MOB_HEALTH,
xpReward: 0,
hasLoot: false,
moveSpeed: 0,
aggroRange: 45,
leashRange: 60,
attacks,
mechanicImmunities: BENCHMARK_BOSS_CONTROL_IMMUNITIES,
});
});
useCombatStore.getState().synchronizeActors();
seedThreat(mobIds, build.role, now);
useCombatStore.getState().selectMob(mobIds[0]);
useCombatStore.getState().synchronizeActors();
recorder = new BenchmarkRecorder(build, scenario, seed, now, mobIds[0]);
const controller = new BenchmarkController();
recorder.sample({
now,
deltaMs: 0,
combat: useCombatStore.getState(),
party: usePartyStore.getState(),
movedDistance: 0,
moving: false,
stackCompliant: stackCompliant(scenario),
heldMobs: 0,
totalThreatMobs: 0,
});
let resourceFailureDecisions = 0;
let previousPosition: CombatPosition = [0, 0, 0];
let nextThreatCompetitionAt = 2_000;
while (simulatedDurationMs < durationMs) {
const deltaMs = Math.min(scenario.tickMs, durationMs - simulatedDurationMs);
simulatedDurationMs += deltaMs;
now = BENCHMARK_CLOCK_ORIGIN + simulatedDurationMs;
const movement = movementPosition(scenario, simulatedDurationMs);
useCombatStore.getState().setPlayerPosition(movement.position);
const movedDistance = distance(previousPosition, movement.position);
previousPosition = movement.position;
restoreBenchmarkMobPositions(scenario, mobIds);
useCombatStore.getState().tick(deltaMs / 1_000, now);
enforceExecuteWindow(scenario, mobIds[0], simulatedDurationMs, durationMs);
restoreBenchmarkMobPositions(scenario, mobIds);
if (scenario.attacks.length) {
clearBenchmarkControlStatuses();
useCombatStore.getState().advanceMobCombat(now, movement.position);
}
if (scenario.id === "tank-multi-target" && simulatedDurationMs >= nextThreatCompetitionAt) {
applyTankThreatCompetition(mobIds, now);
nextThreatCompetitionAt += 2_000;
}
useCombatStore.getState().synchronizeActors();
const decision = controller.act(now, build.role, scenario, movement.moving);
for (let index = 0; index < decision.invalidFailures; index += 1) recorder.recordInvalidCast();
if (scenario.resourceFailureGcds && decision.made && anyInjuredBelow(0.8)) {
resourceFailureDecisions = decision.cast || !decision.resourceBlocked
? 0
: resourceFailureDecisions + 1;
if (resourceFailureDecisions >= scenario.resourceFailureGcds) recorder.markResourceFailure(now);
}
const currentCombat = useCombatStore.getState();
const currentParty = usePartyStore.getState();
const activeMobs = Object.values(currentCombat.mobs).filter((mob) => mob.engaged && !mob.dead);
recorder.sample({
now,
deltaMs,
combat: currentCombat,
party: currentParty,
movedDistance,
moving: movement.moving,
stackCompliant: stackCompliant(scenario),
heldMobs: build.role === "tank"
? activeMobs.filter((mob) => mob.targetActorId === PLAYER_AGGRO_ID).length
: 0,
totalThreatMobs: build.role === "tank" ? activeMobs.length : 0,
});
const playerDied = currentCombat.health <= 0 && (scenario.stopOnPlayerDeath || build.role === "tank");
const partyDied = currentParty.members.some((member) => member.health <= 0)
&& (scenario.stopOnPartyDeath || build.role === "healer");
const resourceFailed = scenario.resourceFailureGcds !== undefined
&& resourceFailureDecisions >= scenario.resourceFailureGcds;
if (playerDied || partyDied || resourceFailed) {
completed = false;
break;
}
}
return recorder.finish(simulatedDurationMs, completed);
} catch (error: unknown) {
if (!recorder) throw error;
const partial = recorder.finish(simulatedDurationMs, false);
const message = error instanceof Error ? error.message : String(error);
return Object.freeze({
...partial,
completed: false,
validation: Object.freeze({
valid: false,
reasons: Object.freeze([...partial.validation.reasons, `Runtime error: ${message}`]),
}),
});
} finally {
recorder?.dispose();
restoreClock();
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
resetPartyRuntimeClock();
resetCombatSpatialProvider();
}
}
/** Selects only scenario/build combinations that share a declared role. */
export function compatibleBenchmarkPairs(
builds: readonly BenchmarkBuildDefinition[],
scenarios: readonly BenchmarkScenarioDefinition[],
): readonly (readonly [BenchmarkBuildDefinition, BenchmarkScenarioDefinition])[] {
return builds.flatMap((build) => scenarios
.filter((scenario) => scenario.roles.includes(build.role))
.map((scenario) => [build, scenario] as const));
}