forked from phenom/healer-man
175 lines
7.2 KiB
TypeScript
175 lines
7.2 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 {
|
|
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 Limit primary classes.",
|
|
" --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.",
|
|
"",
|
|
].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 ?? "coefficient-empty",
|
|
seeds: options.seeds,
|
|
durationScale: options.durationScale,
|
|
policyId: BENCHMARK_POLICY_ID,
|
|
},
|
|
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;
|
|
});
|