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 = 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 { 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)); }