443 lines
16 KiB
TypeScript
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));
|
|
}
|