forked from phenom/healer-man
371 lines
14 KiB
TypeScript
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 })));
|
|
}
|
|
}
|