forked from phenom/healer-man
841 lines
33 KiB
TypeScript
841 lines
33 KiB
TypeScript
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
import { dirname, resolve } from "node:path";
|
|
import { createInterface } from "node:readline";
|
|
import {
|
|
CLASSES,
|
|
classById,
|
|
contentCategoryForClass,
|
|
type ClassId,
|
|
} from "../../src/app/characterCatalog";
|
|
import type { ContentCategoryId } from "../../src/app/contentCategories";
|
|
import {
|
|
abilityById,
|
|
abilitiesForCharacter,
|
|
learnedAbilityForBuild,
|
|
preloadAbilityCatalogsForClasses,
|
|
type ResourceType,
|
|
} from "../../src/game/abilityCatalog";
|
|
import { aiHintsForAbility } from "../../src/game/abilityRules";
|
|
import { PLAYER_AGGRO_ID } from "../../src/game/aggro";
|
|
import { installCombatClock } from "../../src/game/combatClock";
|
|
import { companionResourceMaximum } from "../../src/game/combatActorAuthority";
|
|
import { subscribeCombatEvents } from "../../src/game/combatEvents";
|
|
import { subscribeCombatPresentation } from "../../src/game/combatPresentation";
|
|
import { resetCombatSpatialProvider } from "../../src/game/combatSpatial";
|
|
import { useCombatStore } from "../../src/game/combatStore";
|
|
import { dungeonEnemyScaling } from "../../src/game/dungeonBossScaling";
|
|
import { requireDungeonDefinition } from "../../src/game/dungeonRegistry";
|
|
import {
|
|
resolvedAverageItemLevel,
|
|
resolvedEquipmentItems,
|
|
resolvedEquipmentStats,
|
|
} from "../../src/game/heirloomEquipment";
|
|
import {
|
|
BOSS_TESTING_CATALOG,
|
|
createBossTestingRoom,
|
|
type TestableBoss,
|
|
} from "../../src/game/bossTesting";
|
|
import { clearMobRuntimeRegistry, setMobPosition } from "../../src/game/mobRuntimeRegistry";
|
|
import {
|
|
advanceDungeonPartyCombat,
|
|
partyCombatRangeBand,
|
|
resetPartyRuntimeClock,
|
|
} from "../../src/game/partyRuntime";
|
|
import {
|
|
clearPartyRuntimeRegistry,
|
|
registerPartyRuntimePosition,
|
|
updatePartyRuntimeTargetVisibility,
|
|
} from "../../src/game/partyRuntimeRegistry";
|
|
import {
|
|
createConfiguredPartyMember,
|
|
usePartyStore,
|
|
type PartyMember,
|
|
} from "../../src/game/partyStore";
|
|
import { specializationsForClass } from "../../src/game/specializationCatalog";
|
|
import { BENCHMARK_POLICY_ID, BenchmarkController } from "../../src/game/benchmark/benchmarkController";
|
|
import { COMBAT_ROTATION_VERSION, rotationProfileFor } from "../../src/game/combatRotation";
|
|
import { subscribeRotationDecisions } from "../../src/game/rotationTelemetry";
|
|
import { PartyCastTelemetry } from "../../src/game/benchmark/partyCastTelemetry";
|
|
import {
|
|
DEFAULT_COMPANION_COOLDOWN_POLICY,
|
|
installCompanionCooldownPolicy,
|
|
type CompanionCooldownPolicy,
|
|
} from "../../src/game/companionCooldownPolicy";
|
|
import {
|
|
CANONICAL_BENCHMARK_GEAR_PRESET_ID,
|
|
loadSelectableBenchmarkBuilds,
|
|
selectableBenchmarkBuildCatalog,
|
|
type SelectableBenchmarkBuildCatalogEntry,
|
|
} from "../../src/game/benchmark/benchmarkBuilds";
|
|
import type {
|
|
BenchmarkBuildDefinition,
|
|
BenchmarkScenarioDefinition,
|
|
} from "../../src/game/benchmark/benchmarkTypes";
|
|
|
|
type BossCategory = "all" | "wow" | "rom";
|
|
type PartyCatalogSource = ContentCategoryId | "all";
|
|
|
|
interface BalanceLabConfig {
|
|
readonly schemaVersion: 1;
|
|
readonly source: ContentCategoryId;
|
|
readonly level: number;
|
|
readonly durationSeconds: number;
|
|
readonly seeds: readonly number[];
|
|
readonly partyBuildIds: readonly string[];
|
|
readonly activeSlot: number;
|
|
readonly bossCategory: BossCategory;
|
|
readonly bossKeys?: readonly string[];
|
|
readonly cooldownPolicy?: CompanionCooldownPolicy;
|
|
}
|
|
|
|
interface MutableAbilityMetrics {
|
|
casts: number;
|
|
rawDamage: number;
|
|
effectiveDamage: number;
|
|
rawHealing: number;
|
|
effectiveHealing: number;
|
|
overheal: number;
|
|
absorbs: number;
|
|
hits: number;
|
|
}
|
|
|
|
interface MutableMemberMetrics {
|
|
readonly slot: number;
|
|
readonly actorId: string;
|
|
readonly build: BenchmarkBuildDefinition;
|
|
damage: number;
|
|
rawDamage: number;
|
|
bossDamage: number;
|
|
healing: number;
|
|
rawHealing: number;
|
|
overheal: number;
|
|
healingReceived: number;
|
|
absorbs: number;
|
|
damageTaken: number;
|
|
rawDamageTaken: number;
|
|
absorbedDamageTaken: number;
|
|
deathEvents: number;
|
|
casts: number;
|
|
minimumHealthPercent: number;
|
|
readonly abilities: Map<string, MutableAbilityMetrics>;
|
|
readonly rotationDecisions: Map<string, {
|
|
profileId: string;
|
|
ruleId: string;
|
|
reason: string;
|
|
abilityId: string;
|
|
decisions: number;
|
|
}>;
|
|
gcdObservedMs: number;
|
|
gcdBusyMs: number;
|
|
readonly resourceSamples: Map<string, { samples: number; starved: number; capped: number }>;
|
|
}
|
|
|
|
const scenario = (durationMs: number): BenchmarkScenarioDefinition => ({
|
|
id: "balance-lab-boss",
|
|
version: 1,
|
|
name: "Balance lab boss",
|
|
description: "A selected five-member party against one isolated authored boss.",
|
|
family: "mechanics",
|
|
roles: ["damage", "tank", "healer"],
|
|
durationMs,
|
|
tickMs: 100,
|
|
hostileTargets: 1,
|
|
partyLayout: "melee",
|
|
attacks: [],
|
|
});
|
|
|
|
function argument(name: string): string | null {
|
|
const index = process.argv.indexOf(name);
|
|
return index >= 0 ? process.argv[index + 1] ?? null : null;
|
|
}
|
|
|
|
function requiredArgument(name: string): string {
|
|
const value = argument(name);
|
|
if (!value) throw new Error(`Missing required ${name} argument.`);
|
|
return value;
|
|
}
|
|
|
|
function safeNumber(value: unknown, fallback: number): number {
|
|
const parsed = Number(value);
|
|
return Number.isFinite(parsed) ? parsed : fallback;
|
|
}
|
|
|
|
function classIdsForSource(source: PartyCatalogSource): readonly ClassId[] {
|
|
return CLASSES
|
|
.map((definition) => definition.id)
|
|
.filter((classId) => source === "all" || contentCategoryForClass(classId) === source);
|
|
}
|
|
|
|
function bossCatalogRows() {
|
|
return BOSS_TESTING_CATALOG.map((boss) => ({
|
|
key: boss.key,
|
|
name: boss.name,
|
|
dungeonId: boss.dungeonId,
|
|
dungeonTitle: boss.dungeonTitle,
|
|
category: boss.category,
|
|
}));
|
|
}
|
|
|
|
function resolvedGearSummary(build: SelectableBenchmarkBuildCatalogEntry) {
|
|
const specialization = specializationsForClass(build.classId)
|
|
.find((definition) => definition.displayName === build.orientation);
|
|
const context = {
|
|
ownerId: `balance-lab-catalog:${build.id}`,
|
|
ownerKind: "player" as const,
|
|
classId: build.classId,
|
|
specializationId: specialization?.id ?? null,
|
|
role: build.role,
|
|
level: build.level,
|
|
};
|
|
const items = resolvedEquipmentItems({}, [], context);
|
|
return {
|
|
presetId: CANONICAL_BENCHMARK_GEAR_PRESET_ID,
|
|
model: "synthetic-specialization-heirloom-v1",
|
|
itemCount: items.length,
|
|
averageItemLevel: resolvedAverageItemLevel({}, [], context),
|
|
itemLevels: [...new Set(items.map((item) => item.itemLevel))].sort((left, right) => left - right),
|
|
stats: resolvedEquipmentStats({}, [], context),
|
|
};
|
|
}
|
|
|
|
async function writeJson(path: string, value: unknown): Promise<void> {
|
|
const absolute = resolve(path);
|
|
await mkdir(dirname(absolute), { recursive: true });
|
|
await writeFile(absolute, `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
|
}
|
|
|
|
function catalogPayload(source: PartyCatalogSource, level: number) {
|
|
if (!["wow", "coa", "rom", "all"].includes(source)) throw new Error(`Unknown party source: ${source}`);
|
|
const builds = selectableBenchmarkBuildCatalog(level, classIdsForSource(source));
|
|
return {
|
|
schemaVersion: 1,
|
|
policyId: BENCHMARK_POLICY_ID,
|
|
rotationVersion: COMBAT_ROTATION_VERSION,
|
|
level: builds[0]?.level ?? level,
|
|
source,
|
|
builds: builds.map((build) => ({
|
|
id: build.id,
|
|
source: build.source,
|
|
classId: build.classId,
|
|
raceId: build.raceId ?? null,
|
|
secondaryClassId: build.secondaryClassId ?? null,
|
|
className: classById(build.classId).name,
|
|
role: build.role,
|
|
specialization: build.orientation,
|
|
specializationId: build.specializationId ?? null,
|
|
readinessStatus: build.readinessStatus ?? null,
|
|
characterName: build.characterName ?? null,
|
|
talentBuildId: build.talentBuildId ?? null,
|
|
talentBuildName: build.talentBuildName ?? null,
|
|
talentBuildSource: build.talentBuildSource ?? null,
|
|
talentBuildSourcePointCount: build.talentBuildSourcePointCount ?? null,
|
|
talentBuildSourcePointsRetained: build.talentBuildSourcePointsRetained ?? null,
|
|
talentBuildFallbackPointCount: build.talentBuildFallbackPointCount ?? null,
|
|
defaultTalentBuild: build.defaultTalentBuild ?? true,
|
|
label: build.secondaryClassId
|
|
? `${classById(build.classId).name}/${classById(build.secondaryClassId).name} — ${build.characterName ?? build.orientation} (${build.role})`
|
|
: `${classById(build.classId).name} — ${build.orientation} (${build.role})${build.talentBuildName ? ` — ${build.talentBuildName}` : ""}`,
|
|
gear: resolvedGearSummary(build),
|
|
})),
|
|
bosses: bossCatalogRows(),
|
|
};
|
|
}
|
|
|
|
async function writeCatalog(): Promise<void> {
|
|
const level = Math.trunc(safeNumber(argument("--level"), 60));
|
|
const source = (argument("--source") ?? "wow") as PartyCatalogSource;
|
|
await writeJson(requiredArgument("--output"), catalogPayload(source, level));
|
|
}
|
|
|
|
function selectedBosses(config: BalanceLabConfig): readonly TestableBoss[] {
|
|
const requested = new Set(config.bossKeys ?? []);
|
|
const rows = BOSS_TESTING_CATALOG.filter((boss) => (
|
|
(config.bossCategory === "all" || boss.category === config.bossCategory)
|
|
&& (!requested.size || requested.has(boss.key))
|
|
));
|
|
if (!rows.length) throw new Error("No bosses matched the requested boss filters.");
|
|
if (requested.size) {
|
|
const found = new Set(rows.map((boss) => boss.key));
|
|
const missing = [...requested].filter((key) => !found.has(key));
|
|
if (missing.length) throw new Error(`Unknown or category-excluded boss keys: ${missing.join(", ")}`);
|
|
}
|
|
return rows;
|
|
}
|
|
|
|
function configuredCompanion(
|
|
build: BenchmarkBuildDefinition,
|
|
slot: number,
|
|
level: number,
|
|
seed: string,
|
|
): PartyMember {
|
|
const specialization = specializationsForClass(build.classId)
|
|
.find((definition) => definition.displayName === build.orientation);
|
|
return createConfiguredPartyMember({
|
|
name: build.characterName ?? `${classById(build.classId).name} ${build.orientation}`,
|
|
classId: build.classId,
|
|
raceId: build.raceId,
|
|
secondaryClassId: build.secondaryClassId,
|
|
specialization: build.orientation,
|
|
specializationId: specialization?.id ?? null,
|
|
talentTreeId: specialization?.id ?? "",
|
|
talentRanks: build.talentRanks,
|
|
role: build.role,
|
|
}, level, slot, seed);
|
|
}
|
|
|
|
function blankAbility(): MutableAbilityMetrics {
|
|
return {
|
|
casts: 0,
|
|
rawDamage: 0,
|
|
effectiveDamage: 0,
|
|
rawHealing: 0,
|
|
effectiveHealing: 0,
|
|
overheal: 0,
|
|
absorbs: 0,
|
|
hits: 0,
|
|
};
|
|
}
|
|
|
|
function abilityMetrics(member: MutableMemberMetrics, abilityId: string | undefined): MutableAbilityMetrics {
|
|
const key = abilityId ?? "unknown";
|
|
const existing = member.abilities.get(key);
|
|
if (existing) return existing;
|
|
const created = blankAbility();
|
|
member.abilities.set(key, created);
|
|
return created;
|
|
}
|
|
|
|
function abilityProducesAttributedCombatEvent(abilityId: string | undefined): boolean {
|
|
const ability = abilityId ? abilityById(abilityId) : null;
|
|
return ability?.effects.some((effect) => [
|
|
"damage", "dot", "finisher-damage", "execute", "heal", "hot", "shield", "resurrection",
|
|
].includes(effect.kind)) ?? true;
|
|
}
|
|
|
|
function currentHealth(actorId: string): readonly [number, number] {
|
|
const combat = useCombatStore.getState();
|
|
if (actorId === PLAYER_AGGRO_ID) return [combat.health, combat.maxHealth];
|
|
const member = usePartyStore.getState().members.find((candidate) => candidate.id === actorId);
|
|
return [member?.health ?? 0, member?.maxHealth ?? 1];
|
|
}
|
|
|
|
function availableAbilities(build: BenchmarkBuildDefinition) {
|
|
const specialization = specializationsForClass(build.classId)
|
|
.find((definition) => definition.displayName === build.orientation);
|
|
const profile = rotationProfileFor(build.classId, specialization?.id, build.orientation);
|
|
return abilitiesForCharacter(build.classId, build.secondaryClassId)
|
|
.filter((ability) => !ability.passive)
|
|
.map((ability) => {
|
|
const ranked = learnedAbilityForBuild(ability, build.level, build.talentRanks);
|
|
if (!ranked) return null;
|
|
if (ranked.effects.every((effect) => effect.kind === "unsupported" || effect.kind === "scripted")) return null;
|
|
const hints = aiHintsForAbility(ranked);
|
|
const rotationPriority = profile?.abilityPriority.indexOf(ranked.name) ?? -1;
|
|
const conditional = hints.categories.some((category) => [
|
|
"area-damage", "control", "dispel", "emergency-heal", "execute", "interrupt",
|
|
"mitigation", "resurrection", "taunt",
|
|
].includes(category));
|
|
return {
|
|
abilityId: ranked.id,
|
|
abilityName: ranked.name,
|
|
icon: ranked.icon,
|
|
categories: hints.categories,
|
|
cooldownSeconds: ranked.cooldownMs / 1_000,
|
|
rotationProfileId: profile?.id ?? null,
|
|
rotationPriority: rotationPriority >= 0 ? rotationPriority + 1 : null,
|
|
expectedUsage: conditional ? "conditional" : rotationPriority >= 0 ? "profile" : "available",
|
|
};
|
|
})
|
|
.filter((ability) => ability !== null)
|
|
.sort((left, right) => left.abilityName.localeCompare(right.abilityName));
|
|
}
|
|
|
|
function serializedMember(
|
|
metrics: MutableMemberMetrics,
|
|
activeSeconds: number,
|
|
) {
|
|
const [health, maxHealth] = currentHealth(metrics.actorId);
|
|
return {
|
|
slot: metrics.slot,
|
|
activePlayer: metrics.actorId === PLAYER_AGGRO_ID,
|
|
actorId: metrics.actorId,
|
|
buildId: metrics.build.id,
|
|
characterName: metrics.build.characterName ?? null,
|
|
talentBuildId: metrics.build.talentBuildId ?? null,
|
|
talentBuildName: metrics.build.talentBuildName ?? null,
|
|
talentBuildSource: metrics.build.talentBuildSource ?? null,
|
|
talentBuildSourcePointCount: metrics.build.talentBuildSourcePointCount ?? null,
|
|
talentBuildSourcePointsRetained: metrics.build.talentBuildSourcePointsRetained ?? null,
|
|
talentBuildFallbackPointCount: metrics.build.talentBuildFallbackPointCount ?? null,
|
|
classId: metrics.build.classId,
|
|
raceId: metrics.build.raceId ?? null,
|
|
secondaryClassId: metrics.build.secondaryClassId ?? null,
|
|
className: classById(metrics.build.classId).name,
|
|
specialization: metrics.build.orientation,
|
|
role: metrics.build.role,
|
|
damage: metrics.damage,
|
|
rawDamage: metrics.rawDamage,
|
|
bossDamage: metrics.bossDamage,
|
|
dps: metrics.damage / Math.max(0.1, activeSeconds),
|
|
bossDps: metrics.bossDamage / Math.max(0.1, activeSeconds),
|
|
healing: metrics.healing,
|
|
rawHealing: metrics.rawHealing,
|
|
overheal: metrics.overheal,
|
|
hps: metrics.healing / Math.max(0.1, activeSeconds),
|
|
healingReceived: metrics.healingReceived,
|
|
hpsReceived: metrics.healingReceived / Math.max(0.1, activeSeconds),
|
|
absorbs: metrics.absorbs,
|
|
damageTaken: metrics.damageTaken,
|
|
rawDamageTaken: metrics.rawDamageTaken,
|
|
absorbedDamageTaken: metrics.absorbedDamageTaken,
|
|
dtps: metrics.damageTaken / Math.max(0.1, activeSeconds),
|
|
deathEvents: metrics.deathEvents,
|
|
alive: health > 0,
|
|
finalHealth: health,
|
|
maxHealth,
|
|
minimumHealthPercent: metrics.minimumHealthPercent,
|
|
casts: metrics.casts,
|
|
gcdUptimePercent: metrics.gcdObservedMs > 0 ? metrics.gcdBusyMs / metrics.gcdObservedMs * 100 : null,
|
|
resources: [...metrics.resourceSamples.entries()].map(([resource, samples]) => ({
|
|
resource,
|
|
samples: samples.samples,
|
|
starvedPercent: samples.starved / Math.max(1, samples.samples) * 100,
|
|
cappedPercent: samples.capped / Math.max(1, samples.samples) * 100,
|
|
})).sort((left, right) => left.resource.localeCompare(right.resource)),
|
|
rotationDecisions: [...metrics.rotationDecisions.values()]
|
|
.sort((left, right) => right.decisions - left.decisions
|
|
|| left.profileId.localeCompare(right.profileId)
|
|
|| left.ruleId.localeCompare(right.ruleId)
|
|
|| left.abilityId.localeCompare(right.abilityId)),
|
|
availableAbilities: availableAbilities(metrics.build),
|
|
abilities: [...metrics.abilities.entries()]
|
|
.map(([abilityId, ability]) => ({ abilityId, ...ability }))
|
|
.sort((left, right) => right.effectiveDamage - left.effectiveDamage
|
|
|| right.effectiveHealing - left.effectiveHealing
|
|
|| left.abilityId.localeCompare(right.abilityId)),
|
|
};
|
|
}
|
|
|
|
async function runBoss(
|
|
config: BalanceLabConfig,
|
|
builds: readonly BenchmarkBuildDefinition[],
|
|
boss: TestableBoss,
|
|
seed: number,
|
|
) {
|
|
const sourceDungeon = requireDungeonDefinition(boss.dungeonId);
|
|
const testingRoom = createBossTestingRoom(sourceDungeon, boss).definition;
|
|
const entity = testingRoom.entities[boss.entityId];
|
|
if (!entity) throw new Error(`Boss entity ${boss.key} is missing from its testing room.`);
|
|
const bossActorId = `${boss.spawn.id}:balance-${seed}`;
|
|
const activeBuild = builds[config.activeSlot];
|
|
const durationMs = Math.max(1_000, Math.min(600_000, Math.round(config.durationSeconds * 1_000)));
|
|
const specialization = specializationsForClass(activeBuild.classId)
|
|
.find((definition) => definition.displayName === activeBuild.orientation);
|
|
const scaling = dungeonEnemyScaling(
|
|
testingRoom,
|
|
entity,
|
|
bossActorId,
|
|
activeBuild.level,
|
|
testingRoom.defaultDifficultyId,
|
|
1,
|
|
activeBuild.source,
|
|
);
|
|
|
|
clearMobRuntimeRegistry();
|
|
clearPartyRuntimeRegistry();
|
|
resetPartyRuntimeClock();
|
|
resetCombatSpatialProvider();
|
|
let now = 1_000_000;
|
|
const restoreClock = installCombatClock(() => now);
|
|
const companions = builds
|
|
.map((build, slot) => ({ build, slot }))
|
|
.filter(({ slot }) => slot !== config.activeSlot)
|
|
.map(({ build, slot }) => configuredCompanion(build, slot, activeBuild.level, `${seed}:${boss.key}`));
|
|
const actorIds = builds.map((_, slot) => (
|
|
slot === config.activeSlot
|
|
? PLAYER_AGGRO_ID
|
|
: companions.find((member) => member.id.includes(`-${slot}-`))?.id ?? ""
|
|
));
|
|
const memberMetrics = builds.map((build, slot): MutableMemberMetrics => ({
|
|
slot,
|
|
actorId: actorIds[slot],
|
|
build,
|
|
damage: 0,
|
|
rawDamage: 0,
|
|
bossDamage: 0,
|
|
healing: 0,
|
|
rawHealing: 0,
|
|
overheal: 0,
|
|
healingReceived: 0,
|
|
absorbs: 0,
|
|
damageTaken: 0,
|
|
rawDamageTaken: 0,
|
|
absorbedDamageTaken: 0,
|
|
deathEvents: 0,
|
|
casts: 0,
|
|
minimumHealthPercent: 1,
|
|
abilities: new Map(),
|
|
rotationDecisions: new Map(),
|
|
gcdObservedMs: 0,
|
|
gcdBusyMs: 0,
|
|
resourceSamples: new Map(),
|
|
}));
|
|
const byActorId = new Map(memberMetrics.map((metrics) => [metrics.actorId, metrics]));
|
|
const actorAlive = new Map<string, boolean>();
|
|
const bossAbilities = new Map<string, MutableAbilityMetrics>();
|
|
const pressureWindows = new Set<number>();
|
|
const healerThroughputWindows = new Set<number>();
|
|
const combatWindow = (occurredAt: number) => Math.max(0, Math.floor((occurredAt - 1_000_000) / 5_000));
|
|
const stopRotationDecisions = subscribeRotationDecisions((event) => {
|
|
const member = byActorId.get(event.actorId);
|
|
if (!member) return;
|
|
const key = `${event.profileId}\u0000${event.ruleId}\u0000${event.abilityId}`;
|
|
const current = member.rotationDecisions.get(key);
|
|
if (current) current.decisions += 1;
|
|
else member.rotationDecisions.set(key, {
|
|
profileId: event.profileId,
|
|
ruleId: event.ruleId,
|
|
reason: event.reason,
|
|
abilityId: event.abilityId,
|
|
decisions: 1,
|
|
});
|
|
});
|
|
// Companion ability releases carry action completion for every payload.
|
|
// Unlike player casts, companions do not append `kind: "cast"` events, so
|
|
// relying on those events undercounts damaging and healing companion casts.
|
|
const partyCastTelemetry = new PartyCastTelemetry();
|
|
const stopPresentation = subscribeCombatPresentation((message) => {
|
|
if (message.kind !== "event" || !partyCastTelemetry.record(message.event)) return;
|
|
const source = byActorId.get(message.event.sourceActorId);
|
|
if (!source) return;
|
|
source.casts += 1;
|
|
abilityMetrics(source, message.event.abilityId).casts += 1;
|
|
});
|
|
const stopEvents = subscribeCombatEvents((event) => {
|
|
const source = byActorId.get(event.sourceActorId);
|
|
const target = byActorId.get(event.targetActorId);
|
|
if (event.kind === "cast" && source) {
|
|
if (!abilityProducesAttributedCombatEvent(event.abilityId)) return;
|
|
source.casts += 1;
|
|
abilityMetrics(source, event.abilityId).casts += 1;
|
|
} else if (event.kind === "damage") {
|
|
if (source && !target) {
|
|
source.rawDamage += event.rawAmount;
|
|
source.damage += event.effectiveAmount;
|
|
if (event.targetActorId === bossActorId) source.bossDamage += event.effectiveAmount;
|
|
const ability = abilityMetrics(source, event.abilityId);
|
|
ability.rawDamage += event.rawAmount;
|
|
ability.effectiveDamage += event.effectiveAmount;
|
|
ability.hits += 1;
|
|
}
|
|
if (target && !source) {
|
|
target.rawDamageTaken += event.rawAmount;
|
|
target.damageTaken += event.effectiveAmount;
|
|
target.absorbedDamageTaken += event.absorbed;
|
|
const key = event.abilityId ?? "unknown";
|
|
const ability = bossAbilities.get(key) ?? blankAbility();
|
|
ability.rawDamage += event.rawAmount;
|
|
ability.effectiveDamage += event.effectiveAmount;
|
|
ability.absorbs += event.absorbed;
|
|
ability.hits += 1;
|
|
bossAbilities.set(key, ability);
|
|
if (event.effectiveAmount > 0) pressureWindows.add(combatWindow(event.occurredAt));
|
|
}
|
|
} else if (event.kind === "healing") {
|
|
if (source) {
|
|
source.rawHealing += event.rawAmount;
|
|
source.healing += event.effectiveAmount;
|
|
source.overheal += event.overheal;
|
|
const ability = abilityMetrics(source, event.abilityId);
|
|
ability.rawHealing += event.rawAmount;
|
|
ability.effectiveHealing += event.effectiveAmount;
|
|
ability.overheal += event.overheal;
|
|
ability.hits += 1;
|
|
if (source.build.role === "healer" && event.effectiveAmount > 0) {
|
|
healerThroughputWindows.add(combatWindow(event.occurredAt));
|
|
}
|
|
}
|
|
if (target) target.healingReceived += event.effectiveAmount;
|
|
} else if (event.kind === "absorb" && source) {
|
|
source.absorbs += event.effectiveAmount;
|
|
abilityMetrics(source, event.abilityId).absorbs += event.effectiveAmount;
|
|
if (source.build.role === "healer" && event.effectiveAmount > 0) {
|
|
healerThroughputWindows.add(combatWindow(event.occurredAt));
|
|
}
|
|
}
|
|
});
|
|
|
|
let elapsedMs = 0;
|
|
let outcome: "defeated" | "wipe" | "timeout" = "timeout";
|
|
try {
|
|
const combat = useCombatStore.getState();
|
|
combat.initializeCharacter({
|
|
classId: activeBuild.classId,
|
|
raceId: activeBuild.raceId,
|
|
secondaryClassId: activeBuild.secondaryClassId,
|
|
specializationId: specialization?.id,
|
|
role: activeBuild.role,
|
|
level: activeBuild.level,
|
|
talentRanks: activeBuild.talentRanks,
|
|
inventory: activeBuild.inventory,
|
|
equipment: activeBuild.equipment,
|
|
});
|
|
combat.setPlayerPosition([-2, 0, 0]);
|
|
usePartyStore.setState({
|
|
seed: `${seed}:${boss.key}`,
|
|
playerRole: activeBuild.role,
|
|
partySize: 5,
|
|
categoryId: config.source,
|
|
members: companions,
|
|
command: "defend",
|
|
selectedMemberId: null,
|
|
selfSelected: false,
|
|
routeBlocked: false,
|
|
activeMobId: null,
|
|
objectiveBossId: null,
|
|
memberTargetIds: {},
|
|
});
|
|
combat.synchronizeActors();
|
|
for (const metrics of memberMetrics) {
|
|
actorAlive.set(metrics.actorId, currentHealth(metrics.actorId)[0] > 0);
|
|
}
|
|
combat.registerMob(bossActorId, {
|
|
name: entity.name,
|
|
boss: true,
|
|
serverEntry: entity.combat?.serverEntry,
|
|
...scaling,
|
|
attacks: entity.combat?.attacks,
|
|
xpReward: 0,
|
|
});
|
|
setMobPosition(bossActorId, [0, 0, 0]);
|
|
for (const member of companions) {
|
|
registerPartyRuntimePosition(member.id, [-partyCombatRangeBand(member).preferred, 0, 0]);
|
|
updatePartyRuntimeTargetVisibility(member.id, bossActorId, true);
|
|
}
|
|
combat.selectMob(bossActorId);
|
|
const tank = memberMetrics.find((member) => member.build.role === "tank") ?? memberMetrics[0];
|
|
combat.damageMob(bossActorId, 1, now, {
|
|
actorId: tank.actorId,
|
|
role: "tank",
|
|
abilityId: "balance-lab-pull",
|
|
}, "physical");
|
|
const controller = new BenchmarkController();
|
|
const runScenario = scenario(durationMs);
|
|
for (elapsedMs = 100; elapsedMs <= durationMs; elapsedMs += 100) {
|
|
now = 1_000_000 + elapsedMs;
|
|
combat.tick(0.1, now);
|
|
combat.advanceMobCombat(now);
|
|
advanceDungeonPartyCombat(now);
|
|
controller.act(now, activeBuild.role, runScenario, false);
|
|
for (const member of usePartyStore.getState().members) {
|
|
const metrics = byActorId.get(member.id);
|
|
if (!metrics) continue;
|
|
// Dead companions cannot act or gain/spend resources. Sampling them until
|
|
// the fight ends makes an early death look like an idle/resource deadlock.
|
|
if (currentHealth(member.id)[0] <= 0) continue;
|
|
metrics.gcdObservedMs += 100;
|
|
if (member.globalCooldownEndsAt > now || member.activeCast) metrics.gcdBusyMs += 100;
|
|
const resources = new Set<ResourceType>([
|
|
...(Object.keys(member.resourcePools) as ResourceType[]),
|
|
member.resourceType,
|
|
]);
|
|
for (const resource of resources) {
|
|
const maximum = companionResourceMaximum(member, resource);
|
|
if (maximum <= 0) continue;
|
|
const value = member.resourcePools[resource] ?? (resource === member.resourceType ? member.resource : 0);
|
|
const sample = metrics.resourceSamples.get(resource) ?? { samples: 0, starved: 0, capped: 0 };
|
|
sample.samples += 1;
|
|
if (value / maximum <= 0.1) sample.starved += 1;
|
|
if (value / maximum >= 0.95) sample.capped += 1;
|
|
metrics.resourceSamples.set(resource, sample);
|
|
}
|
|
}
|
|
for (const metrics of memberMetrics) {
|
|
const [health, maxHealth] = currentHealth(metrics.actorId);
|
|
const alive = health > 0;
|
|
if ((actorAlive.get(metrics.actorId) ?? alive) && !alive) metrics.deathEvents += 1;
|
|
actorAlive.set(metrics.actorId, alive);
|
|
metrics.minimumHealthPercent = Math.min(
|
|
metrics.minimumHealthPercent,
|
|
maxHealth > 0 ? Math.max(0, health / maxHealth) : 0,
|
|
);
|
|
}
|
|
const state = useCombatStore.getState();
|
|
if (state.mobs[bossActorId]?.dead) {
|
|
outcome = "defeated";
|
|
break;
|
|
}
|
|
if (memberMetrics.every((metrics) => currentHealth(metrics.actorId)[0] <= 0)) {
|
|
outcome = "wipe";
|
|
break;
|
|
}
|
|
}
|
|
const activeSeconds = Math.min(durationMs, elapsedMs) / 1_000;
|
|
const mob = useCombatStore.getState().mobs[bossActorId];
|
|
const members = memberMetrics.map((metrics) => serializedMember(metrics, activeSeconds));
|
|
const incomingDamage = members.reduce((total, member) => total + member.damageTaken, 0);
|
|
const rawIncomingDamage = members.reduce((total, member) => total + member.rawDamageTaken, 0);
|
|
const partyDamage = members.reduce((total, member) => total + member.damage, 0);
|
|
const bossDamage = members.reduce((total, member) => total + member.bossDamage, 0);
|
|
const healerResponseWindows = [...pressureWindows].filter((window) => (
|
|
healerThroughputWindows.has(window - 1)
|
|
|| healerThroughputWindows.has(window)
|
|
|| healerThroughputWindows.has(window + 1)
|
|
)).length;
|
|
return {
|
|
bossKey: boss.key,
|
|
bossName: boss.name,
|
|
bossCategory: boss.category,
|
|
dungeonId: boss.dungeonId,
|
|
dungeonTitle: boss.dungeonTitle,
|
|
seed,
|
|
outcome,
|
|
elapsedSeconds: activeSeconds,
|
|
timeToDefeatSeconds: outcome === "defeated" ? activeSeconds : null,
|
|
bossLevel: scaling.level,
|
|
bossMaxHealth: scaling.maxHealth,
|
|
bossRemainingHealth: mob?.health ?? 0,
|
|
partyDamage,
|
|
bossDamage,
|
|
partyDps: partyDamage / Math.max(0.1, activeSeconds),
|
|
bossDpsTaken: bossDamage / Math.max(0.1, activeSeconds),
|
|
incomingDamage,
|
|
rawIncomingDamage,
|
|
incomingDtps: incomingDamage / Math.max(0.1, activeSeconds),
|
|
pressureWindows: pressureWindows.size,
|
|
healerThroughputWindows: healerThroughputWindows.size,
|
|
healerResponseWindows,
|
|
healerResponseRate: healerResponseWindows / Math.max(1, pressureWindows.size),
|
|
deaths: members.filter((member) => !member.alive).length,
|
|
deathEvents: members.reduce((total, member) => total + member.deathEvents, 0),
|
|
bossAttacks: mob?.attackRevision ?? 0,
|
|
members,
|
|
bossAbilities: [...bossAbilities.entries()]
|
|
.map(([abilityId, ability]) => ({ abilityId, ...ability }))
|
|
.sort((left, right) => right.effectiveDamage - left.effectiveDamage
|
|
|| left.abilityId.localeCompare(right.abilityId)),
|
|
};
|
|
} finally {
|
|
stopRotationDecisions();
|
|
stopPresentation();
|
|
stopEvents();
|
|
restoreClock();
|
|
clearMobRuntimeRegistry();
|
|
clearPartyRuntimeRegistry();
|
|
resetPartyRuntimeClock();
|
|
resetCombatSpatialProvider();
|
|
}
|
|
}
|
|
|
|
async function simulateConfig(config: BalanceLabConfig, output: string): Promise<void> {
|
|
if (config.schemaVersion !== 1) throw new Error(`Unsupported config schema: ${config.schemaVersion}`);
|
|
if (config.partyBuildIds.length !== 5) throw new Error("A balance-lab party must contain exactly five builds.");
|
|
if (!Number.isInteger(config.activeSlot) || config.activeSlot < 0 || config.activeSlot >= 5) {
|
|
throw new Error("activeSlot must be an integer from 0 through 4.");
|
|
}
|
|
if (!config.seeds.length) throw new Error("At least one simulation seed is required.");
|
|
installCompanionCooldownPolicy(config.cooldownPolicy ?? DEFAULT_COMPANION_COOLDOWN_POLICY);
|
|
const catalog = selectableBenchmarkBuildCatalog(config.level, classIdsForSource(config.source));
|
|
const catalogById = new Map(catalog.map((build) => [build.id, build]));
|
|
const selectedClassIds = [...new Set(config.partyBuildIds.map((id) => {
|
|
const entry = catalogById.get(id);
|
|
if (!entry) throw new Error(`Unknown ${config.source} party build: ${id}`);
|
|
return entry.classId;
|
|
}))];
|
|
const availableBuilds = await loadSelectableBenchmarkBuilds(config.level, selectedClassIds);
|
|
const byId = new Map(availableBuilds.map((build) => [build.id, build]));
|
|
const builds = config.partyBuildIds.map((id) => {
|
|
const build = byId.get(id);
|
|
if (!build) throw new Error(`Unknown ${config.source} party build: ${id}`);
|
|
return build;
|
|
});
|
|
await preloadAbilityCatalogsForClasses(builds.map((build) => build.classId));
|
|
const bosses = selectedBosses(config);
|
|
const runs = [];
|
|
let completed = 0;
|
|
const total = bosses.length * config.seeds.length;
|
|
for (const boss of bosses) {
|
|
for (const seed of config.seeds) {
|
|
runs.push(await runBoss(config, builds, boss, seed));
|
|
completed += 1;
|
|
if (completed === 1 || completed === total || completed % 10 === 0) {
|
|
process.stderr.write(`[balance-lab] ${completed}/${total} ${boss.name} seed ${seed}\n`);
|
|
}
|
|
}
|
|
}
|
|
await writeJson(output, {
|
|
schemaVersion: 1,
|
|
generatedAt: new Date().toISOString(),
|
|
engine: "live-typescript-runtime",
|
|
policyId: BENCHMARK_POLICY_ID,
|
|
rotationVersion: COMBAT_ROTATION_VERSION,
|
|
config: {
|
|
...config,
|
|
level: builds[0]?.level ?? config.level,
|
|
},
|
|
party: builds.map((build, slot) => ({
|
|
slot,
|
|
activePlayer: slot === config.activeSlot,
|
|
buildId: build.id,
|
|
characterName: build.characterName ?? null,
|
|
talentBuildId: build.talentBuildId ?? null,
|
|
talentBuildName: build.talentBuildName ?? null,
|
|
talentBuildSource: build.talentBuildSource ?? null,
|
|
talentBuildSourcePointCount: build.talentBuildSourcePointCount ?? null,
|
|
talentBuildSourcePointsRetained: build.talentBuildSourcePointsRetained ?? null,
|
|
talentBuildFallbackPointCount: build.talentBuildFallbackPointCount ?? null,
|
|
classId: build.classId,
|
|
raceId: build.raceId ?? null,
|
|
secondaryClassId: build.secondaryClassId ?? null,
|
|
className: classById(build.classId).name,
|
|
specialization: build.orientation,
|
|
role: build.role,
|
|
})),
|
|
bossCount: bosses.length,
|
|
runCount: runs.length,
|
|
runs,
|
|
});
|
|
}
|
|
|
|
async function simulate(): Promise<void> {
|
|
const config = JSON.parse(await readFile(resolve(requiredArgument("--config")), "utf8")) as BalanceLabConfig;
|
|
await simulateConfig(config, requiredArgument("--output"));
|
|
}
|
|
|
|
async function serveSession(): Promise<void> {
|
|
const level = Math.trunc(safeNumber(argument("--level"), 60));
|
|
const source = (argument("--source") ?? "wow") as PartyCatalogSource;
|
|
process.stdout.write(`${JSON.stringify(catalogPayload(source, level))}\n`);
|
|
const lines = createInterface({ input: process.stdin, crlfDelay: Infinity });
|
|
for await (const line of lines) {
|
|
if (!line.trim()) continue;
|
|
const request = JSON.parse(line) as {
|
|
readonly close?: boolean;
|
|
readonly config?: string;
|
|
readonly output?: string;
|
|
};
|
|
if (request.close) {
|
|
process.stdout.write(`${JSON.stringify({ ok: true, closed: true })}\n`);
|
|
lines.close();
|
|
return;
|
|
}
|
|
if (!request.config || !request.output) throw new Error("A session simulation requires config and output paths.");
|
|
const config = JSON.parse(await readFile(resolve(request.config), "utf8")) as BalanceLabConfig;
|
|
await simulateConfig(config, request.output);
|
|
process.stdout.write(`${JSON.stringify({ ok: true })}\n`);
|
|
}
|
|
}
|
|
|
|
async function main(): Promise<void> {
|
|
const command = process.argv[2];
|
|
if (command === "catalog") await writeCatalog();
|
|
else if (command === "simulate") await simulate();
|
|
else if (command === "session") await serveSession();
|
|
else throw new Error("Usage: vite-node scripts/balance-lab/runtime.ts <catalog|simulate|session> [options]");
|
|
}
|
|
|
|
main().catch((error) => {
|
|
console.error(error instanceof Error ? error.stack ?? error.message : error);
|
|
process.exitCode = 1;
|
|
});
|