Files
healer-man/scripts/balance-lab/runtime.ts
T
2026-09-17 13:33:55 -04:00

644 lines
23 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 {
abilitiesForCharacter,
learnedAbilityForBuild,
preloadAbilityCatalogsForClasses,
} 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 { subscribeCombatEvents } from "../../src/game/combatEvents";
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 {
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 } from "../../src/game/combatRotation";
import {
loadSelectableBenchmarkBuilds,
selectableBenchmarkBuildCatalog,
} 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[];
}
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>;
}
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,
}));
}
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,
className: classById(build.classId).name,
role: build.role,
specialization: build.orientation,
label: `${classById(build.classId).name} — ${build.orientation} (${build.role})`,
})),
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: `${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 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) {
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;
return {
abilityId: ranked.id,
abilityName: ranked.name,
icon: ranked.icon,
categories: aiHintsForAbility(ranked).categories,
cooldownSeconds: ranked.cooldownMs / 1_000,
};
})
.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,
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,
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(),
}));
const byActorId = new Map(memberMetrics.map((metrics) => [metrics.actorId, metrics]));
const bossAbilities = new Map<string, MutableAbilityMetrics>();
const stopEvents = subscribeCombatEvents((event) => {
const source = byActorId.get(event.sourceActorId);
const target = byActorId.get(event.targetActorId);
if (event.kind === "cast" && source) {
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);
}
} 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 (target) target.healingReceived += event.effectiveAmount;
} else if (event.kind === "absorb" && source) {
source.absorbs += event.effectiveAmount;
abilityMetrics(source, event.abilityId).absorbs += event.effectiveAmount;
} else if (event.kind === "death" && target) {
target.deathEvents += 1;
}
});
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();
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 metrics of memberMetrics) {
const [health, maxHealth] = currentHealth(metrics.actorId);
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);
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),
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 {
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.");
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,
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<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;
});