Release Healer Man 0.1.56
This commit is contained in:
1 parent
38b3d4d160
commit
a05a79eb83
16 files changed
+341
-69
No files matched your search
+4
-4
@@ -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
|
||||
|
||||
@@ -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`.
|
||||
+2
-2
@@ -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
|
||||
|
||||
+21
-15
@@ -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/<job-id>/
|
||||
@@ -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
|
||||
|
||||
Generated
+2
-2
@@ -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",
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"versionName": "0.1.55",
|
||||
"versionCode": 1055
|
||||
"versionName": "0.1.56",
|
||||
"versionCode": 1056
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
+59
-13
@@ -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,10 +433,9 @@ export function createBalanceLabJobService(options = {}) {
|
||||
return job;
|
||||
}
|
||||
|
||||
return {
|
||||
start(input, account) {
|
||||
function requireAvailableRuntime() {
|
||||
if (closed) throw new GameDatabaseError("Balance Lab runner is shutting down.", 503, "balance_lab_unavailable");
|
||||
const runtime = runtimeProbe();
|
||||
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.`,
|
||||
@@ -425,15 +443,20 @@ export function createBalanceLabJobService(options = {}) {
|
||||
"balance_lab_runtime_unavailable",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function requireQueueCapacity(count) {
|
||||
const pending = [...jobs.values()].filter((job) => job.status === "queued" || job.status === "running").length;
|
||||
if (pending >= maxQueued) {
|
||||
if (pending + count > maxQueued) {
|
||||
throw new GameDatabaseError(
|
||||
"The Balance Lab server queue is full. Wait for a running job to finish.",
|
||||
`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",
|
||||
);
|
||||
}
|
||||
const spec = normalizeBalanceLabJobSpec(input);
|
||||
}
|
||||
|
||||
function queueJob(spec, account) {
|
||||
const id = randomUUID();
|
||||
const outputDir = path.join(jobsDir, id);
|
||||
mkdirSync(outputDir, { recursive: true });
|
||||
@@ -462,9 +485,26 @@ export function createBalanceLabJobService(options = {}) {
|
||||
jobs.set(id, job);
|
||||
persistJob(job);
|
||||
queue.push(job);
|
||||
return job;
|
||||
}
|
||||
|
||||
return {
|
||||
start(input, account) {
|
||||
const spec = normalizeBalanceLabJobSpec(input);
|
||||
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));
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
@@ -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)) {
|
||||
|
||||
+10
-1
@@ -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",
|
||||
|
||||
@@ -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<readonly BalanceLabJob[]> {
|
||||
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<BalanceLabJob> {
|
||||
return (await requestOnlineJson<{ job: BalanceLabJob }>(
|
||||
`/api/gm/balance-lab/jobs/${encodeURIComponent(id)}`,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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)];
|
||||
}
|
||||
+69
-22
@@ -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<Record<string, string>> = {
|
||||
"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<readonly string[]>([]);
|
||||
const [level, setLevel] = useState(60);
|
||||
const [levelCoverage, setLevelCoverage] = useState<LevelCoverage>("single");
|
||||
const [bracketStep, setBracketStep] = useState<BalanceLabBracketStep>(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({
|
||||
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" ? selectedBuilds.map((build) => build.id) : [],
|
||||
level,
|
||||
partyConfigurations: specSelection === "random" ? partyConfigurations : partyCount,
|
||||
selectedBuildIds: specSelection === "specific" ? chosenBuilds.map((build) => build.id) : [],
|
||||
level: targetLevel,
|
||||
partyConfigurations: specSelection === "random" ? partyConfigurations : coveragePartyCount(chosenBuilds),
|
||||
simulationsPerParty,
|
||||
durationSeconds,
|
||||
partySeed,
|
||||
bossCategory: bossScope === "specific" ? "all" : bossScope,
|
||||
bossCategory: bossScope === "specific" ? "all" as const : bossScope,
|
||||
bossKeys: bossScope === "specific" ? selectedBossKeys : [],
|
||||
}, token);
|
||||
setJobs((current) => [job, ...current.filter((candidate) => candidate.id !== job.id)]);
|
||||
setSelectedJobId(job.id);
|
||||
};
|
||||
});
|
||||
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 })
|
||||
<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>
|
||||
<span>{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.`}</span>
|
||||
</div>}
|
||||
<div className="balance-lab-form-grid">
|
||||
<label><span>Party source</span><select value={source} onChange={(event) => {
|
||||
@@ -302,10 +337,19 @@ export function BalanceLabScreen({ onClose }: { readonly onClose: () => void })
|
||||
}}>
|
||||
<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>
|
||||
</select></label>
|
||||
<label><span>Level</span><input type="number" min={1} max={80} value={level} onChange={(event) => {
|
||||
<label><span>Level coverage</span><select value={levelCoverage} onChange={(event) => {
|
||||
setLevelCoverage(event.currentTarget.value as LevelCoverage);
|
||||
setSelectedBuildIds([]);
|
||||
}}><option value="single">Single level</option><option value="brackets">Run all level brackets</option></select></label>
|
||||
{levelCoverage === "single"
|
||||
? <label><span>Level</span><input type="number" min={1} max={80} value={level} onChange={(event) => {
|
||||
setLevel(event.currentTarget.valueAsNumber);
|
||||
setSelectedBuildIds([]);
|
||||
}} /></label>
|
||||
: <label><span>Bracket spacing</span><select value={bracketStep} onChange={(event) => {
|
||||
setBracketStep(Number(event.currentTarget.value) as BalanceLabBracketStep);
|
||||
setSelectedBuildIds([]);
|
||||
}}><option value={10}>Every 10 levels</option><option value={5}>Every 5 levels</option><option value={1}>Every 1 level</option></select></label>}
|
||||
<label className="balance-lab-form-grid__wide"><span>Class/spec selection</span><select value={specSelection} onChange={(event) => setSpecSelection(event.currentTarget.value as BalanceLabSpecSelection)}>
|
||||
<option value="random">Random parties</option><option value="all">All specs</option><option value="specific">Specific specs</option>
|
||||
</select></label>
|
||||
@@ -316,9 +360,12 @@ export function BalanceLabScreen({ onClose }: { readonly onClose: () => void })
|
||||
<label><span>Fight limit (seconds)</span><input type="number" min={5} max={600} value={durationSeconds} onChange={(event) => setDurationSeconds(event.currentTarget.valueAsNumber)} /></label>
|
||||
<label><span>Party seed</span><input type="number" value={partySeed} onChange={(event) => setPartySeed(event.currentTarget.valueAsNumber)} /></label>
|
||||
<label className="balance-lab-form-grid__wide"><span>Bosses</span><select value={bossScope} onChange={(event) => setBossScope(event.currentTarget.value as BossScope)}>
|
||||
<option value="all">All bosses</option><option value="wow">WoW bosses</option><option value="rom">RoM bosses</option><option value="specific">Specific selection</option>
|
||||
<option value="all">Every boss</option><option value="wow">Every WoW boss</option><option value="rom">Every RoM boss</option><option value="specific">Pick specific bosses</option>
|
||||
</select></label>
|
||||
</div>
|
||||
{levelCoverage === "brackets" && <p className="balance-lab-coverage-note">
|
||||
{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.
|
||||
</p>}
|
||||
{specSelection !== "random" && <p className="balance-lab-coverage-note">
|
||||
{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 })
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
<div className="balance-lab-estimate"><span>Party configurations <b>{partyCount.toLocaleString()}</b></span><span>Class/spec coverage <b>{specSelection === "random" ? "Random" : coveredSpecCount.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 || !runtimeStatus?.available || !Number.isFinite(estimatedRuns) || estimatedRuns < 1} onClick={() => void run()}>{submitting ? "Queuing…" : runtimeStatus && !runtimeStatus.available ? "Server runtime unavailable" : "Run on server"}</button>
|
||||
<div className="balance-lab-estimate"><span>Level jobs <b>{targetLevels.length.toLocaleString()}</b></span><span>Parties / level <b>{partyCount.toLocaleString()}</b></span><span>Bosses / level <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 || !runtimeStatus?.available || !Number.isFinite(estimatedRuns) || estimatedRuns < 1} onClick={() => void run()}>{submitting ? `Queuing ${targetLevels.length} job${targetLevels.length === 1 ? "" : "s"}…` : runtimeStatus && !runtimeStatus.available ? "Server runtime unavailable" : targetLevels.length === 1 ? "Run on server" : `Queue ${targetLevels.length} level jobs`}</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>
|
||||
{error && <p className="front-notice" role="alert">{error}</p>}
|
||||
</section>
|
||||
|
||||
Reference in new issue
Block a user