Release Healer Man 0.1.46

This commit is contained in:
phenom committed 2026-09-16 14:30:58 -04:00
1 parent f82daf1c13
commit 9ff574ac0b
73 files changed
+33486 -174

No files matched your search

+416
View File
@@ -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();
},
};
}
+250
View File
@@ -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 });
}
});
+2
View File
@@ -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
View File
@@ -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");
+331
View File
@@ -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
View File
@@ -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)) {
+21
View File
@@ -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",