forked from phenom/healer-man
Release Healer Man 0.1.46
This commit is contained in:
1 parent
f82daf1c13
commit
9ff574ac0b
73 files changed
+33486
-174
No files matched your search
@@ -0,0 +1,635 @@
|
||||
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 {
|
||||
abilitiesForClass,
|
||||
abilityAtLevel,
|
||||
isAbilityUnlocked,
|
||||
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 { BenchmarkController } from "../../src/game/benchmark/benchmarkController";
|
||||
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;
|
||||
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,
|
||||
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 abilitiesForClass(build.classId)
|
||||
.filter((ability) => !ability.passive && isAbilityUnlocked(ability, build.level))
|
||||
.map((ability) => {
|
||||
const learnedRank = ability.talentEntryId === undefined
|
||||
? undefined
|
||||
: build.talentRanks[`coa-entry-${ability.talentEntryId}`] ?? 0;
|
||||
if (ability.talentEntryId !== undefined && !learnedRank) return null;
|
||||
const ranked = abilityAtLevel(ability, build.level, learnedRank);
|
||||
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),
|
||||
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,
|
||||
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" && 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;
|
||||
} 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",
|
||||
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;
|
||||
});
|
||||
Reference in new issue
Block a user