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