Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a05a79eb83 | ||
|
|
38b3d4d160 |
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
|
||||
|
||||
+32
-19
@@ -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.
|
||||
@@ -282,6 +287,10 @@ under `parties/<party-id>/` and adds these suite-level files:
|
||||
`totalHealing` and `totalHealingReceived` are the corresponding absolute
|
||||
values across the included runs. Reports created before target attribution
|
||||
was added remain readable but require a rerun to populate healing received.
|
||||
- Member and ability `absorbs` count shield capacity granted by both direct
|
||||
shield effects and named absorb auras such as Power Word: Shield. Member
|
||||
`deathEvents` counts each alive-to-dead transition, so a member that is
|
||||
resurrected and defeated again contributes another death.
|
||||
- Ability `activeRuns` counts simulations with at least one cast, completed
|
||||
non-throughput release, hit, damage, healing, overheal, or absorb event.
|
||||
Completed resource, aura, pet, and summon actions are recorded as casts even
|
||||
@@ -341,15 +350,18 @@ remains the class's primary resource. A completed companion cast resolves the
|
||||
member's currently learned rank before applying its effects, so cast-time spell
|
||||
damage and healing cannot fall back to rank one.
|
||||
|
||||
`playerbots-inspired-v3` keeps level-10-and-up non-healer builds on their
|
||||
`playerbots-inspired-v4` keeps level-10-and-up non-healer builds on their
|
||||
specialization's authored damage priority whenever that build knows a profile
|
||||
damage action. Reactive interrupts, taunts, control, and defenses still take
|
||||
precedence. Bear and Cat actions are isolated to their matching Feral specs;
|
||||
Overpower, Revenge, and Victory Rush require an explicitly modeled trigger;
|
||||
and Drain Soul, Hammer of Wrath, and Kill Shot obey their target-health gates.
|
||||
Incidental DBC aura categories cannot outrank explicit profile actions. Rage and
|
||||
Runic Power generated during an action persist without erasing that action's
|
||||
cooldown/GCD, and short-range melee abilities use companion combat reach.
|
||||
Off-profile control, interrupt, and debuff buttons cannot consume ordinary
|
||||
rotation GCDs after their reactive conditions have been checked. Incidental DBC
|
||||
aura categories cannot outrank explicit profile actions. Rage and Runic Power
|
||||
generated during an action persist without erasing that action's cooldown/GCD;
|
||||
short-range melee abilities use companion combat reach; and pets move every
|
||||
combat tick while retaining their own attack cadence.
|
||||
|
||||
## Code map
|
||||
|
||||
@@ -365,9 +377,10 @@ cooldown/GCD, and short-range melee abilities use companion combat reach.
|
||||
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
|
||||
|
||||
@@ -18,7 +18,7 @@ The most useful upstream starting points are `src/Ai/Class/<Class>/Strategy/` fo
|
||||
| Combo points | Builders run below four points; finishers require at least four points on the current target. |
|
||||
| Major cooldowns | Offensive cooldowns of 60 seconds or longer are held for bosses or durable targets. |
|
||||
|
||||
`playerbots-inspired-v3` also generates legal level-scaled talent plans for all
|
||||
`playerbots-inspired-v4` also generates legal level-scaled talent plans for all
|
||||
31 classic specializations. Each level-80 plan spends 51 points in the selected
|
||||
tree and 20 in a compatible secondary tree, prefers executable active talents
|
||||
and their prerequisites, and sanitizes obsolete imported prerequisite links.
|
||||
@@ -34,7 +34,7 @@ The leftmost available action wins after reactive rules, learned-talent gating,
|
||||
|
||||
| Class | Specialization | Maintenance and core priority |
|
||||
| --- | --- | --- |
|
||||
| Warrior | Arms | Battle Stance; Bloodrage; Rend; Mortal Strike; Overpower; Execute; Slam; Heroic Strike |
|
||||
| Warrior | Arms | Battle Stance; Bloodrage; Rend; Mortal Strike; Bladestorm; Overpower; Execute; Slam; Heroic Strike |
|
||||
| Warrior | Fury | Berserker/Battle Stance; Bloodrage; Bloodthirst; Whirlwind; Execute; Sunder Armor; Heroic Strike |
|
||||
| Warrior | Protection | Defensive/Battle Stance; Bloodrage; Shield Slam; Revenge; Devastate; Thunder Clap; Sunder Armor; Heroic Strike |
|
||||
| Paladin | Holy | Concentration Aura; Blessing of Might; Holy Shock; Flash of Light; Holy Light; Sacred Shield; Judgement of Light |
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "healer-man",
|
||||
"version": "0.1.54",
|
||||
"version": "0.1.56",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "healer-man",
|
||||
"version": "0.1.54",
|
||||
"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.54",
|
||||
"version": "0.1.56",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"versionName": "0.1.54",
|
||||
"versionCode": 1054
|
||||
"versionName": "0.1.56",
|
||||
"versionCode": 1056
|
||||
}
|
||||
@@ -379,6 +379,7 @@ async function runBoss(
|
||||
abilities: new Map(),
|
||||
}));
|
||||
const byActorId = new Map(memberMetrics.map((metrics) => [metrics.actorId, metrics]));
|
||||
const actorAlive = new Map<string, boolean>();
|
||||
const bossAbilities = new Map<string, MutableAbilityMetrics>();
|
||||
// Instant resource, aura, pet, and summon actions do not produce the damage,
|
||||
// healing, absorb, or cast events used by the original report collector.
|
||||
@@ -435,8 +436,6 @@ async function runBoss(
|
||||
} else if (event.kind === "absorb" && source) {
|
||||
source.absorbs += event.effectiveAmount;
|
||||
abilityMetrics(source, event.abilityId).absorbs += event.effectiveAmount;
|
||||
} else if (event.kind === "death" && target) {
|
||||
target.deathEvents += 1;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -470,6 +469,9 @@ async function runBoss(
|
||||
memberTargetIds: {},
|
||||
});
|
||||
combat.synchronizeActors();
|
||||
for (const metrics of memberMetrics) {
|
||||
actorAlive.set(metrics.actorId, currentHealth(metrics.actorId)[0] > 0);
|
||||
}
|
||||
combat.registerMob(bossActorId, {
|
||||
name: entity.name,
|
||||
boss: true,
|
||||
@@ -500,6 +502,9 @@ async function runBoss(
|
||||
controller.act(now, activeBuild.role, runScenario, false);
|
||||
for (const metrics of memberMetrics) {
|
||||
const [health, maxHealth] = currentHealth(metrics.actorId);
|
||||
const alive = health > 0;
|
||||
if ((actorAlive.get(metrics.actorId) ?? alive) && !alive) metrics.deathEvents += 1;
|
||||
actorAlive.set(metrics.actorId, alive);
|
||||
metrics.minimumHealthPercent = Math.min(
|
||||
metrics.minimumHealthPercent,
|
||||
maxHealth > 0 ? Math.max(0, health / maxHealth) : 0,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+98
-52
@@ -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));
|
||||
|
||||
@@ -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)];
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
|
||||
import { clearPartyRuntimeRegistry } from "./partyRuntimeRegistry";
|
||||
import { createConfiguredPartyMember, usePartyStore } from "./partyStore";
|
||||
import { runtimePartyTalentRanksForLevel } from "./partyTalentRuntime";
|
||||
import { subscribeCombatEvents, type StructuredCombatEvent } from "./combatEvents";
|
||||
|
||||
function registerTarget(id = "target", distance = 2): void {
|
||||
useCombatStore.getState().registerMob(id, { name: id, maxHealth: 1_000, xpReward: 0 });
|
||||
@@ -150,20 +151,36 @@ describe("named aura combat runtime", () => {
|
||||
const summonIds = prepared.summons.map((summon) => summon.id);
|
||||
useCombatStore.getState().applyPrecombatPreparation(now + 1_000);
|
||||
expect(useCombatStore.getState().summons.map((summon) => summon.id)).toEqual(summonIds);
|
||||
|
||||
registerTarget("pet-target");
|
||||
useCombatStore.getState().damageMob("pet-target", 1, now + 1_100);
|
||||
const beforePetAttack = useCombatStore.getState().mobs["pet-target"].health;
|
||||
useCombatStore.getState().tick(0.1, now + 2_100);
|
||||
expect(useCombatStore.getState().mobs["pet-target"].health).toBeLessThan(beforePetAttack);
|
||||
});
|
||||
|
||||
it("consumes a named absorb before player health", () => {
|
||||
const now = Date.now();
|
||||
const events: StructuredCombatEvent[] = [];
|
||||
const stop = subscribeCombatEvents((event) => events.push(event));
|
||||
expect(useCombatStore.getState().castAbility(
|
||||
"wow335-priest-power-word-shield",
|
||||
undefined,
|
||||
now,
|
||||
).ok).toBe(true);
|
||||
stop();
|
||||
const beforeHealth = useCombatStore.getState().health;
|
||||
const beforeAbsorb = useCombatStore.getState().auras
|
||||
.flatMap((aura) => aura.absorbs)
|
||||
.reduce((total, absorb) => total + absorb.remaining, 0);
|
||||
expect(beforeAbsorb).toBeGreaterThan(0);
|
||||
expect(events).toContainEqual(expect.objectContaining({
|
||||
kind: "absorb",
|
||||
sourceActorId: PLAYER_AGGRO_ID,
|
||||
targetActorId: PLAYER_AGGRO_ID,
|
||||
abilityId: "wow335-priest-power-word-shield",
|
||||
effectiveAmount: beforeAbsorb,
|
||||
}));
|
||||
expect(useCombatStore.getState().auras.find((aura) => aura.absorbs.length)?.definition.icon).toBe(
|
||||
useCombatStore.getState().abilities.find((ability) => (
|
||||
ability.id === "wow335-priest-power-word-shield"
|
||||
|
||||
@@ -374,6 +374,32 @@ describe("shared combat rotations", () => {
|
||||
})).toBeNull();
|
||||
});
|
||||
|
||||
it("reserves off-profile utility for reactive needs instead of spending rotation GCDs", () => {
|
||||
const demoralizingShout = ability("warrior", "Demoralizing Shout");
|
||||
const pummel = ability("warrior", "Pummel");
|
||||
const mortalStrike = ability("warrior", "Mortal Strike");
|
||||
const arms = context({
|
||||
classId: "warrior",
|
||||
specializationId: "warrior:arms",
|
||||
primaryResource: "rage",
|
||||
resources: { rage: 100 },
|
||||
maximumResources: { rage: 100 },
|
||||
abilities: [demoralizingShout, pummel],
|
||||
knownDamagingAbilityNames: new Set([mortalStrike.name]),
|
||||
});
|
||||
|
||||
expect(evaluateCombatRotation(arms)).toBeNull();
|
||||
expect(evaluateCombatRotation({
|
||||
...arms,
|
||||
hostileTarget: {
|
||||
id: "caster",
|
||||
health: 1_000,
|
||||
maxHealth: 1_000,
|
||||
activeCast: { interruptible: true },
|
||||
},
|
||||
})?.ability.id).toBe(pummel.id);
|
||||
});
|
||||
|
||||
it("never falls a full-health friendly maintenance heal through to a hostile target", () => {
|
||||
const strike = semanticAbility("strike", [{ kind: "damage", coefficient: 1 }]);
|
||||
const prayer = semanticAbility("prayer", [
|
||||
|
||||
@@ -7,7 +7,7 @@ import { spendDeathKnightRunes } from "./combatResources";
|
||||
import { healingAbilityCanAidOthers } from "./healingAbilityRules";
|
||||
import type { PartyRole } from "./partyRoles";
|
||||
|
||||
export const COMBAT_ROTATION_VERSION = "playerbots-inspired-v3";
|
||||
export const COMBAT_ROTATION_VERSION = "playerbots-inspired-v4";
|
||||
|
||||
export interface RotationProfile {
|
||||
readonly id: string;
|
||||
@@ -90,7 +90,7 @@ const profile = (
|
||||
|
||||
/** Playerbots priorities translated to the executable Healer Man spell model. */
|
||||
export const CLASSIC_ROTATION_PROFILES: readonly RotationProfile[] = Object.freeze([
|
||||
profile("warrior:arms", "warrior", ["Battle Stance", "Bloodrage", "Rend", "Mortal Strike", "Overpower", "Execute", "Slam", "Heroic Strike"]),
|
||||
profile("warrior:arms", "warrior", ["Battle Stance", "Bloodrage", "Rend", "Mortal Strike", "Bladestorm", "Overpower", "Execute", "Slam", "Heroic Strike"]),
|
||||
profile("warrior:fury", "warrior", ["Berserker Stance", "Battle Stance", "Bloodrage", "Bloodthirst", "Whirlwind", "Execute", "Sunder Armor", "Heroic Strike"]),
|
||||
profile("warrior:protection", "warrior", ["Defensive Stance", "Battle Stance", "Bloodrage", "Shield Slam", "Revenge", "Devastate", "Thunder Clap", "Sunder Armor", "Heroic Strike"]),
|
||||
profile("paladin:holy", "paladin", ["Concentration Aura", "Devotion Aura", "Blessing of Might", "Seal of Wisdom", "Seal of Light", "Seal of Righteousness", "Holy Shock", "Flash of Light", "Holy Light", "Sacred Shield", "Judgement of Light"]),
|
||||
@@ -430,6 +430,14 @@ function scoreAbility(
|
||||
if (maintained && (categories.has("debuff") || categories.has("buff"))) return null;
|
||||
if (activeSummon && (categories.has("summon") || categories.has("pet"))) return null;
|
||||
if (rotationProfile && profileIndex < 0 && !damaging && (maintenanceBuff || petSummon || offensiveSummon)) return null;
|
||||
// A specialization profile is also an allow-list for utility GCDs once the
|
||||
// build has a real damage action. Reactive interrupts, taunts, threat drops,
|
||||
// and encounter-requested control already returned above; letting any other
|
||||
// imported debuff/control action through here makes characters spam buttons
|
||||
// such as Expose Armor and Demoralizing Shout instead of their rotation.
|
||||
if (rotationProfile && context.level >= 10 && hasKnownProfileDamageAction
|
||||
&& context.role !== "healer" && profileIndex < 0
|
||||
&& (categories.has("debuff") || categories.has("control") || categories.has("interrupt"))) return null;
|
||||
if (rotationProfile && context.level >= 10 && hasKnownProfileDamageAction
|
||||
&& context.role !== "healer" && profileIndex < 0 && damaging) return null;
|
||||
if (isComboFinisher(ability) && (context.comboPoints < 4 || context.comboTargetId !== context.hostileTarget?.id)) return null;
|
||||
|
||||
+64
-22
@@ -5286,12 +5286,29 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
for (const recipientId of recipients) {
|
||||
const previousAuras = work.auras;
|
||||
if (effect.control && work.mobs[recipientId]?.characterDefender && work.mobs[recipientId].mechanicImmunities.includes(effect.control.kind as AzerothCoreMechanic)) continue;
|
||||
work.auras = [...applyCombatAura(previousAuras, {
|
||||
const auraResult = applyCombatAura(previousAuras, {
|
||||
definition: auraDefinition,
|
||||
sourceId: PLAYER_AURA_ENTITY_ID,
|
||||
targetId: recipientId,
|
||||
now,
|
||||
}).auras];
|
||||
});
|
||||
work.auras = [...auraResult.auras];
|
||||
const grantedAbsorb = auraResult.aura.absorbs.reduce((total, absorb) => total + absorb.remaining, 0);
|
||||
if (grantedAbsorb > 0) {
|
||||
recordCombatEventInWork(work, {
|
||||
occurredAt: now,
|
||||
kind: "absorb",
|
||||
sourceActorId: PLAYER_AURA_ENTITY_ID,
|
||||
targetActorId: recipientId,
|
||||
abilityId: ability.id,
|
||||
school: presentationSchoolForAbility(ability),
|
||||
rawAmount: grantedAbsorb,
|
||||
effectiveAmount: grantedAbsorb,
|
||||
overheal: 0,
|
||||
absorbed: grantedAbsorb,
|
||||
critical: false,
|
||||
});
|
||||
}
|
||||
applyAuraHealthTransitionInWork(work, recipientId, previousAuras, work.auras);
|
||||
if (auraDefinition.controlImmunities?.length) {
|
||||
if (
|
||||
@@ -6278,6 +6295,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
targetId: targetActorId,
|
||||
now,
|
||||
});
|
||||
const grantedAbsorb = result.aura.absorbs.reduce((total, absorb) => total + absorb.remaining, 0);
|
||||
let health = state.health;
|
||||
let maxHealth = state.maxHealth;
|
||||
const actors = { ...state.actors };
|
||||
@@ -6316,24 +6334,40 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
}
|
||||
}
|
||||
}
|
||||
let combatEvents = recordCombatEvent(state.combatEvents, {
|
||||
occurredAt: now,
|
||||
kind: "aura",
|
||||
sourceActorId,
|
||||
targetActorId,
|
||||
abilityId: ability.id,
|
||||
school: presentationSchoolForAbility(ability),
|
||||
rawAmount: 0,
|
||||
effectiveAmount: 1,
|
||||
overheal: 0,
|
||||
absorbed: 0,
|
||||
critical: false,
|
||||
});
|
||||
if (grantedAbsorb > 0) {
|
||||
combatEvents = recordCombatEvent(combatEvents, {
|
||||
occurredAt: now,
|
||||
kind: "absorb",
|
||||
sourceActorId,
|
||||
targetActorId,
|
||||
abilityId: ability.id,
|
||||
school: presentationSchoolForAbility(ability),
|
||||
rawAmount: grantedAbsorb,
|
||||
effectiveAmount: grantedAbsorb,
|
||||
overheal: 0,
|
||||
absorbed: grantedAbsorb,
|
||||
critical: false,
|
||||
});
|
||||
}
|
||||
set({
|
||||
auras: result.auras,
|
||||
health,
|
||||
maxHealth,
|
||||
actors,
|
||||
combatEvents: recordCombatEvent(state.combatEvents, {
|
||||
occurredAt: now,
|
||||
kind: "aura",
|
||||
sourceActorId,
|
||||
targetActorId,
|
||||
abilityId: ability.id,
|
||||
school: presentationSchoolForAbility(ability),
|
||||
rawAmount: 0,
|
||||
effectiveAmount: 1,
|
||||
overheal: 0,
|
||||
absorbed: 0,
|
||||
critical: false,
|
||||
}),
|
||||
combatEvents,
|
||||
});
|
||||
if (companionActor) mirrorCompanionActor(companionActor);
|
||||
return true;
|
||||
@@ -8330,13 +8364,13 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
if (
|
||||
summon.health <= 0
|
||||
|| summon.controlledUntil > now
|
||||
|| summon.nextActionAt > now
|
||||
|| summon.globalCooldownEndsAt > now
|
||||
|| summon.cast
|
||||
|| summon.attackDamage <= 0
|
||||
) return summon;
|
||||
const readyToAct = summon.nextActionAt <= now && summon.globalCooldownEndsAt <= now;
|
||||
const summonAbility = abilityById(summon.sourceAbilityId);
|
||||
if (summon.kind === "totem" && summonAbility?.effects.some((effect) => effect.kind === "heal" || effect.kind === "hot")) {
|
||||
if (!readyToAct) return summon;
|
||||
const partyMembers = usePartyStore.getState().members;
|
||||
const injured = [
|
||||
{ id: PLAYER_AURA_ENTITY_ID, health: work.health, maxHealth: work.maxHealth, position: state.playerPosition },
|
||||
@@ -8374,12 +8408,14 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
}
|
||||
return { ...summon, nextActionAt: now + Math.max(500, summon.pulseIntervalMs) };
|
||||
}
|
||||
const targetId = summon.targetId && !work.mobs[summon.targetId]?.dead
|
||||
const targetId = summon.targetId && work.mobs[summon.targetId] && !work.mobs[summon.targetId].dead
|
||||
? summon.targetId
|
||||
: state.selectedTargetId && !work.mobs[state.selectedTargetId]?.dead
|
||||
: state.selectedTargetId && work.mobs[state.selectedTargetId] && !work.mobs[state.selectedTargetId].dead
|
||||
? state.selectedTargetId
|
||||
: Object.values(work.mobs).find((mob) => mob.engaged && !mob.dead)?.id ?? null;
|
||||
if (!targetId) return { ...summon, nextActionAt: now + Math.max(500, summon.pulseIntervalMs) };
|
||||
if (!targetId) return readyToAct
|
||||
? { ...summon, nextActionAt: now + Math.max(500, summon.pulseIntervalMs) }
|
||||
: summon;
|
||||
const targetPosition = getMobPosition(targetId);
|
||||
let position = summon.position;
|
||||
if (targetPosition) {
|
||||
@@ -8392,7 +8428,8 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
position = projectCombatGroundPoint(requested, step, position) ?? position;
|
||||
}
|
||||
if (
|
||||
Math.sqrt(distanceSquared(position, targetPosition)) <= (summon.kind === "totem" ? 30 : 5)
|
||||
readyToAct
|
||||
&& Math.sqrt(distanceSquared(position, targetPosition)) <= (summon.kind === "totem" ? 30 : 5)
|
||||
&& combatHasLineOfSight(position, targetPosition)
|
||||
) {
|
||||
damageMobInWork(
|
||||
@@ -8406,7 +8443,12 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
);
|
||||
}
|
||||
}
|
||||
return { ...summon, targetId, position, nextActionAt: now + Math.max(500, summon.pulseIntervalMs) };
|
||||
return {
|
||||
...summon,
|
||||
targetId,
|
||||
position,
|
||||
nextActionAt: readyToAct ? now + Math.max(500, summon.pulseIntervalMs) : summon.nextActionAt,
|
||||
};
|
||||
});
|
||||
|
||||
for (const [id, mob] of Object.entries(work.mobs)) {
|
||||
|
||||
@@ -22,7 +22,9 @@ import {
|
||||
import { createConfiguredPartyMember, usePartyStore } from "./partyStore";
|
||||
import { EMPTY_ITEM_STATS } from "./itemStats";
|
||||
import { subscribeCombatPresentation, type CombatPresentationEvent } from "./combatPresentation";
|
||||
import { subscribeCombatEvents, type StructuredCombatEvent } from "./combatEvents";
|
||||
import { abilitiesForClass } from "./abilityCatalog";
|
||||
import { runtimePartyTalentRanksForLevel } from "./partyTalentRuntime";
|
||||
|
||||
function registerEngagedMob(id = "pack:a", maxHealth = 200): void {
|
||||
useCombatStore.getState().registerMob(id, { name: id, maxHealth, xpReward: 0 });
|
||||
@@ -138,6 +140,101 @@ describe("party combat runtime", () => {
|
||||
expect(useCombatStore.getState().summons.filter((summon) => (
|
||||
summon.ownerActorId === shaman.id && summon.sourceAbilityId === feralSpirit.id
|
||||
))).toHaveLength(2);
|
||||
const beforeSummonAttack = useCombatStore.getState().mobs["feral-spirit-boss"].health;
|
||||
useCombatStore.getState().tick(0.1, 10_000);
|
||||
expect(useCombatStore.getState().mobs["feral-spirit-boss"].health).toBeLessThan(beforeSummonAttack);
|
||||
});
|
||||
|
||||
it("gives companion Hunter and Warlock pets a hostile target when summoned from self", () => {
|
||||
useCombatStore.getState().initializeCharacter({ classId: "mage", role: "damage", level: 80 });
|
||||
const hunter = createConfiguredPartyMember({
|
||||
name: "Beast Mastery",
|
||||
classId: "hunter",
|
||||
specialization: "Beast Mastery",
|
||||
specializationId: "hunter:beast-mastery",
|
||||
talentTreeId: "hunter-beast-mastery",
|
||||
role: "damage",
|
||||
}, 80, 0, "pet-target-runtime");
|
||||
const warlock = createConfiguredPartyMember({
|
||||
name: "Demonology",
|
||||
classId: "warlock",
|
||||
specialization: "Demonology",
|
||||
specializationId: "warlock:demonology",
|
||||
talentTreeId: "warlock-demonology",
|
||||
talentRanks: runtimePartyTalentRanksForLevel("warlock", "Demonology", 80),
|
||||
role: "damage",
|
||||
}, 80, 1, "pet-target-runtime");
|
||||
usePartyStore.getState().initializeParty("pet-target-runtime", 80, useCombatStore.getState().health, "damage");
|
||||
usePartyStore.setState({ members: [hunter, warlock] });
|
||||
for (const member of usePartyStore.getState().members) registerPartyRuntimePosition(member.id, [-14.5, 0, 0]);
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
registerEngagedMob("pet-runtime-boss", 100_000);
|
||||
const events: StructuredCombatEvent[] = [];
|
||||
const stop = subscribeCombatEvents((event) => events.push(event));
|
||||
for (let now = 1_000; now <= 30_000; now += 100) {
|
||||
useCombatStore.getState().tick(0.1, now);
|
||||
advanceDungeonPartyCombat(now);
|
||||
}
|
||||
stop();
|
||||
|
||||
const pets = useCombatStore.getState().summons.filter((summon) => (
|
||||
summon.ownerActorId === hunter.id || summon.ownerActorId === warlock.id
|
||||
));
|
||||
expect(new Set(pets.map((summon) => summon.ownerActorId))).toEqual(new Set([hunter.id, warlock.id]));
|
||||
expect(pets.every((summon) => summon.targetId === "pet-runtime-boss")).toBe(true);
|
||||
expect(events.filter((event) => event.kind === "damage"
|
||||
&& [hunter.id, warlock.id].includes(event.sourceActorId))).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ abilityId: "wow335-hunter-call-pet" }),
|
||||
expect.objectContaining({ abilityId: "wow335-warlock-summon-felguard" }),
|
||||
]));
|
||||
});
|
||||
|
||||
it("casts Power Word: Shield on an injured ally and grants absorb capacity", () => {
|
||||
useCombatStore.getState().initializeCharacter({ classId: "mage", role: "damage", level: 80 });
|
||||
const tank = createConfiguredPartyMember({
|
||||
name: "Tank",
|
||||
classId: "warrior",
|
||||
specialization: "Protection",
|
||||
specializationId: "warrior:protection",
|
||||
talentTreeId: "warrior-protection",
|
||||
role: "tank",
|
||||
}, 80, 0, "discipline-shield-runtime");
|
||||
const healer = createConfiguredPartyMember({
|
||||
name: "Discipline",
|
||||
classId: "priest",
|
||||
specialization: "Discipline",
|
||||
specializationId: "priest:discipline",
|
||||
talentTreeId: "priest-discipline",
|
||||
role: "healer",
|
||||
}, 80, 1, "discipline-shield-runtime");
|
||||
const priestAbilities = abilitiesForClass("priest");
|
||||
const shield = priestAbilities.find((ability) => ability.name === "Power Word: Shield")!;
|
||||
usePartyStore.getState().initializeParty("discipline-shield-runtime", 80, useCombatStore.getState().health, "damage");
|
||||
usePartyStore.setState({
|
||||
members: [
|
||||
{ ...tank, health: Math.round(tank.maxHealth * 0.5) },
|
||||
{
|
||||
...healer,
|
||||
resource: 100_000,
|
||||
maxResource: 100_000,
|
||||
resourcePools: { mana: 100_000 },
|
||||
cooldowns: Object.fromEntries(priestAbilities.filter((ability) => ability.id !== shield.id)
|
||||
.map((ability) => [ability.id, 60_000])),
|
||||
},
|
||||
],
|
||||
});
|
||||
for (const member of usePartyStore.getState().members) registerPartyRuntimePosition(member.id, [0, 0, 0]);
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
registerEngagedMob("discipline-shield-boss", 100_000);
|
||||
|
||||
advanceDungeonPartyCombat(1_000);
|
||||
|
||||
const applied = useCombatStore.getState().auras.find((aura) => (
|
||||
aura.sourceId === healer.id
|
||||
&& aura.targetId === tank.id
|
||||
&& aura.definition.name.includes("Power Word: Shield")
|
||||
));
|
||||
expect(applied?.absorbs.reduce((total, absorb) => total + absorb.remaining, 0)).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("lets a resurrected tank rejoin independently instead of taunting or casting from its checkpoint", () => {
|
||||
|
||||
@@ -972,7 +972,8 @@ function choosePartySupportIntent(
|
||||
health: candidate.health,
|
||||
maxHealth: candidate.maxHealth,
|
||||
})),
|
||||
canUseAbility: ability => ability.effects.some(effect => ["heal", "hot", "shield"].includes(effect.kind))
|
||||
canUseAbility: ability => ability.effects.some(effect => ["heal", "hot", "shield"].includes(effect.kind)
|
||||
|| (effect.kind === "apply-aura" && Boolean(effect.aura.absorbs?.length)))
|
||||
&& (healingAbilityCanAidOthers(ability) || target.id === member.id)
|
||||
&& !(healingAbilityHasPendingHot(ability, member.id, target.id, combat.effects)
|
||||
&& (!ability.effects.some(effect => effect.kind === "heal") || target.health / target.maxHealth > 0.5))
|
||||
@@ -1162,7 +1163,12 @@ function executePartyAbility(
|
||||
comboTargetId: comboPoints > 0 ? targetId : null,
|
||||
};
|
||||
} else if (effect.kind === "summon") {
|
||||
useCombatStore.getState().summonActor(member.id, ability, effect, origin, targetId, now);
|
||||
const summonTargetId = combat.mobs[targetId] && !combat.mobs[targetId].dead
|
||||
? targetId
|
||||
: combat.selectedTargetId && combat.mobs[combat.selectedTargetId] && !combat.mobs[combat.selectedTargetId].dead
|
||||
? combat.selectedTargetId
|
||||
: Object.values(combat.mobs).find((mob) => mob.engaged && !mob.dead)?.id ?? null;
|
||||
useCombatStore.getState().summonActor(member.id, ability, effect, origin, summonTargetId, now);
|
||||
} else if (effect.kind === "pet" && ["summon", "call", "resurrect", "tame"].includes(effect.action)) {
|
||||
const summonEffect: Extract<AbilityEffect, { kind: "summon" }> = {
|
||||
kind: "summon",
|
||||
@@ -1170,7 +1176,12 @@ function executePartyAbility(
|
||||
coefficient: 0.35,
|
||||
durationMs: Number.MAX_SAFE_INTEGER - now,
|
||||
};
|
||||
useCombatStore.getState().summonActor(member.id, ability, summonEffect, origin, targetId, now);
|
||||
const summonTargetId = combat.mobs[targetId] && !combat.mobs[targetId].dead
|
||||
? targetId
|
||||
: combat.selectedTargetId && combat.mobs[combat.selectedTargetId] && !combat.mobs[combat.selectedTargetId].dead
|
||||
? combat.selectedTargetId
|
||||
: Object.values(combat.mobs).find((mob) => mob.engaged && !mob.dead)?.id ?? null;
|
||||
useCombatStore.getState().summonActor(member.id, ability, summonEffect, origin, summonTargetId, now);
|
||||
} else if (effect.kind === "movement") {
|
||||
const targetPosition = combat.mobs[targetId] ? getMobPosition(targetId) : partyFriendlyPosition(targetId);
|
||||
if (targetPosition && effect.movement !== "knockback" && effect.toward !== "away") {
|
||||
|
||||
@@ -117,6 +117,16 @@ describe("WoW 3.3.5 executable effect mapping", () => {
|
||||
expect.objectContaining({ coefficient: 1.8 }),
|
||||
);
|
||||
expect(effectsOfKind(byId("wow335-warrior-execute"), "heal")).toEqual([]);
|
||||
expect(effectsOfKind(byId("wow335-warrior-bladestorm"), "dot")).toEqual([
|
||||
expect.objectContaining({ coefficient: 0.6, ticks: 6, intervalMs: 1_000 }),
|
||||
]);
|
||||
expect(byId("wow335-warrior-bladestorm")).toMatchObject({ target: "self", radius: 8 });
|
||||
expect(effectsOfKind(byId("wow335-paladin-hammer-of-the-righteous"), "damage")).toEqual([
|
||||
expect.objectContaining({ coefficient: 1.2 }),
|
||||
]);
|
||||
expect(effectsOfKind(byId("wow335-druid-swiftmend"), "heal")).toEqual([
|
||||
expect.objectContaining({ coefficient: 2.2 }),
|
||||
]);
|
||||
expect(effectsOfKind(byId("wow335-rogue-mutilate"), "damage")).toContainEqual(
|
||||
expect.objectContaining({ coefficient: 2 }),
|
||||
);
|
||||
|
||||
@@ -744,6 +744,11 @@ function classScriptEffects(
|
||||
// The player spell delegates its weapon hit to a triggered child spell;
|
||||
// retain the imported self-heal aura and restore the missing attack.
|
||||
case "warrior:Bloodthirst": return [{ kind: "damage", coefficient: 1.2 }];
|
||||
// The player-facing aura delegates each weapon strike to server-only child
|
||||
// spells. Model the six one-second swings as a periodic damage payload.
|
||||
case "warrior:Bladestorm": return [{
|
||||
kind: "dot", coefficient: 0.6, ticks: 6, intervalMs: 1_000, tag: "bladestorm",
|
||||
}];
|
||||
case "warrior:Bloodrage": return [{ kind: "resource", resource: "rage", amount: 20 }];
|
||||
case "warrior:Execute": return [{ kind: "execute", coefficient: 1.8 }];
|
||||
case "warrior:Charge": return [{ kind: "status", status: "stun", durationMs: 1_500 }];
|
||||
@@ -764,6 +769,9 @@ function classScriptEffects(
|
||||
case "paladin:Judgement of Justice": return [{ kind: "status", status: "root", durationMs: 10_000 }];
|
||||
case "paladin:Righteous Defense": return [{ kind: "taunt", durationMs: 3_000, maxTargets: 3 }];
|
||||
case "paladin:Hand of Salvation": return [{ kind: "threat", mode: "percent", amount: -20, recipient: "ability-target" }];
|
||||
// The DBC parent carries a one-point trigger marker; its weapon hit lives in
|
||||
// a child spell that is not present in the player-facing progression rows.
|
||||
case "paladin:Hammer of the Righteous": return [{ kind: "damage", coefficient: 1.2 }];
|
||||
case "paladin:Divine Intervention": return [{
|
||||
kind: "apply-aura",
|
||||
aura: {
|
||||
@@ -881,6 +889,9 @@ function classScriptEffects(
|
||||
triggerSpellId: 0,
|
||||
}];
|
||||
case "warlock:Inferno": return [{ kind: "summon", creatureId: 89, coefficient: 0.8, durationMs: 60_000 }];
|
||||
// Swiftmend's DBC row exposes only the one-point consume marker; supply the
|
||||
// direct heal while leaving HoT consumption out until aura choice is modeled.
|
||||
case "druid:Swiftmend": return [{ kind: "heal", coefficient: 2.2 }];
|
||||
case "druid:Pounce": return [{ kind: "dot", coefficient: 0.18, ticks: 6, intervalMs: 3_000, tag: "pounce" }];
|
||||
default: return [];
|
||||
}
|
||||
@@ -1178,6 +1189,9 @@ function effectsForRank(classId: BaseClassId, rank: Wow335Rank, abilityName: str
|
||||
|| (classId === "priest" && abilityName === "Penance"
|
||||
&& (effect.kind === "damage" || effect.kind === "heal"))
|
||||
|| (classId === "shaman" && abilityName === "Feral Spirit" && effect.kind === "summon")
|
||||
|| (classId === "warrior" && abilityName === "Bladestorm" && effect.kind === "dot")
|
||||
|| (classId === "paladin" && abilityName === "Hammer of the Righteous" && effect.kind === "damage")
|
||||
|| (classId === "druid" && abilityName === "Swiftmend" && effect.kind === "heal")
|
||||
|| (classId === "warrior" && abilityName === "Bloodrage"
|
||||
&& (effect.kind === "resource" || effect.kind === "heal"))
|
||||
))
|
||||
@@ -1351,6 +1365,7 @@ function abilityDefinition(classId: BaseClassId, chain: Wow335Chain): AbilityDef
|
||||
}
|
||||
|
||||
function abilityNameRadius(abilityName: string): number {
|
||||
if (abilityName === "Bladestorm") return 8;
|
||||
if (abilityName === "Hellfire") return 10;
|
||||
if (abilityName === "Mind Sear") return 10;
|
||||
if (abilityName === "Pestilence") return 10;
|
||||
@@ -1359,7 +1374,7 @@ function abilityNameRadius(abilityName: string): number {
|
||||
}
|
||||
|
||||
function abilityNameTarget(abilityName: string): AbilityTarget | undefined {
|
||||
if (["Divine Hymn", "Tranquility", "Holy Nova", "Hellfire", "Army of the Dead"].includes(abilityName)) return "self";
|
||||
if (["Bladestorm", "Divine Hymn", "Tranquility", "Holy Nova", "Hellfire", "Army of the Dead"].includes(abilityName)) return "self";
|
||||
if (abilityName === "Penance") return "friendly";
|
||||
if (abilityName === "Death Coil") return "hostile";
|
||||
return undefined;
|
||||
|
||||
+77
-30
@@ -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({
|
||||
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 })
|
||||
<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) => {
|
||||
setLevel(event.currentTarget.valueAsNumber);
|
||||
<label><span>Level coverage</span><select value={levelCoverage} onChange={(event) => {
|
||||
setLevelCoverage(event.currentTarget.value as LevelCoverage);
|
||||
setSelectedBuildIds([]);
|
||||
}} /></label>
|
||||
}}><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