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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { PassThrough } from "node:stream";
|
||||
import test from "node:test";
|
||||
import {
|
||||
balanceLabCommand,
|
||||
createBalanceLabJobService,
|
||||
normalizeBalanceLabJobSpec,
|
||||
} from "./balance-lab-jobs.mjs";
|
||||
|
||||
test("normalizes and bounds server Balance Lab requests", () => {
|
||||
const spec = normalizeBalanceLabJobSpec({
|
||||
source: "ALL",
|
||||
level: 40,
|
||||
partyConfigurations: 2,
|
||||
simulationsPerParty: 3,
|
||||
durationSeconds: 45,
|
||||
partySeed: 17,
|
||||
bossCategory: "all",
|
||||
bossKeys: ["gundrak/entry-29932", "gundrak/entry-29932"],
|
||||
});
|
||||
assert.equal(spec.source, "all");
|
||||
assert.deepEqual(spec.bossKeys, ["gundrak/entry-29932"]);
|
||||
assert.throws(
|
||||
() => normalizeBalanceLabJobSpec({ source: "wow", level: 81 }),
|
||||
/Level must be a whole number/,
|
||||
);
|
||||
assert.throws(
|
||||
() => normalizeBalanceLabJobSpec({ source: "wow", bossKeys: ["boss; shutdown"] }),
|
||||
/boss key is invalid/,
|
||||
);
|
||||
});
|
||||
|
||||
test("builds an argument-array command without a shell", () => {
|
||||
const spec = normalizeBalanceLabJobSpec({
|
||||
source: "rom", level: 60, partyConfigurations: 4, simulationsPerParty: 2,
|
||||
durationSeconds: 30, partySeed: 9, bossCategory: "rom", bossKeys: ["abbey/boss-1"],
|
||||
});
|
||||
const command = balanceLabCommand(spec, "C:/reports/job", {
|
||||
projectRoot: "C:/app",
|
||||
pythonExecutable: "python-custom",
|
||||
});
|
||||
assert.equal(command.executable, "python-custom");
|
||||
assert.equal(command.args.includes("--party-mode"), true);
|
||||
assert.equal(command.args.includes("automatic"), true);
|
||||
assert.equal(command.args.includes("abbey/boss-1"), true);
|
||||
assert.equal(command.cwd, path.resolve("C:/app"));
|
||||
});
|
||||
|
||||
test("queues a job, reports progress, and exposes only allow-listed artifacts", async () => {
|
||||
const directory = mkdtempSync(path.join(os.tmpdir(), "healer-man-balance-job-"));
|
||||
try {
|
||||
const spawnProcess = (_executable, args) => {
|
||||
const child = new EventEmitter();
|
||||
child.stdout = new PassThrough();
|
||||
child.stderr = new PassThrough();
|
||||
child.kill = () => child.emit("close", null);
|
||||
const outputIndex = args.indexOf("--output-dir");
|
||||
const outputDir = args[outputIndex + 1];
|
||||
setImmediate(() => {
|
||||
mkdirSync(outputDir, { recursive: true });
|
||||
writeFileSync(path.join(outputDir, "suite-report.json"), "{}\n");
|
||||
child.stdout.write("Party 1/1: wow-party-001\n");
|
||||
child.stderr.write("[balance-lab] 1/1 Eck the Ferocious seed 1\n");
|
||||
child.stdout.end();
|
||||
child.stderr.end();
|
||||
child.emit("close", 0);
|
||||
});
|
||||
return child;
|
||||
};
|
||||
const service = createBalanceLabJobService({
|
||||
dataDir: directory,
|
||||
projectRoot: directory,
|
||||
spawnProcess,
|
||||
pythonExecutable: "python-test",
|
||||
});
|
||||
const started = service.start({
|
||||
source: "wow", level: 40, partyConfigurations: 1, simulationsPerParty: 1,
|
||||
durationSeconds: 5, partySeed: 1, bossCategory: "all", bossKeys: ["gundrak/entry-29932"],
|
||||
}, { username: "Phenom" });
|
||||
assert.equal(started.status, "running");
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
const completed = service.get(started.id);
|
||||
assert.equal(completed.status, "complete");
|
||||
assert.equal(completed.progress.partiesCompleted, 1);
|
||||
assert.deepEqual(completed.artifacts, ["suite-report.json"]);
|
||||
assert.equal(service.artifact(started.id, "suite-report.json").size, 3);
|
||||
assert.throws(() => service.artifact(started.id, "../../game.db"), /artifact was not found/);
|
||||
service.close();
|
||||
} finally {
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("restores completed reports as previous runs after a server restart", async () => {
|
||||
const directory = mkdtempSync(path.join(os.tmpdir(), "healer-man-balance-history-"));
|
||||
try {
|
||||
let launches = 0;
|
||||
const spawnProcess = (_executable, args) => {
|
||||
launches += 1;
|
||||
const child = new EventEmitter();
|
||||
child.stdout = new PassThrough();
|
||||
child.stderr = new PassThrough();
|
||||
child.kill = () => child.emit("close", null);
|
||||
const outputDir = args[args.indexOf("--output-dir") + 1];
|
||||
setImmediate(() => {
|
||||
mkdirSync(outputDir, { recursive: true });
|
||||
writeFileSync(path.join(outputDir, "suite-report.json"), JSON.stringify({
|
||||
summary: {
|
||||
partyConfigurations: 2,
|
||||
bosses: 3,
|
||||
runs: 6,
|
||||
classSpecs: 5,
|
||||
bossPressureMedianDtps: 123.5,
|
||||
},
|
||||
}));
|
||||
child.emit("close", 0);
|
||||
});
|
||||
return child;
|
||||
};
|
||||
const firstService = createBalanceLabJobService({
|
||||
dataDir: directory,
|
||||
projectRoot: directory,
|
||||
spawnProcess,
|
||||
pythonExecutable: "python-test",
|
||||
});
|
||||
const started = firstService.start({
|
||||
source: "wow", level: 40, partyConfigurations: 2, simulationsPerParty: 1,
|
||||
durationSeconds: 5, partySeed: 1, bossCategory: "all", bossKeys: ["gundrak/entry-29932"],
|
||||
}, { username: "Phenom" });
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(firstService.get(started.id).status, "complete");
|
||||
firstService.close();
|
||||
|
||||
const secondService = createBalanceLabJobService({
|
||||
dataDir: directory,
|
||||
projectRoot: directory,
|
||||
spawnProcess: () => { throw new Error("restored jobs must not relaunch"); },
|
||||
pythonExecutable: "python-test",
|
||||
});
|
||||
const restored = secondService.list();
|
||||
assert.equal(restored.length, 1);
|
||||
assert.equal(restored[0].id, started.id);
|
||||
assert.equal(restored[0].status, "complete");
|
||||
assert.equal(restored[0].summary?.runs, 6);
|
||||
assert.deepEqual(restored[0].artifacts, ["suite-report.json"]);
|
||||
assert.equal(secondService.artifact(started.id, "suite-report.json").size > 0, true);
|
||||
assert.equal(launches, 1);
|
||||
secondService.close();
|
||||
} finally {
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("marks an interrupted persisted run as failed when the service returns", () => {
|
||||
const directory = mkdtempSync(path.join(os.tmpdir(), "healer-man-balance-interrupted-"));
|
||||
try {
|
||||
const jobsDir = path.join(directory, "balance-lab", "jobs");
|
||||
const id = "11111111-1111-4111-8111-111111111111";
|
||||
const outputDir = path.join(jobsDir, id);
|
||||
mkdirSync(outputDir, { recursive: true });
|
||||
writeFileSync(path.join(outputDir, "job.json"), JSON.stringify({
|
||||
id,
|
||||
status: "running",
|
||||
createdAt: "2026-09-15T12:00:00.000Z",
|
||||
startedAt: "2026-09-15T12:00:01.000Z",
|
||||
requestedBy: "Phenom",
|
||||
spec: {
|
||||
source: "rom", level: 55, partyConfigurations: 1, simulationsPerParty: 1,
|
||||
durationSeconds: 30, partySeed: 3, bossCategory: "rom", bossKeys: [],
|
||||
},
|
||||
progress: { party: 1, partiesCompleted: 0, partiesTotal: 1 },
|
||||
artifacts: [],
|
||||
error: null,
|
||||
logTail: ["Party 1/1: rom-party-001"],
|
||||
}));
|
||||
const service = createBalanceLabJobService({ dataDir: directory, projectRoot: directory });
|
||||
const restored = service.get(id);
|
||||
assert.equal(restored.status, "failed");
|
||||
assert.match(restored.error, /server restarted/i);
|
||||
service.close();
|
||||
} finally {
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("imports completed report directories created before history metadata existed", () => {
|
||||
const directory = mkdtempSync(path.join(os.tmpdir(), "healer-man-balance-legacy-"));
|
||||
try {
|
||||
const id = "22222222-2222-4222-8222-222222222222";
|
||||
const outputDir = path.join(directory, "balance-lab", "jobs", id);
|
||||
mkdirSync(outputDir, { recursive: true });
|
||||
writeFileSync(path.join(outputDir, "suite-report.json"), JSON.stringify({
|
||||
generatedAt: "2026-09-14T10:30:00.000Z",
|
||||
summary: { partyConfigurations: 3, bosses: 2, runs: 12, classSpecs: 8, bossPressureMedianDtps: 50 },
|
||||
}));
|
||||
writeFileSync(path.join(outputDir, "suite-config.json"), JSON.stringify({
|
||||
source: "coa",
|
||||
level: 35,
|
||||
partySeed: 12,
|
||||
partyConfigurationsPerSource: 3,
|
||||
simulationsPerParty: 2,
|
||||
durationSeconds: 45,
|
||||
bossCategory: "wow",
|
||||
bossKeys: ["gundrak/entry-29932", "gundrak/entry-29304"],
|
||||
}));
|
||||
const service = createBalanceLabJobService({ dataDir: directory, projectRoot: directory });
|
||||
const restored = service.get(id);
|
||||
assert.equal(restored.status, "complete");
|
||||
assert.equal(restored.spec.source, "coa");
|
||||
assert.equal(restored.summary?.runs, 12);
|
||||
assert.equal(restored.requestedBy, "unknown (legacy run)");
|
||||
assert.equal(existsSync(path.join(outputDir, "job.json")), true);
|
||||
service.close();
|
||||
} finally {
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("bounds the number of pending server jobs", () => {
|
||||
const directory = mkdtempSync(path.join(os.tmpdir(), "healer-man-balance-queue-"));
|
||||
try {
|
||||
const spawnProcess = () => {
|
||||
const child = new EventEmitter();
|
||||
child.stdout = new PassThrough();
|
||||
child.stderr = new PassThrough();
|
||||
child.kill = () => child.emit("close", null);
|
||||
return child;
|
||||
};
|
||||
const service = createBalanceLabJobService({
|
||||
dataDir: directory,
|
||||
projectRoot: directory,
|
||||
spawnProcess,
|
||||
pythonExecutable: "python-test",
|
||||
maxQueued: 1,
|
||||
});
|
||||
const request = {
|
||||
source: "wow", level: 40, partyConfigurations: 1, simulationsPerParty: 1,
|
||||
durationSeconds: 5, partySeed: 1, bossCategory: "all", bossKeys: ["gundrak/entry-29932"],
|
||||
};
|
||||
service.start(request, { username: "Phenom" });
|
||||
assert.throws(() => service.start(request, { username: "Phenom" }), /server queue is full/);
|
||||
service.close();
|
||||
} finally {
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -12,6 +12,7 @@ import { arenaRatingBand, calculateArenaRating, clampArenaRating } from "./arena
|
||||
import { arenaRoleForSpecialization } from "./arena-specializations.mjs";
|
||||
import { createDungeonReportRepository } from "./dungeon-reports.mjs";
|
||||
import { createDungeonLeaderboardRepository } from "./dungeon-leaderboards.mjs";
|
||||
import { createDungeonSimulationRepository } from "./dungeon-simulation.mjs";
|
||||
import dungeonLeaderboardCatalog from "../src/game/generated/dungeonLeaderboardCatalog.generated.json" with { type: "json" };
|
||||
|
||||
const scrypt = promisify(scryptCallback);
|
||||
@@ -910,6 +911,7 @@ export function openGameDatabase(options = {}) {
|
||||
databasePath,
|
||||
...createDungeonReportRepository(database, { isGameMaster, ErrorType: GameDatabaseError }),
|
||||
...createDungeonLeaderboardRepository(database, { ErrorType: GameDatabaseError }),
|
||||
...createDungeonSimulationRepository(database, { isGameMaster, ErrorType: GameDatabaseError }),
|
||||
register,
|
||||
login,
|
||||
authenticate,
|
||||
|
||||
+100
-1
@@ -1,11 +1,13 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
import { GameDatabaseError, openGameDatabase } from "./database.mjs";
|
||||
|
||||
const routeCatalog = JSON.parse(readFileSync(new URL("../src/game/generated/dungeonRouteCatalog.generated.json", import.meta.url), "utf8"));
|
||||
|
||||
function withDatabase(run) {
|
||||
const directory = mkdtempSync(path.join(os.tmpdir(), "healer-man-db-test-"));
|
||||
const database = openGameDatabase({ dataDir: directory, sessionTtlDays: 1 });
|
||||
@@ -137,6 +139,103 @@ test("persists revisioned Manastorm GM configuration and rejects stale or unauth
|
||||
}), (error) => error instanceof GameDatabaseError && error.status === 400);
|
||||
}));
|
||||
|
||||
test("stores GM simulation replays and gates exact-hash route approval", () => withDatabase(async (database) => {
|
||||
const gmAuth = await database.register("Phenom", "storm-admin");
|
||||
const playerAuth = await database.register("RoutePlayer", "route-player");
|
||||
const gm = database.authenticate(gmAuth.token);
|
||||
const player = database.authenticate(playerAuth.token);
|
||||
const routeHash = routeCatalog.routes.find((route) => route.dungeonId === "deadmines").hash;
|
||||
const replay = {
|
||||
schemaVersion: 1,
|
||||
dungeonId: "deadmines",
|
||||
routeId: "primary",
|
||||
routeHash,
|
||||
mode: "navigation-only",
|
||||
seed: "deadmines:primary",
|
||||
result: "completed",
|
||||
startedAt: 1_700_000_000_000,
|
||||
durationMs: 42_000,
|
||||
report: { schemaVersion: 1, findings: [] },
|
||||
frames: [{ atMs: 0, actors: [] }],
|
||||
events: [],
|
||||
};
|
||||
|
||||
assert.throws(() => database.saveDungeonSimulationReplay(player, replay), (error) => (
|
||||
error instanceof GameDatabaseError && error.status === 403
|
||||
));
|
||||
assert.throws(() => database.putDungeonRouteReview(gm, "deadmines", "primary", {
|
||||
routeHash, status: "Approved", expectedRevision: 0,
|
||||
}), (error) => error instanceof GameDatabaseError && error.code === "route_run_required");
|
||||
|
||||
const saved = database.saveDungeonSimulationReplay(gm, replay);
|
||||
assert.equal(saved.criticalFindings, 0);
|
||||
assert.equal(database.getDungeonSimulationReplay(gm, saved.id).frames.length, 1);
|
||||
assert.equal(database.getDungeonSimulationReport(gm, saved.id).schemaVersion, 1);
|
||||
|
||||
const review = database.putDungeonRouteReview(gm, "deadmines", "primary", {
|
||||
routeHash, status: "Approved", expectedRevision: 0, note: "Clean navigation-only pass.",
|
||||
});
|
||||
assert.equal(review.revision, 1);
|
||||
assert.deepEqual(database.listDungeonRouteApprovals().approvals.map(({ reviewedAt: _reviewedAt, ...entry }) => entry), [{
|
||||
dungeonId: "deadmines", routeId: "primary", routeHash,
|
||||
}]);
|
||||
assert.throws(() => database.putDungeonRouteReview(gm, "deadmines", "primary", {
|
||||
routeHash, status: "NeedsWork", expectedRevision: 0,
|
||||
}), (error) => error instanceof GameDatabaseError && error.status === 409);
|
||||
assert.equal(database.deleteDungeonSimulationReplay(gm, saved.id).ok, true);
|
||||
}));
|
||||
|
||||
test("requires an override note when the latest exact-hash route run has critical findings", () => withDatabase(async (database) => {
|
||||
const gmAuth = await database.register("Phenom", "storm-admin");
|
||||
const gm = database.authenticate(gmAuth.token);
|
||||
const routeHash = routeCatalog.routes.find((route) => route.dungeonId === "blackfathom-deeps").hash;
|
||||
database.saveDungeonSimulationReplay(gm, {
|
||||
schemaVersion: 1,
|
||||
dungeonId: "blackfathom-deeps",
|
||||
routeId: "primary",
|
||||
routeHash,
|
||||
mode: "navigation-only",
|
||||
seed: "route-seed",
|
||||
result: "completed",
|
||||
startedAt: 1_700_000_000_000,
|
||||
durationMs: 10_000,
|
||||
report: { schemaVersion: 1, findings: [{ severity: "critical", code: "no-path" }] },
|
||||
frames: [],
|
||||
events: [],
|
||||
});
|
||||
assert.throws(() => database.putDungeonRouteReview(gm, "blackfathom-deeps", "primary", {
|
||||
routeHash, status: "Approved", expectedRevision: 0,
|
||||
}), (error) => error instanceof GameDatabaseError && error.code === "route_override_required");
|
||||
const review = database.putDungeonRouteReview(gm, "blackfathom-deeps", "primary", {
|
||||
routeHash, status: "Approved", expectedRevision: 0, criticalOverrideNote: "Known scripted lift gap.",
|
||||
});
|
||||
assert.equal(review.status, "Approved");
|
||||
assert.equal(review.criticalOverrideNote, "Known scripted lift gap.");
|
||||
}));
|
||||
|
||||
test("requires the latest exact-hash route run to be completed before approval", () => withDatabase(async (database) => {
|
||||
const gmAuth = await database.register("Phenom", "storm-admin");
|
||||
const gm = database.authenticate(gmAuth.token);
|
||||
const routeHash = routeCatalog.routes.find((route) => route.dungeonId === "razorfen-downs").hash;
|
||||
database.saveDungeonSimulationReplay(gm, {
|
||||
schemaVersion: 1,
|
||||
dungeonId: "razorfen-downs",
|
||||
routeId: "primary",
|
||||
routeHash,
|
||||
mode: "full-combat",
|
||||
seed: "incomplete-route-seed",
|
||||
result: "stopped",
|
||||
startedAt: 1_700_000_000_000,
|
||||
durationMs: 10_000,
|
||||
report: { schemaVersion: 1, findings: [] },
|
||||
frames: [],
|
||||
events: [],
|
||||
});
|
||||
assert.throws(() => database.putDungeonRouteReview(gm, "razorfen-downs", "primary", {
|
||||
routeHash, status: "Approved", expectedRevision: 0,
|
||||
}), (error) => error instanceof GameDatabaseError && error.code === "route_completed_run_required");
|
||||
}));
|
||||
|
||||
test("creates online groups through invitations and transfers leadership when the leader leaves", () => withDatabase(async (database) => {
|
||||
const leaderAuth = await database.register("GroupLeader", "group-pass");
|
||||
const healerAuth = await database.register("GroupHealer", "healer-pass");
|
||||
|
||||
@@ -0,0 +1,331 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { gunzipSync, gzipSync } from "node:zlib";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
const routeCatalog = JSON.parse(readFileSync(new URL("../src/game/generated/dungeonRouteCatalog.generated.json", import.meta.url), "utf8"));
|
||||
const generatedRoutes = new Map(routeCatalog.routes.map((route) => [`${route.dungeonId}:${route.id}:${route.hash}`, route]));
|
||||
|
||||
export const DUNGEON_SIMULATION_REPLAY_SCHEMA_VERSION = 1;
|
||||
export const DUNGEON_ROUTE_REVIEW_STATUSES = Object.freeze(["Candidate", "Approved", "NeedsWork"]);
|
||||
export const DUNGEON_SIMULATION_RETENTION_MS = 30 * 24 * 60 * 60 * 1_000;
|
||||
export const DUNGEON_SIMULATION_MAX_DURATION_MS = 2 * 60 * 60 * 1_000;
|
||||
export const DUNGEON_SIMULATION_MAX_COMPRESSED_BYTES = 32 * 1024 * 1024;
|
||||
export const DUNGEON_SIMULATION_MAX_DECOMPRESSED_BYTES = 256 * 1024 * 1024;
|
||||
const MAX_ID_LENGTH = 256;
|
||||
const MAX_NOTE_LENGTH = 1_000;
|
||||
const HASH_PATTERN = /^[a-f0-9]{64}$/;
|
||||
|
||||
function text(value, label, ErrorType, maximum = MAX_ID_LENGTH) {
|
||||
if (typeof value !== "string" || !value.trim() || value.length > maximum) {
|
||||
throw new ErrorType(`${label} must be a non-empty string no longer than ${maximum} characters.`, 400, "invalid_simulation_payload");
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
function optionalNote(value, label, ErrorType) {
|
||||
if (value === undefined || value === null) return null;
|
||||
if (typeof value !== "string" || value.length > MAX_NOTE_LENGTH) {
|
||||
throw new ErrorType(`${label} must be a string no longer than ${MAX_NOTE_LENGTH} characters.`, 400, "invalid_simulation_payload");
|
||||
}
|
||||
return value.replace(/\r\n?/g, "\n").trim() || null;
|
||||
}
|
||||
|
||||
function routeHash(value, ErrorType) {
|
||||
const normalized = text(value, "routeHash", ErrorType, 64).toLowerCase();
|
||||
if (!HASH_PATTERN.test(normalized)) {
|
||||
throw new ErrorType("routeHash must be a lowercase SHA-256 hash.", 400, "invalid_route_hash");
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function integer(value, label, ErrorType, minimum, maximum) {
|
||||
if (!Number.isInteger(value) || value < minimum || value > maximum) {
|
||||
throw new ErrorType(`${label} must be an integer between ${minimum} and ${maximum}.`, 400, "invalid_simulation_payload");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function object(value, label, ErrorType) {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new ErrorType(`${label} must be an object.`, 400, "invalid_simulation_payload");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function publicReplay(row) {
|
||||
return {
|
||||
id: row.id,
|
||||
dungeonId: row.dungeon_id,
|
||||
routeId: row.route_id,
|
||||
routeHash: row.route_hash,
|
||||
mode: row.mode,
|
||||
seed: row.seed,
|
||||
result: row.result,
|
||||
startedAt: Number(row.started_at),
|
||||
durationMs: Number(row.duration_ms),
|
||||
criticalFindings: Number(row.critical_findings),
|
||||
submittedBy: row.submitted_by_username,
|
||||
createdAt: Number(row.created_at),
|
||||
expiresAt: Number(row.expires_at),
|
||||
compressedBytes: Number(row.compressed_bytes),
|
||||
};
|
||||
}
|
||||
|
||||
function publicReview(row) {
|
||||
return {
|
||||
dungeonId: row.dungeon_id,
|
||||
routeId: row.route_id,
|
||||
routeHash: row.route_hash,
|
||||
status: row.status,
|
||||
note: row.note,
|
||||
criticalOverrideNote: row.critical_override_note,
|
||||
reviewedBy: row.reviewed_by_username,
|
||||
reviewedAt: Number(row.reviewed_at),
|
||||
revision: Number(row.revision),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeReplay(input, ErrorType) {
|
||||
const source = object(input, "replay", ErrorType);
|
||||
if (source.schemaVersion !== DUNGEON_SIMULATION_REPLAY_SCHEMA_VERSION) {
|
||||
throw new ErrorType("Unsupported dungeon simulation replay schema.", 400, "invalid_replay_schema");
|
||||
}
|
||||
const mode = source.mode;
|
||||
if (mode !== "full-combat" && mode !== "navigation-only") {
|
||||
throw new ErrorType("Choose a valid simulation mode.", 400, "invalid_simulation_mode");
|
||||
}
|
||||
const result = source.result;
|
||||
if (!["completed", "wiped", "stopped", "recording-limit"].includes(result)) {
|
||||
throw new ErrorType("Choose a valid simulation result.", 400, "invalid_simulation_result");
|
||||
}
|
||||
const report = object(source.report, "report", ErrorType);
|
||||
const findings = Array.isArray(report.findings) ? report.findings : [];
|
||||
const criticalFindings = findings.filter((finding) => finding?.severity === "critical").length;
|
||||
const normalized = {
|
||||
...source,
|
||||
dungeonId: text(source.dungeonId, "dungeonId", ErrorType),
|
||||
routeId: text(source.routeId, "routeId", ErrorType),
|
||||
routeHash: routeHash(source.routeHash, ErrorType),
|
||||
seed: text(source.seed, "seed", ErrorType),
|
||||
startedAt: integer(source.startedAt, "startedAt", ErrorType, 1, Number.MAX_SAFE_INTEGER),
|
||||
durationMs: integer(source.durationMs, "durationMs", ErrorType, 0, DUNGEON_SIMULATION_MAX_DURATION_MS),
|
||||
mode,
|
||||
result,
|
||||
report,
|
||||
};
|
||||
const serialized = Buffer.from(JSON.stringify(normalized));
|
||||
if (serialized.length > DUNGEON_SIMULATION_MAX_DECOMPRESSED_BYTES) {
|
||||
throw new ErrorType("Replay is too large after decompression.", 413, "replay_too_large");
|
||||
}
|
||||
const compressed = gzipSync(serialized, { level: 9 });
|
||||
if (compressed.length > DUNGEON_SIMULATION_MAX_COMPRESSED_BYTES) {
|
||||
throw new ErrorType("Replay is too large after compression.", 413, "replay_too_large");
|
||||
}
|
||||
return { normalized, compressed, criticalFindings };
|
||||
}
|
||||
|
||||
export function createDungeonSimulationRepository(database, { isGameMaster, ErrorType }) {
|
||||
database.exec(`
|
||||
CREATE TABLE IF NOT EXISTS dungeon_route_reviews (
|
||||
dungeon_id TEXT NOT NULL,
|
||||
route_id TEXT NOT NULL,
|
||||
route_hash TEXT NOT NULL,
|
||||
status TEXT NOT NULL CHECK (status IN ('Candidate', 'Approved', 'NeedsWork')),
|
||||
note TEXT,
|
||||
critical_override_note TEXT,
|
||||
reviewed_by_account_id TEXT REFERENCES accounts(id) ON DELETE SET NULL,
|
||||
reviewed_by_username TEXT NOT NULL,
|
||||
reviewed_at INTEGER NOT NULL,
|
||||
revision INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (dungeon_id, route_id)
|
||||
) STRICT;
|
||||
CREATE TABLE IF NOT EXISTS dungeon_simulation_replays (
|
||||
id TEXT PRIMARY KEY,
|
||||
schema_version INTEGER NOT NULL,
|
||||
dungeon_id TEXT NOT NULL,
|
||||
route_id TEXT NOT NULL,
|
||||
route_hash TEXT NOT NULL,
|
||||
mode TEXT NOT NULL CHECK (mode IN ('full-combat', 'navigation-only')),
|
||||
seed TEXT NOT NULL,
|
||||
result TEXT NOT NULL CHECK (result IN ('completed', 'wiped', 'stopped', 'recording-limit')),
|
||||
started_at INTEGER NOT NULL,
|
||||
duration_ms INTEGER NOT NULL,
|
||||
critical_findings INTEGER NOT NULL,
|
||||
report_json TEXT NOT NULL,
|
||||
payload_gzip BLOB NOT NULL,
|
||||
compressed_bytes INTEGER NOT NULL,
|
||||
submitted_by_account_id TEXT REFERENCES accounts(id) ON DELETE SET NULL,
|
||||
submitted_by_username TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
expires_at INTEGER NOT NULL
|
||||
) STRICT;
|
||||
CREATE INDEX IF NOT EXISTS dungeon_simulation_replays_route_idx
|
||||
ON dungeon_simulation_replays(dungeon_id, route_id, route_hash, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS dungeon_simulation_replays_expiry_idx
|
||||
ON dungeon_simulation_replays(expires_at);
|
||||
`);
|
||||
|
||||
const replayById = database.prepare("SELECT * FROM dungeon_simulation_replays WHERE id = ?");
|
||||
const reviewByRoute = database.prepare("SELECT * FROM dungeon_route_reviews WHERE dungeon_id = ? AND route_id = ?");
|
||||
|
||||
function requireGm(account) {
|
||||
if (!isGameMaster(account)) {
|
||||
throw new ErrorType("Game Master access is required.", 403, "gm_required");
|
||||
}
|
||||
}
|
||||
|
||||
function pruneExpiredDungeonSimulationReplays(now = Date.now()) {
|
||||
return Number(database.prepare("DELETE FROM dungeon_simulation_replays WHERE expires_at <= ?").run(now).changes);
|
||||
}
|
||||
|
||||
return {
|
||||
pruneExpiredDungeonSimulationReplays,
|
||||
listDungeonRouteApprovals() {
|
||||
return {
|
||||
approvals: database.prepare(`
|
||||
SELECT dungeon_id, route_id, route_hash, reviewed_at
|
||||
FROM dungeon_route_reviews WHERE status = 'Approved'
|
||||
ORDER BY dungeon_id, route_id
|
||||
`).all().filter((row) => generatedRoutes.get(`${row.dungeon_id}:${row.route_id}:${row.route_hash}`)?.generationStatus === "Candidate").map((row) => ({
|
||||
dungeonId: row.dungeon_id,
|
||||
routeId: row.route_id,
|
||||
routeHash: row.route_hash,
|
||||
reviewedAt: Number(row.reviewed_at),
|
||||
})),
|
||||
};
|
||||
},
|
||||
listDungeonRouteReviews(account) {
|
||||
requireGm(account);
|
||||
pruneExpiredDungeonSimulationReplays();
|
||||
const reviews = database.prepare("SELECT * FROM dungeon_route_reviews ORDER BY dungeon_id, route_id").all();
|
||||
const latest = database.prepare(`
|
||||
SELECT * FROM (
|
||||
SELECT dungeon_simulation_replays.*,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY dungeon_id, route_id, route_hash
|
||||
ORDER BY created_at DESC, id DESC
|
||||
) AS route_rank
|
||||
FROM dungeon_simulation_replays
|
||||
) WHERE route_rank = 1
|
||||
`).all();
|
||||
return {
|
||||
reviews: reviews.map(publicReview),
|
||||
latestReplays: latest
|
||||
.filter((row) => generatedRoutes.has(`${row.dungeon_id}:${row.route_id}:${row.route_hash}`))
|
||||
.map(publicReplay),
|
||||
};
|
||||
},
|
||||
putDungeonRouteReview(account, dungeonIdValue, routeIdValue, input) {
|
||||
requireGm(account);
|
||||
const dungeonId = text(dungeonIdValue, "dungeonId", ErrorType);
|
||||
const routeId = text(routeIdValue, "routeId", ErrorType);
|
||||
const hash = routeHash(input?.routeHash, ErrorType);
|
||||
const status = input?.status;
|
||||
if (!DUNGEON_ROUTE_REVIEW_STATUSES.includes(status)) {
|
||||
throw new ErrorType("Choose a valid route review status.", 400, "invalid_route_status");
|
||||
}
|
||||
const expectedRevision = integer(input?.expectedRevision, "expectedRevision", ErrorType, 0, Number.MAX_SAFE_INTEGER);
|
||||
const note = optionalNote(input?.note, "note", ErrorType);
|
||||
const criticalOverrideNote = optionalNote(input?.criticalOverrideNote, "criticalOverrideNote", ErrorType);
|
||||
const current = reviewByRoute.get(dungeonId, routeId);
|
||||
const currentRevision = Number(current?.revision ?? 0);
|
||||
if (currentRevision !== expectedRevision) {
|
||||
throw new ErrorType("This route review changed. Reload and try again.", 409, "route_review_conflict");
|
||||
}
|
||||
const generatedRoute = generatedRoutes.get(`${dungeonId}:${routeId}:${hash}`);
|
||||
if (!generatedRoute) {
|
||||
throw new ErrorType("The route hash does not match the bundled route catalog.", 409, "route_hash_unknown");
|
||||
}
|
||||
if (status === "Approved") {
|
||||
if (generatedRoute.generationStatus !== "Candidate") {
|
||||
throw new ErrorType("This generated route failed offline navigation validation and cannot be approved.", 409, "route_generation_rejected");
|
||||
}
|
||||
const latest = database.prepare(`
|
||||
SELECT critical_findings, result FROM dungeon_simulation_replays
|
||||
WHERE dungeon_id = ? AND route_id = ? AND route_hash = ?
|
||||
ORDER BY created_at DESC, id DESC LIMIT 1
|
||||
`).get(dungeonId, routeId, hash);
|
||||
if (!latest) {
|
||||
throw new ErrorType("Run and save this exact route before approving it.", 409, "route_run_required");
|
||||
}
|
||||
if (latest.result !== "completed") {
|
||||
throw new ErrorType("Approval requires a completed run for this exact route hash.", 409, "route_completed_run_required");
|
||||
}
|
||||
if (Number(latest.critical_findings) > 0 && !criticalOverrideNote) {
|
||||
throw new ErrorType("Critical findings require an override note before approval.", 409, "route_override_required");
|
||||
}
|
||||
}
|
||||
const reviewedAt = Date.now();
|
||||
if (current) {
|
||||
database.prepare(`
|
||||
UPDATE dungeon_route_reviews SET route_hash = ?, status = ?, note = ?, critical_override_note = ?,
|
||||
reviewed_by_account_id = ?, reviewed_by_username = ?, reviewed_at = ?, revision = revision + 1
|
||||
WHERE dungeon_id = ? AND route_id = ? AND revision = ?
|
||||
`).run(hash, status, note, criticalOverrideNote, account.id, account.username, reviewedAt,
|
||||
dungeonId, routeId, expectedRevision);
|
||||
} else {
|
||||
database.prepare(`
|
||||
INSERT INTO dungeon_route_reviews (
|
||||
dungeon_id, route_id, route_hash, status, note, critical_override_note,
|
||||
reviewed_by_account_id, reviewed_by_username, reviewed_at, revision
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1)
|
||||
`).run(dungeonId, routeId, hash, status, note, criticalOverrideNote,
|
||||
account.id, account.username, reviewedAt);
|
||||
}
|
||||
return publicReview(reviewByRoute.get(dungeonId, routeId));
|
||||
},
|
||||
listDungeonSimulationReplays(account, { dungeonId = null } = {}) {
|
||||
requireGm(account);
|
||||
pruneExpiredDungeonSimulationReplays();
|
||||
const rows = database.prepare(`
|
||||
SELECT id, dungeon_id, route_id, route_hash, mode, seed, result, started_at, duration_ms,
|
||||
critical_findings, submitted_by_username, created_at, expires_at, compressed_bytes
|
||||
FROM dungeon_simulation_replays
|
||||
WHERE (? IS NULL OR dungeon_id = ?)
|
||||
ORDER BY created_at DESC, id DESC LIMIT 500
|
||||
`).all(dungeonId, dungeonId);
|
||||
return { replays: rows.map(publicReplay) };
|
||||
},
|
||||
saveDungeonSimulationReplay(account, input) {
|
||||
requireGm(account);
|
||||
pruneExpiredDungeonSimulationReplays();
|
||||
const { normalized, compressed, criticalFindings } = normalizeReplay(input, ErrorType);
|
||||
const id = randomUUID();
|
||||
const createdAt = Date.now();
|
||||
const expiresAt = createdAt + DUNGEON_SIMULATION_RETENTION_MS;
|
||||
database.prepare(`
|
||||
INSERT INTO dungeon_simulation_replays (
|
||||
id, schema_version, dungeon_id, route_id, route_hash, mode, seed, result,
|
||||
started_at, duration_ms, critical_findings, report_json, payload_gzip, compressed_bytes,
|
||||
submitted_by_account_id, submitted_by_username, created_at, expires_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(id, normalized.schemaVersion, normalized.dungeonId, normalized.routeId, normalized.routeHash,
|
||||
normalized.mode, normalized.seed, normalized.result, normalized.startedAt, normalized.durationMs,
|
||||
criticalFindings, JSON.stringify(normalized.report), compressed, compressed.length,
|
||||
account.id, account.username, createdAt, expiresAt);
|
||||
return publicReplay(replayById.get(id));
|
||||
},
|
||||
getDungeonSimulationReplay(account, id) {
|
||||
requireGm(account);
|
||||
pruneExpiredDungeonSimulationReplays();
|
||||
const row = replayById.get(text(id, "replay id", ErrorType));
|
||||
if (!row) throw new ErrorType("Replay not found.", 404, "replay_not_found");
|
||||
const payload = gunzipSync(row.payload_gzip, { maxOutputLength: DUNGEON_SIMULATION_MAX_DECOMPRESSED_BYTES });
|
||||
return JSON.parse(payload.toString("utf8"));
|
||||
},
|
||||
getDungeonSimulationReport(account, id) {
|
||||
requireGm(account);
|
||||
pruneExpiredDungeonSimulationReplays();
|
||||
const row = replayById.get(text(id, "replay id", ErrorType));
|
||||
if (!row) throw new ErrorType("Replay not found.", 404, "replay_not_found");
|
||||
return JSON.parse(row.report_json);
|
||||
},
|
||||
deleteDungeonSimulationReplay(account, id) {
|
||||
requireGm(account);
|
||||
const result = database.prepare("DELETE FROM dungeon_simulation_replays WHERE id = ?")
|
||||
.run(text(id, "replay id", ErrorType));
|
||||
if (!result.changes) throw new ErrorType("Replay not found.", 404, "replay_not_found");
|
||||
return { ok: true };
|
||||
},
|
||||
};
|
||||
}
|
||||
+106
-1
@@ -8,6 +8,7 @@ import { publicManastormAdminConfig } from "./manastorm-config.mjs";
|
||||
import { createOnlineSessionManager } from "./online-session.mjs";
|
||||
import { upgradeArenaWebSocket } from "./websocket.mjs";
|
||||
import { createBundledContentHandler } from "./bundled-content.mjs";
|
||||
import { createBalanceLabJobService } from "./balance-lab-jobs.mjs";
|
||||
|
||||
const MIME_TYPES = new Map([
|
||||
[".css", "text/css; charset=utf-8"], [".glb", "model/gltf-binary"],
|
||||
@@ -178,6 +179,11 @@ export function createGameServer(options = {}) {
|
||||
const trustProxy = String(options.trustProxy ?? process.env.TRUST_PROXY ?? "").toLowerCase() === "true";
|
||||
const enableShelvedModes = options.enableShelvedModes ?? booleanFlag(process.env.HEALER_MAN_ENABLE_SHELVED_MODES);
|
||||
const gameDatabase = options.database ?? openGameDatabase(options);
|
||||
const balanceLabJobs = options.balanceLabJobs ?? createBalanceLabJobService({
|
||||
dataDir: options.dataDir ?? path.dirname(gameDatabase.databasePath),
|
||||
projectRoot: options.projectRoot,
|
||||
pythonExecutable: options.pythonExecutable,
|
||||
});
|
||||
const onlineSessions = createOnlineSessionManager();
|
||||
const arenaService = createArenaService({ database: gameDatabase, autoPump: enableShelvedModes });
|
||||
const allowAuthRequest = createAuthLimiter();
|
||||
@@ -211,6 +217,60 @@ export function createGameServer(options = {}) {
|
||||
json(response, 200, { ok: true, service: "healer-man", time: new Date().toISOString() });
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/api/dungeon-route-approvals" && request.method === "GET") {
|
||||
json(response, 200, gameDatabase.listDungeonRouteApprovals());
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/api/gm/dungeon-routes" && request.method === "GET") {
|
||||
const account = gameDatabase.authenticate(bearerToken(request));
|
||||
json(response, 200, gameDatabase.listDungeonRouteReviews(account));
|
||||
return;
|
||||
}
|
||||
const dungeonRouteReviewMatch = /^\/api\/gm\/dungeon-routes\/([^/]+)\/([^/]+)\/review$/.exec(url.pathname);
|
||||
if (dungeonRouteReviewMatch && request.method === "PUT") {
|
||||
const account = gameDatabase.authenticate(bearerToken(request));
|
||||
const body = await readJson(request, 64 * 1024);
|
||||
const review = gameDatabase.putDungeonRouteReview(
|
||||
account,
|
||||
decodeURIComponent(dungeonRouteReviewMatch[1]),
|
||||
decodeURIComponent(dungeonRouteReviewMatch[2]),
|
||||
body,
|
||||
);
|
||||
json(response, 200, { review });
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/api/gm/dungeon-sim/replays" && request.method === "GET") {
|
||||
const account = gameDatabase.authenticate(bearerToken(request));
|
||||
json(response, 200, gameDatabase.listDungeonSimulationReplays(account, {
|
||||
dungeonId: url.searchParams.get("dungeon"),
|
||||
}));
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/api/gm/dungeon-sim/replays" && request.method === "POST") {
|
||||
const account = gameDatabase.authenticate(bearerToken(request));
|
||||
const replay = gameDatabase.saveDungeonSimulationReplay(account, await readJson(request, 64 * 1024 * 1024));
|
||||
json(response, 201, { replay });
|
||||
return;
|
||||
}
|
||||
const dungeonSimulationReportMatch = /^\/api\/gm\/dungeon-sim\/replays\/([^/]+)\/report$/.exec(url.pathname);
|
||||
if (dungeonSimulationReportMatch && request.method === "GET") {
|
||||
const account = gameDatabase.authenticate(bearerToken(request));
|
||||
json(response, 200, gameDatabase.getDungeonSimulationReport(account, dungeonSimulationReportMatch[1]), {
|
||||
"Content-Disposition": `attachment; filename="dungeon-simulation-${dungeonSimulationReportMatch[1]}.json"`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const dungeonSimulationReplayMatch = /^\/api\/gm\/dungeon-sim\/replays\/([^/]+)$/.exec(url.pathname);
|
||||
if (dungeonSimulationReplayMatch && request.method === "GET") {
|
||||
const account = gameDatabase.authenticate(bearerToken(request));
|
||||
json(response, 200, gameDatabase.getDungeonSimulationReplay(account, dungeonSimulationReplayMatch[1]));
|
||||
return;
|
||||
}
|
||||
if (dungeonSimulationReplayMatch && request.method === "DELETE") {
|
||||
const account = gameDatabase.authenticate(bearerToken(request));
|
||||
json(response, 200, gameDatabase.deleteDungeonSimulationReplay(account, dungeonSimulationReplayMatch[1]));
|
||||
return;
|
||||
}
|
||||
if (!enableShelvedModes && (
|
||||
url.pathname === "/api/manastorm-config"
|
||||
|| url.pathname.startsWith("/api/gm/manastorm-config")
|
||||
@@ -438,6 +498,50 @@ export function createGameServer(options = {}) {
|
||||
json(response, 200, result);
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/api/gm/balance-lab/jobs" && request.method === "GET") {
|
||||
const account = gameDatabase.authenticate(bearerToken(request));
|
||||
if (!gameDatabase.isGameMaster(account)) {
|
||||
throw new GameDatabaseError("Game Master access is required.", 403, "gm_required");
|
||||
}
|
||||
json(response, 200, { jobs: balanceLabJobs.list() });
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/api/gm/balance-lab/jobs" && request.method === "POST") {
|
||||
const account = gameDatabase.authenticate(bearerToken(request));
|
||||
if (!gameDatabase.isGameMaster(account)) {
|
||||
throw new GameDatabaseError("Game Master access is required.", 403, "gm_required");
|
||||
}
|
||||
const job = balanceLabJobs.start(await readJson(request, 32 * 1024), account);
|
||||
console.info(`Balance Lab job ${job.id} queued by ${account.username}`);
|
||||
json(response, 202, { job });
|
||||
return;
|
||||
}
|
||||
const balanceLabArtifactMatch = /^\/api\/gm\/balance-lab\/jobs\/([0-9a-f-]{36})\/artifacts\/([A-Za-z0-9._-]+)$/.exec(url.pathname);
|
||||
if (balanceLabArtifactMatch && request.method === "GET") {
|
||||
const account = gameDatabase.authenticate(bearerToken(request));
|
||||
if (!gameDatabase.isGameMaster(account)) {
|
||||
throw new GameDatabaseError("Game Master access is required.", 403, "gm_required");
|
||||
}
|
||||
const artifact = balanceLabJobs.artifact(balanceLabArtifactMatch[1], balanceLabArtifactMatch[2]);
|
||||
response.writeHead(200, {
|
||||
"Content-Type": artifact.contentType,
|
||||
"Content-Length": artifact.size,
|
||||
"Cache-Control": "no-store",
|
||||
"Content-Disposition": `inline; filename="${balanceLabArtifactMatch[2]}"`,
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
});
|
||||
artifact.stream().pipe(response);
|
||||
return;
|
||||
}
|
||||
const balanceLabJobMatch = /^\/api\/gm\/balance-lab\/jobs\/([0-9a-f-]{36})$/.exec(url.pathname);
|
||||
if (balanceLabJobMatch && request.method === "GET") {
|
||||
const account = gameDatabase.authenticate(bearerToken(request));
|
||||
if (!gameDatabase.isGameMaster(account)) {
|
||||
throw new GameDatabaseError("Game Master access is required.", 403, "gm_required");
|
||||
}
|
||||
json(response, 200, { job: balanceLabJobs.get(balanceLabJobMatch[1]) });
|
||||
return;
|
||||
}
|
||||
if (url.pathname.startsWith("/api/")) {
|
||||
json(response, 404, { error: "API route not found.", code: "not_found" });
|
||||
return;
|
||||
@@ -491,11 +595,12 @@ export function createGameServer(options = {}) {
|
||||
});
|
||||
|
||||
server.on("close", () => {
|
||||
balanceLabJobs.close?.();
|
||||
onlineSessions.clear();
|
||||
arenaService.close();
|
||||
gameDatabase.close();
|
||||
});
|
||||
return { server, database: gameDatabase, arenaService, staticDir, contentDir, enableShelvedModes };
|
||||
return { server, database: gameDatabase, arenaService, balanceLabJobs, staticDir, contentDir, enableShelvedModes };
|
||||
}
|
||||
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
|
||||
@@ -237,6 +237,24 @@ test("serves the game and authenticated cloud-save API", async () => {
|
||||
assert.equal(gmRegistration.status, 201);
|
||||
const gmAuth = await gmRegistration.json();
|
||||
assert.deepEqual(gmAuth.account.roles, ["gm"]);
|
||||
const deniedBalanceLab = await fetch(`${origin}/api/gm/balance-lab/jobs`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Authorization: `Bearer ${auth.token}` },
|
||||
body: JSON.stringify({ source: "wow", level: 40 }),
|
||||
});
|
||||
assert.equal(deniedBalanceLab.status, 403);
|
||||
const balanceLabJobs = await fetch(`${origin}/api/gm/balance-lab/jobs`, {
|
||||
headers: { Authorization: `Bearer ${gmAuth.token}` },
|
||||
});
|
||||
assert.equal(balanceLabJobs.status, 200);
|
||||
assert.deepEqual((await balanceLabJobs.json()).jobs, []);
|
||||
const invalidBalanceLab = await fetch(`${origin}/api/gm/balance-lab/jobs`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Authorization: `Bearer ${gmAuth.token}` },
|
||||
body: JSON.stringify({ source: "wow", level: 81 }),
|
||||
});
|
||||
assert.equal(invalidBalanceLab.status, 400);
|
||||
assert.equal((await invalidBalanceLab.json()).code, "invalid_balance_lab_job");
|
||||
const current = await (await fetch(`${origin}/api/manastorm-config`)).json();
|
||||
const published = await fetch(`${origin}/api/gm/manastorm-config`, {
|
||||
method: "PUT",
|
||||
@@ -290,6 +308,9 @@ test("disables shelved APIs and Manastorm group creation by default", async () =
|
||||
assert.equal(response.status, 503);
|
||||
assert.equal((await response.json()).code, "feature_disabled");
|
||||
}
|
||||
const balanceLab = await fetch(`${origin}/api/gm/balance-lab/jobs`);
|
||||
assert.equal(balanceLab.status, 401);
|
||||
assert.equal((await balanceLab.json()).code, "authentication_required");
|
||||
|
||||
const registration = await fetch(`${origin}/api/auth/register`, {
|
||||
method: "POST",
|
||||
|
||||
Reference in new issue
Block a user