forked from phenom/healer-man
Release Healer Man 0.1.9
This commit is contained in:
1 parent
c245a199b6
commit
d060296ade
74 files changed
+5901
-334
No files matched your search
@@ -0,0 +1,174 @@
|
||||
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;
|
||||
});
|
||||
Reference in new issue
Block a user