Files
2026-10-06 22:25:34 -04:00

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