Release Healer Man 0.1.47
This commit is contained in:
1 parent
9ff574ac0b
commit
2162039b49
24 files changed
+486
-55
No files matched your search
+21
-8
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
Generated
+2
-2
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"versionName": "0.1.46",
|
"versionName": "0.1.47",
|
||||||
"versionCode": 1046
|
"versionCode": 1047
|
||||||
}
|
}
|
||||||
@@ -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');
|
||||||
|
|||||||
@@ -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.');
|
||||||
|
|||||||
@@ -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));
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)) {
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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];
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in new issue
Block a user