Files
healer-man/scripts/combat-benchmark/run.ts
T
2026-09-20 21:50:50 -04:00

180 lines
7.5 KiB
TypeScript

import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { resolve } from "node:path";
import { CLASSES, type ClassId } from "../../src/app/characterCatalog";
import {
CANONICAL_BENCHMARK_LEVEL,
loadCanonicalBenchmarkBuilds,
} from "../../src/game/benchmark/benchmarkBuilds";
import { BENCHMARK_POLICY_ID } from "../../src/game/benchmark/benchmarkController";
import { COMBAT_ROTATION_VERSION } from "../../src/game/combatRotation";
import {
renderBenchmarkMatrixMarkdown,
serializeBenchmarkMatrixCsv,
} from "../../src/game/benchmark/benchmarkReport";
import { runCombatBenchmark } from "../../src/game/benchmark/benchmarkRunner";
import { BENCHMARK_SCENARIOS } from "../../src/game/benchmark/benchmarkScenarios";
import type { BenchmarkMatrixReport } from "../../src/game/benchmark/benchmarkTypes";
import type { PartyRole } from "../../src/game/partyRoles";
interface CliOptions {
readonly level: number;
readonly classIds: readonly ClassId[] | null;
readonly roles: readonly PartyRole[] | null;
readonly scenarioIds: readonly string[] | null;
readonly seeds: readonly number[];
readonly durationScale: number;
readonly output: string | null;
}
const projectRoot = resolve(import.meta.dirname, "../..");
function argumentValue(arguments_: readonly string[], name: string): string | null {
const index = arguments_.indexOf(name);
if (index < 0) return null;
const value = arguments_[index + 1];
if (!value || value.startsWith("--")) throw new Error(`${name} requires a value.`);
return value;
}
function list(value: string | null): readonly string[] | null {
if (value === null) return null;
const values = value.split(",").map((entry) => entry.trim()).filter(Boolean);
if (!values.length) throw new Error("A comma-separated filter cannot be empty.");
return values;
}
function finiteNumber(value: string | null, fallback: number, name: string): number {
if (value === null) return fallback;
const parsed = Number(value);
if (!Number.isFinite(parsed)) throw new Error(`${name} must be a finite number.`);
return parsed;
}
function parseOptions(arguments_: readonly string[]): CliOptions {
const knownClasses = new Set(CLASSES.map((definition) => definition.id));
const classIds = list(argumentValue(arguments_, "--class"));
if (classIds?.some((id) => !knownClasses.has(id as ClassId))) {
throw new Error(`Unknown class filter: ${classIds.find((id) => !knownClasses.has(id as ClassId))}.`);
}
const roles = list(argumentValue(arguments_, "--role"));
if (roles?.some((role) => !["tank", "healer", "damage"].includes(role))) {
throw new Error(`Unknown role filter: ${roles.find((role) => !["tank", "healer", "damage"].includes(role))}.`);
}
const scenarioIds = list(argumentValue(arguments_, "--scenario"));
const knownScenarios = new Set(BENCHMARK_SCENARIOS.map((scenario) => scenario.id));
if (scenarioIds?.some((id) => !knownScenarios.has(id))) {
throw new Error(`Unknown scenario filter: ${scenarioIds.find((id) => !knownScenarios.has(id))}.`);
}
const seeds = (list(argumentValue(arguments_, "--seeds")) ?? ["1", "2", "3"])
.map((seed) => Number(seed));
if (!seeds.length || seeds.some((seed) => !Number.isSafeInteger(seed))) {
throw new Error("--seeds must contain comma-separated safe integers.");
}
const durationScale = finiteNumber(argumentValue(arguments_, "--duration-scale"), 1, "--duration-scale");
if (durationScale <= 0) throw new Error("--duration-scale must be greater than zero.");
return {
level: Math.trunc(finiteNumber(argumentValue(arguments_, "--level"), CANONICAL_BENCHMARK_LEVEL, "--level")),
classIds: classIds as readonly ClassId[] | null,
roles: roles as readonly PartyRole[] | null,
scenarioIds,
seeds,
durationScale,
output: argumentValue(arguments_, "--output"),
};
}
function help(): string {
return [
"Healer Man balance benchmark",
"",
"Usage: npm run balance:benchmark -- [options]",
"",
"Options:",
" --class mage,templar Explicit targeted class diagnostic.",
" --role damage,healer Limit roles.",
" --scenario id,id Limit scenario IDs.",
" --level 60 Comparison level (default 60).",
" --seeds 1,2,3 Deterministic seeds.",
" --duration-scale 1 Scale scenario duration (smoke tests only below 1).",
" --output path Output directory under the current path or absolute.",
" --help Show this help.",
"",
"Default scope: the five Ready WoW specializations only.",
"CoA, RoM, and other WoW specializations require an explicit --class filter.",
"",
].join("\n");
}
function timestampDirectory(iso: string): string {
return iso.replaceAll(":", "-").replace(/\.\d{3}Z$/u, "Z");
}
async function main(): Promise<void> {
const arguments_ = process.argv.slice(2);
if (arguments_.includes("--help")) {
process.stdout.write(help());
return;
}
const options = parseOptions(arguments_);
const generatedAt = new Date().toISOString();
const packageJson = JSON.parse(readFileSync(resolve(projectRoot, "package.json"), "utf8")) as { version: string };
const builds = (await loadCanonicalBenchmarkBuilds(options.level, options.classIds ?? undefined))
.filter((build) => !options.roles || options.roles.includes(build.role));
const scenarios = BENCHMARK_SCENARIOS.filter((scenario) => (
!options.scenarioIds || options.scenarioIds.includes(scenario.id)
));
const pairs = builds.flatMap((build) => scenarios
.filter((scenario) => scenario.roles.includes(build.role))
.map((scenario) => ({ build, scenario })));
if (!pairs.length) throw new Error("The selected filters produced no compatible build/scenario pairs.");
const totalRuns = pairs.length * options.seeds.length;
const runs = [];
let completed = 0;
for (const pair of pairs) {
for (const seed of options.seeds) {
completed += 1;
process.stdout.write(
`[${completed}/${totalRuns}] ${pair.build.classId}:${pair.build.role} / ${pair.scenario.id} / seed ${seed}\n`,
);
const run = await runCombatBenchmark(pair.build, pair.scenario, {
seed,
durationScale: options.durationScale,
});
runs.push(run);
if (!run.validation.valid) {
process.stderr.write(` invalid: ${run.validation.reasons.join("; ")}\n`);
}
}
}
const report: BenchmarkMatrixReport = {
schemaVersion: 1,
generatedAt,
appVersion: packageJson.version,
cohort: {
level: options.level,
gearPresetId: builds[0]?.gearPresetId ?? "virtual-heirloom",
seeds: options.seeds,
durationScale: options.durationScale,
policyId: BENCHMARK_POLICY_ID,
rotationVersion: COMBAT_ROTATION_VERSION,
},
scenarios,
builds,
runs,
};
const defaultOutput = resolve(projectRoot, "artifacts/combat-benchmarks", timestampDirectory(generatedAt));
const output = options.output ? resolve(process.cwd(), options.output) : defaultOutput;
mkdirSync(output, { recursive: true });
writeFileSync(resolve(output, "matrix.json"), `${JSON.stringify(report, null, 2)}\n`, "utf8");
writeFileSync(resolve(output, "matrix.csv"), serializeBenchmarkMatrixCsv(report), "utf8");
writeFileSync(resolve(output, "summary.md"), renderBenchmarkMatrixMarkdown(report), "utf8");
process.stdout.write(`Wrote ${runs.length} run(s) to ${output}\n`);
}
main().catch((error: unknown) => {
process.stderr.write(`${error instanceof Error ? error.stack ?? error.message : String(error)}\n`);
process.exitCode = 1;
});