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 { 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; readonly rotationDecisions: Map; gcdObservedMs: number; gcdBusyMs: number; readonly resourceSamples: Map; } 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 { 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, 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: `${classById(build.classId).name} — ${build.orientation} (${build.role})${build.talentBuildName ? ` — ${build.talentBuildName}` : ""}`, gear: resolvedGearSummary(build), })), bosses: bossCatalogRows(), }; } async function writeCatalog(): Promise { 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, 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, 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(); const bossAbilities = new Map(); const pressureWindows = new Set(); const healerThroughputWindows = new Set(); 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, }); }); // Instant resource, aura, pet, and summon actions do not produce the damage, // healing, absorb, or cast events used by the original report collector. // Count their completed release once so Balance Lab does not label working // actions such as Bloodrage and Feral Spirit as available-but-unused. const stopPresentation = subscribeCombatPresentation((message) => { if (message.kind !== "event" || message.event.phase !== "release") return; const source = byActorId.get(message.event.sourceActorId); if (!source || abilityProducesAttributedCombatEvent(message.event.abilityId)) 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, 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([ ...(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 { 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, className: classById(build.classId).name, specialization: build.orientation, role: build.role, })), bossCount: bosses.length, runCount: runs.length, runs, }); } async function simulate(): Promise { const config = JSON.parse(await readFile(resolve(requiredArgument("--config")), "utf8")) as BalanceLabConfig; await simulateConfig(config, requiredArgument("--output")); } async function serveSession(): Promise { 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 { 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 [options]"); } main().catch((error) => { console.error(error instanceof Error ? error.stack ?? error.message : error); process.exitCode = 1; });