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

+21 -8
View File
@@ -129,14 +129,17 @@ services:
healerman: healerman:
image: node:24-bookworm-slim image: node:24-bookworm-slim
command: >- command: >-
sh -lc "npm ci --include=dev && npm run db:init && npm run content:publish && npm run build && npm start" sh -lc "apt-get update && apt-get install -y --no-install-recommends python3 && rm -rf /var/lib/apt/lists/* && python3 --version && npm ci --include=dev && npm run db:init && npm run content:publish && npm run build && npm start"
environment: environment:
BALANCE_LAB_MAX_CONCURRENT: "1"
BALANCE_LAB_MAX_QUEUED: "8"
BALANCE_LAB_PYTHON: "python3"
CORS_ORIGINS: "https://iwanttoheal.phenomrom.com,capacitor://localhost,http://localhost,https://localhost" CORS_ORIGINS: "https://iwanttoheal.phenomrom.com,capacitor://localhost,http://localhost,https://localhost"
CONTENT_DIR: /app/content CONTENT_DIR: /app/content
DATA_DIR: /app/runtime-data DATA_DIR: /app/runtime-data
HOST: 0.0.0.0 HOST: 0.0.0.0
HEALER_MAN_ENABLE_SHELVED_MODES: "0" HEALER_MAN_ENABLE_SHELVED_MODES: "0"
NODE_OPTIONS: "--max-old-space-size=4096" NODE_OPTIONS: "--max-old-space-size=8192"
PORT: "4173" PORT: "4173"
SESSION_TTL_DAYS: "30" SESSION_TTL_DAYS: "30"
STATIC_DIR: /app/dist STATIC_DIR: /app/dist
@@ -162,18 +165,22 @@ new or changed content objects, builds the compact required-content browser
bundle, and starts the combined static/API/content server. Existing content bundle, and starts the combined static/API/content server. Existing content
objects are reused. objects are reused.
### Optional GM Balance Lab server dependency ### Required GM Balance Lab server dependency
To run Balance Lab simulations on TrueNAS instead of the local workstation, Balance Lab uses the repository's Python controller to orchestrate the live
the server container needs Python 3 in addition to the existing Node TypeScript combat runtime. The canonical YAML above therefore installs Python
development dependencies. Replace the service command with: 3 before installing/building the app and explicitly selects `python3` with
`BALANCE_LAB_PYTHON`.
If an existing app reports `Could not start python3`, edit that app's YAML to
use this command:
```yaml ```yaml
command: >- command: >-
sh -lc "apt-get update && apt-get install -y --no-install-recommends python3 && rm -rf /var/lib/apt/lists/* && npm ci --include=dev && npm run db:init && npm run content:publish && npm run build && npm start" sh -lc "apt-get update && apt-get install -y --no-install-recommends python3 && rm -rf /var/lib/apt/lists/* && python3 --version && npm ci --include=dev && npm run db:init && npm run content:publish && npm run build && npm start"
``` ```
Add or change these environment values: and add or correct these environment values:
```yaml ```yaml
BALANCE_LAB_PYTHON: "python3" BALANCE_LAB_PYTHON: "python3"
@@ -192,6 +199,12 @@ job returns as failed after restart, while any job with a complete
unless the server has enough spare CPU for multiple full combat sweeps. All unless the server has enough spare CPU for multiple full combat sweeps. All
Balance Lab routes independently require a server-authorized GM account. Balance Lab routes independently require a server-authorized GM account.
After saving the YAML, restart the TrueNAS app and watch its startup log for a
line beginning with `Python 3.`. Reopen Balance Lab; the setup panel must show
**Server runtime ready** before **Run on server** becomes available. The server
now performs this preflight before queueing, so a missing runtime cannot create
another doomed job.
After startup, test on the LAN: After startup, test on the LAN:
```sh ```sh
+17
View File
@@ -3512,3 +3512,20 @@ Vite's existing large-chunk advisory remains informational.
browser errors. Evidence is in browser errors. Evidence is in
`playtest-artifacts/balance-lab/04b-ability-breakdown.png` and `playtest-artifacts/balance-lab/04b-ability-breakdown.png` and
`playtest-artifacts/balance-lab/06-mobile-abilities.png`. `playtest-artifacts/balance-lab/06-mobile-abilities.png`.
## Balance Lab server Python preflight (2026-09-16)
- The production failure was a deployment mismatch: the TrueNAS app used the
canonical Node-only startup command even though server Balance Lab jobs need
the Python controller. The canonical YAML in both `DEPLOYMENT.md` and the
release-runbook appendix now installs `python3`, prints its version during
startup, sets `BALANCE_LAB_PYTHON=python3`, and includes the queue limits.
- A GM-authenticated runtime endpoint now probes the configured executable with
`--version`. The Balance Lab setup shows the resolved Python 3 version, and a
missing runtime disables **Run on server**. POST requests independently
repeat the preflight and return a dedicated 503 without creating a failed job.
- Nine focused Balance Lab service tests plus two server integration tests, the
8 GB TypeScript check, and the production Vite build passed. Production Edge
displayed `Server runtime ready · Python 3.14.0`, submitted a real Eck run,
generated all reports, and completed desktop/mobile QA without browser errors.
Evidence is in `playtest-artifacts/balance-lab/02-server-job-config.png`.
+5 -2
View File
@@ -651,14 +651,17 @@ services:
healerman: healerman:
image: node:24-bookworm-slim image: node:24-bookworm-slim
command: >- command: >-
sh -lc "npm ci --include=dev && npm run db:init && npm run content:publish && npm run build && npm start" sh -lc "apt-get update && apt-get install -y --no-install-recommends python3 && rm -rf /var/lib/apt/lists/* && python3 --version && npm ci --include=dev && npm run db:init && npm run content:publish && npm run build && npm start"
environment: environment:
BALANCE_LAB_MAX_CONCURRENT: "1"
BALANCE_LAB_MAX_QUEUED: "8"
BALANCE_LAB_PYTHON: "python3"
CORS_ORIGINS: "https://iwanttoheal.phenomrom.com,capacitor://localhost,http://localhost,https://localhost" CORS_ORIGINS: "https://iwanttoheal.phenomrom.com,capacitor://localhost,http://localhost,https://localhost"
CONTENT_DIR: /app/content CONTENT_DIR: /app/content
DATA_DIR: /app/runtime-data DATA_DIR: /app/runtime-data
HOST: 0.0.0.0 HOST: 0.0.0.0
HEALER_MAN_ENABLE_SHELVED_MODES: "0" HEALER_MAN_ENABLE_SHELVED_MODES: "0"
NODE_OPTIONS: "--max-old-space-size=4096" NODE_OPTIONS: "--max-old-space-size=8192"
PORT: "4173" PORT: "4173"
SESSION_TTL_DAYS: "30" SESSION_TTL_DAYS: "30"
STATIC_DIR: /app/dist STATIC_DIR: /app/dist
+9 -1
View File
@@ -106,6 +106,13 @@ Server requirements:
and and
- the signed-in account must have the server-issued `gm` role. - the signed-in account must have the server-issued `gm` role.
The GM screen calls `/api/gm/balance-lab/runtime` when it opens and shows the
resolved Python version. If Python 3 is unavailable, **Run on server** is
disabled and no job is queued. On the canonical TrueNAS deployment, the service
command installs `python3`, prints `python3 --version` during startup, and sets
`BALANCE_LAB_PYTHON=python3`; use the exact YAML in `DEPLOYMENT.md` rather than
the older Node-only command.
`BALANCE_LAB_MAX_CONCURRENT` defaults to `1` and is capped at four. `BALANCE_LAB_MAX_CONCURRENT` defaults to `1` and is capped at four.
`BALANCE_LAB_MAX_QUEUED` defaults to eight and is capped at 50. Raising `BALANCE_LAB_MAX_QUEUED` defaults to eight and is capped at 50. Raising
concurrency permits parallel server processes but can make both the game server concurrency permits parallel server processes but can make both the game server
@@ -307,7 +314,8 @@ noting that control-policy difference.
- `server/balance-lab-jobs.mjs`: validated job specifications, bounded - `server/balance-lab-jobs.mjs`: validated job specifications, bounded
one-at-a-time process queue, progress parsing, `job.json` persistence/startup one-at-a-time process queue, progress parsing, `job.json` persistence/startup
restoration, report-summary extraction, and artifact allow-list. restoration, report-summary extraction, and artifact allow-list.
- `server/server.mjs`: GM-authenticated job/status/artifact HTTP routes. - `server/server.mjs`: GM-authenticated runtime-preflight, job, status, and
artifact HTTP routes.
- `src/game/benchmark/benchmarkBuilds.ts`: selectable spec builds and legal - `src/game/benchmark/benchmarkBuilds.ts`: selectable spec builds and legal
talent allocation. The older canonical cohort remains unchanged. talent allocation. The older canonical cohort remains unchanged.
- `src/game/partyStore.ts`: `createConfiguredPartyMember`, the narrow factory - `src/game/partyStore.ts`: `createConfiguredPartyMember`, the narrow factory
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "healer-man", "name": "healer-man",
"version": "0.1.46", "version": "0.1.47",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "healer-man", "name": "healer-man",
"version": "0.1.46", "version": "0.1.47",
"dependencies": { "dependencies": {
"@capacitor/android": "8.4.1", "@capacitor/android": "8.4.1",
"@capacitor/core": "8.4.1", "@capacitor/core": "8.4.1",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "healer-man", "name": "healer-man",
"private": true, "private": true,
"version": "0.1.46", "version": "0.1.47",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite --host 0.0.0.0", "dev": "vite --host 0.0.0.0",
+2 -2
View File
@@ -1,4 +1,4 @@
{ {
"versionName": "0.1.46", "versionName": "0.1.47",
"versionCode": 1046 "versionCode": 1047
} }
+2
View File
@@ -52,6 +52,8 @@ const runtimeData = fs.mkdtempSync(path.join(os.tmpdir(), 'healer-man-balance-ui
await balanceLab.click(); await balanceLab.click();
await page.locator('.balance-lab-screen').waitFor(); await page.locator('.balance-lab-screen').waitFor();
await page.getByText('Server runtime ready').waitFor();
assert.match(await page.locator('.balance-lab-runtime').innerText(), /Python 3\./);
await page.getByLabel('Party source').selectOption('wow'); await page.getByLabel('Party source').selectOption('wow');
await page.getByLabel('Level').fill('20'); await page.getByLabel('Level').fill('20');
await page.getByLabel('Parties per source').fill('1'); await page.getByLabel('Parties per source').fill('1');
+65 -7
View File
@@ -1,5 +1,6 @@
const { chromium } = require('C:/Users/phenom/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright'); const { chromium } = require('C:/Users/phenom/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright');
const assert = require('node:assert/strict'); const assert = require('node:assert/strict');
const crypto = require('node:crypto');
const fs = require('node:fs'); const fs = require('node:fs');
const path = require('node:path'); const path = require('node:path');
@@ -18,10 +19,22 @@ fs.rmSync(databaseDirectory, { recursive: true, force: true });
const browser = await chromium.launch({ channel: 'msedge', headless: true }); const browser = await chromium.launch({ channel: 'msedge', headless: true });
const context = await browser.newContext({ viewport: { width: 1600, height: 1000 }, acceptDownloads: true }); const context = await browser.newContext({ viewport: { width: 1600, height: 1000 }, acceptDownloads: true });
const page = await context.newPage(); const page = await context.newPage();
const cameraModuleUrls = fs.readdirSync('dist/assets')
.filter((file) => /^(?:index|GameRuntime)-.*\.js$/.test(file))
.map((file) => `/assets/${file}`);
await page.addInitScript((moduleUrls) => {
window.readSpectatorCameraState = async () => {
const values = (await Promise.all(moduleUrls.map((url) => import(url)))).flatMap(Object.values);
const game = values.find((value) => value?.getState && 'cameraYaw' in value.getState() && 'activeDungeonId' in value.getState());
if (!game) throw new Error('Game camera store export was not found.');
return { yaw: game.getState().cameraYaw, pointerLocked: document.pointerLockElement !== null };
};
}, cameraModuleUrls);
page.setDefaultTimeout(90000); page.setDefaultTimeout(90000);
const errors = []; const errors = [];
page.on('pageerror', (error) => errors.push(error.message)); page.on('pageerror', (error) => errors.push(error.message));
const capture = (name) => page.screenshot({ path: path.join(directory, `${name}.png`), fullPage: true }); const capture = (name) => page.screenshot({ path: path.join(directory, `${name}.png`), fullPage: true });
const canvasHash = async () => crypto.createHash('sha256').update(await page.locator('canvas').screenshot()).digest('hex');
try { try {
await page.goto('http://127.0.0.1:4189', { waitUntil: 'domcontentloaded' }); await page.goto('http://127.0.0.1:4189', { waitUntil: 'domcontentloaded' });
@@ -55,21 +68,66 @@ fs.rmSync(databaseDirectory, { recursive: true, force: true });
await page.getByRole('button', { name: 'Step' }).click(); await page.getByRole('button', { name: 'Step' }).click();
await page.waitForTimeout(200); await page.waitForTimeout(200);
assert.notEqual(await page.locator('.dungeon-sim-hud__header > time').innerText(), pausedTime, 'single-step advances simulation time'); assert.notEqual(await page.locator('.dungeon-sim-hud__header > time').innerText(), pausedTime, 'single-step advances simulation time');
await page.getByRole('button', { name: 'Follow camera' }).click(); const memberPicker = page.getByLabel('Spectate party member');
await page.getByRole('button', { name: 'Free-fly camera' }).waitFor(); const memberButtons = memberPicker.locator('div > button');
await capture('03-paused-free-fly'); assert.equal(await memberButtons.count(), 5, 'all five simulated party members are spectatable');
const chosenName = await memberButtons.nth(2).locator('strong').innerText();
await memberButtons.nth(2).click();
assert.equal(await memberButtons.nth(2).getAttribute('aria-pressed'), 'true');
await page.waitForTimeout(300);
await capture('03-follow-selected-member');
await page.getByRole('button', { name: 'Free camera' }).click();
assert.equal(await page.getByRole('button', { name: 'Free camera' }).getAttribute('aria-pressed'), 'true');
const beforeFlight = await canvasHash();
await page.keyboard.down('w');
await page.keyboard.down('e');
await page.waitForTimeout(700);
await page.keyboard.up('e');
await page.keyboard.up('w');
await page.waitForTimeout(150);
const afterFlight = await canvasHash();
assert.notEqual(afterFlight, beforeFlight, 'W/E free-camera input changes the rendered viewpoint');
const canvasBounds = await page.locator('canvas').boundingBox();
assert(canvasBounds, 'simulation canvas is visible');
const lookStart = { x: canvasBounds.x + canvasBounds.width * 0.5, y: canvasBounds.y + canvasBounds.height * 0.55 };
const beforeLook = await page.evaluate(() => window.readSpectatorCameraState());
await page.mouse.move(lookStart.x, lookStart.y);
await page.mouse.down({ button: 'right' });
await page.mouse.move(lookStart.x + 140, lookStart.y - 45, { steps: 10 });
await page.waitForTimeout(250);
const duringLook = await page.evaluate(() => window.readSpectatorCameraState());
assert.notEqual(duringLook.yaw, beforeLook.yaw, 'right-drag rotates the free camera');
assert.equal(duringLook.pointerLocked, false, 'right-drag does not request Pointer Lock');
await page.mouse.up({ button: 'right' });
const releasedLook = await page.evaluate(() => window.readSpectatorCameraState());
await page.mouse.move(lookStart.x - 120, lookStart.y + 50, { steps: 8 });
await page.waitForTimeout(250);
assert.equal(
(await page.evaluate(() => window.readSpectatorCameraState())).yaw,
releasedLook.yaw,
'mouse movement after right-button release does not rotate the camera',
);
await capture('04-paused-free-camera-moved');
await memberButtons.nth(4).click();
assert.equal(await page.getByRole('button', { name: 'Follow member' }).getAttribute('aria-pressed'), 'true');
assert.match(await memberPicker.locator('header strong').innerText(), /\S/);
assert.notEqual(await memberPicker.locator('header strong').innerText(), chosenName);
await page.waitForTimeout(300);
await capture('05-follow-another-member');
await page.getByRole('button', { name: '4×' }).click(); await page.getByRole('button', { name: '4×' }).click();
await page.getByRole('button', { name: 'Play' }).click(); await page.getByRole('button', { name: 'Play' }).click();
await page.waitForTimeout(1200); await page.waitForTimeout(1200);
await page.getByRole('button', { name: 'Stop & save' }).click(); await page.getByRole('button', { name: 'Stop & save' }).click();
await page.getByRole('button', { name: 'Return to GM Tools' }).waitFor(); await page.getByRole('button', { name: 'Return to GM Tools' }).waitFor();
await capture('04-finished-report'); await capture('06-finished-report');
await page.getByRole('button', { name: 'Return to GM Tools' }).click(); await page.getByRole('button', { name: 'Return to GM Tools' }).click();
await page.getByRole('heading', { name: 'Dungeon route simulations' }).waitFor(); await page.getByRole('heading', { name: 'Dungeon route simulations' }).waitFor();
await page.locator('.dungeon-sim-admin__catalog select').nth(0).selectOption('wailing-caverns'); await page.locator('.dungeon-sim-admin__catalog select').nth(0).selectOption('wailing-caverns');
await page.getByText(/1 runs for this route/).waitFor(); await page.getByText(/1 runs for this route/).waitFor();
await capture('05-saved-replay'); await capture('07-saved-replay');
await page.getByRole('button', { name: 'Watch' }).first().click(); await page.getByRole('button', { name: 'Watch' }).first().click();
await page.getByText('Recorded replay').waitFor(); await page.getByText('Recorded replay').waitFor();
@@ -77,7 +135,7 @@ fs.rmSync(databaseDirectory, { recursive: true, force: true });
await page.waitForTimeout(6000); await page.waitForTimeout(6000);
await page.getByRole('button', { name: 'Play' }).click(); await page.getByRole('button', { name: 'Play' }).click();
await page.waitForTimeout(800); await page.waitForTimeout(800);
await capture('06-replay-controls'); await capture('08-replay-controls');
await page.getByRole('button', { name: 'Pause' }).click(); await page.getByRole('button', { name: 'Pause' }).click();
const pausedReplayTime = await page.locator('.dungeon-sim-hud__header > time').innerText(); const pausedReplayTime = await page.locator('.dungeon-sim-hud__header > time').innerText();
await page.waitForTimeout(500); await page.waitForTimeout(500);
@@ -85,7 +143,7 @@ fs.rmSync(databaseDirectory, { recursive: true, force: true });
await page.getByRole('button', { name: '4×' }).click(); await page.getByRole('button', { name: '4×' }).click();
await page.getByRole('button', { name: 'Play' }).click(); await page.getByRole('button', { name: 'Play' }).click();
await page.waitForTimeout(600); await page.waitForTimeout(600);
await capture('07-replay-4x'); await capture('09-replay-4x');
assert.deepEqual(errors, [], `No browser page errors: ${errors.join('; ')}`); assert.deepEqual(errors, [], `No browser page errors: ${errors.join('; ')}`);
console.log('Dungeon simulation browser smoke test passed.'); console.log('Dungeon simulation browser smoke test passed.');
+50 -2
View File
@@ -1,5 +1,5 @@
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { spawn } from "node:child_process"; import { spawn, spawnSync } from "node:child_process";
import { import {
createReadStream, createReadStream,
existsSync, existsSync,
@@ -101,6 +101,36 @@ export function balanceLabCommand(spec, outputDir, options = {}) {
return { executable, args, cwd: projectRoot }; 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) { function publicJob(job) {
return { return {
id: job.id, id: job.id,
@@ -166,6 +196,13 @@ export function createBalanceLabJobService(options = {}) {
? Math.min(50, Math.floor(configuredQueueLimit)) ? Math.min(50, Math.floor(configuredQueueLimit))
: 8; : 8;
const launch = options.spawnProcess ?? spawn; 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 jobs = new Map();
const queue = []; const queue = [];
const children = new Map(); const children = new Map();
@@ -288,7 +325,7 @@ export function createBalanceLabJobService(options = {}) {
persistJob(job); persistJob(job);
const command = balanceLabCommand(job.spec, job.outputDir, { const command = balanceLabCommand(job.spec, job.outputDir, {
projectRoot, projectRoot,
pythonExecutable: options.pythonExecutable, pythonExecutable,
}); });
const child = launch(command.executable, command.args, { const child = launch(command.executable, command.args, {
cwd: command.cwd, cwd: command.cwd,
@@ -344,6 +381,14 @@ export function createBalanceLabJobService(options = {}) {
return { return {
start(input, account) { start(input, account) {
if (closed) throw new GameDatabaseError("Balance Lab runner is shutting down.", 503, "balance_lab_unavailable"); 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; const pending = [...jobs.values()].filter((job) => job.status === "queued" || job.status === "running").length;
if (pending >= maxQueued) { if (pending >= maxQueued) {
throw new GameDatabaseError( throw new GameDatabaseError(
@@ -390,6 +435,9 @@ export function createBalanceLabJobService(options = {}) {
.slice(0, 100) .slice(0, 100)
.map(publicJob); .map(publicJob);
}, },
runtime() {
return { ...runtimeProbe() };
},
get(id) { get(id) {
return publicJob(requireJob(id)); return publicJob(requireJob(id));
}, },
+34
View File
@@ -8,6 +8,7 @@ import test from "node:test";
import { import {
balanceLabCommand, balanceLabCommand,
createBalanceLabJobService, createBalanceLabJobService,
inspectBalanceLabRuntime,
normalizeBalanceLabJobSpec, normalizeBalanceLabJobSpec,
} from "./balance-lab-jobs.mjs"; } 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")); 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 () => { test("queues a job, reports progress, and exposes only allow-listed artifacts", async () => {
const directory = mkdtempSync(path.join(os.tmpdir(), "healer-man-balance-job-")); const directory = mkdtempSync(path.join(os.tmpdir(), "healer-man-balance-job-"));
try { try {
+8
View File
@@ -506,6 +506,14 @@ export function createGameServer(options = {}) {
json(response, 200, { jobs: balanceLabJobs.list() }); json(response, 200, { jobs: balanceLabJobs.list() });
return; 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") { if (url.pathname === "/api/gm/balance-lab/jobs" && request.method === "POST") {
const account = gameDatabase.authenticate(bearerToken(request)); const account = gameDatabase.authenticate(bearerToken(request));
if (!gameDatabase.isGameMaster(account)) { 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.equal(balanceLabJobs.status, 200);
assert.deepEqual((await balanceLabJobs.json()).jobs, []); 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`, { const invalidBalanceLab = await fetch(`${origin}/api/gm/balance-lab/jobs`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${gmAuth.token}` }, headers: { "Content-Type": "application/json", Authorization: `Bearer ${gmAuth.token}` },
+2 -1
View File
@@ -44,7 +44,8 @@ function InputBridge() {
useEffect(() => installInput({ useEffect(() => installInput({
desktopActionBarsActive: () => currentUiDevice() === "pc" && activeUiLayout().wowBars, desktopActionBarsActive: () => currentUiDevice() === "pc" && activeUiLayout().wowBars,
inputSuspended: () => ["ui-layout", "keybinds", "dungeon-results", "boss-test-review", "report-issue"].includes(useGameStore.getState().overlay ?? ""), inputSuspended: () => useDungeonSimulationStore.getState().sessionMode !== "play"
|| ["ui-layout", "keybinds", "dungeon-results", "boss-test-review", "report-issue"].includes(useGameStore.getState().overlay ?? ""),
getBindings: (device) => { getBindings: (device) => {
const preferences = useUiLayoutStore.getState().preferences; const preferences = useUiLayoutStore.getState().preferences;
const bindings = profileInputBindings(preferences, device); const bindings = profileInputBindings(preferences, device);
+15
View File
@@ -4,6 +4,13 @@ export type BalanceLabPartySource = "wow" | "coa" | "rom" | "all";
export type BalanceLabBossCategory = "all" | "wow" | "rom"; export type BalanceLabBossCategory = "all" | "wow" | "rom";
export type BalanceLabJobStatus = "queued" | "running" | "complete" | "failed"; export type BalanceLabJobStatus = "queued" | "running" | "complete" | "failed";
export interface BalanceLabRuntimeStatus {
readonly available: boolean;
readonly executable: string;
readonly version: string | null;
readonly error: string | null;
}
export interface BalanceLabJobSpec { export interface BalanceLabJobSpec {
readonly source: BalanceLabPartySource; readonly source: BalanceLabPartySource;
readonly level: number; readonly level: number;
@@ -155,6 +162,14 @@ export async function listBalanceLabJobs(token: string): Promise<readonly Balanc
)).jobs; )).jobs;
} }
export async function getBalanceLabRuntime(token: string): Promise<BalanceLabRuntimeStatus> {
return (await requestOnlineJson<{ runtime: BalanceLabRuntimeStatus }>(
"/api/gm/balance-lab/runtime",
{},
token,
)).runtime;
}
export async function startBalanceLabJob(spec: BalanceLabJobSpec, token: string): Promise<BalanceLabJob> { export async function startBalanceLabJob(spec: BalanceLabJobSpec, token: string): Promise<BalanceLabJob> {
return (await requestOnlineJson<{ job: BalanceLabJob }>( return (await requestOnlineJson<{ job: BalanceLabJob }>(
"/api/gm/balance-lab/jobs", "/api/gm/balance-lab/jobs",
+17
View File
@@ -54,6 +54,23 @@ describe("dungeon simulation fixed clock", () => {
}); });
describe("spectator roster and exact-hash approvals", () => { describe("spectator roster and exact-hash approvals", () => {
it("tracks the chosen party member and returns to follow mode when one is picked", () => {
const store = useDungeonSimulationStore.getState();
store.startLive({ dungeonId: route.dungeonId, route, mode: "navigation-only", seed: "camera" });
expect(useDungeonSimulationStore.getState()).toMatchObject({
cameraMode: "follow-member",
spectatedMemberId: null,
});
store.setCameraMode("free-fly");
store.setSpectatedMember("party-healer");
expect(useDungeonSimulationStore.getState()).toMatchObject({
cameraMode: "follow-member",
spectatedMemberId: "party-healer",
});
store.leave();
expect(useDungeonSimulationStore.getState().spectatedMemberId).toBeNull();
});
it("creates a stable tank, healer, and three damage dealers", () => { it("creates a stable tank, healer, and three damage dealers", () => {
const first = createSpectatorDungeonParty("same-seed", 80, "wow"); const first = createSpectatorDungeonParty("same-seed", 80, "wow");
const second = createSpectatorDungeonParty("same-seed", 80, "wow"); const second = createSpectatorDungeonParty("same-seed", 80, "wow");
+5 -1
View File
@@ -33,6 +33,7 @@ export interface DungeonSimulationState {
runState: "idle" | "running" | "paused" | "finished"; runState: "idle" | "running" | "paused" | "finished";
speed: DungeonSimulationSpeed; speed: DungeonSimulationSpeed;
cameraMode: DungeonSpectatorCameraMode; cameraMode: DungeonSpectatorCameraMode;
spectatedMemberId: string | null;
dungeonId: string | null; dungeonId: string | null;
routeId: string | null; routeId: string | null;
routeHash: string | null; routeHash: string | null;
@@ -56,6 +57,7 @@ export interface DungeonSimulationState {
toggleRunning: () => void; toggleRunning: () => void;
setSpeed: (speed: DungeonSimulationSpeed) => void; setSpeed: (speed: DungeonSimulationSpeed) => void;
setCameraMode: (mode: DungeonSpectatorCameraMode) => void; setCameraMode: (mode: DungeonSpectatorCameraMode) => void;
setSpectatedMember: (memberId: string | null) => void;
step: () => void; step: () => void;
seekReplay: (elapsedMs: number) => void; seekReplay: (elapsedMs: number) => void;
consumeTicks: (wallDeltaMs: number) => readonly number[]; consumeTicks: (wallDeltaMs: number) => readonly number[];
@@ -72,7 +74,8 @@ const initialState = {
mode: "full-combat" as DungeonSimulationMode, mode: "full-combat" as DungeonSimulationMode,
runState: "idle" as const, runState: "idle" as const,
speed: 1 as DungeonSimulationSpeed, speed: 1 as DungeonSimulationSpeed,
cameraMode: "follow-leader" as DungeonSpectatorCameraMode, cameraMode: "follow-member" as DungeonSpectatorCameraMode,
spectatedMemberId: null as string | null,
dungeonId: null as string | null, dungeonId: null as string | null,
routeId: null as string | null, routeId: null as string | null,
routeHash: null as string | null, routeHash: null as string | null,
@@ -146,6 +149,7 @@ export const useDungeonSimulationStore = create<DungeonSimulationState>((set, ge
}, },
setSpeed: (speed) => set({ speed }), setSpeed: (speed) => set({ speed }),
setCameraMode: (cameraMode) => set({ cameraMode }), setCameraMode: (cameraMode) => set({ cameraMode }),
setSpectatedMember: (spectatedMemberId) => set({ spectatedMemberId, cameraMode: "follow-member" }),
step: () => set((state) => { step: () => set((state) => {
if (state.runState !== "paused") return state; if (state.runState !== "paused") return state;
if (state.sessionMode === "spectate-replay" && state.replay) { if (state.sessionMode === "spectate-replay" && state.replay) {
+1 -1
View File
@@ -3,7 +3,7 @@ import type { Vector3Tuple } from "./dungeonTypes";
export type DungeonSessionMode = "play" | "spectate-live" | "spectate-replay"; export type DungeonSessionMode = "play" | "spectate-live" | "spectate-replay";
export type DungeonSimulationMode = "full-combat" | "navigation-only"; export type DungeonSimulationMode = "full-combat" | "navigation-only";
export type DungeonSimulationSpeed = 0.5 | 1 | 2 | 4; export type DungeonSimulationSpeed = 0.5 | 1 | 2 | 4;
export type DungeonSpectatorCameraMode = "follow-leader" | "free-fly"; export type DungeonSpectatorCameraMode = "follow-member" | "free-fly";
export type DungeonRouteReviewStatus = "Candidate" | "Approved" | "NeedsWork"; export type DungeonRouteReviewStatus = "Candidate" | "Approved" | "NeedsWork";
export type DungeonSimulationResult = "completed" | "wiped" | "stopped" | "recording-limit"; export type DungeonSimulationResult = "completed" | "wiped" | "stopped" | "recording-limit";
export type DungeonSimulationFindingSeverity = "info" | "warning" | "critical"; export type DungeonSimulationFindingSeverity = "info" | "warning" | "critical";
+38
View File
@@ -0,0 +1,38 @@
import { describe, expect, it } from "vitest";
import {
spectatorCameraBoosted,
spectatorCameraTranslation,
spectatorKeyboardAxes,
} from "./spectatorCameraMath";
describe("dungeon spectator free camera", () => {
it("maps desktop flight controls independently from player movement state", () => {
const held = new Set(["KeyW", "KeyD", "KeyE", "ShiftLeft"]);
expect(spectatorKeyboardAxes(held)).toEqual({ forward: 1, right: 1, vertical: 1 });
expect(spectatorCameraBoosted(held)).toBe(true);
expect(spectatorKeyboardAxes(new Set(["KeyS", "KeyA", "KeyQ"]))).toEqual({
forward: -1,
right: -1,
vertical: -1,
});
});
it("flies along the view direction and uses world-relative vertical movement", () => {
expect(spectatorCameraTranslation(0, 0, { forward: 1, right: 0, vertical: 0 }, 0.1, false))
.toEqual([0, 0, 1]);
expect(spectatorCameraTranslation(0, 0, { forward: 0, right: 0, vertical: 1 }, 0.1, false))
.toEqual([0, 1, 0]);
const pitched = spectatorCameraTranslation(0, Math.PI / 6, { forward: 1, right: 0, vertical: 0 }, 0.1, false);
expect(pitched[1]).toBeCloseTo(0.5, 6);
expect(pitched[2]).toBeCloseTo(Math.sqrt(3) / 2, 6);
});
it("normalizes diagonal speed and clamps long render-frame gaps", () => {
const diagonal = spectatorCameraTranslation(0, 0, { forward: 1, right: 1, vertical: 1 }, 1, false);
expect(Math.hypot(...diagonal)).toBeCloseTo(1, 6);
const boosted = spectatorCameraTranslation(0, 0, { forward: 1, right: 0, vertical: 0 }, 1, true);
expect(Math.hypot(...boosted)).toBeCloseTo(2.8, 6);
expect(spectatorCameraTranslation(Number.NaN, 0, { forward: 1, right: 0, vertical: 0 }, 0.1, false))
.toEqual([0, 0, 0]);
});
});
+51
View File
@@ -0,0 +1,51 @@
import type { Vector3Tuple } from "./dungeonTypes";
export interface SpectatorCameraAxes {
readonly forward: number;
readonly right: number;
readonly vertical: number;
}
const MAX_CAMERA_FRAME_SECONDS = 0.1;
function held(codes: ReadonlySet<string>, ...candidates: readonly string[]): number {
return Number(candidates.some((code) => codes.has(code)));
}
export function spectatorKeyboardAxes(codes: ReadonlySet<string>): SpectatorCameraAxes {
return {
forward: held(codes, "KeyW") - held(codes, "KeyS"),
right: held(codes, "KeyD") - held(codes, "KeyA"),
vertical: held(codes, "KeyE", "Space") - held(codes, "KeyQ", "ControlLeft", "ControlRight"),
};
}
export function spectatorCameraBoosted(codes: ReadonlySet<string>): boolean {
return codes.has("ShiftLeft") || codes.has("ShiftRight");
}
/** Returns a bounded, normalized free-flight translation for one render frame. */
export function spectatorCameraTranslation(
yaw: number,
pitch: number,
axes: SpectatorCameraAxes,
deltaSeconds: number,
boosted: boolean,
): Vector3Tuple {
if (![yaw, pitch, deltaSeconds, axes.forward, axes.right, axes.vertical].every(Number.isFinite)) return [0, 0, 0];
const forwardX = Math.sin(yaw) * Math.cos(pitch);
const forwardY = Math.sin(pitch);
const forwardZ = Math.cos(yaw) * Math.cos(pitch);
const rightX = Math.cos(yaw);
const rightZ = -Math.sin(yaw);
let x = forwardX * axes.forward + rightX * axes.right;
let y = forwardY * axes.forward + axes.vertical;
let z = forwardZ * axes.forward + rightZ * axes.right;
const length = Math.hypot(x, y, z);
if (length <= 1e-6) return [0, 0, 0];
const scale = (boosted ? 28 : 10) * Math.max(0, Math.min(MAX_CAMERA_FRAME_SECONDS, deltaSeconds)) / Math.max(1, length);
x *= scale;
y *= scale;
z *= scale;
return [x, y, z];
}
+52 -20
View File
@@ -2,15 +2,24 @@ import { useFrame, useThree } from "@react-three/fiber";
import { useRapier } from "@react-three/rapier"; import { useRapier } from "@react-three/rapier";
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { Vector3 } from "three"; import { Vector3 } from "three";
import { consumeMouseLook, readInputSnapshot } from "../game/inputManager"; import { consumeMouseLook } from "../game/inputManager";
import { getPartyRuntimePosition } from "../game/partyRuntimeRegistry"; import { getPartyRuntimePosition } from "../game/partyRuntimeRegistry";
import { usePartyStore } from "../game/partyStore"; import { usePartyStore } from "../game/partyStore";
import { useDungeonSimulationStore } from "../game/dungeonSimulationStore"; import { useDungeonSimulationStore } from "../game/dungeonSimulationStore";
import { useGameStore } from "../game/store"; import { useGameStore } from "../game/store";
import {
spectatorCameraBoosted,
spectatorCameraTranslation,
spectatorKeyboardAxes,
} from "../game/spectatorCameraMath";
const FOLLOW_DISTANCE = 7; const FOLLOW_DISTANCE = 7;
const FOLLOW_HEIGHT = 2.4; const FOLLOW_HEIGHT = 2.4;
const LOOK_SENSITIVITY = 0.0021; const LOOK_SENSITIVITY = 0.0021;
const SPECTATOR_FLIGHT_KEYS = new Set([
"KeyW", "KeyA", "KeyS", "KeyD", "KeyQ", "KeyE", "Space",
"ControlLeft", "ControlRight", "ShiftLeft", "ShiftRight",
]);
export function DungeonSpectatorCamera() { export function DungeonSpectatorCamera() {
const camera = useThree((state) => state.camera); const camera = useThree((state) => state.camera);
@@ -18,30 +27,39 @@ export function DungeonSpectatorCamera() {
const yawRef = useRef(useGameStore.getState().cameraYaw); const yawRef = useRef(useGameStore.getState().cameraYaw);
const pitchRef = useRef(-0.22); const pitchRef = useRef(-0.22);
const freePositionRef = useRef(new Vector3().copy(camera.position)); const freePositionRef = useRef(new Vector3().copy(camera.position));
const boostRef = useRef(false); const pressedKeysRef = useRef(new Set<string>());
const initializedFreeRef = useRef(false); const initializedFreeRef = useRef(false);
const followedMemberRef = useRef<string | null>(null);
useEffect(() => { useEffect(() => {
const onKey = (event: KeyboardEvent) => { const onKeyDown = (event: KeyboardEvent) => {
boostRef.current = event.shiftKey; if (!SPECTATOR_FLIGHT_KEYS.has(event.code)) return;
const target = event.target as HTMLElement | null;
if (target?.closest?.("input, textarea, select, [contenteditable='true']")) return;
pressedKeysRef.current.add(event.code);
if (useDungeonSimulationStore.getState().cameraMode === "free-fly") event.preventDefault();
}; };
window.addEventListener("keydown", onKey); const onKeyUp = (event: KeyboardEvent) => {
window.addEventListener("keyup", onKey); pressedKeysRef.current.delete(event.code);
};
const clearKeys = () => pressedKeysRef.current.clear();
window.addEventListener("keydown", onKeyDown);
window.addEventListener("keyup", onKeyUp);
window.addEventListener("blur", clearKeys);
return () => { return () => {
window.removeEventListener("keydown", onKey); window.removeEventListener("keydown", onKeyDown);
window.removeEventListener("keyup", onKey); window.removeEventListener("keyup", onKeyUp);
window.removeEventListener("blur", clearKeys);
clearKeys();
}; };
}, []); }, []);
useFrame((_, delta) => { useFrame((_, delta) => {
const simulation = useDungeonSimulationStore.getState(); const simulation = useDungeonSimulationStore.getState();
if (simulation.sessionMode === "play") return; if (simulation.sessionMode === "play") return;
const input = readInputSnapshot();
const [mouseX, mouseY] = consumeMouseLook(); const [mouseX, mouseY] = consumeMouseLook();
yawRef.current -= mouseX * LOOK_SENSITIVITY; yawRef.current -= mouseX * LOOK_SENSITIVITY;
pitchRef.current = Math.max(-1.35, Math.min(1.15, pitchRef.current - mouseY * LOOK_SENSITIVITY)); pitchRef.current = Math.max(-1.35, Math.min(1.15, pitchRef.current - mouseY * LOOK_SENSITIVITY));
yawRef.current -= input.lookX * delta * 2.25;
pitchRef.current = Math.max(-1.35, Math.min(1.15, pitchRef.current - input.lookY * delta * 1.65));
useGameStore.setState({ cameraYaw: yawRef.current }); useGameStore.setState({ cameraYaw: yawRef.current });
if (simulation.cameraMode === "free-fly") { if (simulation.cameraMode === "free-fly") {
@@ -49,15 +67,23 @@ export function DungeonSpectatorCamera() {
freePositionRef.current.copy(camera.position); freePositionRef.current.copy(camera.position);
initializedFreeRef.current = true; initializedFreeRef.current = true;
} }
followedMemberRef.current = null;
const forward = new Vector3( const forward = new Vector3(
Math.sin(yawRef.current) * Math.cos(pitchRef.current), Math.sin(yawRef.current) * Math.cos(pitchRef.current),
Math.sin(pitchRef.current), Math.sin(pitchRef.current),
Math.cos(yawRef.current) * Math.cos(pitchRef.current), Math.cos(yawRef.current) * Math.cos(pitchRef.current),
); );
const right = new Vector3(Math.cos(yawRef.current), 0, -Math.sin(yawRef.current)); const keyboard = spectatorKeyboardAxes(pressedKeysRef.current);
const speed = boostRef.current ? 28 : 10; const movement = spectatorCameraTranslation(
freePositionRef.current.addScaledVector(forward, input.moveForward * speed * delta); yawRef.current,
freePositionRef.current.addScaledVector(right, input.moveX * speed * delta); pitchRef.current,
keyboard,
delta,
spectatorCameraBoosted(pressedKeysRef.current),
);
freePositionRef.current.x += movement[0];
freePositionRef.current.y += movement[1];
freePositionRef.current.z += movement[2];
camera.position.copy(freePositionRef.current); camera.position.copy(freePositionRef.current);
camera.lookAt(new Vector3().copy(camera.position).add(forward)); camera.lookAt(new Vector3().copy(camera.position).add(forward));
return; return;
@@ -65,10 +91,14 @@ export function DungeonSpectatorCamera() {
initializedFreeRef.current = false; initializedFreeRef.current = false;
const party = usePartyStore.getState(); const party = usePartyStore.getState();
const leader = party.members.find((member) => member.health > 0) ?? party.members[0]; const requested = party.members.find((member) => member.id === simulation.spectatedMemberId);
const leaderPosition = leader ? getPartyRuntimePosition(leader.id) : null; const candidates = requested
if (!leaderPosition) return; ? [requested, ...party.members.filter((member) => member.id !== requested.id)]
const anchor = new Vector3(leaderPosition[0], leaderPosition[1] + FOLLOW_HEIGHT, leaderPosition[2]); : [...party.members.filter((member) => member.health > 0), ...party.members.filter((member) => member.health <= 0)];
const followed = candidates.map((member) => ({ member, position: getPartyRuntimePosition(member.id) }))
.find((candidate) => candidate.position !== null);
if (!followed?.position) return;
const anchor = new Vector3(followed.position[0], followed.position[1] + FOLLOW_HEIGHT, followed.position[2]);
const backward = new Vector3( const backward = new Vector3(
-Math.sin(yawRef.current) * Math.cos(pitchRef.current), -Math.sin(yawRef.current) * Math.cos(pitchRef.current),
-Math.sin(pitchRef.current), -Math.sin(pitchRef.current),
@@ -78,7 +108,9 @@ export function DungeonSpectatorCamera() {
const hit = world.castRay(ray, FOLLOW_DISTANCE, true, rapier.QueryFilterFlags.EXCLUDE_SENSORS); const hit = world.castRay(ray, FOLLOW_DISTANCE, true, rapier.QueryFilterFlags.EXCLUDE_SENSORS);
const distance = hit ? Math.max(0.8, hit.timeOfImpact - 0.25) : FOLLOW_DISTANCE; const distance = hit ? Math.max(0.8, hit.timeOfImpact - 0.25) : FOLLOW_DISTANCE;
const desired = anchor.clone().addScaledVector(backward, distance); const desired = anchor.clone().addScaledVector(backward, distance);
camera.position.lerp(desired, 1 - Math.exp(-12 * Math.min(delta, 0.1))); if (followedMemberRef.current !== followed.member.id) camera.position.copy(desired);
else camera.position.lerp(desired, 1 - Math.exp(-12 * Math.min(delta, 0.1)));
followedMemberRef.current = followed.member.id;
camera.lookAt(anchor); camera.lookAt(anchor);
}); });
+19
View File
@@ -3221,6 +3221,11 @@ button.equipment-slot:hover { background: rgba(174,191,131,.1); }
.balance-lab-config, .balance-lab-jobs { border-right: 1px solid var(--front-line); } .balance-lab-config, .balance-lab-jobs { border-right: 1px solid var(--front-line); }
.balance-lab-config h2, .balance-lab-jobs h2, .balance-lab-detail h2 { margin: 4px 0 10px; font-family: Georgia, serif; font-size: 20px; font-weight: 400; } .balance-lab-config h2, .balance-lab-jobs h2, .balance-lab-detail h2 { margin: 4px 0 10px; font-family: Georgia, serif; font-size: 20px; font-weight: 400; }
.balance-lab-help, .balance-lab-local-note, .balance-lab-detail > p:not(.front-kicker), .balance-lab-empty { color: var(--front-dim); font-size: 8px; line-height: 1.45; } .balance-lab-help, .balance-lab-local-note, .balance-lab-detail > p:not(.front-kicker), .balance-lab-empty { color: var(--front-dim); font-size: 8px; line-height: 1.45; }
.balance-lab-runtime { display: grid; gap: 3px; margin-top: 10px; padding: 8px 9px; border: 1px solid rgba(121, 185, 104, .25); border-radius: 6px; color: #cbe6bb; background: rgba(53, 106, 48, .13); }
.balance-lab-runtime strong { font-size: 8px; }
.balance-lab-runtime span { color: var(--front-dim); font-size: 7px; line-height: 1.45; }
.balance-lab-runtime.is-unavailable { border-color: rgba(205, 104, 85, .4); color: #f0b6aa; background: rgba(133, 48, 39, .15); }
.balance-lab-runtime.is-unavailable span { color: #d7a89e; }
.balance-lab-form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; margin-top: 14px; } .balance-lab-form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; margin-top: 14px; }
.balance-lab-form-grid label { display: grid; gap: 4px; color: var(--front-dim); font-size: 8px; } .balance-lab-form-grid label { display: grid; gap: 4px; color: var(--front-dim); font-size: 8px; }
.balance-lab-form-grid__wide { grid-column: 1 / -1; } .balance-lab-form-grid__wide { grid-column: 1 / -1; }
@@ -4039,6 +4044,20 @@ button.equipment-slot:hover { background: rgba(174,191,131,.1); }
.dungeon-sim-hud button:hover:not(:disabled), .dungeon-sim-hud button.is-active { background: #1d526b; border-color: #70d7ff; } .dungeon-sim-hud button:hover:not(:disabled), .dungeon-sim-hud button.is-active { background: #1d526b; border-color: #70d7ff; }
.dungeon-sim-hud button.is-danger { color: #ffd8d8; border-color: #a94848; background: #502121; } .dungeon-sim-hud button.is-danger { color: #ffd8d8; border-color: #a94848; background: #502121; }
.dungeon-sim-hud button:disabled { opacity: 0.45; } .dungeon-sim-hud button:disabled { opacity: 0.45; }
.dungeon-sim-hud__spectate { display: grid; gap: 7px; margin: 0 0 12px; padding: 10px; border: 1px solid rgba(112, 215, 255, .2); border-radius: 8px; background: rgba(7, 21, 31, .72); }
.dungeon-sim-hud__spectate > header { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; }
.dungeon-sim-hud__spectate > header span { color: #82aaba; font-size: .7rem; letter-spacing: .08em; text-transform: uppercase; }
.dungeon-sim-hud__spectate > header strong { overflow: hidden; color: #e9f8ff; text-overflow: ellipsis; white-space: nowrap; }
.dungeon-sim-hud__spectate > div { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 5px; }
.dungeon-sim-hud__spectate > div > button { display: grid; min-width: 0; grid-template-columns: 5px minmax(0, 1fr); gap: 5px; padding: 6px; text-align: left; }
.dungeon-sim-hud__spectate button > i { width: 5px; height: 100%; min-height: 28px; border-radius: 99px; }
.dungeon-sim-hud__spectate button > span { min-width: 0; }
.dungeon-sim-hud__spectate button strong,
.dungeon-sim-hud__spectate button small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.dungeon-sim-hud__spectate button strong { font-size: .68rem; }
.dungeon-sim-hud__spectate button small { margin-top: 2px; color: #8fb5c4; font-size: .52rem; text-transform: uppercase; }
.dungeon-sim-hud__spectate button em { display: none; }
.dungeon-sim-hud__spectate > p { margin: 0; color: #8fb5c4; font-size: .68rem; }
.dungeon-sim-hud__seek { display: grid; gap: 4px; margin-bottom: 10px; color: #9cc6d5; } .dungeon-sim-hud__seek { display: grid; gap: 4px; margin-bottom: 10px; color: #9cc6d5; }
.dungeon-sim-hud__seek input { width: 100%; } .dungeon-sim-hud__seek input { width: 100%; }
.dungeon-sim-hud__status { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 6px 14px; margin: 0 0 12px; } .dungeon-sim-hud__status { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 6px 14px; margin: 0 0 12px; }
+15 -3
View File
@@ -5,11 +5,13 @@ import {
fetchBalanceLabArtifact, fetchBalanceLabArtifact,
getBalanceLabJob, getBalanceLabJob,
getBalanceLabReport, getBalanceLabReport,
getBalanceLabRuntime,
listBalanceLabJobs, listBalanceLabJobs,
startBalanceLabJob, startBalanceLabJob,
type BalanceLabBossCategory, type BalanceLabBossCategory,
type BalanceLabJob, type BalanceLabJob,
type BalanceLabPartySource, type BalanceLabPartySource,
type BalanceLabRuntimeStatus,
type BalanceLabSuiteReport, type BalanceLabSuiteReport,
} from "../app/balanceLabClient"; } from "../app/balanceLabClient";
import { BOSS_TESTING_CATALOG } from "../game/bossTesting"; import { BOSS_TESTING_CATALOG } from "../game/bossTesting";
@@ -77,6 +79,7 @@ export function BalanceLabScreen({ onClose }: { readonly onClose: () => void })
const [reports, setReports] = useState<Readonly<Record<string, BalanceLabSuiteReport>>>({}); const [reports, setReports] = useState<Readonly<Record<string, BalanceLabSuiteReport>>>({});
const [reportLoadingId, setReportLoadingId] = useState<string | null>(null); const [reportLoadingId, setReportLoadingId] = useState<string | null>(null);
const [reportError, setReportError] = useState(""); const [reportError, setReportError] = useState("");
const [runtimeStatus, setRuntimeStatus] = useState<BalanceLabRuntimeStatus | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState(""); const [error, setError] = useState("");
@@ -106,9 +109,10 @@ export function BalanceLabScreen({ onClose }: { readonly onClose: () => void })
if (!token) return; if (!token) return;
let cancelled = false; let cancelled = false;
setLoading(true); setLoading(true);
void listBalanceLabJobs(token).then((nextJobs) => { void Promise.all([listBalanceLabJobs(token), getBalanceLabRuntime(token)]).then(([nextJobs, runtime]) => {
if (cancelled) return; if (cancelled) return;
setJobs(nextJobs); setJobs(nextJobs);
setRuntimeStatus(runtime);
setSelectedJobId((current) => current ?? nextJobs[0]?.id ?? null); setSelectedJobId((current) => current ?? nextJobs[0]?.id ?? null);
setError(""); setError("");
}).catch((reason) => { }).catch((reason) => {
@@ -169,6 +173,10 @@ export function BalanceLabScreen({ onClose }: { readonly onClose: () => void })
}; };
const run = async () => { const run = async () => {
if (!runtimeStatus?.available) {
setError(runtimeStatus?.error ?? "The server Balance Lab runtime is unavailable.");
return;
}
if (bossScope === "specific" && selectedBossKeys.length === 0) { if (bossScope === "specific" && selectedBossKeys.length === 0) {
setError("Select at least one boss for a specific-boss run."); setError("Select at least one boss for a specific-boss run.");
return; return;
@@ -229,6 +237,10 @@ export function BalanceLabScreen({ onClose }: { readonly onClose: () => void })
<p className="front-kicker">New automatic sweep</p> <p className="front-kicker">New automatic sweep</p>
<h2>Simulation setup</h2> <h2>Simulation setup</h2>
<p className="balance-lab-help">This mode runs on the game server. The browser submits the job and may be closed while the server continues.</p> <p className="balance-lab-help">This mode runs on the game server. The browser submits the job and may be closed while the server continues.</p>
{runtimeStatus && <div className={`balance-lab-runtime ${runtimeStatus.available ? "is-ready" : "is-unavailable"}`} role={runtimeStatus.available ? undefined : "alert"}>
<strong>{runtimeStatus.available ? "Server runtime ready" : "Server runtime unavailable"}</strong>
<span>{runtimeStatus.available ? runtimeStatus.version : `${runtimeStatus.error} Install Python 3 in the server container, set BALANCE_LAB_PYTHON=python3, and restart the app.`}</span>
</div>}
<div className="balance-lab-form-grid"> <div className="balance-lab-form-grid">
<label><span>Party source</span><select value={source} onChange={(event) => setSource(event.currentTarget.value as BalanceLabPartySource)}> <label><span>Party source</span><select value={source} onChange={(event) => setSource(event.currentTarget.value as BalanceLabPartySource)}>
<option value="all">WoW + CoA + RoM</option><option value="wow">WoW only</option><option value="coa">CoA only</option><option value="rom">RoM only</option> <option value="all">WoW + CoA + RoM</option><option value="wow">WoW only</option><option value="coa">CoA only</option><option value="rom">RoM only</option>
@@ -254,7 +266,7 @@ export function BalanceLabScreen({ onClose }: { readonly onClose: () => void })
</section> </section>
)} )}
<div className="balance-lab-estimate"><span>Party configurations <b>{partyCount.toLocaleString()}</b></span><span>Bosses <b>{bossCount.toLocaleString()}</b></span><span>Total simulations <b>{estimatedRuns.toLocaleString()}</b></span></div> <div className="balance-lab-estimate"><span>Party configurations <b>{partyCount.toLocaleString()}</b></span><span>Bosses <b>{bossCount.toLocaleString()}</b></span><span>Total simulations <b>{estimatedRuns.toLocaleString()}</b></span></div>
<button className="front-button front-button--primary balance-lab-run" type="button" disabled={submitting || !Number.isFinite(estimatedRuns) || estimatedRuns < 1} onClick={() => void run()}>{submitting ? "Queuing…" : "Run on server"}</button> <button className="front-button front-button--primary balance-lab-run" type="button" disabled={submitting || !runtimeStatus?.available || !Number.isFinite(estimatedRuns) || estimatedRuns < 1} onClick={() => void run()}>{submitting ? "Queuing…" : runtimeStatus && !runtimeStatus.available ? "Server runtime unavailable" : "Run on server"}</button>
<p className="balance-lab-local-note">To spend this device's CPU instead, run <code>npm run balance:lab</code> from the project checkout.</p> <p className="balance-lab-local-note">To spend this device's CPU instead, run <code>npm run balance:lab</code> from the project checkout.</p>
{error && <p className="front-notice" role="alert">{error}</p>} {error && <p className="front-notice" role="alert">{error}</p>}
</section> </section>
@@ -262,7 +274,7 @@ export function BalanceLabScreen({ onClose }: { readonly onClose: () => void })
<section className="balance-lab-jobs"> <section className="balance-lab-jobs">
<header><div><p className="front-kicker">Saved on server</p><h2>Run history</h2></div><button type="button" disabled={loading} onClick={() => { <header><div><p className="front-kicker">Saved on server</p><h2>Run history</h2></div><button type="button" disabled={loading} onClick={() => {
setLoading(true); setLoading(true);
void listBalanceLabJobs(token).then(setJobs).catch((reason) => setError(errorMessage(reason))).finally(() => setLoading(false)); void Promise.all([listBalanceLabJobs(token), getBalanceLabRuntime(token)]).then(([nextJobs, runtime]) => { setJobs(nextJobs); setRuntimeStatus(runtime); }).catch((reason) => setError(errorMessage(reason))).finally(() => setLoading(false));
}}>Refresh</button></header> }}>Refresh</button></header>
<div className="balance-lab-history-filters"><input type="search" aria-label="Search Balance Lab run history" placeholder="Find source, level, GM…" value={jobSearch} onChange={(event) => setJobSearch(event.currentTarget.value)} /><select aria-label="Filter Balance Lab run status" value={jobStatus} onChange={(event) => setJobStatus(event.currentTarget.value as "all" | BalanceLabJob["status"])}><option value="all">All statuses</option><option value="complete">Complete</option><option value="running">Running</option><option value="queued">Queued</option><option value="failed">Failed</option></select></div> <div className="balance-lab-history-filters"><input type="search" aria-label="Search Balance Lab run history" placeholder="Find source, level, GM…" value={jobSearch} onChange={(event) => setJobSearch(event.currentTarget.value)} /><select aria-label="Filter Balance Lab run status" value={jobStatus} onChange={(event) => setJobStatus(event.currentTarget.value as "all" | BalanceLabJob["status"])}><option value="all">All statuses</option><option value="complete">Complete</option><option value="running">Running</option><option value="queued">Queued</option><option value="failed">Failed</option></select></div>
<div className="balance-lab-job-list"> <div className="balance-lab-job-list">
+50 -4
View File
@@ -3,6 +3,8 @@ import { dungeonRouteDefinition } from "../game/dungeonRoutes";
import type { DungeonSimulationSpeed } from "../game/dungeonSimulationTypes"; import type { DungeonSimulationSpeed } from "../game/dungeonSimulationTypes";
import { finalizeDungeonSimulation } from "../scene/DungeonSimulationSceneBridge"; import { finalizeDungeonSimulation } from "../scene/DungeonSimulationSceneBridge";
import { useShellStore } from "../app/shellStore"; import { useShellStore } from "../app/shellStore";
import { classById } from "../app/characterCatalog";
import { PARTY_ROLE_LABELS, usePartyStore } from "../game/partyStore";
const SPEEDS: readonly DungeonSimulationSpeed[] = [0.5, 1, 2, 4]; const SPEEDS: readonly DungeonSimulationSpeed[] = [0.5, 1, 2, 4];
@@ -25,11 +27,15 @@ function downloadReport(): void {
export function DungeonSimulationHud() { export function DungeonSimulationHud() {
const simulation = useDungeonSimulationStore(); const simulation = useDungeonSimulationStore();
const partyMembers = usePartyStore((state) => state.members);
const route = simulation.dungeonId const route = simulation.dungeonId
? dungeonRouteDefinition(simulation.dungeonId, simulation.routeId ?? "primary") ? dungeonRouteDefinition(simulation.dungeonId, simulation.routeId ?? "primary")
: null; : null;
const leg = route?.legs.find((candidate) => candidate.objectiveId === simulation.objectiveId) ?? null; const leg = route?.legs.find((candidate) => candidate.objectiveId === simulation.objectiveId) ?? null;
const replayDuration = simulation.replay?.durationMs ?? 0; const replayDuration = simulation.replay?.durationMs ?? 0;
const fallbackMember = partyMembers.find((member) => member.health > 0) ?? partyMembers[0] ?? null;
const spectatedMember = partyMembers.find((member) => member.id === simulation.spectatedMemberId)
?? fallbackMember;
const returnToGmTools = () => { const returnToGmTools = () => {
useDungeonSimulationStore.getState().leave(); useDungeonSimulationStore.getState().leave();
@@ -61,10 +67,21 @@ export function DungeonSimulationHud() {
{speed}× {speed}×
</button> </button>
))} ))}
<button type="button" onClick={() => simulation.setCameraMode( <button
simulation.cameraMode === "follow-leader" ? "free-fly" : "follow-leader", type="button"
)}> className={simulation.cameraMode === "follow-member" ? "is-active" : undefined}
{simulation.cameraMode === "follow-leader" ? "Follow camera" : "Free-fly camera"} aria-pressed={simulation.cameraMode === "follow-member"}
onClick={() => simulation.setCameraMode("follow-member")}
>
Follow member
</button>
<button
type="button"
className={simulation.cameraMode === "free-fly" ? "is-active" : undefined}
aria-pressed={simulation.cameraMode === "free-fly"}
onClick={() => simulation.setCameraMode("free-fly")}
>
Free camera
</button> </button>
{simulation.sessionMode === "spectate-live" && simulation.runState !== "finished" ? ( {simulation.sessionMode === "spectate-live" && simulation.runState !== "finished" ? (
<button type="button" className="is-danger" onClick={() => void finalizeDungeonSimulation("stopped")}> <button type="button" className="is-danger" onClick={() => void finalizeDungeonSimulation("stopped")}>
@@ -75,6 +92,35 @@ export function DungeonSimulationHud() {
{simulation.runState === "finished" && <button type="button" onClick={returnToGmTools}>Return to GM Tools</button>} {simulation.runState === "finished" && <button type="button" onClick={returnToGmTools}>Return to GM Tools</button>}
</div> </div>
<section className="dungeon-sim-hud__spectate" aria-label="Spectate party member">
<header>
<span>{simulation.cameraMode === "free-fly" ? "Free camera" : "Following"}</span>
<strong>{simulation.cameraMode === "free-fly" ? "Detached" : spectatedMember?.name ?? "Waiting for party"}</strong>
</header>
<div>
{partyMembers.map((member) => {
const characterClass = classById(member.classId);
const selected = simulation.cameraMode === "follow-member" && member.id === spectatedMember?.id;
return (
<button
type="button"
key={member.id}
className={selected ? "is-active" : undefined}
aria-pressed={selected}
onClick={() => simulation.setSpectatedMember(member.id)}
>
<i style={{ background: characterClass.color }} aria-hidden="true" />
<span><strong>{member.name}</strong><small>{PARTY_ROLE_LABELS[member.role]} · {member.specialization}</small></span>
<em>{Math.max(0, Math.ceil(member.health))}</em>
</button>
);
})}
</div>
<p>{simulation.cameraMode === "free-fly"
? "Right-drag to look · WASD fly · E/Space up · Q/Ctrl down · Shift boost"
: "Choose a party member to follow · Right-drag to orbit"}</p>
</section>
{simulation.sessionMode === "spectate-replay" && ( {simulation.sessionMode === "spectate-replay" && (
<label className="dungeon-sim-hud__seek"> <label className="dungeon-sim-hud__seek">
<span>Replay position</span> <span>Replay position</span>