Files
healer-man/src/game/benchmark/benchmarkRecorder.ts
T
2026-08-30 14:16:56 -04:00

371 lines
14 KiB
TypeScript

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<string>([PLAYER_AGGRO_ID]);
readonly #friendlyTargets = new Set<string>([PLAYER_AGGRO_ID]);
readonly #eventCounts = new Map<string, number>();
readonly #abilities = new Map<string, MutableAbilityMetrics>();
readonly #resources = new Map<ResourceType, MutableResourceMetrics>();
readonly #alive = new Map<string, boolean>();
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<ResourceType>([
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 })));
}
}