Files
healer-man/server/balance-lab-jobs.mjs
T
2026-09-19 21:01:51 -04:00

584 lines
24 KiB
JavaScript

import { randomUUID } from "node:crypto";
import { spawn, spawnSync } from "node:child_process";
import {
createReadStream,
existsSync,
mkdirSync,
readFileSync,
readdirSync,
statSync,
writeFileSync,
} from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { GameDatabaseError } from "./database.mjs";
const SOURCES = new Set(["wow", "coa", "rom", "all"]);
const BOSS_CATEGORIES = new Set(["all", "wow", "rom"]);
const SPEC_SELECTIONS = new Set(["random", "all", "specific"]);
const JOB_STATUSES = new Set(["queued", "running", "complete", "failed"]);
const MAX_BATCH_JOBS = 80;
const JOB_METADATA_NAME = "job.json";
const JOB_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
const ARTIFACTS = new Map([
["suite-report.html", "text/html; charset=utf-8"],
["suite-report.json", "application/json; charset=utf-8"],
["suite-report.md", "text/markdown; charset=utf-8"],
["party_configurations.csv", "text/csv; charset=utf-8"],
["class_specs_across_parties.csv", "text/csv; charset=utf-8"],
["class_abilities_across_parties.csv", "text/csv; charset=utf-8"],
["npc_class_specs_across_parties.csv", "text/csv; charset=utf-8"],
["npc_class_abilities_across_parties.csv", "text/csv; charset=utf-8"],
["class_spec_by_boss.csv", "text/csv; charset=utf-8"],
["bosses_across_parties.csv", "text/csv; charset=utf-8"],
["boss_by_party.csv", "text/csv; charset=utf-8"],
["suite_runs.csv", "text/csv; charset=utf-8"],
["suite-config.json", "application/json; charset=utf-8"],
]);
function integer(value, name, minimum, maximum) {
const parsed = Number(value);
if (!Number.isInteger(parsed) || parsed < minimum || parsed > maximum) {
throw new GameDatabaseError(`${name} must be a whole number from ${minimum} through ${maximum}.`, 400, "invalid_balance_lab_job");
}
return parsed;
}
export function normalizeBalanceLabJobSpec(input) {
const source = String(input?.source ?? "all").toLowerCase();
const bossCategory = String(input?.bossCategory ?? "all").toLowerCase();
const specSelection = String(input?.specSelection ?? "random").toLowerCase();
if (!SOURCES.has(source)) {
throw new GameDatabaseError("Party source must be wow, coa, rom, or all.", 400, "invalid_balance_lab_job");
}
if (!BOSS_CATEGORIES.has(bossCategory)) {
throw new GameDatabaseError("Boss category must be all, wow, or rom.", 400, "invalid_balance_lab_job");
}
if (!SPEC_SELECTIONS.has(specSelection)) {
throw new GameDatabaseError("Class/spec selection must be random, all, or specific.", 400, "invalid_balance_lab_job");
}
if (input?.includeBetaSpecs !== undefined && typeof input.includeBetaSpecs !== "boolean") {
throw new GameDatabaseError("Include Beta Specs must be boolean.", 400, "invalid_balance_lab_job");
}
const rawBossKeys = Array.isArray(input?.bossKeys) ? input.bossKeys : [];
if (rawBossKeys.length > 500) {
throw new GameDatabaseError("Select no more than 500 bosses.", 400, "invalid_balance_lab_job");
}
const bossKeys = [...new Set(rawBossKeys.map((value) => String(value).trim()).filter(Boolean))];
if (bossKeys.some((key) => key.length > 240 || !/^[A-Za-z0-9._:/-]+$/.test(key))) {
throw new GameDatabaseError("A selected boss key is invalid.", 400, "invalid_balance_lab_job");
}
const rawBuildIds = Array.isArray(input?.selectedBuildIds) ? input.selectedBuildIds : [];
if (rawBuildIds.length > 200) {
throw new GameDatabaseError("Select no more than 200 class/spec builds.", 400, "invalid_balance_lab_job");
}
const selectedBuildIds = [...new Set(rawBuildIds.map((value) => String(value).trim()).filter(Boolean))];
if (selectedBuildIds.some((id) => id.length > 240 || !/^[A-Za-z0-9._:/-]+$/.test(id))) {
throw new GameDatabaseError("A selected class/spec build ID is invalid.", 400, "invalid_balance_lab_job");
}
if (specSelection === "specific" && selectedBuildIds.length === 0) {
throw new GameDatabaseError("Select at least one class/spec build.", 400, "invalid_balance_lab_job");
}
const spec = Object.freeze({
source,
specSelection,
selectedBuildIds: Object.freeze(specSelection === "specific" ? selectedBuildIds : []),
level: integer(input?.level ?? 60, "Level", 1, 80),
partyConfigurations: integer(input?.partyConfigurations ?? 10, "Party configurations", 1, 100),
simulationsPerParty: integer(input?.simulationsPerParty ?? 3, "Simulations per party", 1, 100),
durationSeconds: integer(input?.durationSeconds ?? 60, "Fight duration", 5, 600),
partySeed: integer(input?.partySeed ?? 1, "Party seed", -2_147_483_648, 2_147_483_647),
bossCategory,
bossKeys: Object.freeze(bossKeys),
seatSweep: input?.seatSweep === true,
includeBetaSpecs: input?.includeBetaSpecs === true,
});
const sourceCount = spec.source === "all" ? 3 : 1;
const bossEstimate = Math.max(1, spec.bossKeys.length || (spec.bossCategory === "all" ? 500 : 300));
const partyEstimate = spec.specSelection === "random"
? sourceCount * spec.partyConfigurations
: spec.specSelection === "specific"
? Math.max(spec.partyConfigurations, spec.selectedBuildIds.length)
: Math.max(spec.partyConfigurations, spec.source === "all" ? 100 : 50);
const estimatedRuns = partyEstimate * (spec.seatSweep ? 5 : 1) * spec.simulationsPerParty * bossEstimate;
if (estimatedRuns > 250_000) {
throw new GameDatabaseError(
"That job is too large for one server request. Reduce parties, simulations, or selected bosses.",
400,
"balance_lab_job_too_large",
);
}
return spec;
}
export function normalizeBalanceLabJobBatch(input) {
const rawSpecs = Array.isArray(input?.specs) ? input.specs : [];
if (rawSpecs.length < 1 || rawSpecs.length > MAX_BATCH_JOBS) {
throw new GameDatabaseError(
`A Balance Lab batch must contain 1 through ${MAX_BATCH_JOBS} level jobs.`,
400,
"invalid_balance_lab_batch",
);
}
return Object.freeze(rawSpecs.map((spec) => normalizeBalanceLabJobSpec(spec)));
}
export function balanceLabCommand(spec, outputDir, options = {}) {
const projectRoot = path.resolve(options.projectRoot ?? fileURLToPath(new URL("..", import.meta.url)));
const executable = String(options.pythonExecutable
?? process.env.BALANCE_LAB_PYTHON
?? (process.platform === "win32" ? "python" : "python3"));
const args = [
path.join(projectRoot, "scripts", "balance_lab.py"),
"--source", spec.source,
"--level", String(spec.level),
"--party-mode", "automatic",
"--spec-selection", spec.specSelection,
"--party-seed", String(spec.partySeed),
"--party-configurations", String(spec.partyConfigurations),
"--simulations-per-party", String(spec.simulationsPerParty),
"--duration", String(spec.durationSeconds),
"--boss-category", spec.bossCategory,
"--output-dir", outputDir,
];
for (const buildId of spec.selectedBuildIds) args.push("--spec", buildId);
for (const bossKey of spec.bossKeys) args.push("--boss", bossKey);
if (spec.seatSweep) args.push("--seat-sweep");
if (spec.includeBetaSpecs) args.push("--include-beta-specs");
if (options.cooldownPolicyPath) args.push("--cooldown-policy", options.cooldownPolicyPath);
if (options.readinessPolicyPath) args.push("--companion-readiness-policy", options.readinessPolicyPath);
return { executable, args, cwd: projectRoot };
}
export function inspectBalanceLabRuntime(executable, run = spawnSync) {
const command = String(executable || (process.platform === "win32" ? "python" : "python3"));
try {
const result = run(command, ["--version"], {
encoding: "utf8",
timeout: 5_000,
windowsHide: true,
});
const version = `${result?.stdout ?? ""} ${result?.stderr ?? ""}`.trim();
if (result?.status === 0 && /^Python 3\./i.test(version)) {
return { available: true, executable: command, version, error: null };
}
return {
available: false,
executable: command,
version: version || null,
error: version && /^Python 2\./i.test(version)
? "Balance Lab requires Python 3; this server resolved Python 2."
: `The server could not execute ${command} as Python 3.`,
};
} catch (error) {
return {
available: false,
executable: command,
version: null,
error: `The server could not execute ${command}: ${error?.message ?? error}`,
};
}
}
function publicJob(job) {
return {
id: job.id,
status: job.status,
createdAt: job.createdAt,
startedAt: job.startedAt,
completedAt: job.completedAt,
requestedBy: job.requestedBy,
spec: job.spec,
progress: { ...job.progress },
artifacts: [...job.artifacts],
error: job.error,
logTail: [...job.logTail],
summary: job.summary ? { ...job.summary } : null,
};
}
function reportSummary(outputDir) {
const reportPath = path.join(outputDir, "suite-report.json");
if (!existsSync(reportPath)) return null;
try {
const summary = JSON.parse(readFileSync(reportPath, "utf8"))?.summary;
if (!summary || typeof summary !== "object") return null;
return {
partyConfigurations: Number(summary.partyConfigurations) || 0,
bosses: Number(summary.bosses) || 0,
runs: Number(summary.runs) || 0,
classSpecs: Number(summary.classSpecs) || 0,
classAbilities: Number(summary.classAbilities) || 0,
npcClassSpecs: Number(summary.npcClassSpecs) || 0,
npcClassAbilities: Number(summary.npcClassAbilities) || 0,
bossPressureMedianDtps: Number(summary.bossPressureMedianDtps) || 0,
};
} catch {
return null;
}
}
function expectedPartyConfigurations(spec) {
const parties = spec.specSelection === "random"
? spec.partyConfigurations * (spec.source === "all" ? 3 : 1)
: spec.partyConfigurations;
return parties * (spec.seatSweep ? 5 : 1);
}
function lineCollector(onLine) {
let pending = "";
return {
push(chunk) {
pending += String(chunk);
const lines = pending.split(/\r?\n/);
pending = lines.pop() ?? "";
for (const line of lines) if (line.trim()) onLine(line.trim());
},
flush() {
if (pending.trim()) onLine(pending.trim());
pending = "";
},
};
}
function processFailureMessage(job, code) {
if (job.logTail.some((line) => line.includes("ENOSPC: System limit for number of file watchers reached"))) {
return "Balance Lab could not start because the server file-watcher limit was exhausted.";
}
return code === 0
? "Balance Lab ended without producing its suite report."
: `Balance Lab process exited with code ${code ?? "unknown"}.`;
}
export function createBalanceLabJobService(options = {}) {
const projectRoot = path.resolve(options.projectRoot ?? fileURLToPath(new URL("..", import.meta.url)));
const dataDir = path.resolve(options.dataDir ?? process.env.DATA_DIR ?? "data/runtime");
const jobsDir = path.join(dataDir, "balance-lab", "jobs");
const configuredConcurrency = Number(options.maxConcurrent ?? process.env.BALANCE_LAB_MAX_CONCURRENT ?? 4);
const maxConcurrent = Number.isFinite(configuredConcurrency) && configuredConcurrency >= 1
? Math.min(16, Math.floor(configuredConcurrency))
: 4;
const configuredQueueLimit = Number(options.maxQueued ?? process.env.BALANCE_LAB_MAX_QUEUED ?? 100);
const maxQueued = Number.isFinite(configuredQueueLimit) && configuredQueueLimit >= 1
? Math.min(200, Math.floor(configuredQueueLimit))
: 100;
const launch = options.spawnProcess ?? spawn;
const cooldownPolicyProvider = options.cooldownPolicyProvider;
const readinessPolicyProvider = options.readinessPolicyProvider;
const pythonExecutable = String(options.pythonExecutable
?? process.env.BALANCE_LAB_PYTHON
?? (process.platform === "win32" ? "python" : "python3"));
const runtimeProbe = options.runtimeProbe
?? (options.spawnProcess
? () => ({ available: true, executable: pythonExecutable, version: "test process", error: null })
: () => inspectBalanceLabRuntime(pythonExecutable));
const jobs = new Map();
const queue = [];
const children = new Map();
let running = 0;
let closed = false;
let inspectedRuntime = null;
mkdirSync(jobsDir, { recursive: true });
function currentRuntime() {
inspectedRuntime ??= runtimeProbe();
return inspectedRuntime;
}
function persistJob(job) {
const record = publicJob(job);
writeFileSync(path.join(job.outputDir, JOB_METADATA_NAME), `${JSON.stringify(record, null, 2)}\n`, "utf8");
}
function restoreJobs() {
for (const entry of readdirSync(jobsDir, { withFileTypes: true })) {
if (!entry.isDirectory() || !JOB_ID_PATTERN.test(entry.name)) continue;
const outputDir = path.join(jobsDir, entry.name);
const metadataPath = path.join(outputDir, JOB_METADATA_NAME);
const reportPath = path.join(outputDir, "suite-report.json");
if (!existsSync(metadataPath) && !existsSync(reportPath)) continue;
try {
let stored;
if (existsSync(metadataPath)) {
stored = JSON.parse(readFileSync(metadataPath, "utf8"));
} else {
const legacyReport = JSON.parse(readFileSync(reportPath, "utf8"));
const legacyConfig = JSON.parse(readFileSync(path.join(outputDir, "suite-config.json"), "utf8"));
stored = {
status: "complete",
createdAt: legacyReport.generatedAt,
completedAt: legacyReport.generatedAt,
requestedBy: "unknown (legacy run)",
spec: {
source: legacyConfig.source,
specSelection: legacyConfig.specSelection ?? "random",
selectedBuildIds: legacyConfig.selectedBuildIds ?? [],
level: legacyConfig.level,
partyConfigurations: legacyConfig.specSelection && legacyConfig.specSelection !== "random"
? legacyConfig.partyConfigurations
: legacyConfig.partyConfigurationsPerSource,
simulationsPerParty: legacyConfig.simulationsPerParty,
durationSeconds: legacyConfig.durationSeconds,
partySeed: legacyConfig.partySeed,
bossCategory: legacyConfig.bossCategory,
bossKeys: legacyConfig.bossKeys ?? [],
},
progress: {},
logTail: ["Imported from a Balance Lab report created before run-history persistence."],
};
}
const spec = normalizeBalanceLabJobSpec(stored.spec);
const hasReport = existsSync(reportPath);
const storedStatus = JOB_STATUSES.has(stored.status) ? stored.status : "failed";
const interrupted = !hasReport && (storedStatus === "queued" || storedStatus === "running");
const restoredSummary = hasReport ? reportSummary(outputDir) : null;
const totalParties = restoredSummary?.partyConfigurations || expectedPartyConfigurations(spec);
const createdAt = typeof stored.createdAt === "string"
? stored.createdAt
: statSync(outputDir).birthtime.toISOString();
const job = {
id: entry.name,
status: hasReport ? "complete" : interrupted ? "failed" : storedStatus,
createdAt,
startedAt: typeof stored.startedAt === "string" ? stored.startedAt : null,
completedAt: hasReport || interrupted
? (typeof stored.completedAt === "string" ? stored.completedAt : new Date().toISOString())
: null,
requestedBy: typeof stored.requestedBy === "string" ? stored.requestedBy : "unknown",
spec,
progress: {
party: Number(stored.progress?.party) || 0,
partiesCompleted: hasReport ? totalParties : Number(stored.progress?.partiesCompleted) || 0,
partiesTotal: totalParties,
simulationsCompleted: Number(stored.progress?.simulationsCompleted) || 0,
simulationsTotal: Number(stored.progress?.simulationsTotal) || 0,
},
artifacts: hasReport
? [...ARTIFACTS.keys()].filter((name) => existsSync(path.join(outputDir, name)))
: [],
error: interrupted
? "The server restarted before this Balance Lab job completed. Run it again to produce a report."
: (typeof stored.error === "string" ? stored.error : null),
logTail: Array.isArray(stored.logTail)
? stored.logTail.filter((line) => typeof line === "string").slice(-80)
: [],
summary: restoredSummary,
outputDir,
};
jobs.set(job.id, job);
persistJob(job);
} catch (error) {
console.warn(`[balance-lab] Could not restore ${entry.name}: ${error?.message ?? error}`);
}
}
}
restoreJobs();
function appendLog(job, line) {
job.logTail.push(line.slice(0, 1_000));
if (job.logTail.length > 80) job.logTail.splice(0, job.logTail.length - 80);
const party = /^Party\s+(\d+)\/(\d+):/i.exec(line);
if (party) {
job.progress.party = Number(party[1]);
job.progress.partiesTotal = Number(party[2]);
job.progress.partiesCompleted = Math.max(0, Number(party[1]) - 1);
job.progress.simulationsCompleted = 0;
job.progress.simulationsTotal = 0;
}
const simulations = /^\[balance-lab]\s+(\d+)\/(\d+)\s+/i.exec(line);
if (simulations) {
job.progress.simulationsCompleted = Number(simulations[1]);
job.progress.simulationsTotal = Number(simulations[2]);
}
persistJob(job);
}
function pump() {
if (closed) return;
while (running < maxConcurrent && queue.length) {
const job = queue.shift();
if (!job || job.status !== "queued") continue;
running += 1;
job.status = "running";
job.startedAt = new Date().toISOString();
persistJob(job);
const command = balanceLabCommand(job.spec, job.outputDir, {
projectRoot,
pythonExecutable,
cooldownPolicyPath: job.cooldownPolicyPath,
readinessPolicyPath: job.readinessPolicyPath,
});
const child = launch(command.executable, command.args, {
cwd: command.cwd,
env: { ...process.env, PYTHONUNBUFFERED: "1" },
windowsHide: true,
stdio: ["ignore", "pipe", "pipe"],
});
children.set(job.id, child);
const stdout = lineCollector((line) => appendLog(job, line));
const stderr = lineCollector((line) => appendLog(job, line));
child.stdout?.on("data", (chunk) => stdout.push(chunk));
child.stderr?.on("data", (chunk) => stderr.push(chunk));
let settled = false;
const finish = (error, code = null) => {
if (settled) return;
settled = true;
stdout.flush();
stderr.flush();
children.delete(job.id);
running = Math.max(0, running - 1);
job.completedAt = new Date().toISOString();
if (!error && code === 0 && existsSync(path.join(job.outputDir, "suite-report.json"))) {
job.status = "complete";
job.progress.partiesCompleted = job.progress.partiesTotal;
job.progress.simulationsCompleted = job.progress.simulationsTotal;
job.artifacts = [...ARTIFACTS.keys()].filter((name) => existsSync(path.join(job.outputDir, name)));
job.summary = reportSummary(job.outputDir);
} else {
job.status = "failed";
job.error = error?.message
?? processFailureMessage(job, code);
}
persistJob(job);
pump();
};
child.once("error", (error) => finish(new Error(
error?.code === "ENOENT"
? `Could not start ${command.executable}. Install Python 3 on the server or set BALANCE_LAB_PYTHON.`
: `Could not start the Balance Lab process: ${error?.message ?? error}`,
)));
child.once("close", (code) => finish(null, code));
}
}
function requireJob(id) {
const job = jobs.get(id);
if (!job) throw new GameDatabaseError("Balance Lab job was not found.", 404, "balance_lab_job_not_found");
return job;
}
function requireAvailableRuntime() {
if (closed) throw new GameDatabaseError("Balance Lab runner is shutting down.", 503, "balance_lab_unavailable");
const runtime = currentRuntime();
if (!runtime.available) {
throw new GameDatabaseError(
`${runtime.error} Install Python 3 in the server container and set BALANCE_LAB_PYTHON=python3, then restart the app.`,
503,
"balance_lab_runtime_unavailable",
);
}
}
function requireQueueCapacity(count) {
const pending = [...jobs.values()].filter((job) => job.status === "queued" || job.status === "running").length;
if (pending + count > maxQueued) {
throw new GameDatabaseError(
`The Balance Lab server queue is full for this request; it has room for ${Math.max(0, maxQueued - pending)} more jobs. Wait for running jobs to finish or submit a smaller level batch.`,
429,
"balance_lab_queue_full",
);
}
}
function queueJob(spec, account) {
const id = randomUUID();
const outputDir = path.join(jobsDir, id);
mkdirSync(outputDir, { recursive: true });
const cooldownPolicyPath = path.join(outputDir, "companion-cooldown-policy.json");
if (cooldownPolicyProvider) {
const published = cooldownPolicyProvider();
writeFileSync(cooldownPolicyPath, `${JSON.stringify(published.config ?? published, null, 2)}\n`, "utf8");
}
const readinessPolicyPath = path.join(outputDir, "companion-spec-readiness.json");
if (readinessPolicyProvider) {
const published = readinessPolicyProvider();
writeFileSync(readinessPolicyPath, `${JSON.stringify(published, null, 2)}\n`, "utf8");
}
const totalParties = expectedPartyConfigurations(spec);
const job = {
id,
status: "queued",
createdAt: new Date().toISOString(),
startedAt: null,
completedAt: null,
requestedBy: account.username,
spec,
progress: {
party: 0,
partiesCompleted: 0,
partiesTotal: totalParties,
simulationsCompleted: 0,
simulationsTotal: 0,
},
artifacts: [],
error: null,
logTail: [],
summary: null,
outputDir,
cooldownPolicyPath: cooldownPolicyProvider ? cooldownPolicyPath : null,
readinessPolicyPath: readinessPolicyProvider ? readinessPolicyPath : null,
};
jobs.set(id, job);
persistJob(job);
queue.push(job);
return job;
}
return {
start(input, account) {
const spec = normalizeBalanceLabJobSpec(input);
requireAvailableRuntime();
requireQueueCapacity(1);
const job = queueJob(spec, account);
pump();
return publicJob(job);
},
startMany(input, account) {
const specs = normalizeBalanceLabJobBatch(input);
requireAvailableRuntime();
requireQueueCapacity(specs.length);
const batch = specs.map((spec) => queueJob(spec, account));
pump();
return batch.map(publicJob);
},
list() {
return [...jobs.values()]
.sort((left, right) => Date.parse(right.createdAt) - Date.parse(left.createdAt))
.slice(0, 100)
.map(publicJob);
},
runtime() {
return {
...currentRuntime(),
maxConcurrent,
running,
queued: queue.length,
maxQueued,
};
},
get(id) {
return publicJob(requireJob(id));
},
artifact(id, name) {
const job = requireJob(id);
if (!JOB_STATUSES.has(job.status) || job.status !== "complete") {
throw new GameDatabaseError("Balance Lab artifacts are available after the job completes.", 409, "balance_lab_job_incomplete");
}
const contentType = ARTIFACTS.get(name);
if (!contentType || !job.artifacts.includes(name)) {
throw new GameDatabaseError("Balance Lab artifact was not found.", 404, "balance_lab_artifact_not_found");
}
const filePath = path.join(job.outputDir, name);
const stats = statSync(filePath);
return { filePath, contentType, size: stats.size, stream: () => createReadStream(filePath) };
},
close() {
closed = true;
queue.length = 0;
for (const child of children.values()) child.kill();
children.clear();
},
};
}