diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index 9b146eef..e049a9f7 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -129,14 +129,17 @@ services: healerman: image: node:24-bookworm-slim 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: + 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" CONTENT_DIR: /app/content DATA_DIR: /app/runtime-data HOST: 0.0.0.0 HEALER_MAN_ENABLE_SHELVED_MODES: "0" - NODE_OPTIONS: "--max-old-space-size=4096" + NODE_OPTIONS: "--max-old-space-size=8192" PORT: "4173" SESSION_TTL_DAYS: "30" 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 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, -the server container needs Python 3 in addition to the existing Node -development dependencies. Replace the service command with: +Balance Lab uses the repository's Python controller to orchestrate the live +TypeScript combat runtime. The canonical YAML above therefore installs Python +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 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 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 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: ```sh diff --git a/PLAYTEST_NOTES.md b/PLAYTEST_NOTES.md index 9f5f6a47..1df85b6a 100644 --- a/PLAYTEST_NOTES.md +++ b/PLAYTEST_NOTES.md @@ -3512,3 +3512,20 @@ Vite's existing large-chunk advisory remains informational. browser errors. Evidence is in `playtest-artifacts/balance-lab/04b-ability-breakdown.png` and `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`. diff --git a/RELEASE_RUNBOOK.md b/RELEASE_RUNBOOK.md index b0d53a28..19cf376f 100644 --- a/RELEASE_RUNBOOK.md +++ b/RELEASE_RUNBOOK.md @@ -651,14 +651,17 @@ services: healerman: image: node:24-bookworm-slim 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: + 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" CONTENT_DIR: /app/content DATA_DIR: /app/runtime-data HOST: 0.0.0.0 HEALER_MAN_ENABLE_SHELVED_MODES: "0" - NODE_OPTIONS: "--max-old-space-size=4096" + NODE_OPTIONS: "--max-old-space-size=8192" PORT: "4173" SESSION_TTL_DAYS: "30" STATIC_DIR: /app/dist diff --git a/docs/BALANCE_LAB.md b/docs/BALANCE_LAB.md index f6135e9f..3de69659 100644 --- a/docs/BALANCE_LAB.md +++ b/docs/BALANCE_LAB.md @@ -106,6 +106,13 @@ Server requirements: and - 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_QUEUED` defaults to eight and is capped at 50. Raising 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 one-at-a-time process queue, progress parsing, `job.json` persistence/startup 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 talent allocation. The older canonical cohort remains unchanged. - `src/game/partyStore.ts`: `createConfiguredPartyMember`, the narrow factory diff --git a/package-lock.json b/package-lock.json index 1836c7a7..19c98ab1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "healer-man", - "version": "0.1.46", + "version": "0.1.47", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "healer-man", - "version": "0.1.46", + "version": "0.1.47", "dependencies": { "@capacitor/android": "8.4.1", "@capacitor/core": "8.4.1", diff --git a/package.json b/package.json index 571253ab..0b05c678 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "healer-man", "private": true, - "version": "0.1.46", + "version": "0.1.47", "type": "module", "scripts": { "dev": "vite --host 0.0.0.0", diff --git a/release-version.json b/release-version.json index 849ad41e..467d07c5 100644 --- a/release-version.json +++ b/release-version.json @@ -1,4 +1,4 @@ { - "versionName": "0.1.46", - "versionCode": 1046 + "versionName": "0.1.47", + "versionCode": 1047 } diff --git a/scripts/playtest-balance-lab.cjs b/scripts/playtest-balance-lab.cjs index a153c6bc..95688038 100644 --- a/scripts/playtest-balance-lab.cjs +++ b/scripts/playtest-balance-lab.cjs @@ -52,6 +52,8 @@ const runtimeData = fs.mkdtempSync(path.join(os.tmpdir(), 'healer-man-balance-ui await balanceLab.click(); 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('Level').fill('20'); await page.getByLabel('Parties per source').fill('1'); diff --git a/scripts/playtest-dungeon-simulation.cjs b/scripts/playtest-dungeon-simulation.cjs index af8a9297..e19266e7 100644 --- a/scripts/playtest-dungeon-simulation.cjs +++ b/scripts/playtest-dungeon-simulation.cjs @@ -1,5 +1,6 @@ const { chromium } = require('C:/Users/phenom/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright'); const assert = require('node:assert/strict'); +const crypto = require('node:crypto'); const fs = require('node:fs'); 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 context = await browser.newContext({ viewport: { width: 1600, height: 1000 }, acceptDownloads: true }); 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); const errors = []; page.on('pageerror', (error) => errors.push(error.message)); 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 { 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.waitForTimeout(200); 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(); - await page.getByRole('button', { name: 'Free-fly camera' }).waitFor(); - await capture('03-paused-free-fly'); + const memberPicker = page.getByLabel('Spectate party member'); + const memberButtons = memberPicker.locator('div > button'); + 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: 'Play' }).click(); await page.waitForTimeout(1200); await page.getByRole('button', { name: 'Stop & save' }).click(); 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('heading', { name: 'Dungeon route simulations' }).waitFor(); await page.locator('.dungeon-sim-admin__catalog select').nth(0).selectOption('wailing-caverns'); 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.getByText('Recorded replay').waitFor(); @@ -77,7 +135,7 @@ fs.rmSync(databaseDirectory, { recursive: true, force: true }); await page.waitForTimeout(6000); await page.getByRole('button', { name: 'Play' }).click(); await page.waitForTimeout(800); - await capture('06-replay-controls'); + await capture('08-replay-controls'); await page.getByRole('button', { name: 'Pause' }).click(); const pausedReplayTime = await page.locator('.dungeon-sim-hud__header > time').innerText(); 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: 'Play' }).click(); await page.waitForTimeout(600); - await capture('07-replay-4x'); + await capture('09-replay-4x'); assert.deepEqual(errors, [], `No browser page errors: ${errors.join('; ')}`); console.log('Dungeon simulation browser smoke test passed.'); diff --git a/server/balance-lab-jobs.mjs b/server/balance-lab-jobs.mjs index 634dfe55..39578ec6 100644 --- a/server/balance-lab-jobs.mjs +++ b/server/balance-lab-jobs.mjs @@ -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)); }, diff --git a/server/balance-lab-jobs.test.mjs b/server/balance-lab-jobs.test.mjs index b89a704c..29b43c94 100644 --- a/server/balance-lab-jobs.test.mjs +++ b/server/balance-lab-jobs.test.mjs @@ -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 { diff --git a/server/server.mjs b/server/server.mjs index b1be227a..e16c89e9 100644 --- a/server/server.mjs +++ b/server/server.mjs @@ -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)) { diff --git a/server/server.test.mjs b/server/server.test.mjs index 57fe81c4..90da3a06 100644 --- a/server/server.test.mjs +++ b/server/server.test.mjs @@ -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}` }, diff --git a/src/GameRuntime.tsx b/src/GameRuntime.tsx index db557b97..4d543c8e 100644 --- a/src/GameRuntime.tsx +++ b/src/GameRuntime.tsx @@ -44,7 +44,8 @@ function InputBridge() { useEffect(() => installInput({ 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) => { const preferences = useUiLayoutStore.getState().preferences; const bindings = profileInputBindings(preferences, device); diff --git a/src/app/balanceLabClient.ts b/src/app/balanceLabClient.ts index 2d03d093..b573b87f 100644 --- a/src/app/balanceLabClient.ts +++ b/src/app/balanceLabClient.ts @@ -4,6 +4,13 @@ export type BalanceLabPartySource = "wow" | "coa" | "rom" | "all"; export type BalanceLabBossCategory = "all" | "wow" | "rom"; 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 { readonly source: BalanceLabPartySource; readonly level: number; @@ -155,6 +162,14 @@ export async function listBalanceLabJobs(token: string): Promise { + return (await requestOnlineJson<{ runtime: BalanceLabRuntimeStatus }>( + "/api/gm/balance-lab/runtime", + {}, + token, + )).runtime; +} + export async function startBalanceLabJob(spec: BalanceLabJobSpec, token: string): Promise { return (await requestOnlineJson<{ job: BalanceLabJob }>( "/api/gm/balance-lab/jobs", diff --git a/src/game/dungeonSimulationStore.test.ts b/src/game/dungeonSimulationStore.test.ts index 99a56039..2aebe5fe 100644 --- a/src/game/dungeonSimulationStore.test.ts +++ b/src/game/dungeonSimulationStore.test.ts @@ -54,6 +54,23 @@ describe("dungeon simulation fixed clock", () => { }); 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", () => { const first = createSpectatorDungeonParty("same-seed", 80, "wow"); const second = createSpectatorDungeonParty("same-seed", 80, "wow"); diff --git a/src/game/dungeonSimulationStore.ts b/src/game/dungeonSimulationStore.ts index 1012db06..9886cdeb 100644 --- a/src/game/dungeonSimulationStore.ts +++ b/src/game/dungeonSimulationStore.ts @@ -33,6 +33,7 @@ export interface DungeonSimulationState { runState: "idle" | "running" | "paused" | "finished"; speed: DungeonSimulationSpeed; cameraMode: DungeonSpectatorCameraMode; + spectatedMemberId: string | null; dungeonId: string | null; routeId: string | null; routeHash: string | null; @@ -56,6 +57,7 @@ export interface DungeonSimulationState { toggleRunning: () => void; setSpeed: (speed: DungeonSimulationSpeed) => void; setCameraMode: (mode: DungeonSpectatorCameraMode) => void; + setSpectatedMember: (memberId: string | null) => void; step: () => void; seekReplay: (elapsedMs: number) => void; consumeTicks: (wallDeltaMs: number) => readonly number[]; @@ -72,7 +74,8 @@ const initialState = { mode: "full-combat" as DungeonSimulationMode, runState: "idle" as const, speed: 1 as DungeonSimulationSpeed, - cameraMode: "follow-leader" as DungeonSpectatorCameraMode, + cameraMode: "follow-member" as DungeonSpectatorCameraMode, + spectatedMemberId: null as string | null, dungeonId: null as string | null, routeId: null as string | null, routeHash: null as string | null, @@ -146,6 +149,7 @@ export const useDungeonSimulationStore = create((set, ge }, setSpeed: (speed) => set({ speed }), setCameraMode: (cameraMode) => set({ cameraMode }), + setSpectatedMember: (spectatedMemberId) => set({ spectatedMemberId, cameraMode: "follow-member" }), step: () => set((state) => { if (state.runState !== "paused") return state; if (state.sessionMode === "spectate-replay" && state.replay) { diff --git a/src/game/dungeonSimulationTypes.ts b/src/game/dungeonSimulationTypes.ts index cd0539db..557a8e8d 100644 --- a/src/game/dungeonSimulationTypes.ts +++ b/src/game/dungeonSimulationTypes.ts @@ -3,7 +3,7 @@ import type { Vector3Tuple } from "./dungeonTypes"; export type DungeonSessionMode = "play" | "spectate-live" | "spectate-replay"; export type DungeonSimulationMode = "full-combat" | "navigation-only"; 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 DungeonSimulationResult = "completed" | "wiped" | "stopped" | "recording-limit"; export type DungeonSimulationFindingSeverity = "info" | "warning" | "critical"; diff --git a/src/game/spectatorCameraMath.test.ts b/src/game/spectatorCameraMath.test.ts new file mode 100644 index 00000000..a979c4c9 --- /dev/null +++ b/src/game/spectatorCameraMath.test.ts @@ -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]); + }); +}); diff --git a/src/game/spectatorCameraMath.ts b/src/game/spectatorCameraMath.ts new file mode 100644 index 00000000..3f76dcfe --- /dev/null +++ b/src/game/spectatorCameraMath.ts @@ -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, ...candidates: readonly string[]): number { + return Number(candidates.some((code) => codes.has(code))); +} + +export function spectatorKeyboardAxes(codes: ReadonlySet): 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): 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]; +} diff --git a/src/scene/DungeonSpectatorCamera.tsx b/src/scene/DungeonSpectatorCamera.tsx index 5900121c..0a2e4a5c 100644 --- a/src/scene/DungeonSpectatorCamera.tsx +++ b/src/scene/DungeonSpectatorCamera.tsx @@ -2,15 +2,24 @@ import { useFrame, useThree } from "@react-three/fiber"; import { useRapier } from "@react-three/rapier"; import { useEffect, useRef } from "react"; import { Vector3 } from "three"; -import { consumeMouseLook, readInputSnapshot } from "../game/inputManager"; +import { consumeMouseLook } from "../game/inputManager"; import { getPartyRuntimePosition } from "../game/partyRuntimeRegistry"; import { usePartyStore } from "../game/partyStore"; import { useDungeonSimulationStore } from "../game/dungeonSimulationStore"; import { useGameStore } from "../game/store"; +import { + spectatorCameraBoosted, + spectatorCameraTranslation, + spectatorKeyboardAxes, +} from "../game/spectatorCameraMath"; const FOLLOW_DISTANCE = 7; const FOLLOW_HEIGHT = 2.4; 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() { const camera = useThree((state) => state.camera); @@ -18,30 +27,39 @@ export function DungeonSpectatorCamera() { const yawRef = useRef(useGameStore.getState().cameraYaw); const pitchRef = useRef(-0.22); const freePositionRef = useRef(new Vector3().copy(camera.position)); - const boostRef = useRef(false); + const pressedKeysRef = useRef(new Set()); const initializedFreeRef = useRef(false); + const followedMemberRef = useRef(null); useEffect(() => { - const onKey = (event: KeyboardEvent) => { - boostRef.current = event.shiftKey; + const onKeyDown = (event: KeyboardEvent) => { + 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); - window.addEventListener("keyup", onKey); + const onKeyUp = (event: KeyboardEvent) => { + pressedKeysRef.current.delete(event.code); + }; + const clearKeys = () => pressedKeysRef.current.clear(); + window.addEventListener("keydown", onKeyDown); + window.addEventListener("keyup", onKeyUp); + window.addEventListener("blur", clearKeys); return () => { - window.removeEventListener("keydown", onKey); - window.removeEventListener("keyup", onKey); + window.removeEventListener("keydown", onKeyDown); + window.removeEventListener("keyup", onKeyUp); + window.removeEventListener("blur", clearKeys); + clearKeys(); }; }, []); useFrame((_, delta) => { const simulation = useDungeonSimulationStore.getState(); if (simulation.sessionMode === "play") return; - const input = readInputSnapshot(); const [mouseX, mouseY] = consumeMouseLook(); yawRef.current -= mouseX * 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 }); if (simulation.cameraMode === "free-fly") { @@ -49,15 +67,23 @@ export function DungeonSpectatorCamera() { freePositionRef.current.copy(camera.position); initializedFreeRef.current = true; } + followedMemberRef.current = null; const forward = new Vector3( Math.sin(yawRef.current) * Math.cos(pitchRef.current), Math.sin(pitchRef.current), Math.cos(yawRef.current) * Math.cos(pitchRef.current), ); - const right = new Vector3(Math.cos(yawRef.current), 0, -Math.sin(yawRef.current)); - const speed = boostRef.current ? 28 : 10; - freePositionRef.current.addScaledVector(forward, input.moveForward * speed * delta); - freePositionRef.current.addScaledVector(right, input.moveX * speed * delta); + const keyboard = spectatorKeyboardAxes(pressedKeysRef.current); + const movement = spectatorCameraTranslation( + yawRef.current, + 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.lookAt(new Vector3().copy(camera.position).add(forward)); return; @@ -65,10 +91,14 @@ export function DungeonSpectatorCamera() { initializedFreeRef.current = false; const party = usePartyStore.getState(); - const leader = party.members.find((member) => member.health > 0) ?? party.members[0]; - const leaderPosition = leader ? getPartyRuntimePosition(leader.id) : null; - if (!leaderPosition) return; - const anchor = new Vector3(leaderPosition[0], leaderPosition[1] + FOLLOW_HEIGHT, leaderPosition[2]); + const requested = party.members.find((member) => member.id === simulation.spectatedMemberId); + const candidates = requested + ? [requested, ...party.members.filter((member) => member.id !== requested.id)] + : [...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( -Math.sin(yawRef.current) * Math.cos(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 distance = hit ? Math.max(0.8, hit.timeOfImpact - 0.25) : FOLLOW_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); }); diff --git a/src/styles.css b/src/styles.css index eb2f22b6..28e8c2dc 100644 --- a/src/styles.css +++ b/src/styles.css @@ -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 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-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 label { display: grid; gap: 4px; color: var(--front-dim); font-size: 8px; } .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.is-danger { color: #ffd8d8; border-color: #a94848; background: #502121; } .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 input { width: 100%; } .dungeon-sim-hud__status { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 6px 14px; margin: 0 0 12px; } diff --git a/src/ui/BalanceLabScreen.tsx b/src/ui/BalanceLabScreen.tsx index 544e9946..b87023d4 100644 --- a/src/ui/BalanceLabScreen.tsx +++ b/src/ui/BalanceLabScreen.tsx @@ -5,11 +5,13 @@ import { fetchBalanceLabArtifact, getBalanceLabJob, getBalanceLabReport, + getBalanceLabRuntime, listBalanceLabJobs, startBalanceLabJob, type BalanceLabBossCategory, type BalanceLabJob, type BalanceLabPartySource, + type BalanceLabRuntimeStatus, type BalanceLabSuiteReport, } from "../app/balanceLabClient"; import { BOSS_TESTING_CATALOG } from "../game/bossTesting"; @@ -77,6 +79,7 @@ export function BalanceLabScreen({ onClose }: { readonly onClose: () => void }) const [reports, setReports] = useState>>({}); const [reportLoadingId, setReportLoadingId] = useState(null); const [reportError, setReportError] = useState(""); + const [runtimeStatus, setRuntimeStatus] = useState(null); const [loading, setLoading] = useState(true); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(""); @@ -106,9 +109,10 @@ export function BalanceLabScreen({ onClose }: { readonly onClose: () => void }) if (!token) return; let cancelled = false; setLoading(true); - void listBalanceLabJobs(token).then((nextJobs) => { + void Promise.all([listBalanceLabJobs(token), getBalanceLabRuntime(token)]).then(([nextJobs, runtime]) => { if (cancelled) return; setJobs(nextJobs); + setRuntimeStatus(runtime); setSelectedJobId((current) => current ?? nextJobs[0]?.id ?? null); setError(""); }).catch((reason) => { @@ -169,6 +173,10 @@ export function BalanceLabScreen({ onClose }: { readonly onClose: () => void }) }; const run = async () => { + if (!runtimeStatus?.available) { + setError(runtimeStatus?.error ?? "The server Balance Lab runtime is unavailable."); + return; + } if (bossScope === "specific" && selectedBossKeys.length === 0) { setError("Select at least one boss for a specific-boss run."); return; @@ -229,6 +237,10 @@ export function BalanceLabScreen({ onClose }: { readonly onClose: () => void })

New automatic sweep

Simulation setup

This mode runs on the game server. The browser submits the job and may be closed while the server continues.

+ {runtimeStatus &&
+ {runtimeStatus.available ? "Server runtime ready" : "Server runtime unavailable"} + {runtimeStatus.available ? runtimeStatus.version : `${runtimeStatus.error} Install Python 3 in the server container, set BALANCE_LAB_PYTHON=python3, and restart the app.`} +
}
diff --git a/src/ui/DungeonSimulationHud.tsx b/src/ui/DungeonSimulationHud.tsx index 2f1d4e99..4b065860 100644 --- a/src/ui/DungeonSimulationHud.tsx +++ b/src/ui/DungeonSimulationHud.tsx @@ -3,6 +3,8 @@ import { dungeonRouteDefinition } from "../game/dungeonRoutes"; import type { DungeonSimulationSpeed } from "../game/dungeonSimulationTypes"; import { finalizeDungeonSimulation } from "../scene/DungeonSimulationSceneBridge"; 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]; @@ -25,11 +27,15 @@ function downloadReport(): void { export function DungeonSimulationHud() { const simulation = useDungeonSimulationStore(); + const partyMembers = usePartyStore((state) => state.members); const route = simulation.dungeonId ? dungeonRouteDefinition(simulation.dungeonId, simulation.routeId ?? "primary") : null; const leg = route?.legs.find((candidate) => candidate.objectiveId === simulation.objectiveId) ?? null; 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 = () => { useDungeonSimulationStore.getState().leave(); @@ -61,10 +67,21 @@ export function DungeonSimulationHud() { {speed}× ))} - + {simulation.sessionMode === "spectate-live" && simulation.runState !== "finished" ? ( }
+
+
+ {simulation.cameraMode === "free-fly" ? "Free camera" : "Following"} + {simulation.cameraMode === "free-fly" ? "Detached" : spectatedMember?.name ?? "Waiting for party"} +
+
+ {partyMembers.map((member) => { + const characterClass = classById(member.classId); + const selected = simulation.cameraMode === "follow-member" && member.id === spectatedMember?.id; + return ( +