From a05a79eb83ee1b46b56445bfa84bdc865602774a Mon Sep 17 00:00:00 2001 From: phenom Date: Sat, 19 Sep 2026 13:16:17 -0400 Subject: [PATCH] Release Healer Man 0.1.56 --- DEPLOYMENT.md | 8 +- PLAYTEST_NOTES.md | 20 ++++ RELEASE_RUNBOOK.md | 4 +- docs/BALANCE_LAB.md | 36 ++++--- package-lock.json | 4 +- package.json | 2 +- release-version.json | 4 +- scripts/playtest-balance-lab.cjs | 25 +++-- server/balance-lab-jobs.mjs | 150 ++++++++++++++++++---------- server/balance-lab-jobs.test.mjs | 81 +++++++++++++++ server/server.mjs | 10 ++ server/server.test.mjs | 11 +- src/app/balanceLabClient.ts | 15 +++ src/app/balanceLabSchedules.test.ts | 21 ++++ src/app/balanceLabSchedules.ts | 6 ++ src/ui/BalanceLabScreen.tsx | 107 ++++++++++++++------ 16 files changed, 388 insertions(+), 116 deletions(-) create mode 100644 src/app/balanceLabSchedules.test.ts create mode 100644 src/app/balanceLabSchedules.ts diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index e049a9f7..bf9d1552 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -131,8 +131,8 @@ services: command: >- 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_MAX_CONCURRENT: "4" + BALANCE_LAB_MAX_QUEUED: "100" BALANCE_LAB_PYTHON: "python3" CORS_ORIGINS: "https://iwanttoheal.phenomrom.com,capacitor://localhost,http://localhost,https://localhost" CONTENT_DIR: /app/content @@ -184,8 +184,8 @@ and add or correct these environment values: ```yaml BALANCE_LAB_PYTHON: "python3" -BALANCE_LAB_MAX_CONCURRENT: "1" -BALANCE_LAB_MAX_QUEUED: "8" +BALANCE_LAB_MAX_CONCURRENT: "4" +BALANCE_LAB_MAX_QUEUED: "100" ``` The app stores job reports in diff --git a/PLAYTEST_NOTES.md b/PLAYTEST_NOTES.md index 582ebe74..ef5d4b9e 100644 --- a/PLAYTEST_NOTES.md +++ b/PLAYTEST_NOTES.md @@ -3619,3 +3619,23 @@ Vite's existing large-chunk advisory remains informational. encounters still retain their 25/45-second mechanic windows. These are structured combat checks, so no scene screenshot is applicable. Remaining cross-spec DPS/HPS outliers require a separate coefficient-tuning pass. + +## Balance Lab level batches and parallel workers (2026-09-19) + +- The GM setup now switches between one level and all level brackets. The three + schedules expose 9 jobs (level 1 plus every 10), 17 jobs (level 1 plus every + 5), or 80 jobs (every level). Every-boss, source-specific boss, and explicit + boss selection remain available, and the estimate totals all scheduled jobs. +- A browser-driven nine-level exploratory submission created nine persisted + jobs in one request, started four independent Python/TypeScript processes, + and completed the first four level reports before the automation time limit; + the test server then stopped the remaining isolated test jobs. Server tests + separately prove parallel worker utilization, queued overflow, and all-or- + nothing rejection when a complete batch will not fit. +- The final production-build smoke pass verified the 9/17/80 schedule controls, + worker/queue status, specific Paladin Holy and Eck selection, one complete + real level-20 report, all report/ability analytics, and 480x900 layout with no + browser errors. Evidence is in + `playtest-artifacts/balance-lab/02-level-batch-config.png`, + `playtest-artifacts/balance-lab/04-server-job-complete.png`, and + `playtest-artifacts/balance-lab/06-mobile-analytics.png`. diff --git a/RELEASE_RUNBOOK.md b/RELEASE_RUNBOOK.md index fcbb0b39..7784ad6a 100644 --- a/RELEASE_RUNBOOK.md +++ b/RELEASE_RUNBOOK.md @@ -661,8 +661,8 @@ services: command: >- 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_MAX_CONCURRENT: "4" + BALANCE_LAB_MAX_QUEUED: "100" BALANCE_LAB_PYTHON: "python3" CORS_ORIGINS: "https://iwanttoheal.phenomrom.com,capacitor://localhost,http://localhost,https://localhost" CONTENT_DIR: /app/content diff --git a/docs/BALANCE_LAB.md b/docs/BALANCE_LAB.md index 277f2f9d..71cadd86 100644 --- a/docs/BALANCE_LAB.md +++ b/docs/BALANCE_LAB.md @@ -56,15 +56,18 @@ use the live companion AI. The healer is the default active member. Use A server-authorized GM sees **Balance Lab** directly underneath **GM Tools** on the main menu. That screen exposes the automatic sweep controls: party source, -level, class/spec selection strategy, a searchable spec picker, simulations per -party/boss, duration, random seed, boss category, and a searchable multi-boss -picker. Random mode exposes the requested parties per source; coverage modes -show the derived minimum party count before submission. +a single level or an all-brackets schedule, class/spec selection strategy, a +searchable spec picker, simulations per party/boss, duration, random seed, boss +category, and a searchable multi-boss picker. The bracket schedules cover level +1 plus every 10 levels, level 1 plus every 5 levels, or every level from 1 +through 80. Random mode exposes the requested parties per source; coverage +modes show the derived minimum party count before submission. -**Run on server** sends only that allow-listed configuration to the authenticated -GM API. The server places it in a one-at-a-time queue, launches the same Python -controller and live TypeScript runtime used by the command line, and writes -artifacts under: +**Run on server** sends one allow-listed configuration to the authenticated GM +API. **Run all level brackets** submits the complete schedule atomically and +creates one independently persisted job and report per level. The bounded worker +pool runs several jobs at the same time, launches the same Python controller and +live TypeScript runtime used by the command line, and writes artifacts under: ```text DATA_DIR/balance-lab/jobs// @@ -139,10 +142,12 @@ 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 -and simulations less responsive. Requests are capped before launch, subprocess +`BALANCE_LAB_MAX_CONCURRENT` defaults to four and is capped at 16. +`BALANCE_LAB_MAX_QUEUED` defaults to 100 and is capped at 200, which leaves room +for an 80-job every-level sweep. Raising concurrency permits more parallel +server processes but can make both the game server and simulations less +responsive. A batch contains at most 80 jobs and is validated against the +remaining queue capacity before any of its jobs are created. Subprocess arguments are passed as an array without a shell, artifact names are allow-listed, and every job and artifact endpoint authenticates the account's server-issued GM role. @@ -372,9 +377,10 @@ combat tick while retaining their own attack cadence. cards, and complete boss/class-spec/party tables. Add new dashboard metrics here only after they exist in `suite-report.json`. - `src/app/balanceLabClient.ts`: typed GM Balance Lab API client. -- `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/balance-lab-jobs.mjs`: validated single and batch job specifications, + bounded parallel process queue, progress parsing, `job.json` + persistence/startup restoration, report-summary extraction, and artifact + allow-list. - `server/server.mjs`: GM-authenticated runtime-preflight, job, status, and artifact HTTP routes. - `src/game/benchmark/benchmarkBuilds.ts`: selectable spec builds and legal diff --git a/package-lock.json b/package-lock.json index cafa7b12..24e039bc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "healer-man", - "version": "0.1.55", + "version": "0.1.56", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "healer-man", - "version": "0.1.55", + "version": "0.1.56", "dependencies": { "@capacitor/android": "8.4.1", "@capacitor/core": "8.4.1", diff --git a/package.json b/package.json index 034cfc62..7161e20a 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "healer-man", "private": true, - "version": "0.1.55", + "version": "0.1.56", "type": "module", "scripts": { "dev": "vite --host 0.0.0.0", diff --git a/release-version.json b/release-version.json index fbd426da..201321ba 100644 --- a/release-version.json +++ b/release-version.json @@ -1,4 +1,4 @@ { - "versionName": "0.1.55", - "versionCode": 1055 + "versionName": "0.1.56", + "versionCode": 1056 } diff --git a/scripts/playtest-balance-lab.cjs b/scripts/playtest-balance-lab.cjs index 5f58a32a..1550c371 100644 --- a/scripts/playtest-balance-lab.cjs +++ b/scripts/playtest-balance-lab.cjs @@ -54,8 +54,19 @@ const runtimeData = fs.mkdtempSync(path.join(os.tmpdir(), 'healer-man-balance-ui 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\./); + assert.match(await page.locator('.balance-lab-runtime').innerText(), /0\/4 workers active/); await page.getByLabel('Party source').selectOption('wow'); - await page.getByLabel('Level').fill('20'); + await page.getByLabel('Level coverage').selectOption('brackets'); + assert.equal(await page.getByLabel('Bracket spacing').inputValue(), '10'); + assert.equal(await page.locator('.balance-lab-estimate span').filter({ hasText: 'Level jobs' }).locator('b').innerText(), '9'); + await page.getByLabel('Bracket spacing').selectOption('5'); + assert.equal(await page.locator('.balance-lab-estimate span').filter({ hasText: 'Level jobs' }).locator('b').innerText(), '17'); + await page.getByLabel('Bracket spacing').selectOption('1'); + assert.equal(await page.locator('.balance-lab-estimate span').filter({ hasText: 'Level jobs' }).locator('b').innerText(), '80'); + await page.getByLabel('Bracket spacing').selectOption('10'); + await capture('02-level-batch-config'); + await page.getByLabel('Level coverage').selectOption('single'); + await page.getByLabel('Level', { exact: true }).fill('20'); await page.getByLabel('Class/spec selection').selectOption('all'); assert(Number(await page.getByLabel('Coverage parties').inputValue()) > 1, 'all-spec coverage should derive multiple parties'); await page.getByLabel('Class/spec selection').selectOption('specific'); @@ -70,19 +81,19 @@ const runtimeData = fs.mkdtempSync(path.join(os.tmpdir(), 'healer-man-balance-ui const totalSimulations = page.locator('.balance-lab-estimate span').filter({ hasText: 'Total simulations' }).locator('b'); await totalSimulations.waitFor(); assert.equal(await totalSimulations.innerText(), '1'); - await capture('02-server-job-config'); + await capture('03-server-job-config'); await page.getByRole('button', { name: 'Run on server' }).click(); await page.locator('.balance-lab-job-list em.is-complete').waitFor({ timeout: 240_000 }); await page.getByRole('button', { name: 'Open full report' }).waitFor(); assert.equal(await page.locator('.balance-lab-artifacts > button').count(), 12); - await capture('03-server-job-complete'); + await capture('04-server-job-complete'); await page.getByRole('button', { name: 'View graphs and filterable data' }).click(); await page.locator('.balance-results').waitFor(); assert.equal(await page.locator('.balance-results-chart').count(), 7); assert.match(await page.locator('.balance-results-chart').filter({ hasText: 'Boss damage to party' }).innerText(), /Eck the Ferocious/); - await capture('04-run-analytics'); + await capture('05-run-analytics'); await page.getByRole('button', { name: 'bosses', exact: true }).click(); assert.equal(await page.locator('.balance-results-table-panel tbody tr').count(), 1); @@ -117,7 +128,7 @@ const runtimeData = fs.mkdtempSync(path.join(os.tmpdir(), 'healer-man-balance-ui assert(await abilityRows.count() > 0, 'available but unused abilities should remain visible'); await page.getByLabel('Filter ability usage').selectOption('all'); await page.getByLabel('Sort abilities').selectOption('effectiveDamage'); - await capture('04b-ability-breakdown'); + await capture('05b-ability-breakdown'); await page.getByRole('button', { name: 'parties', exact: true }).click(); assert.equal(await page.locator('.balance-results-table-panel tbody tr').count(), 1); await page.getByLabel('Search Balance Lab run history').fill('does-not-match'); @@ -132,11 +143,11 @@ const runtimeData = fs.mkdtempSync(path.join(os.tmpdir(), 'healer-man-balance-ui await page.getByRole('button', { name: 'overview', exact: true }).click(); await page.getByRole('heading', { name: 'Boss damage to party' }).waitFor(); await page.getByRole('button', { name: 'Main menu' }).waitFor(); - await capture('05-mobile-analytics'); + await capture('06-mobile-analytics'); await page.getByRole('button', { name: 'abilities', exact: true }).click(); await page.getByText('Every class/spec ability').scrollIntoViewIfNeeded(); assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth), true); - await capture('06-mobile-abilities'); + await capture('07-mobile-abilities'); assert.deepEqual(pageErrors, []); console.log('Balance Lab GM playtest passed'); } finally { diff --git a/server/balance-lab-jobs.mjs b/server/balance-lab-jobs.mjs index b2fcd0c8..5be72660 100644 --- a/server/balance-lab-jobs.mjs +++ b/server/balance-lab-jobs.mjs @@ -17,6 +17,7 @@ const SOURCES = new Set(["wow", "coa", "rom", "all"]); const BOSS_CATEGORIES = new Set(["all", "wow", "rom"]); const SPEC_SELECTIONS = new Set(["random", "all", "specific"]); const JOB_STATUSES = new Set(["queued", "running", "complete", "failed"]); +const MAX_BATCH_JOBS = 80; const JOB_METADATA_NAME = "job.json"; const JOB_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; const ARTIFACTS = new Map([ @@ -103,6 +104,18 @@ export function normalizeBalanceLabJobSpec(input) { return spec; } +export function normalizeBalanceLabJobBatch(input) { + const rawSpecs = Array.isArray(input?.specs) ? input.specs : []; + if (rawSpecs.length < 1 || rawSpecs.length > MAX_BATCH_JOBS) { + throw new GameDatabaseError( + `A Balance Lab batch must contain 1 through ${MAX_BATCH_JOBS} level jobs.`, + 400, + "invalid_balance_lab_batch", + ); + } + return Object.freeze(rawSpecs.map((spec) => normalizeBalanceLabJobSpec(spec))); +} + export function balanceLabCommand(spec, outputDir, options = {}) { const projectRoot = path.resolve(options.projectRoot ?? fileURLToPath(new URL("..", import.meta.url))); const executable = String(options.pythonExecutable @@ -218,14 +231,14 @@ export function createBalanceLabJobService(options = {}) { const projectRoot = path.resolve(options.projectRoot ?? fileURLToPath(new URL("..", import.meta.url))); const dataDir = path.resolve(options.dataDir ?? process.env.DATA_DIR ?? "data/runtime"); const jobsDir = path.join(dataDir, "balance-lab", "jobs"); - const configuredConcurrency = Number(options.maxConcurrent ?? process.env.BALANCE_LAB_MAX_CONCURRENT ?? 1); + const configuredConcurrency = Number(options.maxConcurrent ?? process.env.BALANCE_LAB_MAX_CONCURRENT ?? 4); const maxConcurrent = Number.isFinite(configuredConcurrency) && configuredConcurrency >= 1 - ? Math.min(4, Math.floor(configuredConcurrency)) - : 1; - const configuredQueueLimit = Number(options.maxQueued ?? process.env.BALANCE_LAB_MAX_QUEUED ?? 8); + ? Math.min(16, Math.floor(configuredConcurrency)) + : 4; + const configuredQueueLimit = Number(options.maxQueued ?? process.env.BALANCE_LAB_MAX_QUEUED ?? 100); const maxQueued = Number.isFinite(configuredQueueLimit) && configuredQueueLimit >= 1 - ? Math.min(50, Math.floor(configuredQueueLimit)) - : 8; + ? Math.min(200, Math.floor(configuredQueueLimit)) + : 100; const launch = options.spawnProcess ?? spawn; const pythonExecutable = String(options.pythonExecutable ?? process.env.BALANCE_LAB_PYTHON @@ -239,8 +252,14 @@ export function createBalanceLabJobService(options = {}) { const children = new Map(); let running = 0; let closed = false; + let inspectedRuntime = null; mkdirSync(jobsDir, { recursive: true }); + function currentRuntime() { + inspectedRuntime ??= runtimeProbe(); + return inspectedRuntime; + } + function persistJob(job) { const record = publicJob(job); writeFileSync(path.join(job.outputDir, JOB_METADATA_NAME), `${JSON.stringify(record, null, 2)}\n`, "utf8"); @@ -414,57 +433,78 @@ export function createBalanceLabJobService(options = {}) { return job; } + function requireAvailableRuntime() { + if (closed) throw new GameDatabaseError("Balance Lab runner is shutting down.", 503, "balance_lab_unavailable"); + const runtime = currentRuntime(); + 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", + ); + } + } + + function requireQueueCapacity(count) { + const pending = [...jobs.values()].filter((job) => job.status === "queued" || job.status === "running").length; + if (pending + count > maxQueued) { + throw new GameDatabaseError( + `The Balance Lab server queue is full for this request; it has room for ${Math.max(0, maxQueued - pending)} more jobs. Wait for running jobs to finish or submit a smaller level batch.`, + 429, + "balance_lab_queue_full", + ); + } + } + + function queueJob(spec, account) { + const id = randomUUID(); + const outputDir = path.join(jobsDir, id); + mkdirSync(outputDir, { recursive: true }); + const totalParties = expectedPartyConfigurations(spec); + const job = { + id, + status: "queued", + createdAt: new Date().toISOString(), + startedAt: null, + completedAt: null, + requestedBy: account.username, + spec, + progress: { + party: 0, + partiesCompleted: 0, + partiesTotal: totalParties, + simulationsCompleted: 0, + simulationsTotal: 0, + }, + artifacts: [], + error: null, + logTail: [], + summary: null, + outputDir, + }; + jobs.set(id, job); + persistJob(job); + queue.push(job); + return job; + } + 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( - "The Balance Lab server queue is full. Wait for a running job to finish.", - 429, - "balance_lab_queue_full", - ); - } const spec = normalizeBalanceLabJobSpec(input); - const id = randomUUID(); - const outputDir = path.join(jobsDir, id); - mkdirSync(outputDir, { recursive: true }); - const totalParties = expectedPartyConfigurations(spec); - const job = { - id, - status: "queued", - createdAt: new Date().toISOString(), - startedAt: null, - completedAt: null, - requestedBy: account.username, - spec, - progress: { - party: 0, - partiesCompleted: 0, - partiesTotal: totalParties, - simulationsCompleted: 0, - simulationsTotal: 0, - }, - artifacts: [], - error: null, - logTail: [], - summary: null, - outputDir, - }; - jobs.set(id, job); - persistJob(job); - queue.push(job); + requireAvailableRuntime(); + requireQueueCapacity(1); + const job = queueJob(spec, account); pump(); return publicJob(job); }, + startMany(input, account) { + const specs = normalizeBalanceLabJobBatch(input); + requireAvailableRuntime(); + requireQueueCapacity(specs.length); + const batch = specs.map((spec) => queueJob(spec, account)); + pump(); + return batch.map(publicJob); + }, list() { return [...jobs.values()] .sort((left, right) => Date.parse(right.createdAt) - Date.parse(left.createdAt)) @@ -472,7 +512,13 @@ export function createBalanceLabJobService(options = {}) { .map(publicJob); }, runtime() { - return { ...runtimeProbe() }; + return { + ...currentRuntime(), + maxConcurrent, + running, + queued: queue.length, + maxQueued, + }; }, get(id) { return publicJob(requireJob(id)); diff --git a/server/balance-lab-jobs.test.mjs b/server/balance-lab-jobs.test.mjs index 09ef7b00..8e39273d 100644 --- a/server/balance-lab-jobs.test.mjs +++ b/server/balance-lab-jobs.test.mjs @@ -9,6 +9,7 @@ import { balanceLabCommand, createBalanceLabJobService, inspectBalanceLabRuntime, + normalizeBalanceLabJobBatch, normalizeBalanceLabJobSpec, } from "./balance-lab-jobs.mjs"; @@ -43,6 +44,20 @@ test("normalizes and bounds server Balance Lab requests", () => { ); }); +test("normalizes complete level batches before any jobs are queued", () => { + const batch = normalizeBalanceLabJobBatch({ + specs: [10, 20, 30].map((level) => ({ + source: "wow", level, partyConfigurations: 1, simulationsPerParty: 1, + durationSeconds: 5, partySeed: 1, bossCategory: "all", bossKeys: ["gundrak/entry-29932"], + })), + }); + assert.deepEqual(batch.map((spec) => spec.level), [10, 20, 30]); + assert.throws(() => normalizeBalanceLabJobBatch({ specs: [] }), /must contain 1 through 80/); + assert.throws(() => normalizeBalanceLabJobBatch({ + specs: [{ source: "wow", level: 10 }, { source: "wow", level: 81 }], + }), /Level must be a whole number/); +}); + test("builds an argument-array command without a shell", () => { const spec = normalizeBalanceLabJobSpec({ source: "rom", level: 60, partyConfigurations: 4, simulationsPerParty: 2, @@ -293,3 +308,69 @@ test("bounds the number of pending server jobs", () => { rmSync(directory, { recursive: true, force: true }); } }); + +test("runs separate level jobs concurrently and reports worker utilization", () => { + const directory = mkdtempSync(path.join(os.tmpdir(), "healer-man-balance-parallel-")); + try { + let launches = 0; + const spawnProcess = () => { + launches += 1; + const child = new EventEmitter(); + child.stdout = new PassThrough(); + child.stderr = new PassThrough(); + child.kill = () => child.emit("close", null); + return child; + }; + const service = createBalanceLabJobService({ + dataDir: directory, + projectRoot: directory, + spawnProcess, + pythonExecutable: "python-test", + maxConcurrent: 3, + maxQueued: 10, + }); + const requestForLevel = (level) => ({ + source: "wow", level, partyConfigurations: 1, simulationsPerParty: 1, + durationSeconds: 5, partySeed: 1, bossCategory: "all", bossKeys: ["gundrak/entry-29932"], + }); + const jobs = service.startMany({ specs: [10, 20, 30].map(requestForLevel) }, { username: "Phenom" }); + assert.equal(launches, 3); + assert.deepEqual(jobs.map((job) => job.status), ["running", "running", "running"]); + assert.deepEqual(jobs.map((job) => job.spec.level), [10, 20, 30]); + assert.deepEqual(service.runtime(), { + available: true, + executable: "python-test", + version: "test process", + error: null, + maxConcurrent: 3, + running: 3, + queued: 0, + maxQueued: 10, + }); + service.close(); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +}); + +test("rejects an oversized batch without queueing a partial sweep", () => { + const directory = mkdtempSync(path.join(os.tmpdir(), "healer-man-balance-batch-capacity-")); + try { + const service = createBalanceLabJobService({ + dataDir: directory, + projectRoot: directory, + spawnProcess: () => { throw new Error("an over-capacity batch must not launch"); }, + pythonExecutable: "python-test", + maxQueued: 2, + }); + const specs = [10, 20, 30].map((level) => ({ + source: "wow", level, partyConfigurations: 1, simulationsPerParty: 1, + durationSeconds: 5, partySeed: 1, bossCategory: "all", bossKeys: ["gundrak/entry-29932"], + })); + assert.throws(() => service.startMany({ specs }, { username: "Phenom" }), /server queue is full/); + assert.equal(service.list().length, 0); + service.close(); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +}); diff --git a/server/server.mjs b/server/server.mjs index e16c89e9..d630c44a 100644 --- a/server/server.mjs +++ b/server/server.mjs @@ -514,6 +514,16 @@ export function createGameServer(options = {}) { json(response, 200, { runtime: balanceLabJobs.runtime() }); return; } + if (url.pathname === "/api/gm/balance-lab/jobs/batch" && request.method === "POST") { + const account = gameDatabase.authenticate(bearerToken(request)); + if (!gameDatabase.isGameMaster(account)) { + throw new GameDatabaseError("Game Master access is required.", 403, "gm_required"); + } + const jobs = balanceLabJobs.startMany(await readJson(request, 4 * 1024 * 1024), account); + console.info(`Balance Lab batch of ${jobs.length} jobs queued by ${account.username}`); + json(response, 202, { jobs }); + 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 90da3a06..42bbfac5 100644 --- a/server/server.test.mjs +++ b/server/server.test.mjs @@ -252,7 +252,9 @@ test("serves the game and authenticated cloud-save API", async () => { headers: { Authorization: `Bearer ${gmAuth.token}` }, }); assert.equal(balanceLabRuntime.status, 200); - assert.equal((await balanceLabRuntime.json()).runtime.available, true); + const balanceLabRuntimePayload = await balanceLabRuntime.json(); + assert.equal(balanceLabRuntimePayload.runtime.available, true); + assert.equal(balanceLabRuntimePayload.runtime.maxConcurrent, 4); const invalidBalanceLab = await fetch(`${origin}/api/gm/balance-lab/jobs`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${gmAuth.token}` }, @@ -260,6 +262,13 @@ test("serves the game and authenticated cloud-save API", async () => { }); assert.equal(invalidBalanceLab.status, 400); assert.equal((await invalidBalanceLab.json()).code, "invalid_balance_lab_job"); + const invalidBalanceLabBatch = await fetch(`${origin}/api/gm/balance-lab/jobs/batch`, { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${gmAuth.token}` }, + body: JSON.stringify({ specs: [{ source: "wow", level: 10 }, { source: "wow", level: 81 }] }), + }); + assert.equal(invalidBalanceLabBatch.status, 400); + assert.equal((await invalidBalanceLabBatch.json()).code, "invalid_balance_lab_job"); const current = await (await fetch(`${origin}/api/manastorm-config`)).json(); const published = await fetch(`${origin}/api/gm/manastorm-config`, { method: "PUT", diff --git a/src/app/balanceLabClient.ts b/src/app/balanceLabClient.ts index d63776c9..0b73ad54 100644 --- a/src/app/balanceLabClient.ts +++ b/src/app/balanceLabClient.ts @@ -10,6 +10,10 @@ export interface BalanceLabRuntimeStatus { readonly executable: string; readonly version: string | null; readonly error: string | null; + readonly maxConcurrent: number; + readonly running: number; + readonly queued: number; + readonly maxQueued: number; } export interface BalanceLabJobSpec { @@ -183,6 +187,17 @@ export async function startBalanceLabJob(spec: BalanceLabJobSpec, token: string) )).job; } +export async function startBalanceLabJobs( + specs: readonly BalanceLabJobSpec[], + token: string, +): Promise { + return (await requestOnlineJson<{ jobs: BalanceLabJob[] }>( + "/api/gm/balance-lab/jobs/batch", + { method: "POST", body: JSON.stringify({ specs }) }, + token, + )).jobs; +} + export async function getBalanceLabJob(id: string, token: string): Promise { return (await requestOnlineJson<{ job: BalanceLabJob }>( `/api/gm/balance-lab/jobs/${encodeURIComponent(id)}`, diff --git a/src/app/balanceLabSchedules.test.ts b/src/app/balanceLabSchedules.test.ts new file mode 100644 index 00000000..3261bbaf --- /dev/null +++ b/src/app/balanceLabSchedules.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; +import { balanceLabBracketLevels } from "./balanceLabSchedules"; + +describe("Balance Lab level schedules", () => { + it("includes level 1 and every ten-level bracket through 80", () => { + expect(balanceLabBracketLevels(10)).toEqual([1, 10, 20, 30, 40, 50, 60, 70, 80]); + }); + + it("supports every five levels without duplicating level 1", () => { + expect(balanceLabBracketLevels(5)).toEqual([ + 1, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55, 60, 65, 70, 75, 80, + ]); + }); + + it("supports a job for every level from 1 through 80", () => { + const levels = balanceLabBracketLevels(1); + expect(levels).toHaveLength(80); + expect(levels[0]).toBe(1); + expect(levels.at(-1)).toBe(80); + }); +}); diff --git a/src/app/balanceLabSchedules.ts b/src/app/balanceLabSchedules.ts new file mode 100644 index 00000000..4635a0c0 --- /dev/null +++ b/src/app/balanceLabSchedules.ts @@ -0,0 +1,6 @@ +export type BalanceLabBracketStep = 1 | 5 | 10; + +export function balanceLabBracketLevels(step: BalanceLabBracketStep): readonly number[] { + if (step === 1) return Array.from({ length: 80 }, (_, index) => index + 1); + return [1, ...Array.from({ length: Math.floor(80 / step) }, (_, index) => (index + 1) * step)]; +} diff --git a/src/ui/BalanceLabScreen.tsx b/src/ui/BalanceLabScreen.tsx index 83efd95d..e1100b03 100644 --- a/src/ui/BalanceLabScreen.tsx +++ b/src/ui/BalanceLabScreen.tsx @@ -3,11 +3,11 @@ import { sessionCanUseGmTools } from "../app/releaseFeatures"; import { useShellStore } from "../app/shellStore"; import { fetchBalanceLabArtifact, - getBalanceLabJob, getBalanceLabReport, getBalanceLabRuntime, listBalanceLabJobs, startBalanceLabJob, + startBalanceLabJobs, type BalanceLabBossCategory, type BalanceLabJob, type BalanceLabPartySource, @@ -15,6 +15,10 @@ import { type BalanceLabSpecSelection, type BalanceLabSuiteReport, } from "../app/balanceLabClient"; +import { + balanceLabBracketLevels, + type BalanceLabBracketStep, +} from "../app/balanceLabSchedules"; import { classById } from "../app/characterCatalog"; import { BOSS_TESTING_CATALOG } from "../game/bossTesting"; import { @@ -25,6 +29,7 @@ import { BalanceLabResults } from "./BalanceLabResults"; import { BrandMark, FrontSurface } from "./FrontSurface"; type BossScope = BalanceLabBossCategory | "specific"; +type LevelCoverage = "single" | "brackets"; const ARTIFACT_LABELS: Readonly> = { "suite-report.html": "Open full report", @@ -54,7 +59,7 @@ function progressPercent(job: BalanceLabJob): number { } function describeJob(job: BalanceLabJob): string { - if (job.status === "queued") return "Waiting for the current server job to finish."; + if (job.status === "queued") return "Waiting for an available server worker."; if (job.status === "failed") return job.error ?? "The server job failed."; if (job.status === "complete") return job.summary ? `Completed ${job.summary.runs.toLocaleString()} simulations across ${job.summary.bosses.toLocaleString()} bosses and ${job.summary.partyConfigurations.toLocaleString()} party configurations.` @@ -77,6 +82,10 @@ function coveragePartyCount(builds: readonly SelectableBenchmarkBuildCatalogEntr }, 0); } +function buildIdentity(build: SelectableBenchmarkBuildCatalogEntry): string { + return `${build.source}:${build.classId}:${build.role}:${build.orientation}`; +} + export function BalanceLabScreen({ onClose }: { readonly onClose: () => void }) { const session = useShellStore((state) => state.session); const token = session?.accessToken ?? ""; @@ -85,6 +94,8 @@ export function BalanceLabScreen({ onClose }: { readonly onClose: () => void }) const [specSearch, setSpecSearch] = useState(""); const [selectedBuildIds, setSelectedBuildIds] = useState([]); const [level, setLevel] = useState(60); + const [levelCoverage, setLevelCoverage] = useState("single"); + const [bracketStep, setBracketStep] = useState(10); const [partyConfigurations, setPartyConfigurations] = useState(10); const [simulationsPerParty, setSimulationsPerParty] = useState(3); const [durationSeconds, setDurationSeconds] = useState(60); @@ -113,14 +124,18 @@ export function BalanceLabScreen({ onClose }: { readonly onClose: () => void }) && (!query || `${job.requestedBy} ${job.spec.source} ${job.spec.level} ${job.spec.specSelection ?? "random"} ${job.id} ${job.status}`.toLowerCase().includes(query)) )); }, [jobSearch, jobStatus, jobs]); + const targetLevels = useMemo(() => levelCoverage === "single" + ? [level] + : balanceLabBracketLevels(bracketStep), [bracketStep, level, levelCoverage]); + const catalogLevel = levelCoverage === "single" && Number.isFinite(level) ? level : 80; const availableBuilds = useMemo(() => selectableBenchmarkBuildCatalog( - Number.isFinite(level) ? level : 60, + catalogLevel, ).filter((build) => source === "all" || build.source === source).sort((left, right) => ( left.source.localeCompare(right.source) || classById(left.classId).name.localeCompare(classById(right.classId).name) || left.orientation.localeCompare(right.orientation) || left.role.localeCompare(right.role) - )), [level, source]); + )), [catalogLevel, source]); const selectedBuildSet = useMemo(() => new Set(selectedBuildIds), [selectedBuildIds]); const selectedBuilds = useMemo(() => availableBuilds.filter((build) => selectedBuildSet.has(build.id)), [ availableBuilds, @@ -144,7 +159,9 @@ export function BalanceLabScreen({ onClose }: { readonly onClose: () => void }) ? partyConfigurations * (source === "all" ? 3 : 1) : coveragePartyCount(coverageBuilds); const coveredSpecCount = specSelection === "all" ? availableBuilds.length : selectedBuilds.length; - const estimatedRuns = partyCount * bossCount * simulationsPerParty; + const estimatedRunsPerLevel = partyCount * bossCount * simulationsPerParty; + const estimatedRuns = estimatedRunsPerLevel * targetLevels.length; + const hasPendingJobs = jobs.some((job) => job.status === "queued" || job.status === "running"); useEffect(() => { if (!token) return; @@ -165,18 +182,19 @@ export function BalanceLabScreen({ onClose }: { readonly onClose: () => void }) }, [token]); useEffect(() => { - if (!token || !selectedJob || (selectedJob.status !== "queued" && selectedJob.status !== "running")) return; + if (!token || !hasPendingJobs) return; let cancelled = false; const timer = window.setInterval(() => { - void getBalanceLabJob(selectedJob.id, token).then((updated) => { + void Promise.all([listBalanceLabJobs(token), getBalanceLabRuntime(token)]).then(([nextJobs, runtime]) => { if (cancelled) return; - setJobs((current) => [updated, ...current.filter((job) => job.id !== updated.id)]); + setJobs(nextJobs); + setRuntimeStatus(runtime); }).catch((reason) => { if (!cancelled) setError(errorMessage(reason)); }); }, 1_500); return () => { cancelled = true; window.clearInterval(timer); }; - }, [selectedJob, token]); + }, [hasPendingJobs, token]); useEffect(() => { if (!token || !selectedJob || selectedJob.status !== "complete" || selectedReport @@ -235,20 +253,35 @@ export function BalanceLabScreen({ onClose }: { readonly onClose: () => void }) setSubmitting(true); setError(""); try { - const job = await startBalanceLabJob({ - source, - specSelection, - selectedBuildIds: specSelection === "specific" ? selectedBuilds.map((build) => build.id) : [], - level, - partyConfigurations: specSelection === "random" ? partyConfigurations : partyCount, - simulationsPerParty, - durationSeconds, - partySeed, - bossCategory: bossScope === "specific" ? "all" : bossScope, - bossKeys: bossScope === "specific" ? selectedBossKeys : [], - }, token); - setJobs((current) => [job, ...current.filter((candidate) => candidate.id !== job.id)]); - setSelectedJobId(job.id); + const selectedIdentities = new Set(selectedBuilds.map(buildIdentity)); + const specs = targetLevels.map((targetLevel) => { + const levelBuilds = selectableBenchmarkBuildCatalog(targetLevel) + .filter((build) => source === "all" || build.source === source); + const chosenBuilds = specSelection === "specific" + ? levelBuilds.filter((build) => selectedIdentities.has(buildIdentity(build))) + : specSelection === "all" ? levelBuilds : []; + if (specSelection === "specific" && chosenBuilds.length !== selectedIdentities.size) { + throw new Error(`One or more selected class/spec builds are unavailable at level ${targetLevel}.`); + } + return { + source, + specSelection, + selectedBuildIds: specSelection === "specific" ? chosenBuilds.map((build) => build.id) : [], + level: targetLevel, + partyConfigurations: specSelection === "random" ? partyConfigurations : coveragePartyCount(chosenBuilds), + simulationsPerParty, + durationSeconds, + partySeed, + bossCategory: bossScope === "specific" ? "all" as const : bossScope, + bossKeys: bossScope === "specific" ? selectedBossKeys : [], + }; + }); + const submittedJobs = specs.length === 1 + ? [await startBalanceLabJob(specs[0], token)] + : await startBalanceLabJobs(specs, token); + const submittedIds = new Set(submittedJobs.map((job) => job.id)); + setJobs((current) => [...submittedJobs, ...current.filter((job) => !submittedIds.has(job.id))]); + setSelectedJobId(submittedJobs[0]?.id ?? null); } catch (reason) { setError(errorMessage(reason)); } finally { @@ -292,7 +325,9 @@ export function BalanceLabScreen({ onClose }: { readonly onClose: () => void })

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.`} + {runtimeStatus.available + ? `${runtimeStatus.version} · ${runtimeStatus.running}/${runtimeStatus.maxConcurrent} workers active · ${runtimeStatus.queued} queued` + : `${runtimeStatus.error} Install Python 3 in the server container, set BALANCE_LAB_PYTHON=python3, and restart the app.`}
}
-
+ {levelCoverage === "brackets" &&

+ {targetLevels.length} separate level jobs: {bracketStep === 1 ? "levels 1 through 80" : targetLevels.join(", ")}. Up to {runtimeStatus?.maxConcurrent ?? 4} run at the same time; each keeps its own report. +

} {specSelection !== "random" &&

{specSelection === "all" ? `${coveredSpecCount} class/spec builds will be covered at least once across the minimum valid party set.` @@ -349,8 +396,8 @@ export function BalanceLabScreen({ onClose }: { readonly onClose: () => void }) )} -

Party configurations {partyCount.toLocaleString()}Class/spec coverage {specSelection === "random" ? "Random" : coveredSpecCount.toLocaleString()}Bosses {bossCount.toLocaleString()}Total simulations {estimatedRuns.toLocaleString()}
- +
Level jobs {targetLevels.length.toLocaleString()}Parties / level {partyCount.toLocaleString()}Bosses / level {bossCount.toLocaleString()}Total simulations {estimatedRuns.toLocaleString()}
+

To spend this device's CPU instead, run npm run balance:lab from the project checkout.

{error &&

{error}

}