forked from phenom/healer-man
Release Healer Man 0.1.46
This commit is contained in:
1 parent
f82daf1c13
commit
9ff574ac0b
73 files changed
+33486
-174
No files matched your search
@@ -0,0 +1,416 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { spawn } 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 JOB_STATUSES = new Set(["queued", "running", "complete", "failed"]);
|
||||
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"],
|
||||
["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();
|
||||
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");
|
||||
}
|
||||
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 spec = Object.freeze({
|
||||
source,
|
||||
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),
|
||||
});
|
||||
const sourceCount = spec.source === "all" ? 3 : 1;
|
||||
const bossEstimate = Math.max(1, spec.bossKeys.length || (spec.bossCategory === "all" ? 500 : 300));
|
||||
const estimatedRuns = sourceCount * spec.partyConfigurations * 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 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",
|
||||
"--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 bossKey of spec.bossKeys) args.push("--boss", bossKey);
|
||||
return { executable, args, cwd: projectRoot };
|
||||
}
|
||||
|
||||
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,
|
||||
bossPressureMedianDtps: Number(summary.bossPressureMedianDtps) || 0,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
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 = "";
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
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 ?? 1);
|
||||
const maxConcurrent = Number.isFinite(configuredConcurrency) && configuredConcurrency >= 1
|
||||
? Math.min(4, Math.floor(configuredConcurrency))
|
||||
: 1;
|
||||
const configuredQueueLimit = Number(options.maxQueued ?? process.env.BALANCE_LAB_MAX_QUEUED ?? 8);
|
||||
const maxQueued = Number.isFinite(configuredQueueLimit) && configuredQueueLimit >= 1
|
||||
? Math.min(50, Math.floor(configuredQueueLimit))
|
||||
: 8;
|
||||
const launch = options.spawnProcess ?? spawn;
|
||||
const jobs = new Map();
|
||||
const queue = [];
|
||||
const children = new Map();
|
||||
let running = 0;
|
||||
let closed = false;
|
||||
mkdirSync(jobsDir, { recursive: true });
|
||||
|
||||
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,
|
||||
level: legacyConfig.level,
|
||||
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 totalParties = spec.partyConfigurations * (spec.source === "all" ? 3 : 1);
|
||||
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: hasReport ? reportSummary(outputDir) : null,
|
||||
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: options.pythonExecutable,
|
||||
});
|
||||
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
|
||||
?? (code === 0
|
||||
? "Balance Lab ended without producing its suite report."
|
||||
: `Balance Lab process exited with code ${code ?? "unknown"}.`);
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
return {
|
||||
start(input, account) {
|
||||
if (closed) throw new GameDatabaseError("Balance Lab runner is shutting down.", 503, "balance_lab_unavailable");
|
||||
const pending = [...jobs.values()].filter((job) => job.status === "queued" || job.status === "running").length;
|
||||
if (pending >= maxQueued) {
|
||||
throw new GameDatabaseError(
|
||||
"The Balance Lab server queue is full. Wait for a running job to finish.",
|
||||
429,
|
||||
"balance_lab_queue_full",
|
||||
);
|
||||
}
|
||||
const spec = normalizeBalanceLabJobSpec(input);
|
||||
const id = randomUUID();
|
||||
const outputDir = path.join(jobsDir, id);
|
||||
mkdirSync(outputDir, { recursive: true });
|
||||
const totalParties = spec.partyConfigurations * (spec.source === "all" ? 3 : 1);
|
||||
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,
|
||||
};
|
||||
jobs.set(id, job);
|
||||
persistJob(job);
|
||||
queue.push(job);
|
||||
pump();
|
||||
return publicJob(job);
|
||||
},
|
||||
list() {
|
||||
return [...jobs.values()]
|
||||
.sort((left, right) => Date.parse(right.createdAt) - Date.parse(left.createdAt))
|
||||
.slice(0, 100)
|
||||
.map(publicJob);
|
||||
},
|
||||
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();
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user