import { PLAYER_AGGRO_ID } from "../aggro"; import { resourceProfileForClass, type ResourceType } from "../abilityCatalog"; import type { CombatState } from "../combatStore"; import { subscribeCombatEvents, type StructuredCombatEvent } from "../combatEvents"; import type { PartyState } from "../partyStore"; import type { BenchmarkAbilityMetrics, BenchmarkBuildDefinition, BenchmarkResourceMetrics, BenchmarkRunMetrics, BenchmarkRunReport, BenchmarkScenarioDefinition, } from "./benchmarkTypes"; import { BENCHMARK_POLICY_ID } from "./benchmarkController"; interface MutableAbilityMetrics { castsStarted: number; castsCompleted: number; interrupts: number; rawDamage: number; effectiveDamage: number; effectiveHealing: number; overheal: number; absorbsApplied: number; } interface MutableResourceMetrics { starting: number; ending: number; minimum: number; maximum: number; timeBelowTenPercentMs: number; } export interface BenchmarkRecorderSample { readonly now: number; readonly deltaMs: number; readonly combat: CombatState; readonly party: PartyState; readonly movedDistance: number; readonly moving: boolean; readonly stackCompliant: boolean | null; readonly heldMobs: number; readonly totalThreatMobs: number; } const emptyAbilityMetrics = (): MutableAbilityMetrics => ({ castsStarted: 0, castsCompleted: 0, interrupts: 0, rawDamage: 0, effectiveDamage: 0, effectiveHealing: 0, overheal: 0, absorbsApplied: 0, }); function percent(numerator: number, denominator: number): number { return denominator > 0 ? numerator / denominator * 100 : 0; } /** Collects the uncapped event stream and fixed-step state samples for one run. */ export class BenchmarkRecorder { readonly #build: BenchmarkBuildDefinition; readonly #scenario: BenchmarkScenarioDefinition; readonly #seed: number; readonly #startedAt: number; readonly #priorityTargetId: string; readonly #playerSources = new Set([PLAYER_AGGRO_ID]); readonly #friendlyTargets = new Set([PLAYER_AGGRO_ID]); readonly #eventCounts = new Map(); readonly #abilities = new Map(); readonly #resources = new Map(); readonly #alive = new Map(); readonly #unsubscribe: () => void; #rawDamage = 0; #effectiveDamage = 0; #priorityTargetDamage = 0; #effectiveHealing = 0; #rawHealing = 0; #overheal = 0; #absorbsApplied = 0; #absorbsConsumed = 0; #rawDamageTaken = 0; #effectiveDamageTaken = 0; #incomingFriendlyDamageEvents = 0; #incomingAttacks = 0; #avoidedAttacks = 0; #deaths = 0; #minimumPlayerHealthPercent = 100; #minimumPartyHealthPercent = 100; #castsStarted = 0; #castsCompleted = 0; #movementInterrupts = 0; #invalidCasts = 0; #distanceMoved = 0; #movementTimeMs = 0; #stackCompliantMs = 0; #stackObservedMs = 0; #threatHeldMobMs = 0; #threatObservedMobMs = 0; #timeToResourceFailureMs: number | null = null; #timeToFirstDeathMs: number | null = null; #closed = false; constructor( build: BenchmarkBuildDefinition, scenario: BenchmarkScenarioDefinition, seed: number, startedAt: number, priorityTargetId: string, ) { this.#build = build; this.#scenario = scenario; this.#seed = seed; this.#startedAt = startedAt; this.#priorityTargetId = priorityTargetId; this.#unsubscribe = subscribeCombatEvents((event) => this.#recordEvent(event)); } trackPlayerSummons(combat: CombatState): void { for (const summon of combat.summons) { if (summon.ownerActorId === PLAYER_AGGRO_ID) this.#playerSources.add(summon.id); } } recordInvalidCast(): void { this.#invalidCasts += 1; } markResourceFailure(now: number): void { if (this.#timeToResourceFailureMs === null) { this.#timeToResourceFailureMs = Math.max(0, now - this.#startedAt); } } dispose(): void { if (this.#closed) return; this.#closed = true; this.#unsubscribe(); } sample(sample: BenchmarkRecorderSample): void { const { combat, party, deltaMs } = sample; this.captureGearStats(combat); this.trackPlayerSummons(combat); this.#distanceMoved += Math.max(0, sample.movedDistance); if (sample.moving) this.#movementTimeMs += deltaMs; if (sample.stackCompliant !== null) { this.#stackObservedMs += deltaMs; if (sample.stackCompliant) this.#stackCompliantMs += deltaMs; } if (sample.totalThreatMobs > 0) { this.#threatObservedMobMs += sample.totalThreatMobs * deltaMs; this.#threatHeldMobMs += sample.heldMobs * deltaMs; } const playerHealthPercent = percent(combat.health, combat.maxHealth); this.#minimumPlayerHealthPercent = Math.min(this.#minimumPlayerHealthPercent, playerHealthPercent); const livingPartyHealth = party.members .filter((member) => member.health > 0) .map((member) => percent(member.health, member.maxHealth)); if (livingPartyHealth.length) { this.#minimumPartyHealthPercent = Math.min(this.#minimumPartyHealthPercent, ...livingPartyHealth); } else if (party.members.length) { this.#minimumPartyHealthPercent = 0; } this.#observeAlive(PLAYER_AGGRO_ID, combat.health > 0, sample.now); for (const member of party.members) { this.#friendlyTargets.add(member.id); this.#observeAlive(member.id, member.health > 0, sample.now); } const activeResource = resourceProfileForClass(combat.classId).type; const resourceIds = new Set([ activeResource, ...Object.keys(combat.maxResourcePools) as ResourceType[], ]); for (const id of resourceIds) { const maximum = id === activeResource ? combat.maxResource : combat.maxResourcePools[id] ?? 0; const current = id === activeResource ? combat.resource : combat.resourcePools[id] ?? 0; const metrics = this.#resources.get(id) ?? { starting: current, ending: current, minimum: current, maximum, timeBelowTenPercentMs: 0, }; metrics.ending = current; metrics.minimum = Math.min(metrics.minimum, current); metrics.maximum = Math.max(metrics.maximum, maximum); if (maximum > 0 && current / maximum < 0.1) metrics.timeBelowTenPercentMs += deltaMs; this.#resources.set(id, metrics); } } finish(simulatedDurationMs: number, completed: boolean): BenchmarkRunReport { this.dispose(); const seconds = Math.max(0.001, simulatedDurationMs / 1_000); const reasons: string[] = []; if (simulatedDurationMs <= 0) reasons.push("No combat time was simulated."); if (this.#castsCompleted <= 0 && this.#effectiveDamage <= 0) { reasons.push("The policy completed no abilities and dealt no damage."); } if (this.#scenario.roles.includes("damage") && this.#build.role === "damage" && this.#effectiveDamage <= 0) { reasons.push("The damage build produced no effective damage."); } if (this.#scenario.roles.includes("healer") && this.#build.role === "healer" && this.#incomingFriendlyDamageEvents <= 0 && this.#rawHealing <= 0) { reasons.push("The healing scenario produced neither incoming pressure nor healing."); } if (this.#scenario.roles.includes("tank") && this.#build.role === "tank" && this.#incomingAttacks <= 0) { reasons.push("The tanking scenario produced no incoming player attacks."); } const metrics: BenchmarkRunMetrics = Object.freeze({ rawDamage: this.#rawDamage, effectiveDamage: this.#effectiveDamage, priorityTargetDamage: this.#priorityTargetDamage, effectiveHealing: this.#effectiveHealing, rawHealing: this.#rawHealing, overheal: this.#overheal, absorbsApplied: this.#absorbsApplied, absorbsConsumed: this.#absorbsConsumed, rawDamageTaken: this.#rawDamageTaken, effectiveDamageTaken: this.#effectiveDamageTaken, incomingAttacks: this.#incomingAttacks, avoidedAttacks: this.#avoidedAttacks, deaths: this.#deaths, minimumPlayerHealthPercent: this.#minimumPlayerHealthPercent, minimumPartyHealthPercent: this.#minimumPartyHealthPercent, castsStarted: this.#castsStarted, castsCompleted: this.#castsCompleted, movementInterrupts: this.#movementInterrupts, invalidCasts: this.#invalidCasts, distanceMoved: this.#distanceMoved, movementTimeMs: this.#movementTimeMs, stackCompliancePercent: this.#stackObservedMs > 0 ? percent(this.#stackCompliantMs, this.#stackObservedMs) : null, threatControlPercent: this.#threatObservedMobMs > 0 ? percent(this.#threatHeldMobMs, this.#threatObservedMobMs) : null, timeToResourceFailureMs: this.#timeToResourceFailureMs, timeToFirstDeathMs: this.#timeToFirstDeathMs, dps: this.#effectiveDamage / seconds, hps: this.#effectiveHealing / seconds, dtps: this.#effectiveDamageTaken / seconds, overhealPercent: percent(this.#overheal, this.#rawHealing), mitigationPercent: percent(this.#rawDamageTaken - this.#effectiveDamageTaken, this.#rawDamageTaken), avoidancePercent: percent(this.#avoidedAttacks, this.#incomingAttacks), }); return Object.freeze({ schemaVersion: 1, runId: `${this.#build.id}__${this.#scenario.id}-v${this.#scenario.version}__seed-${this.#seed}`, policyId: BENCHMARK_POLICY_ID, seed: this.#seed, build: this.#build, scenario: this.#scenario, simulatedDurationMs, completed, metrics, resources: this.#resourceReport(), abilities: this.#abilityReport(), eventCounts: Object.freeze(Object.fromEntries([...this.#eventCounts].sort(([left], [right]) => left.localeCompare(right)))), validation: Object.freeze({ valid: reasons.length === 0, reasons: Object.freeze(reasons) }), gearStats: this.#lastGearStats, }); } #lastGearStats: BenchmarkRunReport["gearStats"] = {} as BenchmarkRunReport["gearStats"]; captureGearStats(combat: CombatState): void { this.#lastGearStats = combat.gearStats; } #observeAlive(id: string, alive: boolean, now: number): void { const previous = this.#alive.get(id); this.#alive.set(id, alive); if (previous === undefined || !previous || alive) return; this.#deaths += 1; if (this.#timeToFirstDeathMs === null) this.#timeToFirstDeathMs = Math.max(0, now - this.#startedAt); } #ability(id: string): MutableAbilityMetrics { const metrics = this.#abilities.get(id) ?? emptyAbilityMetrics(); this.#abilities.set(id, metrics); return metrics; } #recordEvent(event: StructuredCombatEvent): void { this.#eventCounts.set(event.kind, (this.#eventCounts.get(event.kind) ?? 0) + 1); const playerSource = this.#playerSources.has(event.sourceActorId) || event.sourceActorId.startsWith("player-summon-") || event.sourceActorId.startsWith("player-triggered-summon-") || event.sourceActorId.startsWith("player-pet-") || event.sourceActorId.startsWith("player-utility-") || event.sourceActorId.startsWith(`${PLAYER_AGGRO_ID}:summon-`) || event.sourceActorId.startsWith(`${PLAYER_AGGRO_ID}:totem-`); const ability = playerSource && event.abilityId ? this.#ability(event.abilityId) : null; if (playerSource && event.kind === "damage" && !this.#friendlyTargets.has(event.targetActorId)) { this.#rawDamage += event.rawAmount; this.#effectiveDamage += event.effectiveAmount; if (event.targetActorId === this.#priorityTargetId) this.#priorityTargetDamage += event.effectiveAmount; if (ability) { ability.rawDamage += event.rawAmount; ability.effectiveDamage += event.effectiveAmount; } } if (playerSource && event.kind === "healing") { this.#rawHealing += event.rawAmount; this.#effectiveHealing += event.effectiveAmount; this.#overheal += event.overheal; if (ability) { ability.effectiveHealing += event.effectiveAmount; ability.overheal += event.overheal; } } if (playerSource && event.kind === "absorb") { const amount = Math.max(event.effectiveAmount, event.absorbed); this.#absorbsApplied += amount; if (ability) ability.absorbsApplied += amount; } if (event.kind === "damage" && !playerSource && this.#friendlyTargets.has(event.targetActorId)) { this.#incomingFriendlyDamageEvents += 1; this.#absorbsConsumed += event.absorbed; if (event.targetActorId === PLAYER_AGGRO_ID) { this.#incomingAttacks += 1; if (["miss", "dodge", "parry", "resist"].includes(event.outcome ?? "")) this.#avoidedAttacks += 1; this.#rawDamageTaken += event.rawAmount; this.#effectiveDamageTaken += event.effectiveAmount; } } if (playerSource && event.kind === "cast" && ability) { if (event.outcome === "started") { ability.castsStarted += 1; this.#castsStarted += 1; } else if (event.outcome === "completed") { if (ability.castsStarted <= ability.castsCompleted) { ability.castsStarted += 1; this.#castsStarted += 1; } ability.castsCompleted += 1; this.#castsCompleted += 1; } } if (playerSource && event.kind === "interrupt") { if (ability) ability.interrupts += 1; if (event.outcome === "movement") this.#movementInterrupts += 1; } } #resourceReport(): readonly BenchmarkResourceMetrics[] { return Object.freeze([...this.#resources] .sort(([left], [right]) => left.localeCompare(right)) .map(([id, metrics]) => Object.freeze({ id, ...metrics }))); } #abilityReport(): readonly BenchmarkAbilityMetrics[] { return Object.freeze([...this.#abilities] .sort(([left], [right]) => left.localeCompare(right)) .map(([abilityId, metrics]) => Object.freeze({ abilityId, ...metrics }))); } }