Release Healer Man 0.1.47

This commit is contained in:
phenom committed 2026-09-16 20:00:13 -04:00
1 parent 9ff574ac0b
commit 2162039b49
24 files changed
+486 -55

No files matched your search

+50 -2
View File
@@ -1,5 +1,5 @@
import { randomUUID } from "node:crypto";
import { spawn } from "node:child_process";
import { spawn, spawnSync } from "node:child_process";
import {
createReadStream,
existsSync,
@@ -101,6 +101,36 @@ export function balanceLabCommand(spec, outputDir, options = {}) {
return { executable, args, cwd: projectRoot };
}
export function inspectBalanceLabRuntime(executable, run = spawnSync) {
const command = String(executable || (process.platform === "win32" ? "python" : "python3"));
try {
const result = run(command, ["--version"], {
encoding: "utf8",
timeout: 5_000,
windowsHide: true,
});
const version = `${result?.stdout ?? ""} ${result?.stderr ?? ""}`.trim();
if (result?.status === 0 && /^Python 3\./i.test(version)) {
return { available: true, executable: command, version, error: null };
}
return {
available: false,
executable: command,
version: version || null,
error: version && /^Python 2\./i.test(version)
? "Balance Lab requires Python 3; this server resolved Python 2."
: `The server could not execute ${command} as Python 3.`,
};
} catch (error) {
return {
available: false,
executable: command,
version: null,
error: `The server could not execute ${command}: ${error?.message ?? error}`,
};
}
}
function publicJob(job) {
return {
id: job.id,
@@ -166,6 +196,13 @@ export function createBalanceLabJobService(options = {}) {
? Math.min(50, Math.floor(configuredQueueLimit))
: 8;
const launch = options.spawnProcess ?? spawn;
const pythonExecutable = String(options.pythonExecutable
?? process.env.BALANCE_LAB_PYTHON
?? (process.platform === "win32" ? "python" : "python3"));
const runtimeProbe = options.runtimeProbe
?? (options.spawnProcess
? () => ({ available: true, executable: pythonExecutable, version: "test process", error: null })
: () => inspectBalanceLabRuntime(pythonExecutable));
const jobs = new Map();
const queue = [];
const children = new Map();
@@ -288,7 +325,7 @@ export function createBalanceLabJobService(options = {}) {
persistJob(job);
const command = balanceLabCommand(job.spec, job.outputDir, {
projectRoot,
pythonExecutable: options.pythonExecutable,
pythonExecutable,
});
const child = launch(command.executable, command.args, {
cwd: command.cwd,
@@ -344,6 +381,14 @@ export function createBalanceLabJobService(options = {}) {
return {
start(input, account) {
if (closed) throw new GameDatabaseError("Balance Lab runner is shutting down.", 503, "balance_lab_unavailable");
const runtime = runtimeProbe();
if (!runtime.available) {
throw new GameDatabaseError(
`${runtime.error} Install Python 3 in the server container and set BALANCE_LAB_PYTHON=python3, then restart the app.`,
503,
"balance_lab_runtime_unavailable",
);
}
const pending = [...jobs.values()].filter((job) => job.status === "queued" || job.status === "running").length;
if (pending >= maxQueued) {
throw new GameDatabaseError(
@@ -390,6 +435,9 @@ export function createBalanceLabJobService(options = {}) {
.slice(0, 100)
.map(publicJob);
},
runtime() {
return { ...runtimeProbe() };
},
get(id) {
return publicJob(requireJob(id));
},
+34
View File
@@ -8,6 +8,7 @@ import test from "node:test";
import {
balanceLabCommand,
createBalanceLabJobService,
inspectBalanceLabRuntime,
normalizeBalanceLabJobSpec,
} from "./balance-lab-jobs.mjs";
@@ -50,6 +51,39 @@ test("builds an argument-array command without a shell", () => {
assert.equal(command.cwd, path.resolve("C:/app"));
});
test("detects available and missing Python 3 runtimes", () => {
const available = inspectBalanceLabRuntime("python3", () => ({
status: 0, stdout: "Python 3.12.8\n", stderr: "",
}));
assert.equal(available.available, true);
assert.equal(available.version, "Python 3.12.8");
const missing = inspectBalanceLabRuntime("python3", () => ({
status: 127, stdout: "", stderr: "python3: not found",
}));
assert.equal(missing.available, false);
assert.match(missing.error, /could not execute python3/i);
});
test("rejects a job before queueing when the server runtime is unavailable", () => {
const directory = mkdtempSync(path.join(os.tmpdir(), "healer-man-balance-runtime-"));
try {
const service = createBalanceLabJobService({
dataDir: directory,
projectRoot: directory,
runtimeProbe: () => ({ available: false, executable: "python3", version: null, error: "python3 is missing." }),
});
assert.throws(() => service.start({
source: "wow", level: 40, partyConfigurations: 1, simulationsPerParty: 1,
durationSeconds: 5, partySeed: 1, bossCategory: "all", bossKeys: ["gundrak/entry-29932"],
}, { username: "Phenom" }), /Install Python 3 in the server container/);
assert.equal(service.list().length, 0);
assert.equal(service.runtime().available, false);
service.close();
} finally {
rmSync(directory, { recursive: true, force: true });
}
});
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 {
+8
View File
@@ -506,6 +506,14 @@ export function createGameServer(options = {}) {
json(response, 200, { jobs: balanceLabJobs.list() });
return;
}
if (url.pathname === "/api/gm/balance-lab/runtime" && 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, { runtime: balanceLabJobs.runtime() });
return;
}
if (url.pathname === "/api/gm/balance-lab/jobs" && request.method === "POST") {
const account = gameDatabase.authenticate(bearerToken(request));
if (!gameDatabase.isGameMaster(account)) {
+5
View File
@@ -248,6 +248,11 @@ test("serves the game and authenticated cloud-save API", async () => {
});
assert.equal(balanceLabJobs.status, 200);
assert.deepEqual((await balanceLabJobs.json()).jobs, []);
const balanceLabRuntime = await fetch(`${origin}/api/gm/balance-lab/runtime`, {
headers: { Authorization: `Bearer ${gmAuth.token}` },
});
assert.equal(balanceLabRuntime.status, 200);
assert.equal((await balanceLabRuntime.json()).runtime.available, true);
const invalidBalanceLab = await fetch(`${origin}/api/gm/balance-lab/jobs`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${gmAuth.token}` },