Compare commits

...
6 Commits
Author SHA1 Message Date
phenom 79153ca40d Release Healer Man 0.1.50 2026-09-17 13:33:55 -04:00
phenom 717d12e5af Document Obsidian project memory 2026-09-17 11:36:54 -04:00
phenom 612170afd0 Release Healer Man 0.1.49 2026-09-17 09:13:15 -04:00
phenom 39425f2494 Release Healer Man 0.1.48 2026-09-16 20:58:10 -04:00
phenom 2162039b49 Release Healer Man 0.1.47 2026-09-16 20:00:13 -04:00
phenom 9ff574ac0b Release Healer Man 0.1.46 2026-09-16 14:30:58 -04:00
151 changed files with 36419 additions and 414 deletions

No files matched your search

+21 -4
View File
@@ -11,10 +11,11 @@ These rules apply to every change under this project root.
## Workspace and reference boundaries
- The active project root is `D:\Projects\HealerMan`. All implementation,
generated content, scripts, tests, builds, documentation, and validation for
this project belong here.
- `D:\Projects\wow-pvt-server-backups-2` is historical reference material only.
- The active project root is the directory containing this `AGENTS.md`
(currently `F:\Projects\HealerMan`); do not assume a fixed drive letter. All
implementation, generated content, scripts, tests, builds, documentation, and
validation for this project belong here.
- The sibling `../wow-pvt-server-backups-2` tree is historical reference material only.
Inspect it only when old-server behavior, AzerothCore data, creature IDs,
spells, encounters, maps, or assets are useful as source information.
- Never treat `wow-pvt-server-backups-2` as the active project, primary checkout,
@@ -27,6 +28,22 @@ These rules apply to every change under this project root.
structure as appropriate, and validate it through HealerMan's tests/build and
playtest workflow.
## Obsidian project memory
- The separate Healer-Man knowledge vault lives at the sibling path
`../ObsidianNotes/ObsidianVault1`. Start at
`00 Home/Healer-Man Hub.md` and follow
`40 Agent Workflows/Agent Memory Protocol.md` for broad or cross-system work.
- The code and tests in this repository remain authoritative. Check a note's
`last_verified` date and verify material claims against current code before
acting on them.
- Update the relevant vault note in the same task when a change alters feature
behavior, architectural ownership, entry points, cross-file relationships,
invariants, or required validation. Use repository-relative code paths and
keep transient investigation details in the vault's `50 Worklog/` area.
- The vault is its own Git repository. Review and commit its changes separately
from HealerMan changes; never stage one repository from the other.
## Dungeon report triage
- For any request to inspect or fix current dungeon reports, start with
+45 -2
View File
@@ -129,14 +129,17 @@ services:
healerman:
image: node:24-bookworm-slim
command: >-
sh -lc "npm ci --include=dev && npm run db:init && npm run content:publish && npm run build && npm start"
sh -lc "apt-get update && apt-get install -y --no-install-recommends python3 && rm -rf /var/lib/apt/lists/* && python3 --version && npm ci --include=dev && npm run db:init && npm run content:publish && npm run build && npm start"
environment:
BALANCE_LAB_MAX_CONCURRENT: "1"
BALANCE_LAB_MAX_QUEUED: "8"
BALANCE_LAB_PYTHON: "python3"
CORS_ORIGINS: "https://iwanttoheal.phenomrom.com,capacitor://localhost,http://localhost,https://localhost"
CONTENT_DIR: /app/content
DATA_DIR: /app/runtime-data
HOST: 0.0.0.0
HEALER_MAN_ENABLE_SHELVED_MODES: "0"
NODE_OPTIONS: "--max-old-space-size=4096"
NODE_OPTIONS: "--max-old-space-size=8192"
PORT: "4173"
SESSION_TTL_DAYS: "30"
STATIC_DIR: /app/dist
@@ -162,6 +165,46 @@ new or changed content objects, builds the compact required-content browser
bundle, and starts the combined static/API/content server. Existing content
objects are reused.
### Required GM Balance Lab server dependency
Balance Lab uses the repository's Python controller to orchestrate the live
TypeScript combat runtime. The canonical YAML above therefore installs Python
3 before installing/building the app and explicitly selects `python3` with
`BALANCE_LAB_PYTHON`.
If an existing app reports `Could not start python3`, edit that app's YAML to
use this command:
```yaml
command: >-
sh -lc "apt-get update && apt-get install -y --no-install-recommends python3 && rm -rf /var/lib/apt/lists/* && python3 --version && npm ci --include=dev && npm run db:init && npm run content:publish && npm run build && npm start"
```
and add or correct these environment values:
```yaml
BALANCE_LAB_PYTHON: "python3"
BALANCE_LAB_MAX_CONCURRENT: "1"
BALANCE_LAB_MAX_QUEUED: "8"
```
The app stores job reports in
`/app/runtime-data/balance-lab/jobs/<job-id>/`, so the existing persistent
runtime-data mount also preserves generated reports and the GM app's run
history across container/server restarts. Each directory includes `job.json`
metadata used to rebuild the newest 100 history entries; do not exclude those
files when migrating the runtime-data dataset. An interrupted queued/running
job returns as failed after restart, while any job with a complete
`suite-report.json` returns as complete. Keep concurrency at one
unless the server has enough spare CPU for multiple full combat sweeps. All
Balance Lab routes independently require a server-authorized GM account.
After saving the YAML, restart the TrueNAS app and watch its startup log for a
line beginning with `Python 3.`. Reopen Balance Lab; the setup panel must show
**Server runtime ready** before **Run on server** becomes available. The server
now performs this preflight before queueing, so a missing runtime cannot create
another doomed job.
After startup, test on the LAN:
```sh
+125
View File
@@ -3436,3 +3436,128 @@ Vite's existing large-chunk advisory remains informational.
`playtest-artifacts/boss-spell-effects-black-stalker/results.json` and
`03-empty-room.png`. Headless automation verified audio node selection and
fallback behavior but did not perform a subjective loudspeaker/listening test.
## Boss Testing random follow-up (2026-09-16)
- The Boss Testing review shown after victory now includes **Fight another
random boss**. It excludes the boss just completed, preserves the active party
role, downloads missing content through the existing verified pack flow, and
enters the new testing room directly. Review controls remain disabled while
the next boss is being prepared.
- All 15 focused Boss Testing, preparation, review, and display-snapshot tests
passed. The production build, asset audits, and release-bundle validation
passed.
- Production Edge defeated Lady Anacondra, displayed the new victory action,
selected Varos Cloudstrider, and entered his active testing room without
returning to the menu. No browser runtime errors occurred. Evidence is in
`playtest-artifacts/boss-testing/06-victory-review.png`,
`06b-another-random-boss.png`, and `results.json`.
## Eck the Ferocious visibility and healer pressure (2026-09-16)
- Eck's Oracle GLB used a bind-pose-derived `groundOffset` of `11.859`, which
lifted the animated body out of the gameplay camera while the encounter ring
remained visible. The Gundrak adapter now keeps the original model but uses a
reviewed zero ground offset and a seven-unit nameplate height.
- Eck no longer repeats Spring Init and Berserk as 135% damage attacks. The
runtime now follows `boss_eck.cpp`: Bite opens at 5 seconds and repeats every
8–12 seconds, Spit opens in 10–37 seconds and repeats every 8–12 seconds,
Spring targets one random party member every 10–24 seconds, and Berserk is a
one-time non-damaging aura at 90 seconds. The original spell IDs remain bound
so release/impact visual and sound presentation plus mechanic indicators are
preserved; Spring also retains a target-circle warning.
- The reusable `npm run dungeon:boss-pressure:audit` command compares all WoW
bosses at level 80 over 60 seconds and writes the complete ranking plus
per-attack contribution to `dungeon-pipeline/WOW_BOSS_PRESSURE_REPORT.json`
and a top-30 summary to the adjacent Markdown report.
- Production Edge rendered Eck, his nameplate, health bar, mechanic timers, and
target marker with no browser errors. Evidence is in
`playtest-artifacts/eck-final-visual/03-empty-room.png` and `results.json`.
## Balance Lab run history and analytics (2026-09-16)
- Server Balance Lab jobs now write private `job.json` metadata, restore the
newest 100 runs after a restart, import completed pre-history reports, and
mark interrupted work failed instead of losing it from the GM screen.
- The selected completed run now has five in-app graphs for boss-to-party
pressure, party-to-boss damage, kill time, class/spec boss damage, and
class/spec damage taken. Boss, class/spec, and party tabs retain every row and
add source, role, search, metric, and direction filtering plus wipe, defeat,
death, healing, absorb, and tuning-outlier context.
- Seven focused server job tests passed, including restart recovery, legacy
import, and interrupted-run behavior; the combined Balance Lab/server set
passed nine tests. The 8 GB TypeScript check and production Vite build passed.
- Production Edge automation submitted a real Eck simulation through the GM
UI, opened the generated JSON in the dashboard, exercised all tables and
filters, and verified the 480×900 layout with no browser errors. Evidence is
under `playtest-artifacts/balance-lab/`, especially
`04-run-analytics.png` and `05-mobile-analytics.png`.
## Balance Lab class/spec ability breakdown (2026-09-16)
- Each simulated member now exports the full learned, level-appropriate,
executable ability list in addition to observed combat events. Suite reports
aggregate every class/spec ability with active-run coverage, usage rate,
casts, hits, casts/minute, raw/effective damage, DPS, damage share, damage per
hit, healing, overheal, absorbs, AI categories, and cooldowns. Learned
abilities that produce no events remain visible as `available · unused`.
- The GM dashboard adds an **Abilities** tab with class/spec, source, role,
usage-state, free-text, metric, and direction filters; an unused-damage view;
a damage-contribution graph; a complete horizontally scrollable table; and a
downloadable `class_abilities_across_parties.csv` artifact.
- Six Python aggregation tests, nine server tests, the 8 GB TypeScript check,
and the production Vite build passed. Production Edge automation completed a
real Eck run, verified 11 downloadable suite artifacts, exercised the unused
ability filter, and passed desktop plus 480×900 overflow checks with no
browser errors. Evidence is in
`playtest-artifacts/balance-lab/04b-ability-breakdown.png` and
`playtest-artifacts/balance-lab/06-mobile-abilities.png`.
## Balance Lab server Python preflight (2026-09-16)
- The production failure was a deployment mismatch: the TrueNAS app used the
canonical Node-only startup command even though server Balance Lab jobs need
the Python controller. The canonical YAML in both `DEPLOYMENT.md` and the
release-runbook appendix now installs `python3`, prints its version during
startup, sets `BALANCE_LAB_PYTHON=python3`, and includes the queue limits.
- A GM-authenticated runtime endpoint now probes the configured executable with
`--version`. The Balance Lab setup shows the resolved Python 3 version, and a
missing runtime disables **Run on server**. POST requests independently
repeat the preflight and return a dedicated 503 without creating a failed job.
- Nine focused Balance Lab service tests plus two server integration tests, the
8 GB TypeScript check, and the production Vite build passed. Production Edge
displayed `Server runtime ready · Python 3.14.0`, submitted a real Eck run,
generated all reports, and completed desktop/mobile QA without browser errors.
Evidence is in `playtest-artifacts/balance-lab/02-server-job-config.png`.
## Party melee animation cadence (2026-09-16)
- Party character animation now follows authoritative combat-presentation
phases instead of treating every `nextActionAt` retry timestamp as an attack.
Auto-attacks produce one ordinary attack event, while real abilities retain
their cast-start, release, and cancel identities.
- Warrior and Paladin auto-attacks now prefer the normal one-handed/two-handed
attack clips before special clips. Special/spinning clips remain available to
explicitly matched abilities rather than every weapon swing.
- The focused combat-animation suite passed 10 tests, all 47 avatar tests
passed, and the production build plus texture/performance and release-bundle
audits passed. The live level-three Stockades pass completed without browser
errors; all five bodies and their equipped weapon/staff silhouettes rendered
intact. Evidence is in
`playtest-artifacts/dungeon-health-scaling/stockades-level-three.png`.
## Balance Lab healing done and received analytics (2026-09-17)
- Raw Balance Lab member metrics now attribute effective healing to both the
caster and the party target. Party and suite reports expose healing done and
received per active second plus absolute totals; self-heals count on both
sides of the attribution.
- The GM overview adds separate **Healing done by class/spec** and **Healing
received by class/spec** graphs. The class/spec table can display and sort
both rates and totals. Older saved reports remain readable but show no target
healing until they are rerun.
- All six Python controller tests and the production build passed. A live
one-seed, 15-second Eck the Ferocious run recorded the Holy Paladin's 867
effective healing as 57.8 HPS done and the Blood Death Knight's same 867 as
57.8 HPS received. A matching automatic-suite run verified the new JSON and
CSV fields under `playtest-artifacts/balance-lab-healing-suite/`.
+10
View File
@@ -41,6 +41,16 @@ npm test
npm run build
```
Boss and party balance simulations, including exact, random, and automatic
multi-party WoW, CoA, and RoM sweeps with complete per-boss/member/ability reports, are documented in
[`docs/BALANCE_LAB.md`](docs/BALANCE_LAB.md). Start the guided tool with
`npm run balance:lab`, or use the server-powered **Balance Lab** beneath GM
Tools. The GM screen keeps previous runs across server restarts and provides
filterable in-app boss, class/spec, party, pressure, damage, and kill-time
graphs/tables. The Abilities tab retains every learned/available ability,
including zero-use rows, so missing rotations can be compared with casts, hits,
damage share, healing, and absorbs.
Manastorms, Arenas/PvP, and their GM tools are shelved in normal browser and
Android builds. Their source and saved data remain intact. Use
`npm run dev:shelved` or `npm run build:shelved` for maintenance builds, and
+5 -2
View File
@@ -651,14 +651,17 @@ services:
healerman:
image: node:24-bookworm-slim
command: >-
sh -lc "npm ci --include=dev && npm run db:init && npm run content:publish && npm run build && npm start"
sh -lc "apt-get update && apt-get install -y --no-install-recommends python3 && rm -rf /var/lib/apt/lists/* && python3 --version && npm ci --include=dev && npm run db:init && npm run content:publish && npm run build && npm start"
environment:
BALANCE_LAB_MAX_CONCURRENT: "1"
BALANCE_LAB_MAX_QUEUED: "8"
BALANCE_LAB_PYTHON: "python3"
CORS_ORIGINS: "https://iwanttoheal.phenomrom.com,capacitor://localhost,http://localhost,https://localhost"
CONTENT_DIR: /app/content
DATA_DIR: /app/runtime-data
HOST: 0.0.0.0
HEALER_MAN_ENABLE_SHELVED_MODES: "0"
NODE_OPTIONS: "--max-old-space-size=4096"
NODE_OPTIONS: "--max-old-space-size=8192"
PORT: "4173"
SESSION_TTL_DAYS: "30"
STATIC_DIR: /app/dist
+362
View File
@@ -0,0 +1,362 @@
# Boss and party balance lab
`scripts/balance_lab.py` runs a chosen World of Warcraft, Conquest of Azeroth,
or Runes of Magic five-player party against the authored boss catalog. It is an
interactive Python controller and report generator, not a second combat model.
The actual fights execute through `scripts/balance-lab/runtime.ts`, which imports
the game's live TypeScript combat store, party AI, abilities, talents, gear,
threat, mitigation, healing, boss scaling, and enemy attacks.
This split is intentional. Balance formulas stay in the game runtime. Python
owns selection, repeatable run configuration, aggregation, CSV/Markdown/HTML
output, and outlier flags. Do not copy combat coefficients into the Python
script.
## Quick start
Install the normal project dependencies, then start the guided prompt:
```powershell
npm run balance:lab
```
The prompt selects:
1. one random party, five manual members, or an automatic multi-party sweep;
2. a WoW, CoA, or RoM party catalog; automatic mode can select all three;
3. a level from 1 through 80;
4. how many unique 1-tank/1-healer/3-damage configurations to generate per source;
5. all bosses, WoW bosses, RoM bosses, or a searchable multi-boss selection;
6. how many deterministic simulations to run for every party/boss pairing; and
7. the maximum duration of each fight.
The active member is driven by the shared benchmark policy while the other four
use the live companion AI. The healer is the default active member. Use
`--active-slot` to change it.
## GM app and server execution
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, unique parties per source, simulations per party/boss, duration, random
seed, boss category, and a searchable multi-boss picker.
**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:
```text
DATA_DIR/balance-lab/jobs/<job-id>/
```
The browser polls lightweight status records; it does not execute combat ticks.
Closing the browser does not stop a running server job. Completed jobs and their
metadata remain in **Run history** across server restarts, with the newest run
selected automatically. The history shows the most recent 100 jobs and can be
searched or filtered by status. A job that was still queued or running when the
server stopped is restored as failed (it cannot safely resume a half-finished
deterministic sweep) and can be rerun.
Each job directory contains a private `job.json` status record alongside its
report artifacts. On startup, `server/balance-lab-jobs.mjs` rebuilds history from
those records and treats a directory containing `suite-report.json` as complete.
Completed server-job directories from the pre-history implementation are
imported from `suite-report.json` plus `suite-config.json`, then receive a new
`job.json`; their requester is shown as `unknown (legacy run)` because that was
not previously recorded.
Do not add `job.json` to the artifact allow-list: it is server state, not a GM
download. The standalone `npm run balance:lab` path remains the way to spend the
local development device's CPU.
## In-app analysis and previous runs
Select any completed entry in **Run history**, then choose **View graphs and
filterable data**. The app reads that run's `suite-report.json` through the
authenticated artifact endpoint and caches it for the current screen session.
The dashboard includes:
- boss damage to the whole party (incoming DTPS);
- party damage applied directly to each boss;
- average successful kill time;
- boss DPS and damage taken for every class/specialization;
- effective healing done and effective healing received per second for every
class/specialization;
- complete boss tables with pressure versus median, total incoming damage,
defeat/wipe rates, deaths, and health;
- complete class/spec tables with total and boss DPS, healing done/received per
second, DTPS, absolute damage taken, healing done/received and absorb totals,
deaths, and source/role-median tuning flags; and
- a complete Abilities tab with learned/available abilities (including zero-use
rows), exact runtime IDs, AI categories, active-run coverage, casts, hits,
casts per minute, effective/raw damage, DPS, damage share, damage per hit,
healing, overheal, absorbs, and cooldown; and
- complete party-composition tables with output, incoming pressure, outcomes,
and deaths.
The overview graphs intentionally show at most the 15 highest visible values so
labels remain readable. They never discard report data: the Bosses, Class/spec,
and Parties tabs retain every matching row. Search, party-source, boss-source,
role, metric, and sort-direction controls filter the display without modifying
the saved report.
Server requirements:
- the normal checkout and development dependencies must be present because the
combat bridge uses `vite-node`;
- Python 3 must be installed, or `BALANCE_LAB_PYTHON` must name its executable;
and
- the signed-in account must have the server-issued `gm` role.
The GM screen calls `/api/gm/balance-lab/runtime` when it opens and shows the
resolved Python version. If Python 3 is unavailable, **Run on server** is
disabled and no job is queued. On the canonical TrueNAS deployment, the service
command installs `python3`, prints `python3 --version` during startup, and sets
`BALANCE_LAB_PYTHON=python3`; use the exact YAML in `DEPLOYMENT.md` rather than
the older Node-only command.
`BALANCE_LAB_MAX_CONCURRENT` defaults to `1` and is capped at four.
`BALANCE_LAB_MAX_QUEUED` defaults to eight and is capped at 50. Raising
concurrency permits parallel server processes but can make both the game server
and simulations less responsive. Requests are capped before launch, 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.
## Repeatable command-line runs
List exact party build IDs at a level:
```powershell
python scripts/balance_lab.py --source wow --level 60 --list-members
```
List boss keys:
```powershell
python scripts/balance_lab.py --source wow --level 60 --list-bosses
```
Run a random party against every authored boss:
```powershell
python scripts/balance_lab.py --source coa --level 60 --party-mode random --party-seed 42 --seeds 1,2,3 --boss-category all
```
Generate 25 unique WoW parties and run five simulations per party against all
bosses:
```powershell
python scripts/balance_lab.py `
--source wow `
--level 60 `
--party-mode automatic `
--party-configurations 25 `
--simulations-per-party 5 `
--boss-category all
```
Generate ten parties for each of WoW, CoA, and RoM, but test only RoM bosses:
```powershell
python scripts/balance_lab.py `
--source all `
--level 60 `
--party-mode automatic `
--party-configurations 10 `
--simulations-per-party 3 `
--boss-category rom
```
Automatically test a selected group of bosses:
```powershell
python scripts/balance_lab.py `
--source coa `
--party-mode automatic `
--party-configurations 20 `
--simulations-per-party 5 `
--boss "gundrak/entry-29932" `
--boss "utgarde-keep/entry-23954"
```
Run an exact party against Eck only:
```powershell
python scripts/balance_lab.py `
--source wow `
--level 40 `
--party-mode manual `
--member "warrior:Protection" `
--member "priest:Holy" `
--member "mage:Fire" `
--member "rogue:Combat" `
--member "hunter:Marksmanship" `
--active-slot 2 `
--boss "gundrak/entry-29932" `
--seeds 1,2,3 `
--duration 60
```
Manual selectors accept an exact generated build ID or a unique
`class-id:specialization`/`class-name:specialization` value. Repeat `--boss` to
run any selected subset. Omit it to run every boss in the selected boss
category. `--seeds 4,8,15` is an advanced exact-seed override for
`--simulations-per-party`.
## Output
Each run creates `artifacts/balance-lab/<timestamp>/` unless `--output-dir` is
provided.
| File | Contents |
| --- | --- |
| `report.html` | Searchable and sortable tables containing every selected member and every boss. |
| `report.md` | Human-readable complete party and boss tables. |
| `report.json` | Aggregated machine-readable report. |
| `raw-runs.json` | Lossless per-seed, per-boss, per-member, and per-ability runtime results. |
| `bosses.csv` | One aggregate row for every boss, ordered by party damage taken per second. |
| `party_members.csv` | One aggregate row for every selected party member. |
| `member_by_boss.csv` | Every party member against every boss. |
| `member_abilities.csv` | Every observed player/companion ability for every run. |
| `boss_abilities.csv` | Every observed boss ability for every run. |
| `runs.csv` | One row per boss and deterministic seed. |
| `config.json` | Exact rerunnable selection. |
| `catalog.json` | Party and boss choices shown to the controller. |
No report is truncated to a top-N list.
Automatic mode creates a complete report directory for every generated party
under `parties/<party-id>/` and adds these suite-level files:
| File | Contents |
| --- | --- |
| `suite-report.html` | Searchable/sortable tables for every tested class/spec, party configuration, and boss. |
| `suite-report.md` / `suite-report.json` | Complete human- and machine-readable suite summaries. |
| `party_configurations.csv` | Every generated party, its full composition, party DPS, DTPS, deaths, and outcomes. |
| `class_specs_across_parties.csv` | Every class/spec observed across every configuration, compared with its source/role median. |
| `class_abilities_across_parties.csv` | Every learned/available and observed class/spec ability, including unused rows, usage frequency, damage contribution, healing, and absorbs. |
| `class_spec_by_boss.csv` | Every observed class/spec broken down against every boss it fought. |
| `bosses_across_parties.csv` | Every boss aggregated across all generated parties. |
| `boss_by_party.csv` | Every boss and party-configuration pairing. |
| `suite_runs.csv` | Every individual seed run with its party configuration ID. |
| `suite-config.json` | The exact automatic-generation seed, counts, filters, and generated party build IDs. |
## Metrics and interpretation
- Boss `incomingDtps` is effective damage received by all five members divided
by active fight time. Damage from summoned hostile actors is included.
- `averageIncomingDamage` is total healer workload and therefore includes fight
length. Read it beside DTPS, wipe rate, deaths, and time to defeat.
- Member `dps` includes damage to the boss and adds. `bossDps` includes only
direct boss damage. Suite class/spec `dtps` is damage taken per active second;
`totalDamageTaken` is the absolute healer/survivability burden across every
included run.
- Member `hps` is effective healing done per active second and excludes
overhealing. `hpsReceived` is effective healing received per active second,
attributed from each healing event's target (including self-heals).
`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.
- Ability `activeRuns` counts simulations with at least one cast, hit, damage,
healing, overheal, or absorb event. `usageRate` divides that by every run for
that build; `damageShare` divides the ability's effective damage by the
class/spec's total effective damage. `available · unused` means the ability
was learned and executable at the tested level/talent setup but emitted no
combat event. This is a review signal, not proof of an AI defect: resurrection,
execute, interrupt, dispel, emergency, and long-cooldown abilities can be
legitimately unused when their condition never occurs.
- Companion AI currently applies several instant attacks directly and therefore
may emit hits/damage without a separate cast event. Use **Active runs** and
**Hits** as the authoritative usage indicators; use **Casts** and **Casts/min**
as additional detail, especially for the active-player benchmark controller.
- Boss pressure flags in a party report compare DTPS with the non-zero median
boss for that exact party. Suite boss flags compare across all generated
parties. Suite class/spec flags compare DPS with every observed spec from the
same source and role. `high` begins at 1.25 times the median and `very
high` at 1.5; `low` begins at 0.75 and `very low` at 0.5.
- Flags are triage signals, not automatic tuning decisions. A boss can have high
DTPS but low total damage because it dies quickly. A member can have low boss
DPS because it spends time healing, tanking, or damaging adds.
The isolated boss room deliberately removes campaign-final health promotion so
all bosses use the same testing-room durability rule at the chosen level.
Runtime IDs include the simulation seed, which deterministically varies the
game's stable target and cooldown rolls.
## Coverage and limitations
The selectable roster includes every authored WoW and CoA specialization plus
every supported RoM class-role pair. Existing generated party talent plans are
used when available. Missing WoW plans are legally allocated through their
matching live talent tree; CoA builds allocate the chosen specialization tree
and class tree through the live essence/prerequisite rules.
Headless simulation executes combat-store boss attacks and their authored
damage, healing, control, aura, summon, threat, and timing effects. It does not
mount the Three.js scene, play visuals or sounds, or run scene-owned world
objects. Encounter rules that require movement through the original dungeon,
clicking an object, or a renderer-owned trigger require a live Boss Testing
playtest in addition to this numeric audit.
One selected member must occupy the active player slot because the production
runtime distinguishes the player from four companions. The report marks that
member. When a suspected class outlier could be policy-sensitive, rerun with a
different `--active-slot`; do not compare active and companion results without
noting that control-policy difference.
## Code map
- `scripts/balance_lab.py`: prompts, command-line parsing, random/manual/automatic
party selection, searchable boss selection, process orchestration, aggregates,
flags, and all party/suite report formats.
- `scripts/balance-lab/runtime.ts`: live headless fight setup, controlled clock,
exact party construction, boss execution, and lossless event attribution.
- `src/ui/BalanceLabScreen.tsx`: GM form, searchable boss selection, persistent
run-history selection, progress display, report loading, and authenticated
artifact opening/downloading.
- `src/ui/BalanceLabResults.tsx`: in-app graphs, report filters, sorting, summary
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/server.mjs`: GM-authenticated runtime-preflight, job, status, and
artifact HTTP routes.
- `src/game/benchmark/benchmarkBuilds.ts`: selectable spec builds and legal
talent allocation. The older canonical cohort remains unchanged.
- `src/game/partyStore.ts`: `createConfiguredPartyMember`, the narrow factory
that preserves production stats, equipment, resources, and appearance for an
exact companion.
- `src/game/bossTesting.ts`: the authoritative all-boss catalog and isolated
room construction.
When extending the tool, add fields to `raw-runs.json` first, aggregate them in
Python second, and document the metric here. Keep formula ownership in `src/game`
and keep reports complete rather than introducing top-N filtering.
## Validation
For tool changes, run at minimum:
```powershell
python -m py_compile scripts/balance_lab.py
python -m unittest scripts.test_balance_lab
node --test server/balance-lab-jobs.test.mjs server/server.test.mjs
python scripts/balance_lab.py --source wow --level 20 --party-mode automatic --party-configurations 2 --boss "gundrak/entry-29932" --simulations-per-party 1 --duration 5 --output-dir artifacts/balance-lab-smoke
npm test -- --run src/game/benchmark src/game/partyStore.test.ts src/game/partyRuntime.test.ts
npm run build
npm run balance:lab:playtest
npm run release:check
```
The Playwright smoke test uses an isolated temporary database, logs in as a GM,
checks that Balance Lab is below GM Tools, runs one real Eck server simulation,
verifies all eleven suite artifacts, exercises the overview and ability graphs
plus every complete table, checks history, role, class/spec, and unused-ability
filtering, and captures desktop/mobile screenshots
under `playtest-artifacts/balance-lab/`. The server unit tests separately restart
the job service and prove that completed history is restored while interrupted
work is marked failed. Use a longer all-boss run before making production tuning
changes.
+65
View File
@@ -0,0 +1,65 @@
# Combat rotation matrix
Healer Man uses AzerothCore playerbots commit `531282e` as a read-only behavioral reference. The implementation is an independent TypeScript policy over Healer Man's executable catalog, not a port of playerbots C++.
| Concern | Playerbots behavior translated into Healer Man |
| --- | --- |
| Core rotation | One deterministic priority profile for each of the 31 Wrath specializations. |
| Maintenance | DoTs, debuffs, buffs, pets, and summons are refreshed only after their existing effect ends. |
| Execute | Execute-category abilities move ahead of the normal profile below 20% target health. |
| Multiple targets | Cleave gains value at two hostiles; area damage moves ahead at three or more. |
| Healing | Critical, medium, and light triage bands begin below 30%, 60%, and 90% health. Area healing requires three injured allies. |
| Threat | Tanks interrupt first and taunt a hostile that is not attacking them; non-tanks drop dangerous threat before continuing their profile. |
| Control | Interrupts are reactive. Long crowd control is used only when an encounter requests control. |
| Defense | Mitigation is used below 55% actor health and receives emergency priority below 35%. |
| Resources | Low-resource generators are favored below 30%; spenders still have to pass runtime cost and rune validation. |
| 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. |
CoA and Runes of Magic builds use the same semantic rules when no classic profile applies. Runes of Magic candidates include primary, secondary-general, and primary/secondary elite skills through `abilitiesForCharacter`.
## Classic specialization profiles
The leftmost available action wins after reactive rules, learned-talent gating, resource checks, maintenance checks, execute/AoE adjustments, and major-cooldown conservation. Maintenance entries are intentionally listed first; an active aura or pet removes that entry from consideration.
| Class | Specialization | Maintenance and core priority |
| --- | --- | --- |
| Warrior | Arms | Battle Stance; Mortal Strike; Rend; Overpower; Execute; Slam; Heroic Strike |
| Warrior | Fury | Berserker Stance; Bloodthirst; Whirlwind; Execute; Sunder Armor; Heroic Strike |
| Warrior | Protection | Defensive Stance; 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 |
| Paladin | Protection | Devotion Aura; Blessing of Might; Hammer of the Righteous; Shield of Righteousness; Holy Shield; Consecration; Judgement of Wisdom |
| Paladin | Retribution | Retribution Aura; Blessing of Might; Divine Storm; Crusader Strike; Judgement of Wisdom; Consecration; Exorcism; Hammer of Wrath |
| Hunter | Beast Mastery | Aspect of the Dragonhawk/Hawk; Call Pet; Kill Command; Bestial Wrath; Serpent Sting; Arcane Shot; Multi-Shot; Steady Shot |
| Hunter | Marksmanship | Aspect of the Dragonhawk/Hawk; Call Pet; Kill Shot; Chimera Shot; Aimed Shot; Serpent Sting; Arcane Shot; Steady Shot |
| Hunter | Survival | Aspect of the Dragonhawk/Hawk; Call Pet; Kill Shot; Explosive Shot; Black Arrow; Serpent Sting; Aimed Shot; Arcane Shot; Steady Shot |
| Rogue | Assassination | Envenom; Rupture; Mutilate; Eviscerate; Sinister Strike |
| Rogue | Combat | Killing Spree; Blade Flurry; Eviscerate; Sinister Strike |
| Rogue | Subtlety | Shadow Dance; Rupture; Eviscerate; Hemorrhage; Backstab |
| Priest | Discipline | Inner Fire; Penance; Power Word: Shield; Flash Heal; Greater Heal; Renew |
| Priest | Holy | Inner Fire; Circle of Healing; Prayer of Mending; Flash Heal; Greater Heal; Renew |
| Priest | Shadow | Shadowform; Inner Fire; Vampiric Touch; Devouring Plague; Shadow Word: Pain; Mind Blast; Mind Flay; Shadow Word: Death |
| Death Knight | Blood | Frost Presence (Blood Presence fallback); Heart Strike; Death Strike; Icy Touch; Plague Strike; Death Coil |
| Death Knight | Frost | Blood Presence; Howling Blast; Obliterate; Frost Strike; Icy Touch; Plague Strike |
| Death Knight | Unholy | Unholy Presence (Blood Presence fallback); Raise Dead; Scourge Strike; Death and Decay; Plague Strike; Icy Touch; Death Coil |
| Shaman | Elemental | Flametongue Weapon (Rockbiter fallback); Lightning Shield; Flame Shock; Lava Burst; Chain Lightning; Lightning Bolt |
| Shaman | Enhancement | Windfury Weapon (Rockbiter fallback); Lightning Shield; Feral Spirit; Stormstrike; Lava Lash; Earth Shock; Lightning Bolt |
| Shaman | Restoration | Water Shield; Rockbiter Weapon; Lightning Shield; Riptide; Chain Heal; Lesser Healing Wave; Healing Wave; Earth Shield |
| Mage | Arcane | Mage Armor; Arcane Barrage; Arcane Blast; Arcane Missiles |
| Mage | Fire | Molten Armor; Living Bomb; Pyroblast; Fireball; Scorch; Fire Blast |
| Mage | Frost | Frost Armor; Deep Freeze; Frostbolt; Ice Lance; Cone of Cold |
| Warlock | Affliction | Fel Armor; Felhunter; Haunt; Unstable Affliction; Corruption; Curse of Agony; Drain Soul; Shadow Bolt |
| Warlock | Demonology | Fel Armor; Felguard; Metamorphosis; Immolate; Corruption; Shadow Bolt |
| Warlock | Destruction | Fel Armor; Imp; Chaos Bolt; Conflagrate; Immolate; Incinerate; Shadow Bolt |
| Druid | Balance | Moonkin Form; Starfall; Moonfire; Insect Swarm; Starfire; Wrath |
| Druid | Feral Bear | Dire Bear/Bear Form; Mangle; Lacerate; Swipe; Maul |
| Druid | Feral Cat | Cat Form; Rip; Savage Roar; Mangle; Rake; Shred; Claw |
| Druid | Restoration | Tree of Life; Wild Growth; Swiftmend; Lifebloom; Rejuvenation; Regrowth; Healing Touch |
## Ability audit classification
Active abilities fall into one of three audit buckets:
- **Conditional:** executable but selected only by a semantic trigger, such as interrupts, dispels, resurrection, threat control, movement, crowd control, executes, defensives, and long cooldowns.
- **Unsupported:** the catalog has only `unsupported`, `scripted`, or noncombat `utility` payloads, or represents a noncombat summon such as a scouting eye, ritual object, portal, or mount. These remain player-visible when valid but are deliberately excluded from autonomous rotations.
- **Rotation defect:** an executable learned ability with combat semantics that cannot be selected in its valid state. Profile-reference tests, shared-policy scenario tests, party damage audits, and the expanded-class audit treat these as failures.
+41
View File
@@ -78,6 +78,47 @@ searches:
npm run dungeon:boss-effects:audit
```
For reports that one boss is unusually hard to heal, regenerate the normalized
party-pressure ranking instead of comparing native dungeon levels or relying on
memory:
```powershell
npm run dungeon:boss-pressure:audit
```
This writes `dungeon-pipeline/WOW_BOSS_PRESSURE_REPORT.md` for a quick top-30
view and `dungeon-pipeline/WOW_BOSS_PRESSURE_REPORT.json` for the complete
per-boss, per-attack breakdown. The default comparison holds every boss at
player level 80 for 60 seconds with five party members inside party-wide
effects. It intentionally reports an unmitigated direct-damage ceiling, so use
it to find bad cooldowns, duplicate spells, wrong targets, or accidental damage
effects. It does not claim exact combat-log DPS: armor, resistance, movement,
interrupts, summons, debuff amplification, and phase uptime are excluded.
When a boss is an outlier, inspect its source script before tuning numbers. In
particular, distinguish setup/trigger spells and one-shot enrage auras from
repeatable damaging attacks, and preserve the source `initialCooldownMs`,
`repeatCooldownMs`, `maximumCasts`, and target selection. Re-run the audit after
the fix and record both the boss rank and its per-attack rows.
### Imported animated boss is invisible
If the encounter marker, health bar, or effects render but the creature body
does not, inspect the shipped GLB before replacing the model:
```powershell
.\node_modules\.bin\gltf-transform.cmd inspect public/assets/game/dungeons/<dungeon>/creatures/<model>.glb
```
Compare the scene bounds with the entity's `visual.model.groundOffset`,
`labelHeight`, and `markerRadius`. Skinned bind-pose bounds can include vertices
far below the animated root. The packaging formula normally uses `-bounds.minY`
as the floor offset; for those assets that can lift the posed creature entirely
out of the gameplay camera. Add the narrowest source-reviewed runtime metadata
override in the dungeon content adapter, keep the original model URL, and add a
regression assertion for the corrected offset. Prove the result with a rendered
Boss Testing screenshot—the GLB existing on disk is not visual evidence.
The compiler reads effect, aura, target, duration, radius, and summon-creature
fields from `Spell.dbc`, with `SpellDuration.dbc` and `SpellRadius.dbc` from the
sibling support DBC directory. `spellSemantic()` maps source-backed controls,
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,42 @@
# WoW boss party-pressure audit
Generated 2026-09-16T14:26:06.076Z. This is a deterministic comparison at player level 80: 5 grouped party members, 60 seconds, and live boss attack definitions. It is a pressure ceiling, not a combat-log prediction: mitigation, movement, interrupts, summons, debuff amplification, and phase uptime are excluded.
| Rank | Boss | Dungeon | Party DPS | Tank DPS | Random DPS | Groupwide DPS | vs median |
| ---: | --- | --- | ---: | ---: | ---: | ---: | ---: |
| 1 | Herald Volazj | Ahn'kahet: The Old Kingdom | 31,269 | 25,761 | 0 | 5,508 | 21.3x |
| 2 | Ingvar the Plunderer | Utgarde Keep | 27,004 | 10,339 | 1,341 | 15,325 | 18.4x |
| 3 | Runok Wildmane | Trial of the Champion | 24,872 | 23,424 | 1,448 | 0 | 16.9x |
| 4 | Mokra the Skullcrusher | Trial of the Champion | 22,720 | 21,429 | 1,291 | 0 | 15.5x |
| 5 | Eressea Dawnsinger | Trial of the Champion | 22,222 | 20,963 | 1,259 | 0 | 15.1x |
| 6 | Exarch Maladaar | Auchindoun: Auchenai Crypts | 20,157 | 18,437 | 1,720 | 0 | 13.7x |
| 7 | Quagmirran | Coilfang: The Slave Pens | 18,337 | 7,374 | 0 | 10,963 | 12.5x |
| 8 | Aeonus | The Black Morass | 17,141 | 6,604 | 0 | 10,537 | 11.7x |
| 9 | The Prophet Tharon'ja | Drak'Tharon Keep | 16,462 | 7,357 | 0 | 9,105 | 11.2x |
| 10 | General Bjarngrim | Halls of Lightning | 16,105 | 13,186 | 0 | 2,919 | 11.0x |
| 11 | Varos Cloudstrider | The Oculus | 15,528 | 15,528 | 0 | 0 | 10.6x |
| 12 | Grand Magus Telestra | The Nexus | 14,906 | 11,396 | 0 | 3,510 | 10.1x |
| 13 | Jedoga Shadowseeker | Ahn'kahet: The Old Kingdom | 12,790 | 12,790 | 0 | 0 | 8.7x |
| 14 | Drakos the Interrogator | The Oculus | 12,261 | 5,023 | 0 | 7,238 | 8.3x |
| 15 | King Dred | Drak'Tharon Keep | 12,209 | 8,215 | 0 | 3,994 | 8.3x |
| 16 | King Ymiron | Utgarde Pinnacle | 12,061 | 12,061 | 0 | 0 | 8.2x |
| 17 | The Black Knight | Trial of the Champion | 11,803 | 11,803 | 0 | 0 | 8.0x |
| 18 | Krystallus | Halls of Stone | 11,061 | 7,326 | 0 | 3,735 | 7.5x |
| 19 | Mage-Lord Urom | The Oculus | 10,808 | 4,547 | 1,076 | 5,185 | 7.4x |
| 20 | Swamplord Musel'ek | Coilfang: The Underbog | 10,511 | 10,511 | 0 | 0 | 7.2x |
| 21 | Talon King Ikiss | Auchindoun: Sethekk Halls | 10,456 | 4,039 | 0 | 6,417 | 7.1x |
| 22 | Slad'ran | Gundrak | 10,428 | 3,248 | 0 | 7,180 | 7.1x |
| 23 | Novos the Summoner | Drak'Tharon Keep | 10,175 | 5,571 | 1,140 | 3,463 | 6.9x |
| 24 | Ley-Guardian Eregos | The Oculus | 9,739 | 6,040 | 0 | 3,699 | 6.6x |
| 25 | Gal'darah | Gundrak | 9,683 | 5,904 | 0 | 3,779 | 6.6x |
| 26 | Commander Stoutbeard | The Nexus | 9,680 | 3,662 | 1,491 | 4,527 | 6.6x |
| 27 | Forgemaster Garfrost | Pit of Saron | 9,602 | 8,939 | 663 | 0 | 6.5x |
| 28 | Murmur | Auchindoun: Shadow Labyrinth | 9,560 | 6,206 | 831 | 2,523 | 6.5x |
| 29 | Drakkari Elemental | Gundrak | 9,559 | 5,832 | 0 | 3,727 | 6.5x |
| 30 | Loken | Halls of Lightning | 9,364 | 5,746 | 0 | 3,618 | 6.4x |
## Eck the Ferocious
Eck ranks **97 of 273** at **3,570 raw party DPS** (**2.4x** the median). His corrected schedule contains one 2-second melee swing, Bite every 8–12 seconds, Spit every 8–12 seconds after its delayed opener, Spring every 10–24 seconds against a random party member, and a non-damaging Berserk at 90 seconds.
The complete machine-readable ranking and per-attack breakdown are in `dungeon-pipeline/WOW_BOSS_PRESSURE_REPORT.json`.
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "healer-man",
"version": "0.1.45",
"version": "0.1.50",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "healer-man",
"version": "0.1.45",
"version": "0.1.50",
"dependencies": {
"@capacitor/android": "8.4.1",
"@capacitor/core": "8.4.1",
+5 -1
View File
@@ -1,7 +1,7 @@
{
"name": "healer-man",
"private": true,
"version": "0.1.45",
"version": "0.1.50",
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0",
@@ -57,6 +57,8 @@
"dungeon:five-player:verify": "node scripts/dungeon-pipeline/verify-five-player-dungeon-imports.mjs",
"dungeon:wow:audit": "vite-node scripts/dungeon-pipeline/audit-wow-dungeon-content.mjs",
"dungeon:boss-effects:audit": "node scripts/dungeon-pipeline/audit-boss-spell-effects.mjs",
"dungeon:boss-pressure:audit": "vite-node scripts/dungeon-pipeline/audit-boss-party-pressure.ts",
"dungeon:routes:generate": "node scripts/dungeon-pipeline/generate-dungeon-route-candidates.mjs",
"dungeon:wow:functionality:audit": "vite-node scripts/dungeon-pipeline/audit-wow-dungeon-functionality.ts",
"dungeon:wow:stairs:audit": "node --max-old-space-size=8192 scripts/dungeon-pipeline/audit-wow-stairs.ts",
"dungeon:wow:stairs:refresh": "node --max-old-space-size=8192 scripts/dungeon-pipeline/audit-wow-stairs.ts --write",
@@ -114,6 +116,8 @@
"assets:ktx2:refresh-metadata": "node scripts/asset-pipeline/refresh-ktx2-metadata.mjs",
"assets:performance:audit": "node scripts/asset-pipeline/audit-environment-performance.mjs",
"balance:benchmark": "vite-node scripts/combat-benchmark/run.ts",
"balance:lab": "python scripts/balance_lab.py",
"balance:lab:playtest": "node scripts/playtest-balance-lab.cjs",
"balance:smoke": "vite-node scripts/combat-benchmark/run.ts --class mage --role damage --scenario damage-single-burst --seeds 1 --duration-scale 0.2",
"prebuild": "npm run loot:generate && npm run web:prepare && npm run assets:ktx2:audit && npm run assets:performance:audit",
"build": "node --max-old-space-size=8192 node_modules/typescript/bin/tsc -b && node --max-old-space-size=8192 node_modules/vite/bin/vite.js build",
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

+2 -2
View File
@@ -1,4 +1,4 @@
{
"versionName": "0.1.45",
"versionCode": 1045
"versionName": "0.1.50",
"versionCode": 1050
}
+5 -4
View File
@@ -5,7 +5,7 @@ import { registerCombatSpatialProvider } from "../../src/game/combatSpatial";
import { clearMobRuntimeRegistry, registerMobRuntime, getMobPosition } from "../../src/game/mobRuntimeRegistry";
import { clearPartyRuntimeRegistry, registerPartyRuntimePosition, getPartyRuntimePosition } from "../../src/game/partyRuntimeRegistry";
import { targetingRulesForAbility } from "../../src/game/abilityRules";
import { resourceProfileForClass, abilityAtLevel, isAbilityUnlocked } from "../../src/game/abilityCatalog";
import { resourceProfileForClass, learnedAbilityForBuild } from "../../src/game/abilityCatalog";
import { deriveCharacterStats } from "../../src/game/wotlkStats";
import { subscribeCombatPresentation } from "../../src/game/combatPresentation";
import { arenaDampening } from "../../server/arena-rules.mjs";
@@ -36,9 +36,10 @@ export function initializeArenaCharacter(actor: any, now: number) {
actor.avatar = { classId: state.classId, raceId: seed.raceId ?? (actor.team === 1 ? "orc" : "human"),
gender: (seed as any).gender ?? "male", appearance: (seed as any).appearance ?? createDefaultAppearance() };
actor.visualEquipment = equipmentItemsForOwner(state.equipment, state.inventory, PLAYER_EQUIPMENT_OWNER_ID);
const abilities = state.abilities.filter((ability: any) => !ability.passive && isAbilityUnlocked(ability, state.level)
&& (ability.talentEntryId === undefined || (state.talentRanks[`coa-entry-${ability.talentEntryId}`] ?? 0) > 0))
.map((ability: any) => { const resolved = abilityAtLevel(ability, state.level, ability.talentEntryId === undefined ? undefined : state.talentRanks[`coa-entry-${ability.talentEntryId}`]); return { ...resolved, targeting: targetingRulesForAbility(resolved) }; });
const abilities = state.abilities.filter((ability: any) => !ability.passive)
.map((ability: any) => learnedAbilityForBuild(ability, state.level, state.talentRanks))
.filter((ability: any) => ability !== null)
.map((ability: any) => ({ ...ability, targeting: targetingRulesForAbility(ability) }));
actor.characterKit = { ...kit, seed, classId: state.classId, level: state.level,
resource: { ...resourceProfileForClass(state.classId), maximum: state.maxResource, initial: state.resource },
abilities: Object.fromEntries(abilities.map((ability: any) => [ability.id, ability])), actionBindings: state.actionBindings };
+643
View File
@@ -0,0 +1,643 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { dirname, resolve } from "node:path";
import { createInterface } from "node:readline";
import {
CLASSES,
classById,
contentCategoryForClass,
type ClassId,
} from "../../src/app/characterCatalog";
import type { ContentCategoryId } from "../../src/app/contentCategories";
import {
abilitiesForCharacter,
learnedAbilityForBuild,
preloadAbilityCatalogsForClasses,
} from "../../src/game/abilityCatalog";
import { aiHintsForAbility } from "../../src/game/abilityRules";
import { PLAYER_AGGRO_ID } from "../../src/game/aggro";
import { installCombatClock } from "../../src/game/combatClock";
import { subscribeCombatEvents } from "../../src/game/combatEvents";
import { resetCombatSpatialProvider } from "../../src/game/combatSpatial";
import { useCombatStore } from "../../src/game/combatStore";
import { dungeonEnemyScaling } from "../../src/game/dungeonBossScaling";
import { requireDungeonDefinition } from "../../src/game/dungeonRegistry";
import {
BOSS_TESTING_CATALOG,
createBossTestingRoom,
type TestableBoss,
} from "../../src/game/bossTesting";
import { clearMobRuntimeRegistry, setMobPosition } from "../../src/game/mobRuntimeRegistry";
import {
advanceDungeonPartyCombat,
partyCombatRangeBand,
resetPartyRuntimeClock,
} from "../../src/game/partyRuntime";
import {
clearPartyRuntimeRegistry,
registerPartyRuntimePosition,
updatePartyRuntimeTargetVisibility,
} from "../../src/game/partyRuntimeRegistry";
import {
createConfiguredPartyMember,
usePartyStore,
type PartyMember,
} from "../../src/game/partyStore";
import { specializationsForClass } from "../../src/game/specializationCatalog";
import { BENCHMARK_POLICY_ID, BenchmarkController } from "../../src/game/benchmark/benchmarkController";
import { COMBAT_ROTATION_VERSION } from "../../src/game/combatRotation";
import {
loadSelectableBenchmarkBuilds,
selectableBenchmarkBuildCatalog,
} from "../../src/game/benchmark/benchmarkBuilds";
import type {
BenchmarkBuildDefinition,
BenchmarkScenarioDefinition,
} from "../../src/game/benchmark/benchmarkTypes";
type BossCategory = "all" | "wow" | "rom";
type PartyCatalogSource = ContentCategoryId | "all";
interface BalanceLabConfig {
readonly schemaVersion: 1;
readonly source: ContentCategoryId;
readonly level: number;
readonly durationSeconds: number;
readonly seeds: readonly number[];
readonly partyBuildIds: readonly string[];
readonly activeSlot: number;
readonly bossCategory: BossCategory;
readonly bossKeys?: readonly string[];
}
interface MutableAbilityMetrics {
casts: number;
rawDamage: number;
effectiveDamage: number;
rawHealing: number;
effectiveHealing: number;
overheal: number;
absorbs: number;
hits: number;
}
interface MutableMemberMetrics {
readonly slot: number;
readonly actorId: string;
readonly build: BenchmarkBuildDefinition;
damage: number;
rawDamage: number;
bossDamage: number;
healing: number;
rawHealing: number;
overheal: number;
healingReceived: number;
absorbs: number;
damageTaken: number;
rawDamageTaken: number;
absorbedDamageTaken: number;
deathEvents: number;
casts: number;
minimumHealthPercent: number;
readonly abilities: Map<string, MutableAbilityMetrics>;
}
const scenario = (durationMs: number): BenchmarkScenarioDefinition => ({
id: "balance-lab-boss",
version: 1,
name: "Balance lab boss",
description: "A selected five-member party against one isolated authored boss.",
family: "mechanics",
roles: ["damage", "tank", "healer"],
durationMs,
tickMs: 100,
hostileTargets: 1,
partyLayout: "melee",
attacks: [],
});
function argument(name: string): string | null {
const index = process.argv.indexOf(name);
return index >= 0 ? process.argv[index + 1] ?? null : null;
}
function requiredArgument(name: string): string {
const value = argument(name);
if (!value) throw new Error(`Missing required ${name} argument.`);
return value;
}
function safeNumber(value: unknown, fallback: number): number {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : fallback;
}
function classIdsForSource(source: PartyCatalogSource): readonly ClassId[] {
return CLASSES
.map((definition) => definition.id)
.filter((classId) => source === "all" || contentCategoryForClass(classId) === source);
}
function bossCatalogRows() {
return BOSS_TESTING_CATALOG.map((boss) => ({
key: boss.key,
name: boss.name,
dungeonId: boss.dungeonId,
dungeonTitle: boss.dungeonTitle,
category: boss.category,
}));
}
async function writeJson(path: string, value: unknown): Promise<void> {
const absolute = resolve(path);
await mkdir(dirname(absolute), { recursive: true });
await writeFile(absolute, `${JSON.stringify(value, null, 2)}\n`, "utf8");
}
function catalogPayload(source: PartyCatalogSource, level: number) {
if (!["wow", "coa", "rom", "all"].includes(source)) throw new Error(`Unknown party source: ${source}`);
const builds = selectableBenchmarkBuildCatalog(level, classIdsForSource(source));
return {
schemaVersion: 1,
policyId: BENCHMARK_POLICY_ID,
rotationVersion: COMBAT_ROTATION_VERSION,
level: builds[0]?.level ?? level,
source,
builds: builds.map((build) => ({
id: build.id,
source: build.source,
classId: build.classId,
className: classById(build.classId).name,
role: build.role,
specialization: build.orientation,
label: `${classById(build.classId).name} — ${build.orientation} (${build.role})`,
})),
bosses: bossCatalogRows(),
};
}
async function writeCatalog(): Promise<void> {
const level = Math.trunc(safeNumber(argument("--level"), 60));
const source = (argument("--source") ?? "wow") as PartyCatalogSource;
await writeJson(requiredArgument("--output"), catalogPayload(source, level));
}
function selectedBosses(config: BalanceLabConfig): readonly TestableBoss[] {
const requested = new Set(config.bossKeys ?? []);
const rows = BOSS_TESTING_CATALOG.filter((boss) => (
(config.bossCategory === "all" || boss.category === config.bossCategory)
&& (!requested.size || requested.has(boss.key))
));
if (!rows.length) throw new Error("No bosses matched the requested boss filters.");
if (requested.size) {
const found = new Set(rows.map((boss) => boss.key));
const missing = [...requested].filter((key) => !found.has(key));
if (missing.length) throw new Error(`Unknown or category-excluded boss keys: ${missing.join(", ")}`);
}
return rows;
}
function configuredCompanion(
build: BenchmarkBuildDefinition,
slot: number,
level: number,
seed: string,
): PartyMember {
const specialization = specializationsForClass(build.classId)
.find((definition) => definition.displayName === build.orientation);
return createConfiguredPartyMember({
name: `${classById(build.classId).name} ${build.orientation}`,
classId: build.classId,
secondaryClassId: build.secondaryClassId,
specialization: build.orientation,
specializationId: specialization?.id ?? null,
talentTreeId: specialization?.id ?? "",
talentRanks: build.talentRanks,
role: build.role,
}, level, slot, seed);
}
function blankAbility(): MutableAbilityMetrics {
return {
casts: 0,
rawDamage: 0,
effectiveDamage: 0,
rawHealing: 0,
effectiveHealing: 0,
overheal: 0,
absorbs: 0,
hits: 0,
};
}
function abilityMetrics(member: MutableMemberMetrics, abilityId: string | undefined): MutableAbilityMetrics {
const key = abilityId ?? "unknown";
const existing = member.abilities.get(key);
if (existing) return existing;
const created = blankAbility();
member.abilities.set(key, created);
return created;
}
function currentHealth(actorId: string): readonly [number, number] {
const combat = useCombatStore.getState();
if (actorId === PLAYER_AGGRO_ID) return [combat.health, combat.maxHealth];
const member = usePartyStore.getState().members.find((candidate) => candidate.id === actorId);
return [member?.health ?? 0, member?.maxHealth ?? 1];
}
function availableAbilities(build: BenchmarkBuildDefinition) {
return abilitiesForCharacter(build.classId, build.secondaryClassId)
.filter((ability) => !ability.passive)
.map((ability) => {
const ranked = learnedAbilityForBuild(ability, build.level, build.talentRanks);
if (!ranked) return null;
if (ranked.effects.every((effect) => effect.kind === "unsupported" || effect.kind === "scripted")) return null;
return {
abilityId: ranked.id,
abilityName: ranked.name,
icon: ranked.icon,
categories: aiHintsForAbility(ranked).categories,
cooldownSeconds: ranked.cooldownMs / 1_000,
};
})
.filter((ability) => ability !== null)
.sort((left, right) => left.abilityName.localeCompare(right.abilityName));
}
function serializedMember(
metrics: MutableMemberMetrics,
activeSeconds: number,
) {
const [health, maxHealth] = currentHealth(metrics.actorId);
return {
slot: metrics.slot,
activePlayer: metrics.actorId === PLAYER_AGGRO_ID,
actorId: metrics.actorId,
buildId: metrics.build.id,
classId: metrics.build.classId,
className: classById(metrics.build.classId).name,
specialization: metrics.build.orientation,
role: metrics.build.role,
damage: metrics.damage,
rawDamage: metrics.rawDamage,
bossDamage: metrics.bossDamage,
dps: metrics.damage / Math.max(0.1, activeSeconds),
bossDps: metrics.bossDamage / Math.max(0.1, activeSeconds),
healing: metrics.healing,
rawHealing: metrics.rawHealing,
overheal: metrics.overheal,
hps: metrics.healing / Math.max(0.1, activeSeconds),
healingReceived: metrics.healingReceived,
hpsReceived: metrics.healingReceived / Math.max(0.1, activeSeconds),
absorbs: metrics.absorbs,
damageTaken: metrics.damageTaken,
rawDamageTaken: metrics.rawDamageTaken,
absorbedDamageTaken: metrics.absorbedDamageTaken,
dtps: metrics.damageTaken / Math.max(0.1, activeSeconds),
deathEvents: metrics.deathEvents,
alive: health > 0,
finalHealth: health,
maxHealth,
minimumHealthPercent: metrics.minimumHealthPercent,
casts: metrics.casts,
availableAbilities: availableAbilities(metrics.build),
abilities: [...metrics.abilities.entries()]
.map(([abilityId, ability]) => ({ abilityId, ...ability }))
.sort((left, right) => right.effectiveDamage - left.effectiveDamage
|| right.effectiveHealing - left.effectiveHealing
|| left.abilityId.localeCompare(right.abilityId)),
};
}
async function runBoss(
config: BalanceLabConfig,
builds: readonly BenchmarkBuildDefinition[],
boss: TestableBoss,
seed: number,
) {
const sourceDungeon = requireDungeonDefinition(boss.dungeonId);
const testingRoom = createBossTestingRoom(sourceDungeon, boss).definition;
const entity = testingRoom.entities[boss.entityId];
if (!entity) throw new Error(`Boss entity ${boss.key} is missing from its testing room.`);
const bossActorId = `${boss.spawn.id}:balance-${seed}`;
const activeBuild = builds[config.activeSlot];
const durationMs = Math.max(1_000, Math.min(600_000, Math.round(config.durationSeconds * 1_000)));
const specialization = specializationsForClass(activeBuild.classId)
.find((definition) => definition.displayName === activeBuild.orientation);
const scaling = dungeonEnemyScaling(
testingRoom,
entity,
bossActorId,
activeBuild.level,
testingRoom.defaultDifficultyId,
1,
activeBuild.source,
);
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
resetPartyRuntimeClock();
resetCombatSpatialProvider();
let now = 1_000_000;
const restoreClock = installCombatClock(() => now);
const companions = builds
.map((build, slot) => ({ build, slot }))
.filter(({ slot }) => slot !== config.activeSlot)
.map(({ build, slot }) => configuredCompanion(build, slot, activeBuild.level, `${seed}:${boss.key}`));
const actorIds = builds.map((_, slot) => (
slot === config.activeSlot
? PLAYER_AGGRO_ID
: companions.find((member) => member.id.includes(`-${slot}-`))?.id ?? ""
));
const memberMetrics = builds.map((build, slot): MutableMemberMetrics => ({
slot,
actorId: actorIds[slot],
build,
damage: 0,
rawDamage: 0,
bossDamage: 0,
healing: 0,
rawHealing: 0,
overheal: 0,
healingReceived: 0,
absorbs: 0,
damageTaken: 0,
rawDamageTaken: 0,
absorbedDamageTaken: 0,
deathEvents: 0,
casts: 0,
minimumHealthPercent: 1,
abilities: new Map(),
}));
const byActorId = new Map(memberMetrics.map((metrics) => [metrics.actorId, metrics]));
const bossAbilities = new Map<string, MutableAbilityMetrics>();
const stopEvents = subscribeCombatEvents((event) => {
const source = byActorId.get(event.sourceActorId);
const target = byActorId.get(event.targetActorId);
if (event.kind === "cast" && source) {
source.casts += 1;
abilityMetrics(source, event.abilityId).casts += 1;
} else if (event.kind === "damage") {
if (source && !target) {
source.rawDamage += event.rawAmount;
source.damage += event.effectiveAmount;
if (event.targetActorId === bossActorId) source.bossDamage += event.effectiveAmount;
const ability = abilityMetrics(source, event.abilityId);
ability.rawDamage += event.rawAmount;
ability.effectiveDamage += event.effectiveAmount;
ability.hits += 1;
}
if (target && !source) {
target.rawDamageTaken += event.rawAmount;
target.damageTaken += event.effectiveAmount;
target.absorbedDamageTaken += event.absorbed;
const key = event.abilityId ?? "unknown";
const ability = bossAbilities.get(key) ?? blankAbility();
ability.rawDamage += event.rawAmount;
ability.effectiveDamage += event.effectiveAmount;
ability.absorbs += event.absorbed;
ability.hits += 1;
bossAbilities.set(key, ability);
}
} else if (event.kind === "healing") {
if (source) {
source.rawHealing += event.rawAmount;
source.healing += event.effectiveAmount;
source.overheal += event.overheal;
const ability = abilityMetrics(source, event.abilityId);
ability.rawHealing += event.rawAmount;
ability.effectiveHealing += event.effectiveAmount;
ability.overheal += event.overheal;
ability.hits += 1;
}
if (target) target.healingReceived += event.effectiveAmount;
} 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;
}
});
let elapsedMs = 0;
let outcome: "defeated" | "wipe" | "timeout" = "timeout";
try {
const combat = useCombatStore.getState();
combat.initializeCharacter({
classId: activeBuild.classId,
secondaryClassId: activeBuild.secondaryClassId,
specializationId: specialization?.id,
role: activeBuild.role,
level: activeBuild.level,
talentRanks: activeBuild.talentRanks,
inventory: activeBuild.inventory,
equipment: activeBuild.equipment,
});
combat.setPlayerPosition([-2, 0, 0]);
usePartyStore.setState({
seed: `${seed}:${boss.key}`,
playerRole: activeBuild.role,
partySize: 5,
categoryId: config.source,
members: companions,
command: "defend",
selectedMemberId: null,
selfSelected: false,
routeBlocked: false,
activeMobId: null,
objectiveBossId: null,
memberTargetIds: {},
});
combat.synchronizeActors();
combat.registerMob(bossActorId, {
name: entity.name,
boss: true,
serverEntry: entity.combat?.serverEntry,
...scaling,
attacks: entity.combat?.attacks,
xpReward: 0,
});
setMobPosition(bossActorId, [0, 0, 0]);
for (const member of companions) {
registerPartyRuntimePosition(member.id, [-partyCombatRangeBand(member).preferred, 0, 0]);
updatePartyRuntimeTargetVisibility(member.id, bossActorId, true);
}
combat.selectMob(bossActorId);
const tank = memberMetrics.find((member) => member.build.role === "tank") ?? memberMetrics[0];
combat.damageMob(bossActorId, 1, now, {
actorId: tank.actorId,
role: "tank",
abilityId: "balance-lab-pull",
}, "physical");
const controller = new BenchmarkController();
const runScenario = scenario(durationMs);
for (elapsedMs = 100; elapsedMs <= durationMs; elapsedMs += 100) {
now = 1_000_000 + elapsedMs;
combat.tick(0.1, now);
combat.advanceMobCombat(now);
advanceDungeonPartyCombat(now);
controller.act(now, activeBuild.role, runScenario, false);
for (const metrics of memberMetrics) {
const [health, maxHealth] = currentHealth(metrics.actorId);
metrics.minimumHealthPercent = Math.min(
metrics.minimumHealthPercent,
maxHealth > 0 ? Math.max(0, health / maxHealth) : 0,
);
}
const state = useCombatStore.getState();
if (state.mobs[bossActorId]?.dead) {
outcome = "defeated";
break;
}
if (memberMetrics.every((metrics) => currentHealth(metrics.actorId)[0] <= 0)) {
outcome = "wipe";
break;
}
}
const activeSeconds = Math.min(durationMs, elapsedMs) / 1_000;
const mob = useCombatStore.getState().mobs[bossActorId];
const members = memberMetrics.map((metrics) => serializedMember(metrics, activeSeconds));
const incomingDamage = members.reduce((total, member) => total + member.damageTaken, 0);
const rawIncomingDamage = members.reduce((total, member) => total + member.rawDamageTaken, 0);
const partyDamage = members.reduce((total, member) => total + member.damage, 0);
const bossDamage = members.reduce((total, member) => total + member.bossDamage, 0);
return {
bossKey: boss.key,
bossName: boss.name,
bossCategory: boss.category,
dungeonId: boss.dungeonId,
dungeonTitle: boss.dungeonTitle,
seed,
outcome,
elapsedSeconds: activeSeconds,
timeToDefeatSeconds: outcome === "defeated" ? activeSeconds : null,
bossLevel: scaling.level,
bossMaxHealth: scaling.maxHealth,
bossRemainingHealth: mob?.health ?? 0,
partyDamage,
bossDamage,
partyDps: partyDamage / Math.max(0.1, activeSeconds),
bossDpsTaken: bossDamage / Math.max(0.1, activeSeconds),
incomingDamage,
rawIncomingDamage,
incomingDtps: incomingDamage / Math.max(0.1, activeSeconds),
deaths: members.filter((member) => !member.alive).length,
deathEvents: members.reduce((total, member) => total + member.deathEvents, 0),
bossAttacks: mob?.attackRevision ?? 0,
members,
bossAbilities: [...bossAbilities.entries()]
.map(([abilityId, ability]) => ({ abilityId, ...ability }))
.sort((left, right) => right.effectiveDamage - left.effectiveDamage
|| left.abilityId.localeCompare(right.abilityId)),
};
} finally {
stopEvents();
restoreClock();
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
resetPartyRuntimeClock();
resetCombatSpatialProvider();
}
}
async function simulateConfig(config: BalanceLabConfig, output: string): Promise<void> {
if (config.schemaVersion !== 1) throw new Error(`Unsupported config schema: ${config.schemaVersion}`);
if (config.partyBuildIds.length !== 5) throw new Error("A balance-lab party must contain exactly five builds.");
if (!Number.isInteger(config.activeSlot) || config.activeSlot < 0 || config.activeSlot >= 5) {
throw new Error("activeSlot must be an integer from 0 through 4.");
}
if (!config.seeds.length) throw new Error("At least one simulation seed is required.");
const catalog = selectableBenchmarkBuildCatalog(config.level, classIdsForSource(config.source));
const catalogById = new Map(catalog.map((build) => [build.id, build]));
const selectedClassIds = [...new Set(config.partyBuildIds.map((id) => {
const entry = catalogById.get(id);
if (!entry) throw new Error(`Unknown ${config.source} party build: ${id}`);
return entry.classId;
}))];
const availableBuilds = await loadSelectableBenchmarkBuilds(config.level, selectedClassIds);
const byId = new Map(availableBuilds.map((build) => [build.id, build]));
const builds = config.partyBuildIds.map((id) => {
const build = byId.get(id);
if (!build) throw new Error(`Unknown ${config.source} party build: ${id}`);
return build;
});
await preloadAbilityCatalogsForClasses(builds.map((build) => build.classId));
const bosses = selectedBosses(config);
const runs = [];
let completed = 0;
const total = bosses.length * config.seeds.length;
for (const boss of bosses) {
for (const seed of config.seeds) {
runs.push(await runBoss(config, builds, boss, seed));
completed += 1;
if (completed === 1 || completed === total || completed % 10 === 0) {
process.stderr.write(`[balance-lab] ${completed}/${total} ${boss.name} seed ${seed}\n`);
}
}
}
await writeJson(output, {
schemaVersion: 1,
generatedAt: new Date().toISOString(),
engine: "live-typescript-runtime",
policyId: BENCHMARK_POLICY_ID,
rotationVersion: COMBAT_ROTATION_VERSION,
config: {
...config,
level: builds[0]?.level ?? config.level,
},
party: builds.map((build, slot) => ({
slot,
activePlayer: slot === config.activeSlot,
buildId: build.id,
classId: build.classId,
className: classById(build.classId).name,
specialization: build.orientation,
role: build.role,
})),
bossCount: bosses.length,
runCount: runs.length,
runs,
});
}
async function simulate(): Promise<void> {
const config = JSON.parse(await readFile(resolve(requiredArgument("--config")), "utf8")) as BalanceLabConfig;
await simulateConfig(config, requiredArgument("--output"));
}
async function serveSession(): Promise<void> {
const level = Math.trunc(safeNumber(argument("--level"), 60));
const source = (argument("--source") ?? "wow") as PartyCatalogSource;
process.stdout.write(`${JSON.stringify(catalogPayload(source, level))}\n`);
const lines = createInterface({ input: process.stdin, crlfDelay: Infinity });
for await (const line of lines) {
if (!line.trim()) continue;
const request = JSON.parse(line) as {
readonly close?: boolean;
readonly config?: string;
readonly output?: string;
};
if (request.close) {
process.stdout.write(`${JSON.stringify({ ok: true, closed: true })}\n`);
lines.close();
return;
}
if (!request.config || !request.output) throw new Error("A session simulation requires config and output paths.");
const config = JSON.parse(await readFile(resolve(request.config), "utf8")) as BalanceLabConfig;
await simulateConfig(config, request.output);
process.stdout.write(`${JSON.stringify({ ok: true })}\n`);
}
}
async function main(): Promise<void> {
const command = process.argv[2];
if (command === "catalog") await writeCatalog();
else if (command === "simulate") await simulate();
else if (command === "session") await serveSession();
else throw new Error("Usage: vite-node scripts/balance-lab/runtime.ts <catalog|simulate|session> [options]");
}
main().catch((error) => {
console.error(error instanceof Error ? error.stack ?? error.message : error);
process.exitCode = 1;
});
File diff suppressed because it is too large. Load diff
+2
View File
@@ -6,6 +6,7 @@ import {
loadCanonicalBenchmarkBuilds,
} from "../../src/game/benchmark/benchmarkBuilds";
import { BENCHMARK_POLICY_ID } from "../../src/game/benchmark/benchmarkController";
import { COMBAT_ROTATION_VERSION } from "../../src/game/combatRotation";
import {
renderBenchmarkMatrixMarkdown,
serializeBenchmarkMatrixCsv,
@@ -154,6 +155,7 @@ async function main(): Promise<void> {
seeds: options.seeds,
durationScale: options.durationScale,
policyId: BENCHMARK_POLICY_ID,
rotationVersion: COMBAT_ROTATION_VERSION,
},
scenarios,
builds,
@@ -0,0 +1,79 @@
#!/usr/bin/env node
import { writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import {
BOSS_PRESSURE_PARTY_SIZE,
BOSS_PRESSURE_WINDOW_MS,
rankBossPartyPressure,
} from "../../src/game/bossPressure";
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const playerLevel = Math.max(1, Math.min(80, Number(process.env.BOSS_PRESSURE_LEVEL) || 80));
const { DUNGEON_DEFINITIONS } = await import("../../src/game/dungeonRegistry");
const wowDefinitions = DUNGEON_DEFINITIONS.filter((dungeon) => dungeon.expansion !== "runewaker");
const ranking = rankBossPartyPressure(wowDefinitions, playerLevel);
const median = ranking.length ? ranking[Math.floor(ranking.length / 2)].partyDamagePerSecond : 0;
const entries = ranking.map((entry, index) => ({
rank: index + 1,
pressureVsMedian: median > 0 ? entry.partyDamagePerSecond / median : 0,
...entry,
}));
const eck = entries.find((entry) => entry.serverEntry === 29_932) ?? null;
const report = {
schemaVersion: 1,
generatedAt: new Date().toISOString(),
assumptions: {
playerLevel,
enemyLevelOffset: 2,
durationSeconds: BOSS_PRESSURE_WINDOW_MS / 1_000,
partySize: BOSS_PRESSURE_PARTY_SIZE,
partyLayout: "all five actors inside nearby-party effects",
metric: "unmitigated direct damage from the live boss attack catalog",
excludes: [
"armor and resistance",
"avoidable movement and interrupts",
"summoned creature damage",
"damage-amplifying debuffs",
"healing and absorbs",
"mutually exclusive phase uptime",
],
},
bosses: entries.length,
medianPartyDamagePerSecond: median,
eck,
entries,
};
const number = (value: number): string => Math.round(value).toLocaleString("en-US");
const decimal = (value: number): string => value.toFixed(1);
const top = entries.slice(0, 30);
const markdown = `# WoW boss party-pressure audit
Generated ${report.generatedAt}. This is a deterministic comparison at player level ${playerLevel}: ${BOSS_PRESSURE_PARTY_SIZE} grouped party members, ${BOSS_PRESSURE_WINDOW_MS / 1_000} seconds, and live boss attack definitions. It is a pressure ceiling, not a combat-log prediction: mitigation, movement, interrupts, summons, debuff amplification, and phase uptime are excluded.
| Rank | Boss | Dungeon | Party DPS | Tank DPS | Random DPS | Groupwide DPS | vs median |
| ---: | --- | --- | ---: | ---: | ---: | ---: | ---: |
${top.map((entry) => `| ${entry.rank} | ${entry.boss} | ${entry.dungeonTitle} | ${number(entry.partyDamagePerSecond)} | ${number(entry.tankDamage / entry.windowSeconds)} | ${number(entry.randomTargetDamage / entry.windowSeconds)} | ${number(entry.groupwideDamage / entry.windowSeconds)} | ${decimal(entry.pressureVsMedian)}x |`).join("\n")}
## Eck the Ferocious
${eck ? `Eck ranks **${eck.rank} of ${entries.length}** at **${number(eck.partyDamagePerSecond)} raw party DPS** (**${decimal(eck.pressureVsMedian)}x** the median). His corrected schedule contains one 2-second melee swing, Bite every 8–12 seconds, Spit every 8–12 seconds after its delayed opener, Spring every 10–24 seconds against a random party member, and a non-damaging Berserk at 90 seconds.` : "Eck was not found in the runtime catalog."}
The complete machine-readable ranking and per-attack breakdown are in \`dungeon-pipeline/WOW_BOSS_PRESSURE_REPORT.json\`.
`;
const jsonPath = path.join(projectRoot, "dungeon-pipeline", "WOW_BOSS_PRESSURE_REPORT.json");
const markdownPath = path.join(projectRoot, "dungeon-pipeline", "WOW_BOSS_PRESSURE_REPORT.md");
await Promise.all([
writeFile(jsonPath, `${JSON.stringify(report, null, 2)}\n`, "utf8"),
writeFile(markdownPath, markdown, "utf8"),
]);
console.log(JSON.stringify({
status: "complete",
bosses: entries.length,
playerLevel,
medianPartyDamagePerSecond: Number(median.toFixed(2)),
eck: eck ? { rank: eck.rank, partyDamagePerSecond: Number(eck.partyDamagePerSecond.toFixed(2)), pressureVsMedian: Number(eck.pressureVsMedian.toFixed(2)) } : null,
outputs: [path.relative(projectRoot, jsonPath), path.relative(projectRoot, markdownPath)],
}, null, 2));
@@ -0,0 +1,426 @@
import { createHash } from "node:crypto";
import { execFileSync } from "node:child_process";
import { readFileSync, writeFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { NodeIO } from "@gltf-transform/core";
import { ALL_EXTENSIONS } from "@gltf-transform/extensions";
import { MeshoptDecoder } from "meshoptimizer";
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
const defaultPlayerbotsRoot = path.resolve(projectRoot, "..", "wow-pvt-server-backups-2", "azerothcore-wotlk", "modules", "mod-playerbots");
const argumentIndex = process.argv.indexOf("--playerbots-root");
const playerbotsRoot = path.resolve(argumentIndex >= 0 ? process.argv[argumentIndex + 1] : defaultPlayerbotsRoot);
const sqlRoot = path.join(playerbotsRoot, "data", "sql", "playerbots", "base");
const catalogPath = path.join(projectRoot, "src", "game", "generated", "dungeonCampaignCatalog.json");
const outputPath = path.join(projectRoot, "src", "game", "generated", "dungeonRouteCatalog.generated.json");
const auditPath = path.join(projectRoot, "dungeon-pipeline", "DUNGEON_ROUTE_GENERATION_AUDIT.json");
const gltfIo = new NodeIO()
.registerExtensions(ALL_EXTENSIONS)
.registerDependencies({ "meshopt.decoder": MeshoptDecoder });
function tuples(fileName, pattern, convert) {
return readFileSync(path.join(sqlRoot, fileName), "utf8")
.split(/\r?\n/)
.map((line) => pattern.exec(line.trim()))
.filter(Boolean)
.map(convert);
}
const number = (value) => Number(value);
const nodes = tuples(
"playerbots_travelnode.sql",
/^\((\d+), '((?:[^'\\]|\\.)*)', (\d+), (-?[\d.eE+]+), (-?[\d.eE+]+), (-?[\d.eE+]+), (?:NULL|\d+)\)[,;]?$/,
(match) => ({ id: number(match[1]), mapId: number(match[3]), wow: [number(match[4]), number(match[5]), number(match[6])] }),
);
const links = tuples(
"playerbots_travelnode_link.sql",
/^\((\d+), (\d+), \d+, \d+, (-?[\d.eE+]+),/,
(match) => ({ from: number(match[1]), to: number(match[2]), distance: Math.max(0.001, number(match[3])) }),
);
const nodesById = new Map(nodes.map((node) => [node.id, node]));
function transformPoint(dungeon, wow) {
const base = dungeon.transform.fromWow === "three(x,y,z)=(wow.y,wow.z,wow.x)"
? [wow[1], wow[2], wow[0]]
: [-wow[0], wow[2], wow[1]];
const scale = dungeon.transform.scale ?? 1;
return [
base[0] * scale + (dungeon.transform.position?.[0] ?? 0),
base[1] * scale + (dungeon.transform.position?.[1] ?? 0),
base[2] * scale + (dungeon.transform.position?.[2] ?? 0),
];
}
function distance(left, right) {
return Math.hypot(right[0] - left[0], right[1] - left[1], right[2] - left[2]);
}
function nearestNode(candidates, point) {
return candidates.reduce((best, candidate) => {
const candidateDistance = distance(candidate.three, point);
return !best || candidateDistance < best.distance || (candidateDistance === best.distance && candidate.id < best.node.id)
? { node: candidate, distance: candidateDistance }
: best;
}, null)?.node ?? null;
}
function shortestNodePath(mapNodes, mapLinks, startId, endId) {
if (startId === endId) return [startId];
const allowed = new Set(mapNodes.map((node) => node.id));
const adjacency = new Map(mapNodes.map((node) => [node.id, []]));
for (const link of mapLinks) {
if (allowed.has(link.from) && allowed.has(link.to)) adjacency.get(link.from).push(link);
}
for (const values of adjacency.values()) values.sort((left, right) => left.to - right.to);
const costs = new Map([[startId, 0]]);
const previous = new Map();
const pending = new Set(allowed);
while (pending.size) {
let current = null;
let currentCost = Number.POSITIVE_INFINITY;
for (const id of pending) {
const cost = costs.get(id) ?? Number.POSITIVE_INFINITY;
if (cost < currentCost || (cost === currentCost && (current === null || id < current))) {
current = id;
currentCost = cost;
}
}
if (current === null || !Number.isFinite(currentCost)) break;
pending.delete(current);
if (current === endId) break;
for (const edge of adjacency.get(current) ?? []) {
if (!pending.has(edge.to)) continue;
const nextCost = currentCost + edge.distance;
if (nextCost < (costs.get(edge.to) ?? Number.POSITIVE_INFINITY)) {
costs.set(edge.to, nextCost);
previous.set(edge.to, current);
}
}
}
if (!previous.has(endId)) return null;
const result = [endId];
while (result[0] !== startId) result.unshift(previous.get(result[0]));
return result;
}
function transformByMatrix(point, matrix) {
return [
matrix[0] * point[0] + matrix[4] * point[1] + matrix[8] * point[2] + matrix[12],
matrix[1] * point[0] + matrix[5] * point[1] + matrix[9] * point[2] + matrix[13],
matrix[2] * point[0] + matrix[6] * point[1] + matrix[10] * point[2] + matrix[14],
];
}
async function loadNavigationGraph(dungeon) {
const navigationUrl = dungeon.assets?.navigation?.url;
if (!navigationUrl) throw new Error(`${dungeon.id}: missing navigation asset.`);
const navigationFile = path.join(projectRoot, "public", navigationUrl.replace(/^\//, ""));
const document = await gltfIo.read(navigationFile);
const welded = new Map();
const triangles = [];
const edgeOwners = new Map();
const vertexId = (point) => {
const key = point.map((value) => Math.round(value * 1_000)).join(":");
if (!welded.has(key)) welded.set(key, welded.size);
return welded.get(key);
};
for (const node of document.getRoot().listNodes()) {
const mesh = node.getMesh();
if (!mesh) continue;
const matrix = node.getWorldMatrix();
for (const primitive of mesh.listPrimitives()) {
const positionAccessor = primitive.getAttribute("POSITION");
const positions = positionAccessor?.getArray();
const sourceIndices = primitive.getIndices()?.getArray();
if (!positions) continue;
const indices = sourceIndices ? Array.from(sourceIndices) : Array.from({ length: positions.length / 3 }, (_, index) => index);
for (let offset = 0; offset + 2 < indices.length; offset += 3) {
const points = [indices[offset], indices[offset + 1], indices[offset + 2]].map((index) => transformByMatrix(
positionAccessor.getElement(index, []),
matrix,
));
const ids = points.map(vertexId);
const id = triangles.length;
triangles.push({
centroid: points.reduce((sum, point) => [sum[0] + point[0] / 3, sum[1] + point[1] / 3, sum[2] + point[2] / 3], [0, 0, 0]),
neighbors: new Set(),
});
for (const edge of [[ids[0], ids[1]], [ids[1], ids[2]], [ids[2], ids[0]]]) {
const key = [...edge].sort((left, right) => left - right).join(":");
const previous = edgeOwners.get(key);
if (previous !== undefined) {
triangles[id].neighbors.add(previous);
triangles[previous].neighbors.add(id);
} else edgeOwners.set(key, id);
}
}
}
}
if (!triangles.length) throw new Error(`${dungeon.id}: navigation asset contains no triangles.`);
const nearest = (point) => {
let id = -1;
let snapDistance = Number.POSITIVE_INFINITY;
triangles.forEach((triangle, index) => {
const candidate = distance(point, triangle.centroid);
if (candidate < snapDistance) {
id = index;
snapDistance = candidate;
}
});
return { id, distance: snapDistance, point: triangles[id].centroid };
};
for (const link of dungeon.navigationLinks ?? []) {
const start = nearest(link.start);
const end = nearest(link.end);
if (start.distance <= 5 && end.distance <= 5) {
triangles[start.id].neighbors.add(end.id);
if (link.bidirectional !== false) triangles[end.id].neighbors.add(start.id);
}
}
return { triangles, nearest };
}
class MinimumHeap {
values = [];
push(value) {
this.values.push(value);
let index = this.values.length - 1;
while (index > 0) {
const parent = (index - 1) >> 1;
if (this.values[parent].cost <= value.cost) break;
this.values[index] = this.values[parent];
index = parent;
}
this.values[index] = value;
}
pop() {
if (!this.values.length) return null;
const first = this.values[0];
const last = this.values.pop();
if (this.values.length) {
let index = 0;
while (true) {
const left = index * 2 + 1;
const right = left + 1;
if (left >= this.values.length) break;
const child = right < this.values.length && this.values[right].cost < this.values[left].cost ? right : left;
if (this.values[child].cost >= last.cost) break;
this.values[index] = this.values[child];
index = child;
}
this.values[index] = last;
}
return first;
}
}
function navigationTrianglePath(graph, startId, endId) {
if (startId === endId) return [startId];
const costs = new Map([[startId, 0]]);
const previous = new Map();
const pending = new MinimumHeap();
pending.push({ id: startId, cost: 0 });
while (pending.values.length) {
const current = pending.pop();
const known = costs.get(current.id);
if (current.cost > known + 1e-6) continue;
if (current.id === endId) break;
const origin = graph.triangles[current.id].centroid;
for (const neighborId of graph.triangles[current.id].neighbors) {
const nextCost = known + distance(origin, graph.triangles[neighborId].centroid);
if (nextCost >= (costs.get(neighborId) ?? Number.POSITIVE_INFINITY)) continue;
costs.set(neighborId, nextCost);
previous.set(neighborId, current.id);
pending.push({ id: neighborId, cost: nextCost });
}
}
if (!previous.has(endId)) return null;
const result = [endId];
while (result[0] !== startId) result.unshift(previous.get(result[0]));
return result;
}
function segmentDistanceSquared(point, start, end) {
const delta = [end[0] - start[0], end[1] - start[1], end[2] - start[2]];
const lengthSquared = delta[0] ** 2 + delta[1] ** 2 + delta[2] ** 2;
if (lengthSquared <= 1e-9) return distance(point, start) ** 2;
const t = Math.max(0, Math.min(1, (
(point[0] - start[0]) * delta[0]
+ (point[1] - start[1]) * delta[1]
+ (point[2] - start[2]) * delta[2]
) / lengthSquared));
return distance(point, [start[0] + delta[0] * t, start[1] + delta[1] * t, start[2] + delta[2] * t]) ** 2;
}
function simplify(points, tolerance = 0.75) {
if (points.length <= 2) return points;
let furthest = -1;
let furthestDistance = tolerance ** 2;
for (let index = 1; index < points.length - 1; index += 1) {
const candidate = segmentDistanceSquared(points[index], points[0], points[points.length - 1]);
if (candidate > furthestDistance) {
furthest = index;
furthestDistance = candidate;
}
}
if (furthest < 0) return [points[0], points[points.length - 1]];
return [...simplify(points.slice(0, furthest + 1), tolerance).slice(0, -1), ...simplify(points.slice(furthest), tolerance)];
}
function boundSegments(points, maximum = 7.5) {
const result = [points[0]];
for (let index = 1; index < points.length; index += 1) {
const start = result[result.length - 1];
const end = points[index];
const length = distance(start, end);
const steps = Math.max(1, Math.ceil(length / maximum));
for (let step = 1; step <= steps; step += 1) {
const t = step / steps;
result.push([
start[0] + (end[0] - start[0]) * t,
start[1] + (end[1] - start[1]) * t,
start[2] + (end[2] - start[2]) * t,
]);
}
}
return result;
}
function rounded(point) {
return point.map((coordinate) => Number(coordinate.toFixed(4)));
}
function canonical(value) {
if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
if (value && typeof value === "object") {
return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonical(value[key])}`).join(",")}}`;
}
return JSON.stringify(value);
}
function playerbotsTopologyEvidence(start, end, mapNodes, mapLinks) {
const startNode = nearestNode(mapNodes, start);
const endNode = nearestNode(mapNodes, end);
if (!startNode || !endNode) return { landmarksAvailable: false, connected: false };
return {
landmarksAvailable: true,
connected: Boolean(shortestNodePath(mapNodes, mapLinks, startNode.id, endNode.id)),
};
}
function routeGeometry(graph, start, end) {
const startSnap = graph.nearest(start);
const endSnap = graph.nearest(end);
const trianglePath = navigationTrianglePath(graph, startSnap.id, endSnap.id);
const points = trianglePath
? trianglePath.map((id) => graph.triangles[id].centroid)
: [startSnap.point, endSnap.point];
const checkpoints = boundSegments(simplify(points, 0.35)).map(rounded);
return {
checkpoints,
connected: Boolean(trianglePath),
startSnapDistance: startSnap.distance,
endSnapDistance: endSnap.distance,
};
}
const campaign = JSON.parse(readFileSync(catalogPath, "utf8"));
const stock = campaign.definitions.filter((dungeon) => String(dungeon.provenance?.databaseAdapter ?? "").startsWith("azerothcore-"));
const routes = [];
const routeAudits = [];
for (const dungeon of stock) {
const mapNodes = nodes.filter((node) => node.mapId === dungeon.mapId).map((node) => ({
...node,
three: transformPoint(dungeon, node.wow),
}));
const mapLinks = links.filter((link) => nodesById.get(link.from)?.mapId === dungeon.mapId && nodesById.get(link.to)?.mapId === dungeon.mapId);
const navigationGraph = await loadNavigationGraph(dungeon);
let start = dungeon.entrance.footPosition;
const legAudits = [];
const legs = dungeon.objectiveOrder.map((objective, index) => {
const topology = playerbotsTopologyEvidence(start, objective.position, mapNodes, mapLinks);
const geometry = routeGeometry(navigationGraph, start, objective.position);
const { checkpoints } = geometry;
if (!checkpoints.length) throw new Error(`${dungeon.id}: empty navigation leg to ${objective.id}.`);
const expectedDistance = checkpoints.slice(1).reduce((total, point, pointIndex) => total + distance(checkpoints[pointIndex], point), 0);
legAudits.push({
objectiveId: objective.id,
playerbotsLandmarksAvailable: topology.landmarksAvailable,
playerbotsTopologyConnected: topology.connected,
navigationStartSnapDistance: Number(geometry.startSnapDistance.toFixed(3)),
navigationEndSnapDistance: Number(geometry.endSnapDistance.toFixed(3)),
checkpointCount: checkpoints.length,
valid: geometry.connected && geometry.startSnapDistance <= 15 && geometry.endSnapDistance <= 15,
issues: [
...(!geometry.connected ? ["disconnected-navigation-leg"] : []),
...(geometry.startSnapDistance > 15 ? ["origin-snap-distance-exceeded"] : []),
...(geometry.endSnapDistance > 15 ? ["endpoint-snap-distance-exceeded"] : []),
],
});
start = objective.position;
return {
id: `leg-${String(index + 1).padStart(2, "0")}-${objective.id}`,
objectiveId: objective.id,
objectiveName: objective.name,
checkpoints,
expectedDistance: Number(expectedDistance.toFixed(2)),
corridorPlanarDistance: 10,
corridorVerticalDistance: 4,
actions: [],
};
});
const unhashed = { schemaVersion: 1, id: "primary", dungeonId: dungeon.id, mapId: dungeon.mapId, legs };
const valid = legAudits.every((leg) => leg.valid);
routes.push({
...unhashed,
hash: createHash("sha256").update(canonical(unhashed)).digest("hex"),
generationStatus: valid ? "Candidate" : "Rejected",
generationIssues: [...new Set(legAudits.flatMap((leg) => leg.issues))],
});
routeAudits.push({
dungeonId: dungeon.id,
mapId: dungeon.mapId,
navigationTriangles: navigationGraph.triangles.length,
valid,
disposition: valid ? "candidate" : "rejected-needs-work",
legs: legAudits,
});
}
if (routes.length !== 51) throw new Error(`Expected 51 stock dungeon routes, generated ${routes.length}.`);
const safePlayerbotsRoot = playerbotsRoot.replaceAll("\\", "/");
const playerbotsGit = (...arguments_) => execFileSync(
"git",
["-c", `safe.directory=${safePlayerbotsRoot}`, "-C", playerbotsRoot, ...arguments_],
{ encoding: "utf8" },
).trim();
const playerbotsCommit = playerbotsGit("rev-parse", "HEAD");
const generatedAt = playerbotsGit("show", "-s", "--format=%cI", "HEAD");
const output = {
schemaVersion: 1,
generatedAt,
source: {
kind: "playerbots-landmark-reference",
auditReport: "dungeon-pipeline/DUNGEON_ROUTE_GENERATION_AUDIT.json",
note: "PlayerBots was used only as offline topology evidence. Shipped checkpoint geometry is computed from HealerMan navigation assets.",
},
routes,
};
writeFileSync(outputPath, `${JSON.stringify(output)}\n`);
writeFileSync(auditPath, `${JSON.stringify({
schemaVersion: 1,
generatedAt,
playerbots: {
repository: "azerothcore/mod-playerbots",
commit: playerbotsCommit,
attribution: "AzerothCore PlayerBots travel data was consulted offline as landmark and connectivity evidence; no source code, node IDs, raw node positions, or dense path rows are shipped.",
},
policy: {
anchors: "HealerMan entrance and required objective positions",
geometry: "HealerMan Recast navigation GLBs",
maximumCheckpointSegment: 7.5,
},
routes: routeAudits,
}, null, 2)}\n`);
console.log(`Generated ${routes.length} candidate routes at ${path.relative(projectRoot, outputPath)}.`);
+50 -11
View File
@@ -25,6 +25,19 @@ const CLASS_SOURCES = Object.freeze({
druid: { classMask: 1024, skillLines: [574, 134, 573] },
});
const CLASS_BY_TALENT_TAB = new Map([
[161, "warrior"], [164, "warrior"], [163, "warrior"],
[382, "paladin"], [383, "paladin"], [381, "paladin"],
[361, "hunter"], [363, "hunter"], [362, "hunter"],
[182, "rogue"], [181, "rogue"], [183, "rogue"],
[201, "priest"], [202, "priest"], [203, "priest"],
[398, "death-knight"], [399, "death-knight"], [400, "death-knight"],
[261, "shaman"], [263, "shaman"], [262, "shaman"],
[81, "mage"], [41, "mage"], [61, "mage"],
[302, "warlock"], [303, "warlock"], [301, "warlock"],
[283, "druid"], [281, "druid"], [282, "druid"],
]);
// Triggered subspells can appear on a class skill line even though the player
// never learns them directly. Their owning active spell/talent remains present.
const TRIGGERED_ONLY_NAMES = new Set([
@@ -202,16 +215,19 @@ function parseSpells(buffer, castTimes, durations, radii, ranges, icons) {
return spells;
}
function talentSpellIds(buffer) {
function talentNodesBySpellId(buffer) {
assertDbc(buffer, "Talent.dbc", 23);
const count = buffer.readUInt32LE(4);
const recordSize = buffer.readUInt32LE(12);
const result = new Set();
const result = new Map();
for (let index = 0; index < count; index += 1) {
const base = 20 + index * recordSize;
const talentNodeId = `talent-${buffer.readUInt32LE(base)}`;
const classId = CLASS_BY_TALENT_TAB.get(buffer.readUInt32LE(base + 4));
if (!classId) continue;
for (let rank = 0; rank < 9; rank += 1) {
const spellId = buffer.readUInt32LE(base + (4 + rank) * 4);
if (spellId) result.add(spellId);
if (spellId) result.set(spellId, { talentNodeId, classId });
}
}
return result;
@@ -271,7 +287,10 @@ function slimRank(spell) {
castTimeMs: spell.castTimeMs,
cooldownMs: Math.max(spell.recoveryTimeMs, spell.categoryRecoveryTimeMs),
gcdMs: spell.startRecoveryTimeMs || 1500,
durationMs: Math.abs(spell.durationMs),
// SpellDuration.dbc uses negative durations for permanent/toggled effects.
// Preserve those as zero so the runtime converts them to a non-expiring aura
// instead of a one-millisecond aura that the AI immediately recasts.
durationMs: Math.max(0, spell.durationMs),
channelDurationMs: spell.channelInterruptFlags ? Math.max(0, spell.durationMs) : 0,
powerType: spell.powerType,
powerCost: spell.powerCost,
@@ -285,6 +304,15 @@ function slimRank(spell) {
};
}
function iconPath(iconBasename) {
const candidates = [
`/assets/ui/spells/${iconBasename}.png`,
`/assets/ui/talents/icons/${iconBasename}.png`,
];
return candidates.find((candidate) => fs.existsSync(path.join(projectRoot, "public", candidate.slice(1))))
?? "/assets/ui/spells/inv_misc_questionmark.png";
}
const archive = MpqArchive.open(archivePath);
let result;
try {
@@ -300,7 +328,8 @@ try {
rangeTable(extract("SpellRange")),
stringTable(extract("SpellIcon")),
);
const talentIds = talentSpellIds(talentBuffer);
const talentNodeBySpellId = talentNodesBySpellId(talentBuffer);
const talentIds = new Set(talentNodeBySpellId.keys());
const passiveTalentNames = new Set(
[...talentIds]
.map((spellId) => spells.get(spellId))
@@ -309,19 +338,24 @@ try {
);
const classes = {};
for (const [classId, source] of Object.entries(CLASS_SOURCES)) {
const selected = [...classSpellIds(skillLineBuffer, source)]
.map((spellId) => spells.get(spellId))
const classSpellSet = classSpellIds(skillLineBuffer, source);
const activeTalentSpellIds = new Set(
[...talentNodeBySpellId.entries()]
.filter(([, owner]) => owner.classId === classId)
.map(([spellId]) => spellId),
);
const selected = [...spells.values()]
.filter((spell) => (
spell
&& spell.name
&& spell.spellLevel > 0
&& (classSpellSet.has(spell.id) || activeTalentSpellIds.has(spell.id))
&& (spell.spellLevel > 0 || talentIds.has(spell.id))
&& spell.spellLevel <= 80
&& (spell.attributes[0] & 0x40) === 0
));
const talentNames = new Set(selected.filter((spell) => talentIds.has(spell.id)).map((spell) => spell.name));
const chains = new Map();
for (const spell of selected) {
if (talentNames.has(spell.name) || TRIGGERED_ONLY_NAMES.has(spell.name)) continue;
if (TRIGGERED_ONLY_NAMES.has(spell.name)) continue;
const ranks = chains.get(spell.name) ?? [];
ranks.push(spell);
chains.set(spell.name, ranks);
@@ -334,6 +368,9 @@ try {
|| left.id - right.id
));
const first = ranks[0];
const talentNodeId = chainSpells
.map((spell) => talentNodeBySpellId.get(spell.id)?.talentNodeId)
.find(Boolean);
// Passive talents sometimes publish a separate, non-passive proc spell
// with the same name on the class skill line. That proc has no player
// activation mechanics, so retain it for the spellbook but classify it
@@ -349,8 +386,10 @@ try {
id: `wow335-${classId}-${name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "")}`,
name,
iconBasename: first.iconBasename,
sourceUnlockLevel: Math.min(...ranks.map((spell) => spell.spellLevel)),
iconPath: iconPath(first.iconBasename),
sourceUnlockLevel: Math.max(1, Math.min(...ranks.map((spell) => spell.spellLevel))),
...(passive ? { passive: true } : {}),
...(talentNodeId ? { talentNodeId } : {}),
ranks: ranks.map(slimRank),
};
}).sort((left, right) => (
+131
View File
@@ -0,0 +1,131 @@
const { chromium } = require('C:/Users/phenom/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const directory = path.resolve(process.env.BALANCE_LAB_PLAYTEST_DIR || 'playtest-artifacts/balance-lab');
const port = Number(process.env.BALANCE_LAB_PLAYTEST_PORT || 4191);
const base = `http://127.0.0.1:${port}`;
fs.mkdirSync(directory, { recursive: true });
const runtimeData = fs.mkdtempSync(path.join(os.tmpdir(), 'healer-man-balance-ui-'));
(async () => {
const { createGameServer } = await import('../server/server.mjs');
const runtime = createGameServer({
dataDir: runtimeData,
enableShelvedModes: false,
pythonExecutable: process.env.BALANCE_LAB_PYTHON,
});
await new Promise((resolve) => runtime.server.listen(port, '127.0.0.1', resolve));
const browser = await chromium.launch({ channel: 'msedge', headless: true });
const context = await browser.newContext({ viewport: { width: 1440, height: 1000 } });
const page = await context.newPage();
page.setDefaultTimeout(90_000);
const pageErrors = [];
page.on('pageerror', (error) => pageErrors.push(error.message));
const capture = async (name) => {
await page.screenshot({ path: path.join(directory, `${name}.png`), fullPage: true });
console.log(`Captured ${name}`);
};
try {
await page.goto(base, { waitUntil: 'domcontentloaded' });
await page.getByLabel('Username').fill('Phenom');
await page.getByLabel('Password').fill('balance-lab-test');
await page.getByRole('button', { name: /Create account/ }).click();
await page.locator('.character-select-screen').waitFor();
await page.getByRole('button', { name: /Create character/ }).click();
await page.getByRole('button', { name: /Customize & name/ }).click();
await page.getByLabel('Character name').fill('Balancer');
await page.getByRole('button', { name: /Create character/ }).click();
await page.getByRole('button', { name: /Continue/ }).first().click();
await page.locator('.main-menu-screen').waitFor();
const gmTools = page.getByRole('button', { name: /GM Tools/ });
const balanceLab = page.getByRole('button', { name: /Balance Lab/ });
await gmTools.waitFor();
await balanceLab.waitFor();
const gmBox = await gmTools.boundingBox();
const labBox = await balanceLab.boundingBox();
assert(gmBox && labBox && labBox.y > gmBox.y, 'Balance Lab is placed underneath GM Tools');
await capture('01-gm-menu');
await balanceLab.click();
await page.locator('.balance-lab-screen').waitFor();
await page.getByText('Server runtime ready').waitFor();
assert.match(await page.locator('.balance-lab-runtime').innerText(), /Python 3\./);
await page.getByLabel('Party source').selectOption('wow');
await page.getByLabel('Level').fill('20');
await page.getByLabel('Parties per source').fill('1');
await page.getByLabel('Runs per party/boss').fill('1');
await page.getByLabel('Fight limit (seconds)').fill('5');
await page.getByLabel('Bosses').selectOption('specific');
await page.getByLabel('Search Balance Lab bosses').fill('Eck the Ferocious');
await page.getByRole('checkbox', { name: /Eck the Ferocious/ }).check();
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 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 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(), 5);
assert.match(await page.locator('.balance-results-chart').filter({ hasText: 'Boss damage to party' }).innerText(), /Eck the Ferocious/);
await capture('04-run-analytics');
await page.getByRole('button', { name: 'bosses', exact: true }).click();
assert.equal(await page.locator('.balance-results-table-panel tbody tr').count(), 1);
assert.match(await page.locator('.balance-results-table-panel tbody').innerText(), /Eck the Ferocious/);
await page.getByRole('button', { name: 'Class / spec', exact: true }).click();
const allClassRows = await page.locator('.balance-results-table-panel tbody tr').count();
assert(allClassRows >= 5, 'the complete five-member party should be present in the class/spec table');
await page.getByLabel('Sort class specs').selectOption('totalDamageTaken');
assert.equal(await page.getByLabel('Sort class specs').inputValue(), 'totalDamageTaken');
await page.getByLabel('Class role').selectOption('damage');
const damageRows = await page.locator('.balance-results-table-panel tbody tr').count();
assert(damageRows > 0 && damageRows < allClassRows, 'role filtering should narrow the complete class/spec table');
await page.getByLabel('Class role').selectOption('all');
await page.getByRole('button', { name: 'abilities', exact: true }).click();
const abilityRows = page.locator('.balance-results-abilities tbody tr');
assert(await abilityRows.count() > 0, 'ability breakdown should retain observed and available abilities');
await page.getByLabel('Filter ability usage').selectOption('unused');
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 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');
await page.getByText('No saved runs match these filters.').waitFor();
await page.getByLabel('Search Balance Lab run history').fill('wow');
assert.equal(await page.locator('.balance-lab-job-list > button').count(), 1);
await page.setViewportSize({ width: 480, height: 900 });
await page.waitForTimeout(250);
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth), true);
await page.locator('#balance-lab-analysis').scrollIntoViewIfNeeded();
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 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');
assert.deepEqual(pageErrors, []);
console.log('Balance Lab GM playtest passed');
} finally {
await browser.close();
await new Promise((resolve) => runtime.server.close(resolve));
fs.rmSync(runtimeData, { recursive: true, force: true });
}
})().catch((error) => {
console.error(error);
process.exitCode = 1;
});
+53 -1
View File
@@ -2,6 +2,7 @@ const { chromium } = require('C:/Users/phenom/.cache/codex-runtimes/codex-primar
const fs = require('node:fs');
const path = require('node:path');
const assert = require('node:assert/strict');
const crypto = require('node:crypto');
const selectedBossName = process.env.BOSS_TEST_BOSS || 'Lady Anacondra';
const directory = path.resolve(process.env.BOSS_TEST_ARTIFACT_DIR || path.join(
'playtest-artifacts',
@@ -38,6 +39,30 @@ const base = process.env.BOSS_TEST_URL || 'http://127.0.0.1:4187';
await page.screenshot({ path: path.join(directory, `${name}.png`) });
console.log(`Captured ${name}`);
};
const canvasHash = async () => {
const canvas = page.locator('canvas');
await canvas.evaluate((element) => {
const hidden = [];
let current = element;
while (current.parentElement) {
for (const sibling of current.parentElement.children) {
if (sibling === current || !(sibling instanceof HTMLElement)) continue;
hidden.push([sibling, sibling.style.visibility]);
sibling.style.visibility = 'hidden';
}
current = current.parentElement;
}
window.__restoreCanvasCapture = () => {
for (const [node, visibility] of hidden) node.style.visibility = visibility;
delete window.__restoreCanvasCapture;
};
});
try {
return crypto.createHash('sha256').update(await canvas.screenshot()).digest('hex');
} finally {
await page.evaluate(() => window.__restoreCanvasCapture?.());
}
};
try {
await page.goto(base, { waitUntil: 'domcontentloaded' });
console.log('Production menu loaded');
@@ -66,6 +91,13 @@ const base = process.env.BOSS_TEST_URL || 'http://127.0.0.1:4187';
return game.assetStatus === 'ready' && game.bossTest?.runtimeIds.every((id) => useCombatStore.getState().mobs[id]);
}, null, { timeout: 90000 });
await page.waitForTimeout(4000);
const bossFrameBefore = await canvasHash();
await page.waitForTimeout(500);
assert.notEqual(
await canvasHash(),
bossFrameBefore,
'Boss Testing redraws the WebGL scene independently from its HUD and nameplates',
);
await capture('03-empty-room');
const before = await page.evaluate(async () => {
const { useGameStore, useCombatStore, useShellStore } = await window.bossTestingStores();
@@ -118,6 +150,25 @@ const base = process.env.BOSS_TEST_URL || 'http://127.0.0.1:4187';
const reports = runtime.database.listDungeonReports().reports;
assert.equal(reports[0].bossKey, before.boss.key);
assert.equal(runtime.database.listBossTestingReviews().reviews[0].status, 'ready');
await page.evaluate(async (previousKey) => {
window.previousBossTestingKey = previousKey;
window.liveBossTestingStores = await window.bossTestingStores();
}, before.boss.key);
await page.getByRole('button', { name: 'Fight another random boss', exact: true }).click();
await page.waitForFunction(() => {
const { useGameStore, useCombatStore } = window.liveBossTestingStores;
const game = useGameStore.getState();
return game.assetStatus === 'ready' && game.bossTest?.key !== window.previousBossTestingKey
&& game.bossTest?.runtimeIds.every((id) => useCombatStore.getState().mobs[id]);
}, null, { timeout: 120000 });
const nextBoss = await page.evaluate(() => {
const { useGameStore } = window.liveBossTestingStores;
return useGameStore.getState().bossTest;
});
assert.notEqual(nextBoss.key, before.boss.key, 'Post-fight random selection must choose another boss');
await page.waitForTimeout(1000);
await capture('06b-another-random-boss');
await page.keyboard.press('Escape');
await page.getByRole('button', { name: 'Return to main menu', exact: true }).click();
await page.locator('.main-menu-screen').waitFor();
await page.getByRole('button', { name: /Boss Testing/ }).click();
@@ -168,7 +219,8 @@ const base = process.env.BOSS_TEST_URL || 'http://127.0.0.1:4187';
assert.deepEqual(errors, [], 'No browser runtime errors');
fs.writeFileSync(path.join(directory, 'results.json'), `${JSON.stringify({ passed: true, boss: before.boss, classId: before.classId,
characterLevel: before.level, bossLevel: before.bossLevel, bossMaxHealth: before.bossMaxHealth,
initialMobCount: before.count, xpUnchanged: true, profileUnchanged: true, automaticDownload: 'dungeon:ragefire-chasm', report: reports[0], errors }, null, 2)}\n`);
initialMobCount: before.count, nextRandomBoss: nextBoss, xpUnchanged: true, profileUnchanged: true,
automaticDownload: 'dungeon:ragefire-chasm', report: reports[0], errors }, null, 2)}\n`);
console.log('Boss Testing live flow passed');
} catch (error) {
await capture('failure');
+192
View File
@@ -0,0 +1,192 @@
const { chromium } = require('C:/Users/phenom/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright');
const assert = require('node:assert/strict');
const crypto = require('node:crypto');
const fs = require('node:fs');
const path = require('node:path');
const directory = path.resolve('playtest-artifacts', 'dungeon-simulation');
fs.mkdirSync(directory, { recursive: true });
const databaseDirectory = path.join(directory, 'test-database');
fs.rmSync(databaseDirectory, { recursive: true, force: true });
(async () => {
const { createGameServer } = await import('../server/server.mjs');
const runtime = createGameServer({
dataDir: databaseDirectory,
enableShelvedModes: false,
});
await new Promise((resolve) => runtime.server.listen(4189, '127.0.0.1', resolve));
const browser = await chromium.launch({ channel: 'msedge', headless: true });
const context = await browser.newContext({ viewport: { width: 1600, height: 1000 }, acceptDownloads: true });
const page = await context.newPage();
const cameraModuleUrls = fs.readdirSync('dist/assets')
.filter((file) => /^(?:index|GameRuntime)-.*\.js$/.test(file))
.map((file) => `/assets/${file}`);
await page.addInitScript((moduleUrls) => {
window.readSpectatorCameraState = async () => {
const values = (await Promise.all(moduleUrls.map((url) => import(url)))).flatMap(Object.values);
const game = values.find((value) => value?.getState && 'cameraYaw' in value.getState() && 'activeDungeonId' in value.getState());
if (!game) throw new Error('Game camera store export was not found.');
return { yaw: game.getState().cameraYaw, pointerLocked: document.pointerLockElement !== null };
};
}, cameraModuleUrls);
page.setDefaultTimeout(90000);
const errors = [];
page.on('pageerror', (error) => errors.push(error.message));
const capture = (name) => page.screenshot({ path: path.join(directory, `${name}.png`), fullPage: true });
const captureCanvas = async (name) => {
const canvas = page.locator('canvas');
await canvas.evaluate((element) => {
const hidden = [];
let current = element;
while (current.parentElement) {
for (const sibling of current.parentElement.children) {
if (sibling === current || !(sibling instanceof HTMLElement)) continue;
hidden.push([sibling, sibling.style.visibility]);
sibling.style.visibility = 'hidden';
}
current = current.parentElement;
}
window.__restoreCanvasCapture = () => {
for (const [node, visibility] of hidden) node.style.visibility = visibility;
delete window.__restoreCanvasCapture;
};
});
try {
const image = await canvas.screenshot(name ? { path: path.join(directory, `${name}.png`) } : undefined);
return crypto.createHash('sha256').update(image).digest('hex');
} finally {
await page.evaluate(() => window.__restoreCanvasCapture?.());
}
};
try {
await page.goto('http://127.0.0.1:4189', { waitUntil: 'domcontentloaded' });
await page.getByLabel('Username').fill('Phenom');
await page.getByLabel('Password').fill('dungeon-route-admin');
await page.getByRole('button', { name: /Create account/ }).click();
await page.getByRole('button', { name: /Create character/ }).click();
await page.getByRole('button', { name: /Customize & name/ }).click();
await page.getByLabel('Character name').fill('Routewatcher');
await page.getByRole('button', { name: /Create character/ }).click();
await page.getByRole('button', { name: /Continue/ }).first().click();
await page.getByRole('button', { name: /GM Tools/ }).click();
await page.getByRole('heading', { name: 'Dungeon route simulations' }).waitFor();
await capture('01-gm-route-launch');
const routeSelects = page.locator('.dungeon-sim-admin__catalog select');
await routeSelects.nth(0).selectOption('wailing-caverns');
await routeSelects.nth(1).selectOption('navigation-only');
await page.locator('.dungeon-sim-admin__catalog input').fill('browser-smoke-fixed-seed');
await page.getByRole('button', { name: 'Launch spectate mode' }).click();
await page.locator('canvas').waitFor();
await page.getByLabel('Dungeon simulation controls').waitFor();
await page.waitForTimeout(6000);
await capture('02-live-navigation-simulation');
await page.getByRole('button', { name: 'Pause' }).click();
const pausedTime = await page.locator('.dungeon-sim-hud__header > time').innerText();
await page.waitForTimeout(700);
assert.equal(await page.locator('.dungeon-sim-hud__header > time').innerText(), pausedTime, 'pause freezes simulation time');
await page.getByRole('button', { name: 'Step' }).click();
await page.getByRole('button', { name: 'Step' }).click();
await page.waitForTimeout(200);
assert.notEqual(await page.locator('.dungeon-sim-hud__header > time').innerText(), pausedTime, 'single-step advances simulation time');
const memberPicker = page.getByLabel('Spectate party member');
const memberButtons = memberPicker.locator('div > button');
assert.equal(await memberButtons.count(), 5, 'all five simulated party members are spectatable');
const chosenName = await memberButtons.nth(2).locator('strong').innerText();
await memberButtons.nth(2).click();
assert.equal(await memberButtons.nth(2).getAttribute('aria-pressed'), 'true');
await page.waitForTimeout(300);
await capture('03-follow-selected-member');
const beforeFollowArrowLook = await page.evaluate(() => window.readSpectatorCameraState());
await page.keyboard.down('ArrowLeft');
await page.waitForTimeout(300);
await page.keyboard.up('ArrowLeft');
const afterFollowArrowLook = await page.evaluate(() => window.readSpectatorCameraState());
assert.notEqual(afterFollowArrowLook.yaw, beforeFollowArrowLook.yaw, 'ArrowLeft orbits the follow camera');
await page.getByRole('button', { name: 'Free camera' }).click();
assert.equal(await page.getByRole('button', { name: 'Free camera' }).getAttribute('aria-pressed'), 'true');
const beforeFlight = await captureCanvas('03-canvas-before-flight');
await page.keyboard.down('w');
await page.waitForTimeout(700);
await page.keyboard.up('w');
await page.waitForTimeout(150);
const afterFlight = await captureCanvas();
assert.notEqual(afterFlight, beforeFlight, 'free-camera movement redraws the 3D dungeon without relying on nameplate overlays');
const beforeArrowLook = await page.evaluate(() => window.readSpectatorCameraState());
await page.keyboard.down('ArrowRight');
await page.waitForTimeout(300);
await page.keyboard.up('ArrowRight');
const afterArrowLook = await page.evaluate(() => window.readSpectatorCameraState());
assert.notEqual(afterArrowLook.yaw, beforeArrowLook.yaw, 'ArrowRight turns the free camera');
const canvasBounds = await page.locator('canvas').boundingBox();
assert(canvasBounds, 'simulation canvas is visible');
const lookStart = { x: canvasBounds.x + canvasBounds.width * 0.5, y: canvasBounds.y + canvasBounds.height * 0.55 };
const beforeLook = await page.evaluate(() => window.readSpectatorCameraState());
await page.mouse.move(lookStart.x, lookStart.y);
await page.mouse.down({ button: 'right' });
await page.mouse.move(lookStart.x + 220, lookStart.y + 100, { steps: 12 });
await page.waitForTimeout(250);
const duringLook = await page.evaluate(() => window.readSpectatorCameraState());
assert.notEqual(duringLook.yaw, beforeLook.yaw, 'right-drag rotates the free camera');
assert.equal(duringLook.pointerLocked, false, 'right-drag does not request Pointer Lock');
await page.mouse.up({ button: 'right' });
const releasedLook = await page.evaluate(() => window.readSpectatorCameraState());
await page.mouse.move(lookStart.x - 120, lookStart.y + 50, { steps: 8 });
await page.waitForTimeout(250);
assert.equal(
(await page.evaluate(() => window.readSpectatorCameraState())).yaw,
releasedLook.yaw,
'mouse movement after right-button release does not rotate the camera',
);
await page.locator('canvas').screenshot({ path: path.join(directory, '04-canvas-after-flight-look.png') });
await capture('04-paused-free-camera-moved');
await memberButtons.nth(4).click();
assert.equal(await page.getByRole('button', { name: 'Follow member' }).getAttribute('aria-pressed'), 'true');
assert.match(await memberPicker.locator('header strong').innerText(), /\S/);
assert.notEqual(await memberPicker.locator('header strong').innerText(), chosenName);
await page.waitForTimeout(300);
await capture('05-follow-another-member');
await page.getByRole('button', { name: '4×' }).click();
await page.getByRole('button', { name: 'Play' }).click();
await page.waitForTimeout(1200);
await page.getByRole('button', { name: 'Stop & save' }).click();
await page.getByRole('button', { name: 'Return to GM Tools' }).waitFor();
await capture('06-finished-report');
await page.getByRole('button', { name: 'Return to GM Tools' }).click();
await page.getByRole('heading', { name: 'Dungeon route simulations' }).waitFor();
await page.locator('.dungeon-sim-admin__catalog select').nth(0).selectOption('wailing-caverns');
await page.getByText(/1 runs for this route/).waitFor();
await capture('07-saved-replay');
await page.getByRole('button', { name: 'Watch' }).first().click();
await page.getByText('Recorded replay').waitFor();
await page.getByLabel('Replay position').waitFor();
await page.waitForTimeout(6000);
await page.getByRole('button', { name: 'Play' }).click();
await page.waitForTimeout(800);
await capture('08-replay-controls');
await page.getByRole('button', { name: 'Pause' }).click();
const pausedReplayTime = await page.locator('.dungeon-sim-hud__header > time').innerText();
await page.waitForTimeout(500);
assert.equal(await page.locator('.dungeon-sim-hud__header > time').innerText(), pausedReplayTime, 'replay pause freezes playback time');
await page.getByRole('button', { name: '4×' }).click();
await page.getByRole('button', { name: 'Play' }).click();
await page.waitForTimeout(600);
await capture('09-replay-4x');
assert.deepEqual(errors, [], `No browser page errors: ${errors.join('; ')}`);
console.log('Dungeon simulation browser smoke test passed.');
} finally {
await browser.close();
await new Promise((resolve) => runtime.server.close(resolve));
}
})().catch((error) => {
console.error(error);
process.exitCode = 1;
});
+180
View File
@@ -0,0 +1,180 @@
const assert = require("node:assert/strict");
const crypto = require("node:crypto");
const fs = require("node:fs");
const path = require("node:path");
const { chromium } = require("C:/Users/phenom/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright");
const base = process.env.MOUSE_CAMERA_TEST_URL || "http://127.0.0.1:5176";
const directory = path.resolve("playtest-artifacts/mouse-camera");
fs.mkdirSync(directory, { recursive: true });
(async () => {
const browser = await chromium.launch({ channel: "msedge", headless: true });
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
page.setDefaultTimeout(120_000);
const errors = [];
page.on("pageerror", (error) => errors.push(error.message));
const canvasHash = async () => {
const canvas = page.locator("canvas");
await canvas.evaluate((element) => {
const hidden = [];
let current = element;
while (current.parentElement) {
for (const sibling of current.parentElement.children) {
if (sibling === current || !(sibling instanceof HTMLElement)) continue;
hidden.push([sibling, sibling.style.visibility]);
sibling.style.visibility = "hidden";
}
current = current.parentElement;
}
window.__restoreCanvasCapture = () => {
for (const [node, visibility] of hidden) node.style.visibility = visibility;
delete window.__restoreCanvasCapture;
};
});
try {
return crypto.createHash("sha256").update(await canvas.screenshot()).digest("hex");
} finally {
await page.evaluate(() => window.__restoreCanvasCapture?.());
}
};
const gameState = () => page.evaluate(async () => {
const storeEntry = performance.getEntriesByType("resource")
.findLast((entry) => new URL(entry.name).pathname === "/src/game/store.ts");
if (!storeEntry) throw new Error("game store module was not loaded");
const { useGameStore } = await import(storeEntry.name);
const state = useGameStore.getState();
return {
cameraLookActive: state.cameraLookActive,
cameraYaw: state.cameraYaw,
pointerLocked: document.pointerLockElement !== null,
selection: window.getSelection()?.toString() ?? "",
};
});
try {
await page.goto(base, { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: /Play offline/ }).click();
await page.getByRole("button", { name: /Create character/ }).click();
await page.getByRole("button", { name: /Customize & name/ }).click();
await page.getByLabel("Character name").fill("Mouselooker");
await page.getByRole("button", { name: /Create character/ }).click();
await page.getByRole("button", { name: /Continue|Enter world/ }).first().click();
await page.locator(".main-menu-screen").waitFor();
await page.getByRole("button", { name: /^Dungeons/ }).click();
await page.getByRole("button", { name: /Choose role/ }).click();
await page.locator(".dungeon-role-option").first().click();
await page.locator(".game-shell").waitFor();
await page.waitForFunction(async () => {
const storeEntry = performance.getEntriesByType("resource")
.findLast((entry) => new URL(entry.name).pathname === "/src/game/store.ts");
if (!storeEntry) return false;
const { useGameStore } = await import(storeEntry.name);
return ["ready", "proxy", "error"].includes(useGameStore.getState().assetStatus);
});
await page.locator(".loading-screen").waitFor({ state: "hidden" });
await page.waitForTimeout(1_000);
const selectionBefore = await page.evaluate(() => {
const fixture = document.createElement("p");
fixture.textContent = "Existing browser selection";
fixture.style.userSelect = "text";
fixture.style.position = "fixed";
fixture.style.left = "-10000px";
document.body.append(fixture);
const range = document.createRange();
range.selectNodeContents(fixture);
const selection = window.getSelection();
if (!selection) throw new Error("browser selection API is unavailable");
selection.removeAllRanges();
selection.addRange(range);
return selection.toString();
});
assert.equal(selectionBefore, "Existing browser selection");
const scene = page.locator(".scene-shell");
const bounds = await scene.boundingBox();
assert(bounds, "3D scene is visible");
const cdp = await page.context().newCDPSession(page);
const start = { x: bounds.x + bounds.width * 0.55, y: bounds.y + bounds.height * 0.55 };
const before = await gameState();
const frameBefore = await canvasHash();
assert.equal(before.cameraLookActive, false);
await page.mouse.move(start.x, start.y);
await page.mouse.down({ button: "right" });
await page.waitForFunction(async () => {
const storeEntry = performance.getEntriesByType("resource")
.findLast((entry) => new URL(entry.name).pathname === "/src/game/store.ts");
if (!storeEntry) return false;
const { useGameStore } = await import(storeEntry.name);
return useGameStore.getState().cameraLookActive;
});
const captured = await gameState();
assert.equal(captured.pointerLocked, false);
assert.equal(captured.selection, "");
await cdp.send("Input.dispatchMouseEvent", {
type: "mouseMoved",
x: start.x + 160,
y: start.y - 55,
button: "right",
buttons: 2,
pointerType: "mouse",
});
await page.waitForTimeout(350);
const moved = await gameState();
assert.notEqual(moved.cameraYaw, before.cameraYaw);
assert.notEqual(
await canvasHash(),
frameBefore,
"regular dungeon camera input redraws the WebGL scene independently from its HUD and nameplates",
);
await page.screenshot({ path: path.join(directory, "right-drag-camera.png") });
await page.mouse.up({ button: "right" });
await page.waitForFunction(async () => {
const storeEntry = performance.getEntriesByType("resource")
.findLast((entry) => new URL(entry.name).pathname === "/src/game/store.ts");
if (!storeEntry) return false;
const { useGameStore } = await import(storeEntry.name);
return !useGameStore.getState().cameraLookActive;
});
const released = await gameState();
await page.mouse.move(start.x - 120, start.y + 70, { steps: 8 });
await page.waitForTimeout(350);
const afterReleaseMove = await gameState();
assert.equal(afterReleaseMove.cameraYaw, released.cameraYaw);
assert.equal(afterReleaseMove.pointerLocked, false);
assert.equal(
await page.locator(".game-shell").evaluate((element) => getComputedStyle(element).userSelect),
"none",
);
assert.deepEqual(errors, []);
const result = {
passed: true,
yawBefore: before.cameraYaw,
yawDuringDrag: moved.cameraYaw,
yawAfterRelease: afterReleaseMove.cameraYaw,
pointerLocked: afterReleaseMove.pointerLocked,
selectionCleared: captured.selection === "",
errors,
};
fs.writeFileSync(path.join(directory, "results.json"), `${JSON.stringify(result, null, 2)}\n`);
console.log(JSON.stringify(result, null, 2));
console.log("Mouse camera playtest passed");
} catch (error) {
await page.mouse.up({ button: "right" }).catch(() => {});
await page.screenshot({ path: path.join(directory, "failure.png") });
console.log((await page.locator("body").innerText()).slice(-2_000));
console.log("Browser errors:", errors);
throw error;
} finally {
await browser.close();
}
})().catch((error) => {
console.error(error);
process.exitCode = 1;
});
+240
View File
@@ -0,0 +1,240 @@
const { chromium } = require('C:/Users/phenom/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const directory = path.resolve('playtest-artifacts', 'rotation-overhaul');
fs.mkdirSync(directory, { recursive: true });
const databaseDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'healer-man-rotation-playtest-'));
const partySeed = 'rotation-boss-test-0';
const bossName = 'Lady Anacondra';
const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
(async () => {
const { createGameServer } = await import('../server/server.mjs');
const runtime = createGameServer({ dataDir: databaseDirectory, enableShelvedModes: false });
await new Promise((resolve) => runtime.server.listen(4191, '127.0.0.1', resolve));
const browser = await chromium.launch({ channel: 'msedge', headless: true });
const context = await browser.newContext({ viewport: { width: 1600, height: 1000 } });
const page = await context.newPage();
page.setDefaultTimeout(90_000);
const errors = [];
page.on('pageerror', (error) => errors.push(error.message));
const moduleUrls = fs.readdirSync('dist/assets')
.filter((file) => /^(?:index|GameRuntime|combatStore)-.*\.js$/.test(file))
.map((file) => `/assets/${file}`);
await page.addInitScript((urls) => {
const runtimeStores = async () => {
const values = (await Promise.all(urls.map((url) => import(url)))).flatMap(Object.values);
const stores = values.filter((value) => value && typeof value.getState === 'function');
const partyStore = stores.find((store) => {
const state = store.getState();
return Array.isArray(state?.members) && typeof state?.initializeParty === 'function'
&& typeof state?.setCommand === 'function';
});
const combatStore = stores.find((store) => {
const state = store.getState();
return state?.mobs && state?.actors && Array.isArray(state?.combatEvents)
&& typeof state?.synchronizeActors === 'function';
});
const gameStore = stores.find((store) => {
const state = store.getState();
return 'bossTest' in (state ?? {}) && 'activeDungeonId' in (state ?? {});
});
if (!partyStore || !combatStore || !gameStore) throw new Error('Required runtime stores were not found.');
return { partyStore, combatStore, gameStore };
};
window.configureRotationBossParty = async (seed) => {
const { partyStore, combatStore } = await runtimeStores();
const combat = combatStore.getState();
partyStore.getState().initializeParty(seed, combat.level, combat.health, 'damage', 5, 'wow');
partyStore.getState().setCommand('stop');
combatStore.getState().synchronizeActors();
combatStore.getState().applyDefaultPartyBuffs();
combatStore.getState().applyPrecombatPreparation();
};
window.startRotationBossParty = async () => {
const { partyStore } = await runtimeStores();
partyStore.getState().setCommand('attack');
};
window.readRotationPlaytestState = async () => {
const { partyStore, combatStore, gameStore } = await runtimeStores();
const party = partyStore.getState();
const combat = combatStore.getState();
const game = gameStore.getState();
const runtimeIds = game.bossTest?.runtimeIds ?? [];
const boss = runtimeIds.map((id) => combat.mobs[id]).find(Boolean)
?? Object.values(combat.mobs).find((mob) => mob.boss);
const currentActorIds = new Set(['__player__', ...party.members.map((member) => member.id)]);
return {
activeDungeonId: game.activeDungeonId,
boss: boss ? {
id: boss.id,
name: boss.name,
health: boss.health,
maxHealth: boss.maxHealth,
engaged: boss.engaged,
dead: boss.dead,
} : null,
party: party.members.map((member) => ({
id: member.id,
name: member.name,
classId: member.classId,
specialization: member.specialization,
role: member.role,
health: member.health,
maxHealth: member.maxHealth,
status: member.status,
})),
auras: combat.auras.filter((aura) => currentActorIds.has(aura.targetId)).map((aura) => ({
name: aura.definition.name,
exclusiveGroup: aura.definition.exclusiveGroup ?? null,
sourceId: aura.sourceId,
targetId: aura.targetId,
expiresAt: aura.expiresAt,
})),
summons: combat.summons.filter((summon) => currentActorIds.has(summon.ownerActorId)).map((summon) => ({
name: summon.name,
kind: summon.kind,
ownerActorId: summon.ownerActorId,
sourceAbilityId: summon.sourceAbilityId,
expiresAt: summon.expiresAt,
})),
events: combat.combatEvents.map((event) => ({
id: event.id,
kind: event.kind,
sourceActorId: event.sourceActorId,
targetActorId: event.targetActorId,
abilityId: event.abilityId,
effectiveAmount: event.effectiveAmount,
})),
};
};
}, moduleUrls);
try {
await page.goto('http://127.0.0.1:4191', { waitUntil: 'domcontentloaded' });
await page.getByLabel('Username').fill('Phenom');
await page.getByLabel('Password').fill('rotation-overhaul-boss-run');
await page.getByRole('button', { name: /Create account/ }).click();
await page.getByRole('button', { name: /Create character/ }).click();
await page.getByLabel('Specialization').getByRole('button', { name: /Shadow/ }).click();
await page.getByRole('button', { name: /Customize & name/ }).click();
await page.getByLabel('Character name').fill('Rotationwatcher');
await page.getByRole('button', { name: /Create character/ }).click();
await page.getByRole('button', { name: /Continue/ }).first().click();
await page.getByRole('button', { name: /Boss Testing/ }).click();
await page.getByRole('heading', { name: 'Boss Testing' }).waitFor();
await page.getByLabel('Search bosses').fill(bossName);
const bossPicker = page.locator('.boss-testing-picker select');
await bossPicker.selectOption({ index: 1 });
await page.getByRole('button', { name: 'Test selected boss' }).click();
await page.locator('canvas').waitFor();
await page.waitForTimeout(3_000);
await page.evaluate((seed) => window.configureRotationBossParty(seed), partySeed);
const initial = await page.evaluate(() => window.readRotationPlaytestState());
assert.equal(initial.boss?.name, bossName, 'the requested boss is active in Boss Testing');
assert.equal(initial.party.length, 4, 'the player has a complete four-companion party');
assert(initial.party.some((member) => member.role === 'tank'), 'party includes a tank');
assert(initial.party.some((member) => member.role === 'healer'), 'party includes a healer');
const damage = initial.party.filter((member) => member.role === 'damage');
const ranged = damage.filter((member) => (
['hunter', 'mage', 'priest', 'warlock'].includes(member.classId)
|| (member.classId === 'druid' && member.specialization === 'Balance')
|| (member.classId === 'shaman' && member.specialization === 'Elemental')
));
const melee = damage.filter((member) => !ranged.some((candidate) => candidate.id === member.id));
assert(ranged.length > 0, `party includes ranged damage: ${JSON.stringify(damage)}`);
assert(melee.length > 0, `party includes melee damage: ${JSON.stringify(damage)}`);
const paladin = initial.party.find((member) => member.classId === 'paladin');
assert(paladin, `party includes the expected paladin setup check: ${JSON.stringify(initial.party)}`);
assert(initial.auras.some((aura) => (
aura.targetId === paladin.id && aura.exclusiveGroup === 'paladin:seal'
)), `paladin seal is active before the attack command: ${JSON.stringify(initial.auras)}`);
await page.evaluate(() => window.startRotationBossParty());
const eventKeys = new Set(initial.events.map((event) => `${event.id}:${event.sourceActorId}:${event.abilityId}`));
const observedEvents = [];
const collect = (state) => {
for (const event of state.events) {
const key = `${event.id}:${event.sourceActorId}:${event.abilityId}`;
if (eventKeys.has(key)) continue;
eventKeys.add(key);
observedEvents.push(event);
}
};
let engaged = null;
const engagementDeadline = Date.now() + 30_000;
while (Date.now() < engagementDeadline) {
const state = await page.evaluate(() => window.readRotationPlaytestState());
collect(state);
if (state.boss?.engaged && !state.boss.dead && state.boss.health < state.boss.maxHealth) {
engaged = state;
break;
}
await sleep(50);
}
assert(engaged, 'the companion party engages Lady Anacondra');
await page.screenshot({ path: path.join(directory, 'boss-engaged.png'), fullPage: true });
const canvasImage = await page.locator('canvas').screenshot({ path: path.join(directory, 'boss-engaged-canvas.png') });
assert(canvasImage.length > 10_000, 'boss screenshot contains a rendered WebGL scene');
let completed = null;
const completionDeadline = Date.now() + 90_000;
while (Date.now() < completionDeadline) {
const state = await page.evaluate(() => window.readRotationPlaytestState());
collect(state);
if (state.boss?.dead || state.boss?.health === 0) {
completed = state;
break;
}
await sleep(50);
}
assert(completed, 'the companion party defeats Lady Anacondra');
await page.waitForTimeout(250);
await page.screenshot({ path: path.join(directory, 'boss-defeated.png'), fullPage: true });
const partyIds = new Set(initial.party.map((member) => member.id));
const partyEvents = observedEvents.filter((event) => partyIds.has(event.sourceActorId));
for (const member of [...initial.party.filter((candidate) => candidate.role === 'tank'), ...melee, ...ranged]) {
assert(partyEvents.some((event) => event.sourceActorId === member.id && event.kind === 'damage'
&& event.abilityId && event.abilityId !== 'party-auto-attack'), `${member.name} uses a damaging ability`);
}
for (const healer of initial.party.filter((member) => member.role === 'healer')) {
assert(partyEvents.some((event) => event.sourceActorId === healer.id
&& ['damage', 'healing'].includes(event.kind) && event.abilityId), `${healer.name} takes an active combat action`);
}
assert.deepEqual(errors, [], `No browser page errors: ${errors.join('; ')}`);
const results = {
dungeon: 'Wailing Caverns — Boss Testing',
boss: bossName,
partySeed,
engagedBoss: engaged.boss,
defeatedBoss: completed.boss,
party: initial.party,
precombatAuras: initial.auras,
precombatSummons: initial.summons,
observedAbilityEvents: partyEvents.filter((event) => event.abilityId !== 'party-auto-attack'),
browserErrors: errors,
};
fs.writeFileSync(path.join(directory, 'results.json'), `${JSON.stringify(results, null, 2)}\n`);
console.log(JSON.stringify(results, null, 2));
console.log('Rotation-overhaul dungeon boss playtest passed.');
} finally {
await browser.close();
await new Promise((resolve) => runtime.server.close(resolve));
}
})().catch((error) => {
console.error(error);
process.exitCode = 1;
});
+124
View File
@@ -0,0 +1,124 @@
import unittest
from scripts.balance_lab import (
aggregate_class_abilities,
aggregate_class_specs,
automatic_parties,
random_party,
search_bosses,
tuning_label,
)
class BalanceLabControllerTests(unittest.TestCase):
def test_random_party_is_repeatable_and_complete(self) -> None:
builds = [
{"id": "tank-a", "role": "tank"},
{"id": "tank-b", "role": "tank"},
{"id": "healer-a", "role": "healer"},
{"id": "healer-b", "role": "healer"},
*({"id": f"damage-{index}", "role": "damage"} for index in range(6)),
]
first = random_party(builds, 17)
second = random_party(builds, 17)
self.assertEqual(first, second)
self.assertEqual([member["role"] for member in first], [
"tank", "healer", "damage", "damage", "damage",
])
self.assertEqual(len({member["id"] for member in first[2:]}), 3)
def test_tuning_flags_keep_both_sides_of_the_median(self) -> None:
self.assertEqual(tuning_label(1.51), "very high")
self.assertEqual(tuning_label(1.25), "high")
self.assertEqual(tuning_label(1.0), "baseline")
self.assertEqual(tuning_label(0.75), "low")
self.assertEqual(tuning_label(0.49), "very low")
self.assertEqual(tuning_label(None), "no baseline")
def test_automatic_parties_are_unique_repeatable_and_source_isolated(self) -> None:
builds = []
for source in ("wow", "coa"):
builds.extend([
{"id": f"{source}-tank-a", "source": source, "role": "tank"},
{"id": f"{source}-tank-b", "source": source, "role": "tank"},
{"id": f"{source}-healer-a", "source": source, "role": "healer"},
{"id": f"{source}-healer-b", "source": source, "role": "healer"},
*({"id": f"{source}-damage-{index}", "source": source, "role": "damage"}
for index in range(6)),
])
first = automatic_parties(builds, ("wow", "coa"), 4, 29)
second = automatic_parties(builds, ("wow", "coa"), 4, 29)
self.assertEqual(first, second)
self.assertEqual(len(first), 8)
self.assertEqual(len({configuration["id"] for configuration in first}), 8)
for configuration in first:
self.assertTrue(all(member["source"] == configuration["source"] for member in configuration["party"]))
self.assertEqual([member["role"] for member in configuration["party"]], [
"tank", "healer", "damage", "damage", "damage",
])
def test_boss_search_matches_name_dungeon_key_and_source(self) -> None:
bosses = [
{"key": "gundrak/eck", "name": "Eck the Ferocious", "dungeonTitle": "Gundrak", "category": "wow"},
{"key": "abbey/duke", "name": "Duke", "dungeonTitle": "Forsaken Abbey", "category": "rom"},
]
self.assertEqual([boss["key"] for boss in search_bosses("ferocious", bosses)], ["gundrak/eck"])
self.assertEqual([boss["key"] for boss in search_bosses("forsaken", bosses)], ["abbey/duke"])
self.assertEqual([boss["key"] for boss in search_bosses("rom", bosses)], ["abbey/duke"])
def test_class_spec_aggregate_keeps_damage_and_healing_received_metrics(self) -> None:
key = ("wow", "priest-holy", "priest", "Priest", "Holy", "healer")
rows = aggregate_class_specs({key: [
{
"partyConfigurationId": "wow-party-001", "elapsedSeconds": 10,
"damage": 200, "bossDamage": 180, "healing": 500, "healingReceived": 300,
"damageTaken": 120, "absorbs": 25, "deathEvents": 0,
},
{
"partyConfigurationId": "wow-party-002", "elapsedSeconds": 20,
"damage": 400, "bossDamage": 360, "healing": 900, "healingReceived": 600,
"damageTaken": 330, "absorbs": 50, "deathEvents": 1,
},
]})
self.assertEqual(len(rows), 1)
self.assertEqual(rows[0]["dtps"], 15)
self.assertEqual(rows[0]["totalDamageTaken"], 450)
self.assertEqual(rows[0]["hpsReceived"], 30)
self.assertEqual(rows[0]["totalHealingReceived"], 900)
def test_ability_aggregate_keeps_used_and_available_but_unused_rows(self) -> None:
class_key = ("wow", "mage-fire", "mage", "Mage", "Fire", "damage")
class_runs = [
{"partyConfigurationId": "party-1", "bossKey": "boss-1", "elapsedSeconds": 10, "damage": 100},
{"partyConfigurationId": "party-2", "bossKey": "boss-2", "elapsedSeconds": 10, "damage": 100},
]
base = {
"abilityName": "Fireball", "icon": "/fireball.png", "categories": ["damage"],
"cooldownSeconds": 0, "available": True, "casts": 0, "hits": 0,
"rawDamage": 0, "effectiveDamage": 0, "rawHealing": 0,
"effectiveHealing": 0, "overheal": 0, "absorbs": 0,
}
groups = {
(*class_key, "mage-fireball"): [
{**base, "partyConfigurationId": "party-1", "bossKey": "boss-1", "elapsedSeconds": 10,
"used": True, "casts": 1, "hits": 1, "rawDamage": 120, "effectiveDamage": 100},
{**base, "partyConfigurationId": "party-2", "bossKey": "boss-2", "elapsedSeconds": 10,
"used": False},
],
(*class_key, "mage-unused-blast"): [
{**base, "abilityName": "Unused Blast", "partyConfigurationId": "party-1",
"bossKey": "boss-1", "elapsedSeconds": 10, "used": False},
{**base, "abilityName": "Unused Blast", "partyConfigurationId": "party-2",
"bossKey": "boss-2", "elapsedSeconds": 10, "used": False},
],
}
rows = aggregate_class_abilities(groups, {class_key: class_runs})
by_id = {row["abilityId"]: row for row in rows}
self.assertEqual(by_id["mage-fireball"]["activeRuns"], 1)
self.assertEqual(by_id["mage-fireball"]["usageRate"], 0.5)
self.assertEqual(by_id["mage-fireball"]["damageShare"], 0.5)
self.assertTrue(by_id["mage-unused-blast"]["unusedAvailable"])
if __name__ == "__main__":
unittest.main()
+464
View File
@@ -0,0 +1,464 @@
import { randomUUID } from "node:crypto";
import { spawn, spawnSync } from "node:child_process";
import {
createReadStream,
existsSync,
mkdirSync,
readFileSync,
readdirSync,
statSync,
writeFileSync,
} from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { GameDatabaseError } from "./database.mjs";
const SOURCES = new Set(["wow", "coa", "rom", "all"]);
const BOSS_CATEGORIES = new Set(["all", "wow", "rom"]);
const JOB_STATUSES = new Set(["queued", "running", "complete", "failed"]);
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([
["suite-report.html", "text/html; charset=utf-8"],
["suite-report.json", "application/json; charset=utf-8"],
["suite-report.md", "text/markdown; charset=utf-8"],
["party_configurations.csv", "text/csv; charset=utf-8"],
["class_specs_across_parties.csv", "text/csv; charset=utf-8"],
["class_abilities_across_parties.csv", "text/csv; charset=utf-8"],
["class_spec_by_boss.csv", "text/csv; charset=utf-8"],
["bosses_across_parties.csv", "text/csv; charset=utf-8"],
["boss_by_party.csv", "text/csv; charset=utf-8"],
["suite_runs.csv", "text/csv; charset=utf-8"],
["suite-config.json", "application/json; charset=utf-8"],
]);
function integer(value, name, minimum, maximum) {
const parsed = Number(value);
if (!Number.isInteger(parsed) || parsed < minimum || parsed > maximum) {
throw new GameDatabaseError(`${name} must be a whole number from ${minimum} through ${maximum}.`, 400, "invalid_balance_lab_job");
}
return parsed;
}
export function normalizeBalanceLabJobSpec(input) {
const source = String(input?.source ?? "all").toLowerCase();
const bossCategory = String(input?.bossCategory ?? "all").toLowerCase();
if (!SOURCES.has(source)) {
throw new GameDatabaseError("Party source must be wow, coa, rom, or all.", 400, "invalid_balance_lab_job");
}
if (!BOSS_CATEGORIES.has(bossCategory)) {
throw new GameDatabaseError("Boss category must be all, wow, or rom.", 400, "invalid_balance_lab_job");
}
const rawBossKeys = Array.isArray(input?.bossKeys) ? input.bossKeys : [];
if (rawBossKeys.length > 500) {
throw new GameDatabaseError("Select no more than 500 bosses.", 400, "invalid_balance_lab_job");
}
const bossKeys = [...new Set(rawBossKeys.map((value) => String(value).trim()).filter(Boolean))];
if (bossKeys.some((key) => key.length > 240 || !/^[A-Za-z0-9._:/-]+$/.test(key))) {
throw new GameDatabaseError("A selected boss key is invalid.", 400, "invalid_balance_lab_job");
}
const spec = Object.freeze({
source,
level: integer(input?.level ?? 60, "Level", 1, 80),
partyConfigurations: integer(input?.partyConfigurations ?? 10, "Party configurations", 1, 100),
simulationsPerParty: integer(input?.simulationsPerParty ?? 3, "Simulations per party", 1, 100),
durationSeconds: integer(input?.durationSeconds ?? 60, "Fight duration", 5, 600),
partySeed: integer(input?.partySeed ?? 1, "Party seed", -2_147_483_648, 2_147_483_647),
bossCategory,
bossKeys: Object.freeze(bossKeys),
});
const sourceCount = spec.source === "all" ? 3 : 1;
const bossEstimate = Math.max(1, spec.bossKeys.length || (spec.bossCategory === "all" ? 500 : 300));
const estimatedRuns = sourceCount * spec.partyConfigurations * spec.simulationsPerParty * bossEstimate;
if (estimatedRuns > 250_000) {
throw new GameDatabaseError(
"That job is too large for one server request. Reduce parties, simulations, or selected bosses.",
400,
"balance_lab_job_too_large",
);
}
return spec;
}
export function balanceLabCommand(spec, outputDir, options = {}) {
const projectRoot = path.resolve(options.projectRoot ?? fileURLToPath(new URL("..", import.meta.url)));
const executable = String(options.pythonExecutable
?? process.env.BALANCE_LAB_PYTHON
?? (process.platform === "win32" ? "python" : "python3"));
const args = [
path.join(projectRoot, "scripts", "balance_lab.py"),
"--source", spec.source,
"--level", String(spec.level),
"--party-mode", "automatic",
"--party-seed", String(spec.partySeed),
"--party-configurations", String(spec.partyConfigurations),
"--simulations-per-party", String(spec.simulationsPerParty),
"--duration", String(spec.durationSeconds),
"--boss-category", spec.bossCategory,
"--output-dir", outputDir,
];
for (const bossKey of spec.bossKeys) args.push("--boss", bossKey);
return { executable, args, cwd: projectRoot };
}
export function inspectBalanceLabRuntime(executable, run = spawnSync) {
const command = String(executable || (process.platform === "win32" ? "python" : "python3"));
try {
const result = run(command, ["--version"], {
encoding: "utf8",
timeout: 5_000,
windowsHide: true,
});
const version = `${result?.stdout ?? ""} ${result?.stderr ?? ""}`.trim();
if (result?.status === 0 && /^Python 3\./i.test(version)) {
return { available: true, executable: command, version, error: null };
}
return {
available: false,
executable: command,
version: version || null,
error: version && /^Python 2\./i.test(version)
? "Balance Lab requires Python 3; this server resolved Python 2."
: `The server could not execute ${command} as Python 3.`,
};
} catch (error) {
return {
available: false,
executable: command,
version: null,
error: `The server could not execute ${command}: ${error?.message ?? error}`,
};
}
}
function publicJob(job) {
return {
id: job.id,
status: job.status,
createdAt: job.createdAt,
startedAt: job.startedAt,
completedAt: job.completedAt,
requestedBy: job.requestedBy,
spec: job.spec,
progress: { ...job.progress },
artifacts: [...job.artifacts],
error: job.error,
logTail: [...job.logTail],
summary: job.summary ? { ...job.summary } : null,
};
}
function reportSummary(outputDir) {
const reportPath = path.join(outputDir, "suite-report.json");
if (!existsSync(reportPath)) return null;
try {
const summary = JSON.parse(readFileSync(reportPath, "utf8"))?.summary;
if (!summary || typeof summary !== "object") return null;
return {
partyConfigurations: Number(summary.partyConfigurations) || 0,
bosses: Number(summary.bosses) || 0,
runs: Number(summary.runs) || 0,
classSpecs: Number(summary.classSpecs) || 0,
classAbilities: Number(summary.classAbilities) || 0,
bossPressureMedianDtps: Number(summary.bossPressureMedianDtps) || 0,
};
} catch {
return null;
}
}
function lineCollector(onLine) {
let pending = "";
return {
push(chunk) {
pending += String(chunk);
const lines = pending.split(/\r?\n/);
pending = lines.pop() ?? "";
for (const line of lines) if (line.trim()) onLine(line.trim());
},
flush() {
if (pending.trim()) onLine(pending.trim());
pending = "";
},
};
}
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 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);
const maxQueued = Number.isFinite(configuredQueueLimit) && configuredQueueLimit >= 1
? Math.min(50, Math.floor(configuredQueueLimit))
: 8;
const launch = options.spawnProcess ?? spawn;
const pythonExecutable = String(options.pythonExecutable
?? process.env.BALANCE_LAB_PYTHON
?? (process.platform === "win32" ? "python" : "python3"));
const runtimeProbe = options.runtimeProbe
?? (options.spawnProcess
? () => ({ available: true, executable: pythonExecutable, version: "test process", error: null })
: () => inspectBalanceLabRuntime(pythonExecutable));
const jobs = new Map();
const queue = [];
const children = new Map();
let running = 0;
let closed = false;
mkdirSync(jobsDir, { recursive: true });
function persistJob(job) {
const record = publicJob(job);
writeFileSync(path.join(job.outputDir, JOB_METADATA_NAME), `${JSON.stringify(record, null, 2)}\n`, "utf8");
}
function restoreJobs() {
for (const entry of readdirSync(jobsDir, { withFileTypes: true })) {
if (!entry.isDirectory() || !JOB_ID_PATTERN.test(entry.name)) continue;
const outputDir = path.join(jobsDir, entry.name);
const metadataPath = path.join(outputDir, JOB_METADATA_NAME);
const reportPath = path.join(outputDir, "suite-report.json");
if (!existsSync(metadataPath) && !existsSync(reportPath)) continue;
try {
let stored;
if (existsSync(metadataPath)) {
stored = JSON.parse(readFileSync(metadataPath, "utf8"));
} else {
const legacyReport = JSON.parse(readFileSync(reportPath, "utf8"));
const legacyConfig = JSON.parse(readFileSync(path.join(outputDir, "suite-config.json"), "utf8"));
stored = {
status: "complete",
createdAt: legacyReport.generatedAt,
completedAt: legacyReport.generatedAt,
requestedBy: "unknown (legacy run)",
spec: {
source: legacyConfig.source,
level: legacyConfig.level,
partyConfigurations: legacyConfig.partyConfigurationsPerSource,
simulationsPerParty: legacyConfig.simulationsPerParty,
durationSeconds: legacyConfig.durationSeconds,
partySeed: legacyConfig.partySeed,
bossCategory: legacyConfig.bossCategory,
bossKeys: legacyConfig.bossKeys ?? [],
},
progress: {},
logTail: ["Imported from a Balance Lab report created before run-history persistence."],
};
}
const spec = normalizeBalanceLabJobSpec(stored.spec);
const hasReport = existsSync(reportPath);
const storedStatus = JOB_STATUSES.has(stored.status) ? stored.status : "failed";
const interrupted = !hasReport && (storedStatus === "queued" || storedStatus === "running");
const totalParties = spec.partyConfigurations * (spec.source === "all" ? 3 : 1);
const createdAt = typeof stored.createdAt === "string"
? stored.createdAt
: statSync(outputDir).birthtime.toISOString();
const job = {
id: entry.name,
status: hasReport ? "complete" : interrupted ? "failed" : storedStatus,
createdAt,
startedAt: typeof stored.startedAt === "string" ? stored.startedAt : null,
completedAt: hasReport || interrupted
? (typeof stored.completedAt === "string" ? stored.completedAt : new Date().toISOString())
: null,
requestedBy: typeof stored.requestedBy === "string" ? stored.requestedBy : "unknown",
spec,
progress: {
party: Number(stored.progress?.party) || 0,
partiesCompleted: hasReport ? totalParties : Number(stored.progress?.partiesCompleted) || 0,
partiesTotal: totalParties,
simulationsCompleted: Number(stored.progress?.simulationsCompleted) || 0,
simulationsTotal: Number(stored.progress?.simulationsTotal) || 0,
},
artifacts: hasReport
? [...ARTIFACTS.keys()].filter((name) => existsSync(path.join(outputDir, name)))
: [],
error: interrupted
? "The server restarted before this Balance Lab job completed. Run it again to produce a report."
: (typeof stored.error === "string" ? stored.error : null),
logTail: Array.isArray(stored.logTail)
? stored.logTail.filter((line) => typeof line === "string").slice(-80)
: [],
summary: hasReport ? reportSummary(outputDir) : null,
outputDir,
};
jobs.set(job.id, job);
persistJob(job);
} catch (error) {
console.warn(`[balance-lab] Could not restore ${entry.name}: ${error?.message ?? error}`);
}
}
}
restoreJobs();
function appendLog(job, line) {
job.logTail.push(line.slice(0, 1_000));
if (job.logTail.length > 80) job.logTail.splice(0, job.logTail.length - 80);
const party = /^Party\s+(\d+)\/(\d+):/i.exec(line);
if (party) {
job.progress.party = Number(party[1]);
job.progress.partiesTotal = Number(party[2]);
job.progress.partiesCompleted = Math.max(0, Number(party[1]) - 1);
job.progress.simulationsCompleted = 0;
job.progress.simulationsTotal = 0;
}
const simulations = /^\[balance-lab]\s+(\d+)\/(\d+)\s+/i.exec(line);
if (simulations) {
job.progress.simulationsCompleted = Number(simulations[1]);
job.progress.simulationsTotal = Number(simulations[2]);
}
persistJob(job);
}
function pump() {
if (closed) return;
while (running < maxConcurrent && queue.length) {
const job = queue.shift();
if (!job || job.status !== "queued") continue;
running += 1;
job.status = "running";
job.startedAt = new Date().toISOString();
persistJob(job);
const command = balanceLabCommand(job.spec, job.outputDir, {
projectRoot,
pythonExecutable,
});
const child = launch(command.executable, command.args, {
cwd: command.cwd,
env: { ...process.env, PYTHONUNBUFFERED: "1" },
windowsHide: true,
stdio: ["ignore", "pipe", "pipe"],
});
children.set(job.id, child);
const stdout = lineCollector((line) => appendLog(job, line));
const stderr = lineCollector((line) => appendLog(job, line));
child.stdout?.on("data", (chunk) => stdout.push(chunk));
child.stderr?.on("data", (chunk) => stderr.push(chunk));
let settled = false;
const finish = (error, code = null) => {
if (settled) return;
settled = true;
stdout.flush();
stderr.flush();
children.delete(job.id);
running = Math.max(0, running - 1);
job.completedAt = new Date().toISOString();
if (!error && code === 0 && existsSync(path.join(job.outputDir, "suite-report.json"))) {
job.status = "complete";
job.progress.partiesCompleted = job.progress.partiesTotal;
job.progress.simulationsCompleted = job.progress.simulationsTotal;
job.artifacts = [...ARTIFACTS.keys()].filter((name) => existsSync(path.join(job.outputDir, name)));
job.summary = reportSummary(job.outputDir);
} else {
job.status = "failed";
job.error = error?.message
?? (code === 0
? "Balance Lab ended without producing its suite report."
: `Balance Lab process exited with code ${code ?? "unknown"}.`);
}
persistJob(job);
pump();
};
child.once("error", (error) => finish(new Error(
error?.code === "ENOENT"
? `Could not start ${command.executable}. Install Python 3 on the server or set BALANCE_LAB_PYTHON.`
: `Could not start the Balance Lab process: ${error?.message ?? error}`,
)));
child.once("close", (code) => finish(null, code));
}
}
function requireJob(id) {
const job = jobs.get(id);
if (!job) throw new GameDatabaseError("Balance Lab job was not found.", 404, "balance_lab_job_not_found");
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 = spec.partyConfigurations * (spec.source === "all" ? 3 : 1);
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);
pump();
return publicJob(job);
},
list() {
return [...jobs.values()]
.sort((left, right) => Date.parse(right.createdAt) - Date.parse(left.createdAt))
.slice(0, 100)
.map(publicJob);
},
runtime() {
return { ...runtimeProbe() };
},
get(id) {
return publicJob(requireJob(id));
},
artifact(id, name) {
const job = requireJob(id);
if (!JOB_STATUSES.has(job.status) || job.status !== "complete") {
throw new GameDatabaseError("Balance Lab artifacts are available after the job completes.", 409, "balance_lab_job_incomplete");
}
const contentType = ARTIFACTS.get(name);
if (!contentType || !job.artifacts.includes(name)) {
throw new GameDatabaseError("Balance Lab artifact was not found.", 404, "balance_lab_artifact_not_found");
}
const filePath = path.join(job.outputDir, name);
const stats = statSync(filePath);
return { filePath, contentType, size: stats.size, stream: () => createReadStream(filePath) };
},
close() {
closed = true;
queue.length = 0;
for (const child of children.values()) child.kill();
children.clear();
},
};
}
+284
View File
@@ -0,0 +1,284 @@
import assert from "node:assert/strict";
import { EventEmitter } from "node:events";
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { PassThrough } from "node:stream";
import test from "node:test";
import {
balanceLabCommand,
createBalanceLabJobService,
inspectBalanceLabRuntime,
normalizeBalanceLabJobSpec,
} from "./balance-lab-jobs.mjs";
test("normalizes and bounds server Balance Lab requests", () => {
const spec = normalizeBalanceLabJobSpec({
source: "ALL",
level: 40,
partyConfigurations: 2,
simulationsPerParty: 3,
durationSeconds: 45,
partySeed: 17,
bossCategory: "all",
bossKeys: ["gundrak/entry-29932", "gundrak/entry-29932"],
});
assert.equal(spec.source, "all");
assert.deepEqual(spec.bossKeys, ["gundrak/entry-29932"]);
assert.throws(
() => normalizeBalanceLabJobSpec({ source: "wow", level: 81 }),
/Level must be a whole number/,
);
assert.throws(
() => normalizeBalanceLabJobSpec({ source: "wow", bossKeys: ["boss; shutdown"] }),
/boss key is invalid/,
);
});
test("builds an argument-array command without a shell", () => {
const spec = normalizeBalanceLabJobSpec({
source: "rom", level: 60, partyConfigurations: 4, simulationsPerParty: 2,
durationSeconds: 30, partySeed: 9, bossCategory: "rom", bossKeys: ["abbey/boss-1"],
});
const command = balanceLabCommand(spec, "C:/reports/job", {
projectRoot: "C:/app",
pythonExecutable: "python-custom",
});
assert.equal(command.executable, "python-custom");
assert.equal(command.args.includes("--party-mode"), true);
assert.equal(command.args.includes("automatic"), true);
assert.equal(command.args.includes("abbey/boss-1"), true);
assert.equal(command.cwd, path.resolve("C:/app"));
});
test("detects available and missing Python 3 runtimes", () => {
const available = inspectBalanceLabRuntime("python3", () => ({
status: 0, stdout: "Python 3.12.8\n", stderr: "",
}));
assert.equal(available.available, true);
assert.equal(available.version, "Python 3.12.8");
const missing = inspectBalanceLabRuntime("python3", () => ({
status: 127, stdout: "", stderr: "python3: not found",
}));
assert.equal(missing.available, false);
assert.match(missing.error, /could not execute python3/i);
});
test("rejects a job before queueing when the server runtime is unavailable", () => {
const directory = mkdtempSync(path.join(os.tmpdir(), "healer-man-balance-runtime-"));
try {
const service = createBalanceLabJobService({
dataDir: directory,
projectRoot: directory,
runtimeProbe: () => ({ available: false, executable: "python3", version: null, error: "python3 is missing." }),
});
assert.throws(() => service.start({
source: "wow", level: 40, partyConfigurations: 1, simulationsPerParty: 1,
durationSeconds: 5, partySeed: 1, bossCategory: "all", bossKeys: ["gundrak/entry-29932"],
}, { username: "Phenom" }), /Install Python 3 in the server container/);
assert.equal(service.list().length, 0);
assert.equal(service.runtime().available, false);
service.close();
} finally {
rmSync(directory, { recursive: true, force: true });
}
});
test("queues a job, reports progress, and exposes only allow-listed artifacts", async () => {
const directory = mkdtempSync(path.join(os.tmpdir(), "healer-man-balance-job-"));
try {
const spawnProcess = (_executable, args) => {
const child = new EventEmitter();
child.stdout = new PassThrough();
child.stderr = new PassThrough();
child.kill = () => child.emit("close", null);
const outputIndex = args.indexOf("--output-dir");
const outputDir = args[outputIndex + 1];
setImmediate(() => {
mkdirSync(outputDir, { recursive: true });
writeFileSync(path.join(outputDir, "suite-report.json"), "{}\n");
child.stdout.write("Party 1/1: wow-party-001\n");
child.stderr.write("[balance-lab] 1/1 Eck the Ferocious seed 1\n");
child.stdout.end();
child.stderr.end();
child.emit("close", 0);
});
return child;
};
const service = createBalanceLabJobService({
dataDir: directory,
projectRoot: directory,
spawnProcess,
pythonExecutable: "python-test",
});
const started = service.start({
source: "wow", level: 40, partyConfigurations: 1, simulationsPerParty: 1,
durationSeconds: 5, partySeed: 1, bossCategory: "all", bossKeys: ["gundrak/entry-29932"],
}, { username: "Phenom" });
assert.equal(started.status, "running");
await new Promise((resolve) => setImmediate(resolve));
const completed = service.get(started.id);
assert.equal(completed.status, "complete");
assert.equal(completed.progress.partiesCompleted, 1);
assert.deepEqual(completed.artifacts, ["suite-report.json"]);
assert.equal(service.artifact(started.id, "suite-report.json").size, 3);
assert.throws(() => service.artifact(started.id, "../../game.db"), /artifact was not found/);
service.close();
} finally {
rmSync(directory, { recursive: true, force: true });
}
});
test("restores completed reports as previous runs after a server restart", async () => {
const directory = mkdtempSync(path.join(os.tmpdir(), "healer-man-balance-history-"));
try {
let launches = 0;
const spawnProcess = (_executable, args) => {
launches += 1;
const child = new EventEmitter();
child.stdout = new PassThrough();
child.stderr = new PassThrough();
child.kill = () => child.emit("close", null);
const outputDir = args[args.indexOf("--output-dir") + 1];
setImmediate(() => {
mkdirSync(outputDir, { recursive: true });
writeFileSync(path.join(outputDir, "suite-report.json"), JSON.stringify({
summary: {
partyConfigurations: 2,
bosses: 3,
runs: 6,
classSpecs: 5,
bossPressureMedianDtps: 123.5,
},
}));
child.emit("close", 0);
});
return child;
};
const firstService = createBalanceLabJobService({
dataDir: directory,
projectRoot: directory,
spawnProcess,
pythonExecutable: "python-test",
});
const started = firstService.start({
source: "wow", level: 40, partyConfigurations: 2, simulationsPerParty: 1,
durationSeconds: 5, partySeed: 1, bossCategory: "all", bossKeys: ["gundrak/entry-29932"],
}, { username: "Phenom" });
await new Promise((resolve) => setImmediate(resolve));
assert.equal(firstService.get(started.id).status, "complete");
firstService.close();
const secondService = createBalanceLabJobService({
dataDir: directory,
projectRoot: directory,
spawnProcess: () => { throw new Error("restored jobs must not relaunch"); },
pythonExecutable: "python-test",
});
const restored = secondService.list();
assert.equal(restored.length, 1);
assert.equal(restored[0].id, started.id);
assert.equal(restored[0].status, "complete");
assert.equal(restored[0].summary?.runs, 6);
assert.deepEqual(restored[0].artifacts, ["suite-report.json"]);
assert.equal(secondService.artifact(started.id, "suite-report.json").size > 0, true);
assert.equal(launches, 1);
secondService.close();
} finally {
rmSync(directory, { recursive: true, force: true });
}
});
test("marks an interrupted persisted run as failed when the service returns", () => {
const directory = mkdtempSync(path.join(os.tmpdir(), "healer-man-balance-interrupted-"));
try {
const jobsDir = path.join(directory, "balance-lab", "jobs");
const id = "11111111-1111-4111-8111-111111111111";
const outputDir = path.join(jobsDir, id);
mkdirSync(outputDir, { recursive: true });
writeFileSync(path.join(outputDir, "job.json"), JSON.stringify({
id,
status: "running",
createdAt: "2026-09-15T12:00:00.000Z",
startedAt: "2026-09-15T12:00:01.000Z",
requestedBy: "Phenom",
spec: {
source: "rom", level: 55, partyConfigurations: 1, simulationsPerParty: 1,
durationSeconds: 30, partySeed: 3, bossCategory: "rom", bossKeys: [],
},
progress: { party: 1, partiesCompleted: 0, partiesTotal: 1 },
artifacts: [],
error: null,
logTail: ["Party 1/1: rom-party-001"],
}));
const service = createBalanceLabJobService({ dataDir: directory, projectRoot: directory });
const restored = service.get(id);
assert.equal(restored.status, "failed");
assert.match(restored.error, /server restarted/i);
service.close();
} finally {
rmSync(directory, { recursive: true, force: true });
}
});
test("imports completed report directories created before history metadata existed", () => {
const directory = mkdtempSync(path.join(os.tmpdir(), "healer-man-balance-legacy-"));
try {
const id = "22222222-2222-4222-8222-222222222222";
const outputDir = path.join(directory, "balance-lab", "jobs", id);
mkdirSync(outputDir, { recursive: true });
writeFileSync(path.join(outputDir, "suite-report.json"), JSON.stringify({
generatedAt: "2026-09-14T10:30:00.000Z",
summary: { partyConfigurations: 3, bosses: 2, runs: 12, classSpecs: 8, bossPressureMedianDtps: 50 },
}));
writeFileSync(path.join(outputDir, "suite-config.json"), JSON.stringify({
source: "coa",
level: 35,
partySeed: 12,
partyConfigurationsPerSource: 3,
simulationsPerParty: 2,
durationSeconds: 45,
bossCategory: "wow",
bossKeys: ["gundrak/entry-29932", "gundrak/entry-29304"],
}));
const service = createBalanceLabJobService({ dataDir: directory, projectRoot: directory });
const restored = service.get(id);
assert.equal(restored.status, "complete");
assert.equal(restored.spec.source, "coa");
assert.equal(restored.summary?.runs, 12);
assert.equal(restored.requestedBy, "unknown (legacy run)");
assert.equal(existsSync(path.join(outputDir, "job.json")), true);
service.close();
} finally {
rmSync(directory, { recursive: true, force: true });
}
});
test("bounds the number of pending server jobs", () => {
const directory = mkdtempSync(path.join(os.tmpdir(), "healer-man-balance-queue-"));
try {
const spawnProcess = () => {
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",
maxQueued: 1,
});
const request = {
source: "wow", level: 40, partyConfigurations: 1, simulationsPerParty: 1,
durationSeconds: 5, partySeed: 1, bossCategory: "all", bossKeys: ["gundrak/entry-29932"],
};
service.start(request, { username: "Phenom" });
assert.throws(() => service.start(request, { username: "Phenom" }), /server queue is full/);
service.close();
} finally {
rmSync(directory, { recursive: true, force: true });
}
});
+2
View File
@@ -12,6 +12,7 @@ import { arenaRatingBand, calculateArenaRating, clampArenaRating } from "./arena
import { arenaRoleForSpecialization } from "./arena-specializations.mjs";
import { createDungeonReportRepository } from "./dungeon-reports.mjs";
import { createDungeonLeaderboardRepository } from "./dungeon-leaderboards.mjs";
import { createDungeonSimulationRepository } from "./dungeon-simulation.mjs";
import dungeonLeaderboardCatalog from "../src/game/generated/dungeonLeaderboardCatalog.generated.json" with { type: "json" };
const scrypt = promisify(scryptCallback);
@@ -910,6 +911,7 @@ export function openGameDatabase(options = {}) {
databasePath,
...createDungeonReportRepository(database, { isGameMaster, ErrorType: GameDatabaseError }),
...createDungeonLeaderboardRepository(database, { ErrorType: GameDatabaseError }),
...createDungeonSimulationRepository(database, { isGameMaster, ErrorType: GameDatabaseError }),
register,
login,
authenticate,
+100 -1
View File
@@ -1,11 +1,13 @@
import assert from "node:assert/strict";
import { mkdtempSync, rmSync } from "node:fs";
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import { DatabaseSync } from "node:sqlite";
import { GameDatabaseError, openGameDatabase } from "./database.mjs";
const routeCatalog = JSON.parse(readFileSync(new URL("../src/game/generated/dungeonRouteCatalog.generated.json", import.meta.url), "utf8"));
function withDatabase(run) {
const directory = mkdtempSync(path.join(os.tmpdir(), "healer-man-db-test-"));
const database = openGameDatabase({ dataDir: directory, sessionTtlDays: 1 });
@@ -137,6 +139,103 @@ test("persists revisioned Manastorm GM configuration and rejects stale or unauth
}), (error) => error instanceof GameDatabaseError && error.status === 400);
}));
test("stores GM simulation replays and gates exact-hash route approval", () => withDatabase(async (database) => {
const gmAuth = await database.register("Phenom", "storm-admin");
const playerAuth = await database.register("RoutePlayer", "route-player");
const gm = database.authenticate(gmAuth.token);
const player = database.authenticate(playerAuth.token);
const routeHash = routeCatalog.routes.find((route) => route.dungeonId === "deadmines").hash;
const replay = {
schemaVersion: 1,
dungeonId: "deadmines",
routeId: "primary",
routeHash,
mode: "navigation-only",
seed: "deadmines:primary",
result: "completed",
startedAt: 1_700_000_000_000,
durationMs: 42_000,
report: { schemaVersion: 1, findings: [] },
frames: [{ atMs: 0, actors: [] }],
events: [],
};
assert.throws(() => database.saveDungeonSimulationReplay(player, replay), (error) => (
error instanceof GameDatabaseError && error.status === 403
));
assert.throws(() => database.putDungeonRouteReview(gm, "deadmines", "primary", {
routeHash, status: "Approved", expectedRevision: 0,
}), (error) => error instanceof GameDatabaseError && error.code === "route_run_required");
const saved = database.saveDungeonSimulationReplay(gm, replay);
assert.equal(saved.criticalFindings, 0);
assert.equal(database.getDungeonSimulationReplay(gm, saved.id).frames.length, 1);
assert.equal(database.getDungeonSimulationReport(gm, saved.id).schemaVersion, 1);
const review = database.putDungeonRouteReview(gm, "deadmines", "primary", {
routeHash, status: "Approved", expectedRevision: 0, note: "Clean navigation-only pass.",
});
assert.equal(review.revision, 1);
assert.deepEqual(database.listDungeonRouteApprovals().approvals.map(({ reviewedAt: _reviewedAt, ...entry }) => entry), [{
dungeonId: "deadmines", routeId: "primary", routeHash,
}]);
assert.throws(() => database.putDungeonRouteReview(gm, "deadmines", "primary", {
routeHash, status: "NeedsWork", expectedRevision: 0,
}), (error) => error instanceof GameDatabaseError && error.status === 409);
assert.equal(database.deleteDungeonSimulationReplay(gm, saved.id).ok, true);
}));
test("requires an override note when the latest exact-hash route run has critical findings", () => withDatabase(async (database) => {
const gmAuth = await database.register("Phenom", "storm-admin");
const gm = database.authenticate(gmAuth.token);
const routeHash = routeCatalog.routes.find((route) => route.dungeonId === "blackfathom-deeps").hash;
database.saveDungeonSimulationReplay(gm, {
schemaVersion: 1,
dungeonId: "blackfathom-deeps",
routeId: "primary",
routeHash,
mode: "navigation-only",
seed: "route-seed",
result: "completed",
startedAt: 1_700_000_000_000,
durationMs: 10_000,
report: { schemaVersion: 1, findings: [{ severity: "critical", code: "no-path" }] },
frames: [],
events: [],
});
assert.throws(() => database.putDungeonRouteReview(gm, "blackfathom-deeps", "primary", {
routeHash, status: "Approved", expectedRevision: 0,
}), (error) => error instanceof GameDatabaseError && error.code === "route_override_required");
const review = database.putDungeonRouteReview(gm, "blackfathom-deeps", "primary", {
routeHash, status: "Approved", expectedRevision: 0, criticalOverrideNote: "Known scripted lift gap.",
});
assert.equal(review.status, "Approved");
assert.equal(review.criticalOverrideNote, "Known scripted lift gap.");
}));
test("requires the latest exact-hash route run to be completed before approval", () => withDatabase(async (database) => {
const gmAuth = await database.register("Phenom", "storm-admin");
const gm = database.authenticate(gmAuth.token);
const routeHash = routeCatalog.routes.find((route) => route.dungeonId === "razorfen-downs").hash;
database.saveDungeonSimulationReplay(gm, {
schemaVersion: 1,
dungeonId: "razorfen-downs",
routeId: "primary",
routeHash,
mode: "full-combat",
seed: "incomplete-route-seed",
result: "stopped",
startedAt: 1_700_000_000_000,
durationMs: 10_000,
report: { schemaVersion: 1, findings: [] },
frames: [],
events: [],
});
assert.throws(() => database.putDungeonRouteReview(gm, "razorfen-downs", "primary", {
routeHash, status: "Approved", expectedRevision: 0,
}), (error) => error instanceof GameDatabaseError && error.code === "route_completed_run_required");
}));
test("creates online groups through invitations and transfers leadership when the leader leaves", () => withDatabase(async (database) => {
const leaderAuth = await database.register("GroupLeader", "group-pass");
const healerAuth = await database.register("GroupHealer", "healer-pass");
+331
View File
@@ -0,0 +1,331 @@
import { randomUUID } from "node:crypto";
import { gunzipSync, gzipSync } from "node:zlib";
import { readFileSync } from "node:fs";
const routeCatalog = JSON.parse(readFileSync(new URL("../src/game/generated/dungeonRouteCatalog.generated.json", import.meta.url), "utf8"));
const generatedRoutes = new Map(routeCatalog.routes.map((route) => [`${route.dungeonId}:${route.id}:${route.hash}`, route]));
export const DUNGEON_SIMULATION_REPLAY_SCHEMA_VERSION = 1;
export const DUNGEON_ROUTE_REVIEW_STATUSES = Object.freeze(["Candidate", "Approved", "NeedsWork"]);
export const DUNGEON_SIMULATION_RETENTION_MS = 30 * 24 * 60 * 60 * 1_000;
export const DUNGEON_SIMULATION_MAX_DURATION_MS = 2 * 60 * 60 * 1_000;
export const DUNGEON_SIMULATION_MAX_COMPRESSED_BYTES = 32 * 1024 * 1024;
export const DUNGEON_SIMULATION_MAX_DECOMPRESSED_BYTES = 256 * 1024 * 1024;
const MAX_ID_LENGTH = 256;
const MAX_NOTE_LENGTH = 1_000;
const HASH_PATTERN = /^[a-f0-9]{64}$/;
function text(value, label, ErrorType, maximum = MAX_ID_LENGTH) {
if (typeof value !== "string" || !value.trim() || value.length > maximum) {
throw new ErrorType(`${label} must be a non-empty string no longer than ${maximum} characters.`, 400, "invalid_simulation_payload");
}
return value.trim();
}
function optionalNote(value, label, ErrorType) {
if (value === undefined || value === null) return null;
if (typeof value !== "string" || value.length > MAX_NOTE_LENGTH) {
throw new ErrorType(`${label} must be a string no longer than ${MAX_NOTE_LENGTH} characters.`, 400, "invalid_simulation_payload");
}
return value.replace(/\r\n?/g, "\n").trim() || null;
}
function routeHash(value, ErrorType) {
const normalized = text(value, "routeHash", ErrorType, 64).toLowerCase();
if (!HASH_PATTERN.test(normalized)) {
throw new ErrorType("routeHash must be a lowercase SHA-256 hash.", 400, "invalid_route_hash");
}
return normalized;
}
function integer(value, label, ErrorType, minimum, maximum) {
if (!Number.isInteger(value) || value < minimum || value > maximum) {
throw new ErrorType(`${label} must be an integer between ${minimum} and ${maximum}.`, 400, "invalid_simulation_payload");
}
return value;
}
function object(value, label, ErrorType) {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new ErrorType(`${label} must be an object.`, 400, "invalid_simulation_payload");
}
return value;
}
function publicReplay(row) {
return {
id: row.id,
dungeonId: row.dungeon_id,
routeId: row.route_id,
routeHash: row.route_hash,
mode: row.mode,
seed: row.seed,
result: row.result,
startedAt: Number(row.started_at),
durationMs: Number(row.duration_ms),
criticalFindings: Number(row.critical_findings),
submittedBy: row.submitted_by_username,
createdAt: Number(row.created_at),
expiresAt: Number(row.expires_at),
compressedBytes: Number(row.compressed_bytes),
};
}
function publicReview(row) {
return {
dungeonId: row.dungeon_id,
routeId: row.route_id,
routeHash: row.route_hash,
status: row.status,
note: row.note,
criticalOverrideNote: row.critical_override_note,
reviewedBy: row.reviewed_by_username,
reviewedAt: Number(row.reviewed_at),
revision: Number(row.revision),
};
}
function normalizeReplay(input, ErrorType) {
const source = object(input, "replay", ErrorType);
if (source.schemaVersion !== DUNGEON_SIMULATION_REPLAY_SCHEMA_VERSION) {
throw new ErrorType("Unsupported dungeon simulation replay schema.", 400, "invalid_replay_schema");
}
const mode = source.mode;
if (mode !== "full-combat" && mode !== "navigation-only") {
throw new ErrorType("Choose a valid simulation mode.", 400, "invalid_simulation_mode");
}
const result = source.result;
if (!["completed", "wiped", "stopped", "recording-limit"].includes(result)) {
throw new ErrorType("Choose a valid simulation result.", 400, "invalid_simulation_result");
}
const report = object(source.report, "report", ErrorType);
const findings = Array.isArray(report.findings) ? report.findings : [];
const criticalFindings = findings.filter((finding) => finding?.severity === "critical").length;
const normalized = {
...source,
dungeonId: text(source.dungeonId, "dungeonId", ErrorType),
routeId: text(source.routeId, "routeId", ErrorType),
routeHash: routeHash(source.routeHash, ErrorType),
seed: text(source.seed, "seed", ErrorType),
startedAt: integer(source.startedAt, "startedAt", ErrorType, 1, Number.MAX_SAFE_INTEGER),
durationMs: integer(source.durationMs, "durationMs", ErrorType, 0, DUNGEON_SIMULATION_MAX_DURATION_MS),
mode,
result,
report,
};
const serialized = Buffer.from(JSON.stringify(normalized));
if (serialized.length > DUNGEON_SIMULATION_MAX_DECOMPRESSED_BYTES) {
throw new ErrorType("Replay is too large after decompression.", 413, "replay_too_large");
}
const compressed = gzipSync(serialized, { level: 9 });
if (compressed.length > DUNGEON_SIMULATION_MAX_COMPRESSED_BYTES) {
throw new ErrorType("Replay is too large after compression.", 413, "replay_too_large");
}
return { normalized, compressed, criticalFindings };
}
export function createDungeonSimulationRepository(database, { isGameMaster, ErrorType }) {
database.exec(`
CREATE TABLE IF NOT EXISTS dungeon_route_reviews (
dungeon_id TEXT NOT NULL,
route_id TEXT NOT NULL,
route_hash TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN ('Candidate', 'Approved', 'NeedsWork')),
note TEXT,
critical_override_note TEXT,
reviewed_by_account_id TEXT REFERENCES accounts(id) ON DELETE SET NULL,
reviewed_by_username TEXT NOT NULL,
reviewed_at INTEGER NOT NULL,
revision INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (dungeon_id, route_id)
) STRICT;
CREATE TABLE IF NOT EXISTS dungeon_simulation_replays (
id TEXT PRIMARY KEY,
schema_version INTEGER NOT NULL,
dungeon_id TEXT NOT NULL,
route_id TEXT NOT NULL,
route_hash TEXT NOT NULL,
mode TEXT NOT NULL CHECK (mode IN ('full-combat', 'navigation-only')),
seed TEXT NOT NULL,
result TEXT NOT NULL CHECK (result IN ('completed', 'wiped', 'stopped', 'recording-limit')),
started_at INTEGER NOT NULL,
duration_ms INTEGER NOT NULL,
critical_findings INTEGER NOT NULL,
report_json TEXT NOT NULL,
payload_gzip BLOB NOT NULL,
compressed_bytes INTEGER NOT NULL,
submitted_by_account_id TEXT REFERENCES accounts(id) ON DELETE SET NULL,
submitted_by_username TEXT NOT NULL,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL
) STRICT;
CREATE INDEX IF NOT EXISTS dungeon_simulation_replays_route_idx
ON dungeon_simulation_replays(dungeon_id, route_id, route_hash, created_at DESC);
CREATE INDEX IF NOT EXISTS dungeon_simulation_replays_expiry_idx
ON dungeon_simulation_replays(expires_at);
`);
const replayById = database.prepare("SELECT * FROM dungeon_simulation_replays WHERE id = ?");
const reviewByRoute = database.prepare("SELECT * FROM dungeon_route_reviews WHERE dungeon_id = ? AND route_id = ?");
function requireGm(account) {
if (!isGameMaster(account)) {
throw new ErrorType("Game Master access is required.", 403, "gm_required");
}
}
function pruneExpiredDungeonSimulationReplays(now = Date.now()) {
return Number(database.prepare("DELETE FROM dungeon_simulation_replays WHERE expires_at <= ?").run(now).changes);
}
return {
pruneExpiredDungeonSimulationReplays,
listDungeonRouteApprovals() {
return {
approvals: database.prepare(`
SELECT dungeon_id, route_id, route_hash, reviewed_at
FROM dungeon_route_reviews WHERE status = 'Approved'
ORDER BY dungeon_id, route_id
`).all().filter((row) => generatedRoutes.get(`${row.dungeon_id}:${row.route_id}:${row.route_hash}`)?.generationStatus === "Candidate").map((row) => ({
dungeonId: row.dungeon_id,
routeId: row.route_id,
routeHash: row.route_hash,
reviewedAt: Number(row.reviewed_at),
})),
};
},
listDungeonRouteReviews(account) {
requireGm(account);
pruneExpiredDungeonSimulationReplays();
const reviews = database.prepare("SELECT * FROM dungeon_route_reviews ORDER BY dungeon_id, route_id").all();
const latest = database.prepare(`
SELECT * FROM (
SELECT dungeon_simulation_replays.*,
ROW_NUMBER() OVER (
PARTITION BY dungeon_id, route_id, route_hash
ORDER BY created_at DESC, id DESC
) AS route_rank
FROM dungeon_simulation_replays
) WHERE route_rank = 1
`).all();
return {
reviews: reviews.map(publicReview),
latestReplays: latest
.filter((row) => generatedRoutes.has(`${row.dungeon_id}:${row.route_id}:${row.route_hash}`))
.map(publicReplay),
};
},
putDungeonRouteReview(account, dungeonIdValue, routeIdValue, input) {
requireGm(account);
const dungeonId = text(dungeonIdValue, "dungeonId", ErrorType);
const routeId = text(routeIdValue, "routeId", ErrorType);
const hash = routeHash(input?.routeHash, ErrorType);
const status = input?.status;
if (!DUNGEON_ROUTE_REVIEW_STATUSES.includes(status)) {
throw new ErrorType("Choose a valid route review status.", 400, "invalid_route_status");
}
const expectedRevision = integer(input?.expectedRevision, "expectedRevision", ErrorType, 0, Number.MAX_SAFE_INTEGER);
const note = optionalNote(input?.note, "note", ErrorType);
const criticalOverrideNote = optionalNote(input?.criticalOverrideNote, "criticalOverrideNote", ErrorType);
const current = reviewByRoute.get(dungeonId, routeId);
const currentRevision = Number(current?.revision ?? 0);
if (currentRevision !== expectedRevision) {
throw new ErrorType("This route review changed. Reload and try again.", 409, "route_review_conflict");
}
const generatedRoute = generatedRoutes.get(`${dungeonId}:${routeId}:${hash}`);
if (!generatedRoute) {
throw new ErrorType("The route hash does not match the bundled route catalog.", 409, "route_hash_unknown");
}
if (status === "Approved") {
if (generatedRoute.generationStatus !== "Candidate") {
throw new ErrorType("This generated route failed offline navigation validation and cannot be approved.", 409, "route_generation_rejected");
}
const latest = database.prepare(`
SELECT critical_findings, result FROM dungeon_simulation_replays
WHERE dungeon_id = ? AND route_id = ? AND route_hash = ?
ORDER BY created_at DESC, id DESC LIMIT 1
`).get(dungeonId, routeId, hash);
if (!latest) {
throw new ErrorType("Run and save this exact route before approving it.", 409, "route_run_required");
}
if (latest.result !== "completed") {
throw new ErrorType("Approval requires a completed run for this exact route hash.", 409, "route_completed_run_required");
}
if (Number(latest.critical_findings) > 0 && !criticalOverrideNote) {
throw new ErrorType("Critical findings require an override note before approval.", 409, "route_override_required");
}
}
const reviewedAt = Date.now();
if (current) {
database.prepare(`
UPDATE dungeon_route_reviews SET route_hash = ?, status = ?, note = ?, critical_override_note = ?,
reviewed_by_account_id = ?, reviewed_by_username = ?, reviewed_at = ?, revision = revision + 1
WHERE dungeon_id = ? AND route_id = ? AND revision = ?
`).run(hash, status, note, criticalOverrideNote, account.id, account.username, reviewedAt,
dungeonId, routeId, expectedRevision);
} else {
database.prepare(`
INSERT INTO dungeon_route_reviews (
dungeon_id, route_id, route_hash, status, note, critical_override_note,
reviewed_by_account_id, reviewed_by_username, reviewed_at, revision
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1)
`).run(dungeonId, routeId, hash, status, note, criticalOverrideNote,
account.id, account.username, reviewedAt);
}
return publicReview(reviewByRoute.get(dungeonId, routeId));
},
listDungeonSimulationReplays(account, { dungeonId = null } = {}) {
requireGm(account);
pruneExpiredDungeonSimulationReplays();
const rows = database.prepare(`
SELECT id, dungeon_id, route_id, route_hash, mode, seed, result, started_at, duration_ms,
critical_findings, submitted_by_username, created_at, expires_at, compressed_bytes
FROM dungeon_simulation_replays
WHERE (? IS NULL OR dungeon_id = ?)
ORDER BY created_at DESC, id DESC LIMIT 500
`).all(dungeonId, dungeonId);
return { replays: rows.map(publicReplay) };
},
saveDungeonSimulationReplay(account, input) {
requireGm(account);
pruneExpiredDungeonSimulationReplays();
const { normalized, compressed, criticalFindings } = normalizeReplay(input, ErrorType);
const id = randomUUID();
const createdAt = Date.now();
const expiresAt = createdAt + DUNGEON_SIMULATION_RETENTION_MS;
database.prepare(`
INSERT INTO dungeon_simulation_replays (
id, schema_version, dungeon_id, route_id, route_hash, mode, seed, result,
started_at, duration_ms, critical_findings, report_json, payload_gzip, compressed_bytes,
submitted_by_account_id, submitted_by_username, created_at, expires_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(id, normalized.schemaVersion, normalized.dungeonId, normalized.routeId, normalized.routeHash,
normalized.mode, normalized.seed, normalized.result, normalized.startedAt, normalized.durationMs,
criticalFindings, JSON.stringify(normalized.report), compressed, compressed.length,
account.id, account.username, createdAt, expiresAt);
return publicReplay(replayById.get(id));
},
getDungeonSimulationReplay(account, id) {
requireGm(account);
pruneExpiredDungeonSimulationReplays();
const row = replayById.get(text(id, "replay id", ErrorType));
if (!row) throw new ErrorType("Replay not found.", 404, "replay_not_found");
const payload = gunzipSync(row.payload_gzip, { maxOutputLength: DUNGEON_SIMULATION_MAX_DECOMPRESSED_BYTES });
return JSON.parse(payload.toString("utf8"));
},
getDungeonSimulationReport(account, id) {
requireGm(account);
pruneExpiredDungeonSimulationReplays();
const row = replayById.get(text(id, "replay id", ErrorType));
if (!row) throw new ErrorType("Replay not found.", 404, "replay_not_found");
return JSON.parse(row.report_json);
},
deleteDungeonSimulationReplay(account, id) {
requireGm(account);
const result = database.prepare("DELETE FROM dungeon_simulation_replays WHERE id = ?")
.run(text(id, "replay id", ErrorType));
if (!result.changes) throw new ErrorType("Replay not found.", 404, "replay_not_found");
return { ok: true };
},
};
}
+114 -1
View File
@@ -8,6 +8,7 @@ import { publicManastormAdminConfig } from "./manastorm-config.mjs";
import { createOnlineSessionManager } from "./online-session.mjs";
import { upgradeArenaWebSocket } from "./websocket.mjs";
import { createBundledContentHandler } from "./bundled-content.mjs";
import { createBalanceLabJobService } from "./balance-lab-jobs.mjs";
const MIME_TYPES = new Map([
[".css", "text/css; charset=utf-8"], [".glb", "model/gltf-binary"],
@@ -178,6 +179,11 @@ export function createGameServer(options = {}) {
const trustProxy = String(options.trustProxy ?? process.env.TRUST_PROXY ?? "").toLowerCase() === "true";
const enableShelvedModes = options.enableShelvedModes ?? booleanFlag(process.env.HEALER_MAN_ENABLE_SHELVED_MODES);
const gameDatabase = options.database ?? openGameDatabase(options);
const balanceLabJobs = options.balanceLabJobs ?? createBalanceLabJobService({
dataDir: options.dataDir ?? path.dirname(gameDatabase.databasePath),
projectRoot: options.projectRoot,
pythonExecutable: options.pythonExecutable,
});
const onlineSessions = createOnlineSessionManager();
const arenaService = createArenaService({ database: gameDatabase, autoPump: enableShelvedModes });
const allowAuthRequest = createAuthLimiter();
@@ -211,6 +217,60 @@ export function createGameServer(options = {}) {
json(response, 200, { ok: true, service: "healer-man", time: new Date().toISOString() });
return;
}
if (url.pathname === "/api/dungeon-route-approvals" && request.method === "GET") {
json(response, 200, gameDatabase.listDungeonRouteApprovals());
return;
}
if (url.pathname === "/api/gm/dungeon-routes" && request.method === "GET") {
const account = gameDatabase.authenticate(bearerToken(request));
json(response, 200, gameDatabase.listDungeonRouteReviews(account));
return;
}
const dungeonRouteReviewMatch = /^\/api\/gm\/dungeon-routes\/([^/]+)\/([^/]+)\/review$/.exec(url.pathname);
if (dungeonRouteReviewMatch && request.method === "PUT") {
const account = gameDatabase.authenticate(bearerToken(request));
const body = await readJson(request, 64 * 1024);
const review = gameDatabase.putDungeonRouteReview(
account,
decodeURIComponent(dungeonRouteReviewMatch[1]),
decodeURIComponent(dungeonRouteReviewMatch[2]),
body,
);
json(response, 200, { review });
return;
}
if (url.pathname === "/api/gm/dungeon-sim/replays" && request.method === "GET") {
const account = gameDatabase.authenticate(bearerToken(request));
json(response, 200, gameDatabase.listDungeonSimulationReplays(account, {
dungeonId: url.searchParams.get("dungeon"),
}));
return;
}
if (url.pathname === "/api/gm/dungeon-sim/replays" && request.method === "POST") {
const account = gameDatabase.authenticate(bearerToken(request));
const replay = gameDatabase.saveDungeonSimulationReplay(account, await readJson(request, 64 * 1024 * 1024));
json(response, 201, { replay });
return;
}
const dungeonSimulationReportMatch = /^\/api\/gm\/dungeon-sim\/replays\/([^/]+)\/report$/.exec(url.pathname);
if (dungeonSimulationReportMatch && request.method === "GET") {
const account = gameDatabase.authenticate(bearerToken(request));
json(response, 200, gameDatabase.getDungeonSimulationReport(account, dungeonSimulationReportMatch[1]), {
"Content-Disposition": `attachment; filename="dungeon-simulation-${dungeonSimulationReportMatch[1]}.json"`,
});
return;
}
const dungeonSimulationReplayMatch = /^\/api\/gm\/dungeon-sim\/replays\/([^/]+)$/.exec(url.pathname);
if (dungeonSimulationReplayMatch && request.method === "GET") {
const account = gameDatabase.authenticate(bearerToken(request));
json(response, 200, gameDatabase.getDungeonSimulationReplay(account, dungeonSimulationReplayMatch[1]));
return;
}
if (dungeonSimulationReplayMatch && request.method === "DELETE") {
const account = gameDatabase.authenticate(bearerToken(request));
json(response, 200, gameDatabase.deleteDungeonSimulationReplay(account, dungeonSimulationReplayMatch[1]));
return;
}
if (!enableShelvedModes && (
url.pathname === "/api/manastorm-config"
|| url.pathname.startsWith("/api/gm/manastorm-config")
@@ -438,6 +498,58 @@ export function createGameServer(options = {}) {
json(response, 200, result);
return;
}
if (url.pathname === "/api/gm/balance-lab/jobs" && request.method === "GET") {
const account = gameDatabase.authenticate(bearerToken(request));
if (!gameDatabase.isGameMaster(account)) {
throw new GameDatabaseError("Game Master access is required.", 403, "gm_required");
}
json(response, 200, { jobs: balanceLabJobs.list() });
return;
}
if (url.pathname === "/api/gm/balance-lab/runtime" && request.method === "GET") {
const account = gameDatabase.authenticate(bearerToken(request));
if (!gameDatabase.isGameMaster(account)) {
throw new GameDatabaseError("Game Master access is required.", 403, "gm_required");
}
json(response, 200, { runtime: balanceLabJobs.runtime() });
return;
}
if (url.pathname === "/api/gm/balance-lab/jobs" && request.method === "POST") {
const account = gameDatabase.authenticate(bearerToken(request));
if (!gameDatabase.isGameMaster(account)) {
throw new GameDatabaseError("Game Master access is required.", 403, "gm_required");
}
const job = balanceLabJobs.start(await readJson(request, 32 * 1024), account);
console.info(`Balance Lab job ${job.id} queued by ${account.username}`);
json(response, 202, { job });
return;
}
const balanceLabArtifactMatch = /^\/api\/gm\/balance-lab\/jobs\/([0-9a-f-]{36})\/artifacts\/([A-Za-z0-9._-]+)$/.exec(url.pathname);
if (balanceLabArtifactMatch && request.method === "GET") {
const account = gameDatabase.authenticate(bearerToken(request));
if (!gameDatabase.isGameMaster(account)) {
throw new GameDatabaseError("Game Master access is required.", 403, "gm_required");
}
const artifact = balanceLabJobs.artifact(balanceLabArtifactMatch[1], balanceLabArtifactMatch[2]);
response.writeHead(200, {
"Content-Type": artifact.contentType,
"Content-Length": artifact.size,
"Cache-Control": "no-store",
"Content-Disposition": `inline; filename="${balanceLabArtifactMatch[2]}"`,
"X-Content-Type-Options": "nosniff",
});
artifact.stream().pipe(response);
return;
}
const balanceLabJobMatch = /^\/api\/gm\/balance-lab\/jobs\/([0-9a-f-]{36})$/.exec(url.pathname);
if (balanceLabJobMatch && request.method === "GET") {
const account = gameDatabase.authenticate(bearerToken(request));
if (!gameDatabase.isGameMaster(account)) {
throw new GameDatabaseError("Game Master access is required.", 403, "gm_required");
}
json(response, 200, { job: balanceLabJobs.get(balanceLabJobMatch[1]) });
return;
}
if (url.pathname.startsWith("/api/")) {
json(response, 404, { error: "API route not found.", code: "not_found" });
return;
@@ -491,11 +603,12 @@ export function createGameServer(options = {}) {
});
server.on("close", () => {
balanceLabJobs.close?.();
onlineSessions.clear();
arenaService.close();
gameDatabase.close();
});
return { server, database: gameDatabase, arenaService, staticDir, contentDir, enableShelvedModes };
return { server, database: gameDatabase, arenaService, balanceLabJobs, staticDir, contentDir, enableShelvedModes };
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
+26
View File
@@ -237,6 +237,29 @@ test("serves the game and authenticated cloud-save API", async () => {
assert.equal(gmRegistration.status, 201);
const gmAuth = await gmRegistration.json();
assert.deepEqual(gmAuth.account.roles, ["gm"]);
const deniedBalanceLab = await fetch(`${origin}/api/gm/balance-lab/jobs`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${auth.token}` },
body: JSON.stringify({ source: "wow", level: 40 }),
});
assert.equal(deniedBalanceLab.status, 403);
const balanceLabJobs = await fetch(`${origin}/api/gm/balance-lab/jobs`, {
headers: { Authorization: `Bearer ${gmAuth.token}` },
});
assert.equal(balanceLabJobs.status, 200);
assert.deepEqual((await balanceLabJobs.json()).jobs, []);
const balanceLabRuntime = await fetch(`${origin}/api/gm/balance-lab/runtime`, {
headers: { Authorization: `Bearer ${gmAuth.token}` },
});
assert.equal(balanceLabRuntime.status, 200);
assert.equal((await balanceLabRuntime.json()).runtime.available, true);
const invalidBalanceLab = await fetch(`${origin}/api/gm/balance-lab/jobs`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${gmAuth.token}` },
body: JSON.stringify({ source: "wow", level: 81 }),
});
assert.equal(invalidBalanceLab.status, 400);
assert.equal((await invalidBalanceLab.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",
@@ -290,6 +313,9 @@ test("disables shelved APIs and Manastorm group creation by default", async () =
assert.equal(response.status, 503);
assert.equal((await response.json()).code, "feature_disabled");
}
const balanceLab = await fetch(`${origin}/api/gm/balance-lab/jobs`);
assert.equal(balanceLab.status, 401);
assert.equal((await balanceLab.json()).code, "authentication_required");
const registration = await fetch(`${origin}/api/auth/register`, {
method: "POST",
+2
View File
@@ -20,6 +20,7 @@ const CharacterSelectScreen = lazy(() => import("./ui/CharacterSelectScreen").th
const ClassHallScreen = lazy(() => import("./ui/ClassHallScreen").then((module) => ({ default: module.ClassHallScreen })));
const DungeonSelectScreen = lazy(() => import("./ui/DungeonSelectScreen").then((module) => ({ default: module.DungeonSelectScreen })));
const MainMenuScreen = lazy(() => import("./ui/MainMenuScreen").then((module) => ({ default: module.MainMenuScreen })));
const DungeonSimulationAdminScreen = lazy(() => import("./ui/DungeonSimulationAdminScreen").then((module) => ({ default: module.DungeonSimulationAdminScreen })));
function ShellTransition() {
return <div className="world-transition"><span>HM</span><strong>Opening expedition</strong><small>Preparing the next screen...</small></div>;
}
@@ -86,6 +87,7 @@ export default function App() {
else if (phase === "main-menu") screen = <Suspense fallback={<ShellTransition />}><MainMenuScreen /></Suspense>;
else if (phase === "class-hall") screen = <Suspense fallback={<ShellTransition />}><ClassHallScreen /></Suspense>;
else if (phase === "dungeons") screen = <Suspense fallback={<ShellTransition />}><DungeonSelectScreen /></Suspense>;
else if (phase === "gm-admin") screen = <Suspense fallback={<ShellTransition />}><DungeonSimulationAdminScreen /></Suspense>;
else if (isShelvedShellPhase(phase)) {
screen = <Suspense fallback={<ShellTransition />}><ShelvedModeRoute phase={phase} session={session} /></Suspense>;
}
+17 -8
View File
@@ -32,6 +32,11 @@ import { useUiLayoutStore } from "./game/uiLayoutStore";
import { profileInputBindings } from "./game/keybindPreferences";
import { KeybindsPanel } from "./ui/KeybindsPanel";
import { lootShelvedBoss, releaseShelvedGameRuntime, ShelvedGameBridges } from "@shelvedGameRuntime";
import { useDungeonSimulationStore } from "./game/dungeonSimulationStore";
import { DungeonSimulationHud } from "./ui/DungeonSimulationHud";
import { useDungeonRouteStore } from "./game/dungeonRouteStore";
import { installCombatClock } from "./game/combatClock";
import { dungeonSimulationNow } from "./game/dungeonSimulationStore";
function InputBridge() {
const overlay = useGameStore((state) => state.overlay);
@@ -39,7 +44,8 @@ function InputBridge() {
useEffect(() => installInput({
desktopActionBarsActive: () => currentUiDevice() === "pc" && activeUiLayout().wowBars,
inputSuspended: () => ["ui-layout", "keybinds", "dungeon-results", "boss-test-review", "report-issue"].includes(useGameStore.getState().overlay ?? ""),
inputSuspended: () => useDungeonSimulationStore.getState().sessionMode !== "play"
|| ["ui-layout", "keybinds", "dungeon-results", "boss-test-review", "report-issue"].includes(useGameStore.getState().overlay ?? ""),
getBindings: (device) => {
const preferences = useUiLayoutStore.getState().preferences;
const bindings = profileInputBindings(preferences, device);
@@ -97,7 +103,10 @@ function InputBridge() {
export default function GameRuntime() {
useEffect(connectDpsMeter, []);
useEffect(() => installCombatClock(dungeonSimulationNow), []);
useEffect(() => { void useDungeonRouteStore.getState().refresh(); }, []);
const editingControls = useGameStore((state) => state.overlay === "keybinds" || state.overlay === "ui-layout");
const dungeonSimulation = useDungeonSimulationStore((state) => state.sessionMode !== "play");
useEffect(() => () => {
const activeDungeon = requireDungeonDefinition(useGameStore.getState().activeDungeonId);
clearDungeonRuntimeCaches();
@@ -127,13 +136,13 @@ export default function GameRuntime() {
<BossTestingBridge />
<ShelvedGameBridges />
<SceneErrorBoundary><GameScene /></SceneErrorBoundary>
<Hud />
<MapPanel />
<GameplayPanels />
<PauseMenu />
<DungeonRunResults />
<UiLayoutEditor />
<KeybindsPanel />
{dungeonSimulation ? <DungeonSimulationHud /> : <Hud />}
{!dungeonSimulation && <MapPanel />}
{!dungeonSimulation && <GameplayPanels />}
{!dungeonSimulation && <PauseMenu />}
{!dungeonSimulation && <DungeonRunResults />}
{!dungeonSimulation && <UiLayoutEditor />}
{!dungeonSimulation && <KeybindsPanel />}
</main>
}
bottom={<LocalCompanionPanel />}
-2
View File
@@ -5,7 +5,6 @@ import { useManastormAdminStore } from "../game/manastormAdminStore";
const ManastormSelectScreen = lazy(() => import("../ui/ManastormSelectScreen").then((module) => ({ default: module.ManastormSelectScreen })));
const ArenaSelectScreen = lazy(() => import("../ui/ArenaSelectScreen").then((module) => ({ default: module.ArenaSelectScreen })));
const ArenaRuntime = lazy(() => import("../ArenaRuntime"));
const GmAdminScreen = lazy(() => import("../ui/GmAdminScreen").then((module) => ({ default: module.GmAdminScreen })));
function ShelvedTransition() {
return <div className="world-transition"><span>HM</span><strong>Opening expedition</strong><small>Preparing the next screen...</small></div>;
@@ -28,6 +27,5 @@ export function ShelvedModeRoute({
if (phase === "manastorms") screen = <ManastormSelectScreen />;
else if (phase === "arenas") screen = <ArenaSelectScreen />;
else if (phase === "arena-game") screen = <ArenaRuntime />;
else if (phase === "gm-admin") screen = <GmAdminScreen />;
return <Suspense fallback={<ShelvedTransition />}>{screen}</Suspense>;
}
+2 -2
View File
@@ -24,7 +24,7 @@ import {
normalizeManastormProgress,
type ManastormProgress,
} from "../game/manastormProgress";
import { abilitiesForClass } from "../game/abilityCatalog";
import { abilitiesForClass, abilityTalentNodeId } from "../game/abilityCatalog";
import {
normalizeSpecializationId,
specializationById,
@@ -576,7 +576,7 @@ export function updateCharacterSpecialization(
}
const talentAbilityIds = resetTalents
? new Set(abilitiesForClass(character.classId)
.filter((ability) => ability.talentEntryId !== undefined || ability.source === "coa-tree")
.filter((ability) => abilityTalentNodeId(ability) !== null || ability.source === "coa-tree")
.map((ability) => ability.id))
: new Set<string>();
const actionBindings = resetTalents
+209
View File
@@ -0,0 +1,209 @@
import { onlineApiOrigin, requestOnlineJson } from "./onlineAccountClient";
export type BalanceLabPartySource = "wow" | "coa" | "rom" | "all";
export type BalanceLabBossCategory = "all" | "wow" | "rom";
export type BalanceLabJobStatus = "queued" | "running" | "complete" | "failed";
export interface BalanceLabRuntimeStatus {
readonly available: boolean;
readonly executable: string;
readonly version: string | null;
readonly error: string | null;
}
export interface BalanceLabJobSpec {
readonly source: BalanceLabPartySource;
readonly level: number;
readonly partyConfigurations: number;
readonly simulationsPerParty: number;
readonly durationSeconds: number;
readonly partySeed: number;
readonly bossCategory: BalanceLabBossCategory;
readonly bossKeys: readonly string[];
}
export interface BalanceLabJob {
readonly id: string;
readonly status: BalanceLabJobStatus;
readonly createdAt: string;
readonly startedAt: string | null;
readonly completedAt: string | null;
readonly requestedBy: string;
readonly spec: BalanceLabJobSpec;
readonly progress: {
readonly party: number;
readonly partiesCompleted: number;
readonly partiesTotal: number;
readonly simulationsCompleted: number;
readonly simulationsTotal: number;
};
readonly artifacts: readonly string[];
readonly error: string | null;
readonly logTail: readonly string[];
readonly summary: BalanceLabReportSummary | null;
}
export interface BalanceLabReportSummary {
readonly partyConfigurations: number;
readonly bosses: number;
readonly runs: number;
readonly classSpecs: number;
readonly classAbilities: number;
readonly bossPressureMedianDtps: number;
}
export interface BalanceLabBossResult {
readonly bossKey: string;
readonly bossName: string;
readonly category: string;
readonly dungeonId: string;
readonly dungeonTitle: string;
readonly runs: number;
readonly incomingDtps: number;
readonly averageIncomingDamage: number;
readonly partyDps: number;
readonly bossDpsTaken: number;
readonly defeatRate: number;
readonly wipeRate: number;
readonly averageDeaths: number;
readonly averageTimeToDefeat: number | null;
readonly bossMaxHealth: number;
readonly pressureVsMedian: number | null;
readonly pressureFlag: string;
}
export interface BalanceLabClassSpecResult {
readonly source: string;
readonly buildId: string;
readonly classId: string;
readonly className: string;
readonly specialization: string;
readonly role: string;
readonly partyConfigurations: number;
readonly runs: number;
readonly dps: number;
readonly bossDps: number;
readonly hps: number;
readonly hpsReceived?: number;
readonly dtps: number;
readonly totalDamage: number;
readonly totalDamageTaken: number;
readonly totalHealing: number;
readonly totalHealingReceived?: number;
readonly totalAbsorbs: number;
readonly deathEvents: number;
readonly damageVsSourceRoleMedian: number | null;
readonly damageFlag: string;
}
export interface BalanceLabPartyResult {
readonly partyConfigurationId: string;
readonly source: string;
readonly members: string;
readonly runs: number;
readonly partyDps: number;
readonly incomingDtps: number;
readonly defeatRate: number;
readonly wipeRate: number;
readonly averageDeaths: number;
readonly reportPath: string;
}
export interface BalanceLabClassAbilityResult {
readonly source: string;
readonly buildId: string;
readonly classId: string;
readonly className: string;
readonly specialization: string;
readonly role: string;
readonly abilityId: string;
readonly abilityName: string;
readonly icon: string;
readonly categories: string;
readonly cooldownSeconds: number | null;
readonly partyConfigurations: number;
readonly bosses: number;
readonly runs: number;
readonly availableRuns: number;
readonly activeRuns: number;
readonly usageRate: number;
readonly casts: number;
readonly castsPerMinute: number;
readonly hits: number;
readonly hitsPerRun: number;
readonly rawDamage: number;
readonly effectiveDamage: number;
readonly dps: number;
readonly damageShare: number;
readonly averageDamagePerHit: number;
readonly rawHealing: number;
readonly effectiveHealing: number;
readonly overheal: number;
readonly absorbs: number;
readonly status: "unused" | "damage" | "support" | "used";
readonly unusedAvailable: boolean;
}
export interface BalanceLabSuiteReport {
readonly schemaVersion: number;
readonly generatedAt: string;
readonly engine: string;
readonly level: number;
readonly summary: BalanceLabReportSummary;
readonly bosses: readonly BalanceLabBossResult[];
readonly classSpecs: readonly BalanceLabClassSpecResult[];
readonly classAbilities?: readonly BalanceLabClassAbilityResult[];
readonly partyConfigurations: readonly BalanceLabPartyResult[];
}
export async function listBalanceLabJobs(token: string): Promise<readonly BalanceLabJob[]> {
return (await requestOnlineJson<{ jobs: BalanceLabJob[] }>(
"/api/gm/balance-lab/jobs",
{},
token,
)).jobs;
}
export async function getBalanceLabRuntime(token: string): Promise<BalanceLabRuntimeStatus> {
return (await requestOnlineJson<{ runtime: BalanceLabRuntimeStatus }>(
"/api/gm/balance-lab/runtime",
{},
token,
)).runtime;
}
export async function startBalanceLabJob(spec: BalanceLabJobSpec, token: string): Promise<BalanceLabJob> {
return (await requestOnlineJson<{ job: BalanceLabJob }>(
"/api/gm/balance-lab/jobs",
{ method: "POST", body: JSON.stringify(spec) },
token,
)).job;
}
export async function getBalanceLabJob(id: string, token: string): Promise<BalanceLabJob> {
return (await requestOnlineJson<{ job: BalanceLabJob }>(
`/api/gm/balance-lab/jobs/${encodeURIComponent(id)}`,
{},
token,
)).job;
}
export async function getBalanceLabReport(id: string, token: string): Promise<BalanceLabSuiteReport> {
return requestOnlineJson<BalanceLabSuiteReport>(
`/api/gm/balance-lab/jobs/${encodeURIComponent(id)}/artifacts/suite-report.json`,
{},
token,
);
}
export async function fetchBalanceLabArtifact(id: string, name: string, token: string): Promise<Blob> {
const response = await fetch(
`${onlineApiOrigin()}/api/gm/balance-lab/jobs/${encodeURIComponent(id)}/artifacts/${encodeURIComponent(name)}`,
{ headers: { Authorization: `Bearer ${token}` } },
);
if (!response.ok) {
const body = await response.json().catch(() => ({})) as { error?: string };
throw new Error(body.error ?? `Could not download ${name} (${response.status}).`);
}
return response.blob();
}
+87
View File
@@ -0,0 +1,87 @@
import type { PlayerSession } from "./types";
import { requestOnlineJson } from "./onlineAccountClient";
import type {
DungeonReplaySummary,
DungeonReplayV1,
DungeonRouteApproval,
DungeonRouteReview,
DungeonRouteReviewStatus,
DungeonSimulationReportV1,
} from "../game/dungeonSimulationTypes";
function gmToken(session: PlayerSession | null): string {
if (session?.kind !== "account" || !session.accessToken || !session.roles?.includes("gm")) {
throw new Error("Game Master access is required.");
}
return session.accessToken;
}
export async function fetchDungeonRouteApprovals(): Promise<readonly DungeonRouteApproval[]> {
return (await requestOnlineJson<{ approvals: DungeonRouteApproval[] }>("/api/dungeon-route-approvals")).approvals;
}
export async function fetchGmDungeonRoutes(session: PlayerSession | null): Promise<{
readonly reviews: readonly DungeonRouteReview[];
readonly latestReplays: readonly DungeonReplaySummary[];
}> {
return requestOnlineJson("/api/gm/dungeon-routes", {}, gmToken(session));
}
export async function putDungeonRouteReview(
session: PlayerSession | null,
route: { readonly dungeonId: string; readonly routeId: string; readonly routeHash: string },
update: {
readonly status: DungeonRouteReviewStatus;
readonly expectedRevision: number;
readonly note?: string;
readonly criticalOverrideNote?: string;
},
): Promise<DungeonRouteReview> {
const response = await requestOnlineJson<{ review: DungeonRouteReview }>(
`/api/gm/dungeon-routes/${encodeURIComponent(route.dungeonId)}/${encodeURIComponent(route.routeId)}/review`,
{ method: "PUT", body: JSON.stringify({ ...update, routeHash: route.routeHash }) },
gmToken(session),
);
return response.review;
}
export async function fetchDungeonSimulationReplays(
session: PlayerSession | null,
dungeonId?: string,
): Promise<readonly DungeonReplaySummary[]> {
const query = dungeonId ? `?dungeon=${encodeURIComponent(dungeonId)}` : "";
return (await requestOnlineJson<{ replays: DungeonReplaySummary[] }>(
`/api/gm/dungeon-sim/replays${query}`,
{},
gmToken(session),
)).replays;
}
export async function saveDungeonSimulationReplay(
session: PlayerSession | null,
replay: DungeonReplayV1,
): Promise<DungeonReplaySummary> {
return (await requestOnlineJson<{ replay: DungeonReplaySummary }>(
"/api/gm/dungeon-sim/replays",
{ method: "POST", body: JSON.stringify(replay) },
gmToken(session),
)).replay;
}
export function fetchDungeonSimulationReplay(
session: PlayerSession | null,
replayId: string,
): Promise<DungeonReplayV1> {
return requestOnlineJson(`/api/gm/dungeon-sim/replays/${encodeURIComponent(replayId)}`, {}, gmToken(session));
}
export function fetchDungeonSimulationReport(
session: PlayerSession | null,
replayId: string,
): Promise<DungeonSimulationReportV1> {
return requestOnlineJson(`/api/gm/dungeon-sim/replays/${encodeURIComponent(replayId)}/report`, {}, gmToken(session));
}
export function deleteDungeonSimulationReplay(session: PlayerSession | null, replayId: string): Promise<{ readonly ok: true }> {
return requestOnlineJson(`/api/gm/dungeon-sim/replays/${encodeURIComponent(replayId)}`, { method: "DELETE" }, gmToken(session));
}
+71
View File
@@ -0,0 +1,71 @@
import { touchCharacter } from "./accountRepository";
import { useShellStore } from "./shellStore";
import { activateDungeonSession } from "../game/dungeonSession";
import { dungeonRouteDefinition } from "../game/dungeonRoutes";
import { useDungeonSimulationStore } from "../game/dungeonSimulationStore";
import type { DungeonReplayV1, DungeonSimulationMode } from "../game/dungeonSimulationTypes";
import { defaultPartyRoleForClass } from "../game/partyRoles";
import { useGameStore } from "../game/store";
function gmCharacter() {
const shell = useShellStore.getState();
if (shell.session?.kind !== "account" || !shell.session.accessToken || !shell.session.roles?.includes("gm")) {
shell.setNotice("Game Master access is required.");
return null;
}
const character = shell.selectedCharacterId
? touchCharacter(shell.session.ownerId, shell.selectedCharacterId)
?? shell.characters.find((candidate) => candidate.id === shell.selectedCharacterId)
: null;
if (!character) shell.setNotice("Select a character before launching a simulation.");
return character;
}
export function enterDungeonSimulation(
dungeonId: string,
mode: DungeonSimulationMode,
seed: string,
): boolean {
const character = gmCharacter();
const route = dungeonRouteDefinition(dungeonId);
if (!character || !route || !seed.trim()) return false;
if (!activateDungeonSession(dungeonId, undefined, {
gmTestMode: true,
dungeonSimulation: true,
})) {
useShellStore.getState().setNotice("The dungeon package is unavailable on this client.");
return false;
}
useDungeonSimulationStore.getState().startLive({ dungeonId, route, mode, seed: seed.trim() });
useGameStore.getState().setCompanionOpen(false);
useShellStore.setState({
phase: "game",
activeCharacter: character,
activeDungeonId: dungeonId,
activeDungeonRole: defaultPartyRoleForClass(character.classId),
notice: "",
});
return true;
}
export function enterDungeonSimulationReplay(replay: DungeonReplayV1): boolean {
const character = gmCharacter();
if (!character || !dungeonRouteDefinition(replay.dungeonId, replay.routeId)) return false;
if (!activateDungeonSession(replay.dungeonId, undefined, {
gmTestMode: true,
dungeonSimulation: true,
})) {
useShellStore.getState().setNotice("The replay dungeon package is unavailable on this client.");
return false;
}
useDungeonSimulationStore.getState().startReplay(replay);
useGameStore.getState().setCompanionOpen(false);
useShellStore.setState({
phase: "game",
activeCharacter: character,
activeDungeonId: replay.dungeonId,
activeDungeonRole: defaultPartyRoleForClass(character.classId),
notice: "",
});
return true;
}
+15 -1
View File
@@ -1,13 +1,15 @@
import { describe, expect, it } from "vitest";
import {
isShelvedShellPhase,
sessionCanUseGmTools,
sessionCanUseShelvedGmTools,
SHELVED_MODES_ENABLED,
} from "./releaseFeatures";
describe("release feature boundary", () => {
it("recognizes every legacy shelved phase", () => {
expect(["manastorms", "arenas", "arena-game", "gm-admin"].every(isShelvedShellPhase)).toBe(true);
expect(["manastorms", "arenas", "arena-game"].every(isShelvedShellPhase)).toBe(true);
expect(isShelvedShellPhase("gm-admin")).toBe(false);
expect(isShelvedShellPhase("main-menu")).toBe(false);
expect(isShelvedShellPhase("game")).toBe(false);
});
@@ -24,4 +26,16 @@ describe("release feature boundary", () => {
expect(sessionCanUseShelvedGmTools({ ...session, roles: [] })).toBe(false);
expect(sessionCanUseShelvedGmTools({ ...session, accessToken: "" })).toBe(false);
});
it("exposes dungeon GM tools in normal releases to authenticated GMs", () => {
const session = {
kind: "account",
ownerId: "gm-account",
displayName: "Phenom",
accessToken: "token",
roles: ["gm"],
} as const;
expect(sessionCanUseGmTools(session)).toBe(true);
expect(sessionCanUseGmTools({ ...session, roles: [] })).toBe(false);
});
});
+8 -1
View File
@@ -4,7 +4,6 @@ export const SHELVED_SHELL_PHASES = Object.freeze([
"manastorms",
"arenas",
"arena-game",
"gm-admin",
] as const);
export type ShelvedShellPhase = (typeof SHELVED_SHELL_PHASES)[number];
@@ -21,3 +20,11 @@ export function sessionCanUseShelvedGmTools(session: {
return SHELVED_MODES_ENABLED
&& Boolean(session?.kind === "account" && session.accessToken && session.roles?.includes("gm"));
}
export function sessionCanUseGmTools(session: {
readonly kind: string;
readonly accessToken?: string;
readonly roles?: readonly string[];
} | null): boolean {
return Boolean(session?.kind === "account" && session.accessToken && session.roles?.includes("gm"));
}
+2 -2
View File
@@ -59,7 +59,7 @@ import {
logoutOnlineSession,
refreshOnlineSession,
} from "./onlineAccountClient";
import { sessionCanUseShelvedGmTools, SHELVED_MODES_ENABLED } from "./releaseFeatures";
import { sessionCanUseGmTools, SHELVED_MODES_ENABLED } from "./releaseFeatures";
import { useGameStore } from "../game/store";
import {
normalizeSpecializationId,
@@ -366,7 +366,7 @@ export const useShellStore = create<ShellState>((set, get) => ({
},
openGmAdmin: () => {
const { session, selectedCharacterId, characters } = get();
if (!sessionCanUseShelvedGmTools(session)) {
if (!sessionCanUseGmTools(session)) {
set({ notice: "Game Master access is required." });
return false;
}
+70 -36
View File
@@ -23,6 +23,10 @@ import {
onlineSessionOwnsWorldSimulation,
} from "./onlineSessionRuntime";
import { combatSimulationTimerShouldRun } from "./combatSimulationLifecycle";
import {
DUNGEON_SIMULATION_FIXED_STEP_MS,
useDungeonSimulationStore,
} from "./dungeonSimulationStore";
/**
* Connects the pure combat runtime to the active character and persistent
@@ -75,7 +79,7 @@ export function CombatBridge() {
// after Character Select has already replaced the active character.
const shell = useShellStore.getState();
if (shell.activeCharacter?.id !== characterId || shell.session?.ownerId !== ownerId) return;
if (useGameStore.getState().bossTest) return;
if (useGameStore.getState().gmTestMode) return;
// Persisted collections are replaced immutably by combat actions. A
// reference/primitive comparison therefore detects the same changes as
@@ -121,54 +125,80 @@ export function CombatBridge() {
let previous = performance.now();
let timer: number | null = null;
const advanceOneTick = (delta: number, epochNow: number, spectator: boolean) => {
const game = useGameStore.getState();
const simulation = useDungeonSimulationStore.getState();
const combat = useCombatStore.getState();
combat.setPlayerPosition(game.playerPosition);
combat.tick(delta, epochNow);
if (
onlineSessionOwnsWorldSimulation()
&& game.gameMode === "dungeon"
&& (!spectator || simulation.mode === "full-combat")
) {
useCombatStore.getState().engageNearbyMobs(epochNow, game.playerPosition);
}
if (onlineSessionOwnsWorldSimulation()) {
if (!spectator || simulation.mode === "full-combat") {
useCombatStore.getState().advanceMobCombat(epochNow, game.playerPosition);
}
advanceDungeonPartyCombat(epochNow);
}
useCombatStore.getState().synchronizeActors();
if (onlineSessionOwnsWorldSimulation() && game.gameMode === "dungeon") {
const mobs = useCombatStore.getState().mobs;
const combatEvents = deriveDungeonEncounterCombatEvents(
encounterMobCursor.current,
mobs,
epochNow,
);
encounterMobCursor.current = combatEvents.cursor;
for (const event of combatEvents.events) dispatchActiveDungeonEntityEvent(event);
const registeredEntityIds = Object.keys(mobs);
advanceDungeonEncounters(game.activeDungeonId, {
kind: "tick",
now: epochNow,
facts: {
registeredEntityIds,
deadEntityIds: registeredEntityIds.filter((id) => mobs[id].dead),
},
});
}
};
const advance = () => {
const now = performance.now();
const delta = Math.min(0.25, Math.max(0, (now - previous) / 1_000));
const wallDeltaMs = Math.min(1_000, Math.max(0, now - previous));
const delta = Math.min(0.25, wallDeltaMs / 1_000);
previous = now;
const simulation = useDungeonSimulationStore.getState();
if (simulation.sessionMode === "spectate-replay") {
simulation.consumeTicks(wallDeltaMs);
return;
}
if (simulation.sessionMode === "spectate-live") {
const assetStatus = useGameStore.getState().assetStatus;
if (assetStatus !== "ready" && assetStatus !== "proxy") return;
for (const tickNow of simulation.consumeTicks(wallDeltaMs)) {
advanceOneTick(DUNGEON_SIMULATION_FIXED_STEP_MS / 1_000, tickNow, true);
}
return;
}
const game = useGameStore.getState();
const sharedOnline = onlineSessionIsActive();
if ((!game.paused || sharedOnline) && (!game.companionOpen || sharedOnline)) {
const epochNow = Date.now();
const combat = useCombatStore.getState();
combat.setPlayerPosition(game.playerPosition);
combat.tick(delta, epochNow);
if (onlineSessionOwnsWorldSimulation() && game.gameMode === "dungeon") {
useCombatStore.getState().engageNearbyMobs(epochNow, game.playerPosition);
}
if (onlineSessionOwnsWorldSimulation()) {
useCombatStore.getState().advanceMobCombat(epochNow, game.playerPosition);
advanceDungeonPartyCombat(epochNow);
}
useCombatStore.getState().synchronizeActors();
if (onlineSessionOwnsWorldSimulation() && game.gameMode === "dungeon") {
const mobs = useCombatStore.getState().mobs;
const combatEvents = deriveDungeonEncounterCombatEvents(
encounterMobCursor.current,
mobs,
epochNow,
);
encounterMobCursor.current = combatEvents.cursor;
for (const event of combatEvents.events) dispatchActiveDungeonEntityEvent(event);
const registeredEntityIds = Object.keys(mobs);
advanceDungeonEncounters(game.activeDungeonId, {
kind: "tick",
now: epochNow,
facts: {
registeredEntityIds,
deadEntityIds: registeredEntityIds.filter((id) => mobs[id].dead),
},
});
}
advanceOneTick(delta, Date.now(), false);
}
};
const reconcileTimer = () => {
const game = useGameStore.getState();
const simulation = useDungeonSimulationStore.getState();
const shouldRun = combatSimulationTimerShouldRun({
sharedOnline: onlineSessionIsActive(),
paused: game.paused,
companionOpen: game.companionOpen,
documentHidden: document.hidden,
paused: simulation.sessionMode === "play" ? game.paused : false,
companionOpen: simulation.sessionMode === "play" ? game.companionOpen : false,
documentHidden: simulation.sessionMode === "play" ? document.hidden : false,
});
if (shouldRun && timer === null) {
// Reset the clock on resume so a long pause/background period cannot
@@ -193,6 +223,9 @@ export function CombatBridge() {
// store notification gives the bridge a reactive point at which to
// reconcile while preserving continuous shared-online simulation.
const unsubscribeOnlineSession = useOnlineSessionStore.subscribe(reconcileTimer);
const unsubscribeSimulation = useDungeonSimulationStore.subscribe((state, previousState) => {
if (state.sessionMode !== previousState.sessionMode || state.runState !== previousState.runState) reconcileTimer();
});
document.addEventListener("visibilitychange", reconcileTimer);
reconcileTimer();
@@ -200,6 +233,7 @@ export function CombatBridge() {
if (timer !== null) window.clearInterval(timer);
unsubscribeGame();
unsubscribeOnlineSession();
unsubscribeSimulation();
document.removeEventListener("visibilitychange", reconcileTimer);
};
}, []);
+3 -2
View File
@@ -13,6 +13,7 @@ import type { DungeonEncounterEffectEnvelope } from "./dungeonEncounterTypes";
import { publishOnlinePartyTeleport } from "./onlineSessionRuntime";
import { dungeonDefinitionById } from "./dungeonRegistry";
import { removeMobPosition } from "./mobRuntimeRegistry";
import { dungeonSimulationNow } from "./dungeonSimulationStore";
function grantEncounterLoot(lootTableId: string, now: number): boolean {
const table = LOOT_CATALOG.bosses.find((candidate) => candidate.lootTableId === lootTableId);
@@ -29,14 +30,14 @@ function grantEncounterLoot(lootTableId: string, now: number): boolean {
/** Applies platform effects emitted by the renderer-neutral encounter reducer. */
export function DungeonEncounterEffectBridge() {
useEffect(() => registerDungeonEncounterEffectHandler((envelope) => {
applyDungeonEncounterEffect(envelope, Date.now());
applyDungeonEncounterEffect(envelope, dungeonSimulationNow());
}), []);
return null;
}
export function applyDungeonEncounterEffect(
{ dungeonId, effect }: DungeonEncounterEffectEnvelope,
now = Date.now(),
now = dungeonSimulationNow(),
): void {
if (effect.kind === "signal") {
dispatchDungeonEncounterSignal(effect.signalId, now);
+19 -9
View File
@@ -6,6 +6,7 @@ import { usePartyStore } from "./partyStore";
import { useGameStore } from "./store";
import { useManastormStore } from "./manastormStore";
import { onlineGroupAiPartySize, useOnlineGroupStore } from "../app/onlineGroupStore";
import { useDungeonSimulationStore } from "./dungeonSimulationStore";
/** Owns party lifecycle; the combat interval itself remains in CombatBridge. */
export function PartyBridge() {
@@ -15,6 +16,8 @@ export function PartyBridge() {
const manastormPartySize = useManastormStore((state) => state.partySize);
const onlineActivity = useOnlineGroupStore((state) => state.activeActivity);
const level = useCombatStore((state) => state.level);
const simulationMode = useDungeonSimulationStore((state) => state.sessionMode);
const simulationSeed = useDungeonSimulationStore((state) => state.seed);
const resetRevision = useGameStore((state) => state.resetRevision);
const mountedResetRevision = useRef(resetRevision);
@@ -27,19 +30,24 @@ export function PartyBridge() {
&& onlineActivity.type === (gameMode === "manastorm" ? "manastorm" : "dungeon")
? onlineGroupAiPartySize(onlineActivity.partySize)
: requestedPartySize;
party.initializeParty(
character.id,
combat.level,
combat.health,
playerRole,
aiPartySize,
character.categoryId ?? "wow",
);
if (simulationMode === "play") {
party.initializeParty(
character.id,
combat.level,
combat.health,
playerRole,
aiPartySize,
character.categoryId ?? "wow",
);
} else {
party.initializeSpectatorParty(simulationSeed, combat.level, character.categoryId ?? "wow");
}
usePartyStore.getState().syncEquipment(combat.equipment, combat.inventory);
combat.synchronizeActors();
combat.applyDefaultPartyBuffs();
combat.applyPrecombatPreparation();
resetPartyRuntimeClock();
}, [character?.id, gameMode, manastormPartySize, onlineActivity?.id, playerRole]);
}, [character?.id, gameMode, manastormPartySize, onlineActivity?.id, playerRole, simulationMode, simulationSeed]);
useEffect(() => {
const party = usePartyStore.getState();
@@ -48,6 +56,7 @@ export function PartyBridge() {
usePartyStore.getState().syncEquipment(combat.equipment, combat.inventory);
combat.synchronizeActors();
combat.applyDefaultPartyBuffs();
combat.applyPrecombatPreparation();
}, [level]);
useEffect(() => {
@@ -58,6 +67,7 @@ export function PartyBridge() {
usePartyStore.getState().syncEquipment(combat.equipment, combat.inventory);
combat.synchronizeActors();
combat.applyDefaultPartyBuffs();
combat.applyPrecombatPreparation();
resetPartyRuntimeClock();
}, [resetRevision]);
+1 -1
View File
@@ -34,7 +34,7 @@ describe("baseline ability catalog", () => {
for (const ability of abilities) {
expect(ability.classId).toBe(characterClass.id);
expect(ability.dbcSpellId).toBeGreaterThan(0);
expect(ability.icon).toMatch(/^\/assets\/ui\/spells\/[a-z0-9_()' -]+\.png$/);
expect(ability.icon).toMatch(/^\/assets\/ui\/(?:spells|talents\/icons)\/[a-z0-9_()' -]+\.png$/);
const iconPath = path.join(process.cwd(), "public", ability.icon.slice(1));
expect(existsSync(iconPath), `${ability.name} icon should exist at ${iconPath}`).toBe(true);
expect(statSync(iconPath).size, `${ability.name} icon should not be empty`).toBeGreaterThan(0);
+56 -4
View File
@@ -70,6 +70,13 @@ export interface AbilityAiHints {
| "resurrection"
| "control"
| "debuff"
| "buff"
| "resource"
| "summon"
| "pet"
| "movement"
| "threat"
| "area-damage"
)[];
readonly priority?: number;
}
@@ -293,6 +300,9 @@ export interface AbilityDefinition {
/** The original client level before Healer Man's starter-kit remapping. */
readonly sourceUnlockLevel?: number;
readonly ranks?: readonly AbilityRankDefinition[];
/** Runtime talent node that must be learned before this ability is available. */
readonly talentNodeId?: string;
/** Legacy CoA source identifier; `abilityTalentNodeId` normalizes it. */
readonly talentEntryId?: number;
}
@@ -953,10 +963,51 @@ export function abilityRankForLevel(
?? ability.ranks[Math.min(ability.ranks.length, normalized) - 1]
?? ability.ranks[0];
}
return [...ability.ranks]
const eligible = [...ability.ranks]
.filter((rank) => rank.level <= playerLevel)
.sort((left, right) => right.rank - left.rank)[0]
?? ability.ranks[0];
.sort((left, right) => right.rank - left.rank);
const highestRank = eligible[0]?.rank;
if (highestRank === undefined) return ability.ranks[0];
const candidates = eligible.filter((rank) => rank.rank === highestRank);
if (candidates.length === 1) return candidates[0];
// Several Wrath spell-name chains contain both the player-facing rank and a
// same-rank triggered child (for example Lightning Shield's retaliation).
// Preserve the structural effect family of the chain's root spell so a
// triggered damage child cannot replace the executable aura at later ranks.
const effectStructure = (effects: readonly AbilityEffect[]): string => effects.map((effect) => (
effect.kind === "apply-aura"
? [effect.kind, effect.sourceEffectType, effect.sourceAuraType].join(":")
: effect.kind
)).join("|");
const rootStructure = effectStructure(ability.effects);
return candidates.find((rank) => effectStructure(rank.effects) === rootStructure) ?? candidates[0];
}
export function abilityTalentNodeId(ability: AbilityDefinition): string | null {
return ability.talentNodeId
?? (ability.talentEntryId === undefined ? null : `coa-entry-${ability.talentEntryId}`);
}
export function learnedAbilityRank(
ability: AbilityDefinition,
talentRanks: Readonly<Record<string, number>>,
): number | undefined {
const nodeId = abilityTalentNodeId(ability);
return nodeId ? Math.max(0, Math.trunc(talentRanks[nodeId] ?? 0)) : undefined;
}
export function learnedAbilityForBuild(
ability: AbilityDefinition,
playerLevel: number,
talentRanks: Readonly<Record<string, number>>,
): AbilityDefinition | null {
if (!isAbilityUnlocked(ability, playerLevel)) return null;
const nodeId = abilityTalentNodeId(ability);
const learnedRank = learnedAbilityRank(ability, talentRanks);
if (nodeId && !learnedRank) return null;
// CoA tree entries use the allocated talent rank as the spell rank. Classic
// active talents continue to learn their later spell ranks by character level.
return abilityAtLevel(ability, playerLevel, ability.talentEntryId === undefined ? undefined : learnedRank);
}
export function abilityAtLevel(
@@ -1040,7 +1091,7 @@ export function defaultActionBarForClass(classId: ClassId): readonly string[] {
.map(({ ability }) => ability)
.filter((ability) => (
!ability.passive
&& ability.source !== "coa-tree"
&& !abilityTalentNodeId(ability)
&& !ability.effects.every((effect) => effect.kind === "unsupported" || effect.kind === "scripted")
))
.map((ability) => ability.id);
@@ -1065,6 +1116,7 @@ export function defaultActionBarForCharacter(classId: ClassId, secondaryClassId?
.filter((ability) => (
!ability.passive
&& ability.unlockLevel <= level
&& !abilityTalentNodeId(ability)
&& !ability.effects.every((effect) => effect.kind === "unsupported" || effect.kind === "scripted")
))
.sort((left, right) => left.unlockLevel - right.unlockLevel || left.name.localeCompare(right.name))
+28 -1
View File
@@ -19,6 +19,10 @@ const MOVEMENT_CAST_ABILITIES = new Set(["scorch", "steady shot", "spiritwalker'
const EMERGENCY_HEAL_ABILITIES = new Set([
"flash heal", "flash of light", "swiftmend", "lay on hands", "nature's swiftness",
]);
const AREA_HEAL_ABILITIES = new Set([
"chain heal", "circle of healing", "divine hymn", "holy nova", "prayer of healing",
"prayer of mending", "tranquility", "wild growth",
]);
const RUNE_COSTS_BY_SPELL_ID: Readonly<Record<number, AbilityRuneCost>> = Object.freeze({
49909: { frost: 1 }, 45524: { frost: 1 }, 49184: { frost: 1 }, 47528: { frost: 1 },
@@ -97,8 +101,31 @@ export function aiHintsForAbility(ability: AbilityDefinition): AbilityAiHints {
categories.add(effect.status === "interrupt" ? "interrupt" : "control");
}
if (effect.kind === "dot" || (effect.kind === "apply-aura" && effect.aura.disposition === "debuff")) categories.add("debuff");
if (effect.kind === "apply-aura" && effect.aura.disposition === "buff") categories.add("buff");
if (effect.kind === "apply-aura" && (
effect.aura.absorbs?.length
|| effect.aura.modifiers?.some((modifier) => modifier.kind === "damage"
&& modifier.direction === "taken" && modifier.value < 0)
)) categories.add("mitigation");
if (effect.kind === "apply-aura" && effect.aura.modifiers?.some((modifier) => (
modifier.kind === "stat" && modifier.stat === "threat"
))) categories.add("threat");
if (effect.kind === "apply-aura" && effect.aura.modifiers?.some((modifier) => modifier.kind === "resource")) {
categories.add("resource");
}
if (effect.kind === "resource" || effect.kind === "rune") categories.add("resource");
if (effect.kind === "summon" || effect.kind === "totem") categories.add("summon");
if (effect.kind === "pet") categories.add("pet");
if (effect.kind === "movement" || effect.kind === "pull") categories.add("movement");
if (effect.kind === "threat") categories.add("threat");
}
if (EMERGENCY_HEAL_ABILITIES.has(name)) categories.add("emergency-heal");
if ((ability.radius ?? 0) > 0 && categories.has("heal")) categories.add("area-heal");
if (((ability.radius ?? 0) > 0 || AREA_HEAL_ABILITIES.has(name)) && categories.has("heal")) categories.add("area-heal");
const targeting = targetingRulesForAbility(ability);
if (categories.has("damage") && (
targeting.shape !== "unit"
|| (targeting.maximumTargets ?? 1) > 1
|| (targeting.radius ?? ability.radius ?? 0) > 0
)) categories.add("area-damage");
return Object.freeze({ categories: Object.freeze([...categories]) });
}
+6 -6
View File
@@ -1,6 +1,6 @@
import type { CharacterProfile } from "../app/types";
import {
abilitiesForCharacter, abilityAtLevel, defaultActionBarForCharacter, resourceProfileForClass,
abilitiesForCharacter, abilityTalentNodeId, defaultActionBarForCharacter, learnedAbilityForBuild, resourceProfileForClass,
type AbilityDefinition, type ClassResourceProfile,
} from "./abilityCatalog";
import { normalizeActionBindings } from "./actionBindings";
@@ -11,13 +11,13 @@ import { activeEquipmentStatsForClass, PLAYER_EQUIPMENT_OWNER_ID } from "./equip
export function arenaCharacterAbilities(character: CharacterProfile, level = character.level): readonly AbilityDefinition[] {
return abilitiesForCharacter(character.classId, character.secondaryClassId)
.filter((ability) => !ability.passive)
.map((ability) => abilityAtLevel(ability, level, ability.talentEntryId === undefined
? undefined : character.talentRanks[`coa-entry-${ability.talentEntryId}`]));
.map((ability) => learnedAbilityForBuild(ability, level, character.talentRanks))
.filter((ability): ability is AbilityDefinition => ability !== null);
}
export function arenaAbilityIsLearned(ability: AbilityDefinition, character: CharacterProfile, level = character.level): boolean {
return ability.unlockLevel <= level && (ability.source !== "coa-tree" || ability.talentEntryId === undefined
|| (character.talentRanks[`coa-entry-${ability.talentEntryId}`] ?? 0) > 0);
return ability.unlockLevel <= level && (!abilityTalentNodeId(ability)
|| learnedAbilityForBuild(ability, level, character.talentRanks) !== null);
}
export function createArenaCharacterKit(character: CharacterProfile, level = character.level): ArenaCharacterKit {
@@ -28,7 +28,7 @@ export function createArenaCharacterKit(character: CharacterProfile, level = cha
const all = abilitiesForCharacter(character.classId, character.secondaryClassId);
const actionBindings = normalizeActionBindings(character.actionBindings, [
...defaultActionBarForCharacter(character.classId, character.secondaryClassId, level),
...all.filter((ability) => ability.source === "coa-tree" && !ability.passive).map((ability) => ability.id),
...all.filter((ability) => abilityTalentNodeId(ability) && !ability.passive).map((ability) => ability.id),
]);
return {
seed: { ...character, level, settings: { ...character.settings, autoTargetNearest: false } },
+2
View File
@@ -6,6 +6,7 @@ import baseStatsData from "./generated/wow335BaseStats.json";
import {
creatureRuntimeBaseStats,
creatureTemplateReference,
standardWowDungeonCreatureHealth,
} from "./azerothCoreReference";
interface PlayerClassRow {
@@ -110,6 +111,7 @@ describe("pinned AzerothCore reference", () => {
});
it("derives representative normal, elite, and boss stats at multiple levels", () => {
expect(standardWowDungeonCreatureHealth(5)).toBe(306);
expect(creatureRuntimeBaseStats(385, 5)).toMatchObject({
health: 102,
armor: 111,
+15 -1
View File
@@ -113,6 +113,21 @@ function classStatsAt(level: number, unitClass: number): CreatureClassLevelRefer
return REFERENCE.creature.classLevelStats[`${safe}:1`] ?? null;
}
/**
* Returns the health of a standard same-level WoW dungeon elite. The pinned
* AzerothCore class-level table supplies the level curve while the x3 modifier
* matches the ordinary elite templates used by opening dungeons such as the
* Stockade. Expansion bands follow normal WoW character progression so this
* baseline can also be used by creatures imported from another game.
*/
export function standardWowDungeonCreatureHealth(level: number): number {
const safe = safeLevel(level);
const stats = classStatsAt(safe, 1);
if (!stats) return Math.max(1, 72 + safe * 22);
const expansion = safe > 70 ? 2 : safe > 60 ? 1 : 0;
return Math.max(1, Math.ceil(stats.baseHealth[expansion] * 3));
}
export function creatureTemplateReference(entry: number): CreatureTemplateReference | null {
if (!Number.isInteger(entry) || entry <= 0) return null;
return REFERENCE.creature.templates[String(entry)] ?? null;
@@ -165,4 +180,3 @@ export function creatureRuntimeBaseStats(
schoolImmunities: template.schoolImmunities,
});
}
@@ -0,0 +1,69 @@
import { describe, expect, it } from "vitest";
import { createConfiguredPartyMember } from "../partyStore";
import { runtimeNormalizeTalentRanks } from "../talentRuntimeCatalog";
import {
loadSelectableBenchmarkBuilds,
selectableBenchmarkBuildCatalog,
} from "./benchmarkBuilds";
describe("balance-lab selectable builds", () => {
it("lists every authored WoW specialization instead of the representative cohort", () => {
const catalog = selectableBenchmarkBuildCatalog(60, [
"warrior", "paladin", "hunter", "rogue", "priest", "death-knight",
"shaman", "mage", "warlock", "druid",
]);
expect(catalog).toHaveLength(31);
expect(catalog).toEqual(expect.arrayContaining([
expect.objectContaining({ classId: "hunter", orientation: "Beast Mastery", role: "damage" }),
expect.objectContaining({ classId: "priest", orientation: "Discipline", role: "healer" }),
expect.objectContaining({ classId: "warlock", orientation: "Destruction", role: "damage" }),
expect.objectContaining({ classId: "druid", orientation: "Feral (Bear)", role: "tank" }),
]));
expect(new Set(catalog.map((build) => build.id)).size).toBe(catalog.length);
});
it("legally allocates a previously omitted specialization", async () => {
const builds = await loadSelectableBenchmarkBuilds(20, ["hunter"]);
const beastMastery = builds.find((build) => build.orientation === "Beast Mastery");
expect(beastMastery?.role).toBe("damage");
expect(Object.keys(beastMastery?.talentRanks ?? {})).not.toHaveLength(0);
});
it("keeps each CoA specialization tree selectable", async () => {
const builds = await loadSelectableBenchmarkBuilds(20, ["witch-doctor"]);
expect(builds.map((build) => `${build.orientation}:${build.role}`)).toEqual([
"Shadowhunting:damage",
"Voodoo:damage",
"Brewing:healer",
]);
expect(builds.every((build) => Object.keys(build.talentRanks).length > 0)).toBe(true);
});
it("constructs a legal build for every selectable WoW, CoA, and RoM entry", async () => {
const catalog = selectableBenchmarkBuildCatalog(20);
const builds = await loadSelectableBenchmarkBuilds(20);
expect(builds.map((build) => build.id)).toEqual(catalog.map((build) => build.id));
for (const build of builds) {
expect(runtimeNormalizeTalentRanks(build.classId, build.level, build.talentRanks))
.toEqual(build.talentRanks);
}
}, 60_000);
it("constructs an exact companion without replacing selected talents", () => {
const talents = Object.freeze({ "hunter-beast-mastery-0": 3 });
const member = createConfiguredPartyMember({
name: "Balance Hunter",
classId: "hunter",
specialization: "Beast Mastery",
specializationId: "hunter:beast-mastery",
talentTreeId: "hunter-beast-mastery",
talentRanks: talents,
role: "damage",
}, 20, 2, "balance-test");
expect(member.name).toBe("Balance Hunter");
expect(member.specialization).toBe("Beast Mastery");
expect(member.specializationId).toBe("hunter:beast-mastery");
expect(member.talentRanks).toBe(talents);
expect(member.id).toContain("party-damage-2-hunter");
});
});
+209 -2
View File
@@ -3,6 +3,7 @@ import {
CLASSES,
COA_CLASS_IDS,
ROM_CLASS_IDS,
classById,
contentCategoryForClass,
type BaseClassId,
type ClassId,
@@ -11,6 +12,7 @@ import {
} from "../../app/characterCatalog";
import {
abilitiesForClass,
abilityTalentNodeId,
preloadAbilityCatalogsForClasses,
type AbilityAiHints,
type AbilityDefinition,
@@ -20,6 +22,10 @@ import type { EquipmentAssignments } from "../equipment";
import { partyRolesForClass, type PartyRole } from "../partyRoles";
import { runtimePartyTalentRanksForLevel } from "../partyTalentRuntime";
import { clampPlayerLevel } from "../progression";
import {
specializationsForClass,
type SpecializationDefinition,
} from "../specializationCatalog";
import {
runtimeAllocateTalentPoint,
runtimeNormalizeTalentRanks,
@@ -153,6 +159,16 @@ function benchmarkBuildId(classId: ClassId, role: PartyRole, level: number): str
return `canonical-${contentCategoryForClass(classId)}-${classId}-${role}-level-${level}`;
}
function benchmarkVariantBuildId(
classId: ClassId,
role: PartyRole,
orientation: string,
level: number,
): string {
const variant = orientation.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
return `balance-${contentCategoryForClass(classId)}-${classId}-${role}-${variant}-level-${level}`;
}
function commonBuildFields(classId: ClassId, role: PartyRole, level: number) {
return {
id: benchmarkBuildId(classId, role, level),
@@ -190,8 +206,8 @@ function isClassTree(tree: TalentUiTree): boolean {
function coaNodeAbilities(classId: CoaClassId): ReadonlyMap<string, readonly AbilityDefinition[]> {
const mutable = new Map<string, AbilityDefinition[]>();
for (const ability of abilitiesForClass(classId)) {
if (ability.talentEntryId === undefined) continue;
const nodeId = `coa-entry-${ability.talentEntryId}`;
const nodeId = abilityTalentNodeId(ability);
if (!nodeId) continue;
const existing = mutable.get(nodeId);
if (existing) existing.push(ability);
else mutable.set(nodeId, [ability]);
@@ -365,6 +381,197 @@ function romBuild(
});
}
function specializationTree(
catalog: TalentUiCatalog,
specialization: SpecializationDefinition,
): TalentUiTree {
const normalized = (value: string) => value.toLowerCase().replace(/[^a-z0-9]+/g, "");
const names = new Set([
normalized(specialization.talentTree),
normalized(specialization.displayName),
]);
const tree = catalog.trees.find((candidate) => names.has(normalized(candidate.name)));
if (!tree) {
throw new Error(
`No talent tree matches ${classById(specialization.classId).name} ${specialization.displayName}.`,
);
}
return tree;
}
function allocateSingleTreeTalentRanks(
classId: BaseClassId,
catalog: TalentUiCatalog,
tree: TalentUiTree,
level: number,
): RuntimeTalentRanks {
const candidates = catalog.nodes
.filter((node) => node.treeId === tree.id)
.sort((left, right) => (
left.row - right.row
|| left.column - right.column
|| left.index - right.index
|| left.id.localeCompare(right.id)
));
const successfulRequests: Record<string, number> = {};
let ranks: RuntimeTalentRanks = EMPTY_TALENT_RANKS;
while (true) {
let allocated = false;
for (const node of candidates) {
if ((successfulRequests[node.id] ?? 0) >= node.maxRank) continue;
const result = runtimeAllocateTalentPoint(ranks, classId, node.id, level);
if (!result.ok) continue;
successfulRequests[node.id] = (successfulRequests[node.id] ?? 0) + 1;
ranks = result.ranks;
allocated = true;
break;
}
if (!allocated) break;
}
return Object.freeze({ ...runtimeNormalizeTalentRanks(classId, level, ranks) });
}
function selectableClassicBuild(
specialization: SpecializationDefinition,
level: number,
catalog: TalentUiCatalog | null,
): BenchmarkBuildDefinition {
const classId = specialization.classId as BaseClassId;
let talentRanks: RuntimeTalentRanks;
try {
talentRanks = runtimePartyTalentRanksForLevel(classId, specialization.displayName, level);
} catch {
if (!catalog) throw new Error(`No talent catalog was loaded for ${classId}.`);
talentRanks = allocateSingleTreeTalentRanks(
classId,
catalog,
specializationTree(catalog, specialization),
level,
);
}
return Object.freeze({
...commonBuildFields(classId, specialization.role, level),
id: benchmarkVariantBuildId(classId, specialization.role, specialization.displayName, level),
orientation: specialization.displayName,
talentRanks,
});
}
function selectableCoaBuild(
specialization: SpecializationDefinition,
level: number,
catalog: TalentUiCatalog,
): BenchmarkBuildDefinition {
const classId = specialization.classId as CoaClassId;
const tree = specializationTree(catalog, specialization);
const abilitiesByNode = coaNodeAbilities(classId);
return Object.freeze({
...commonBuildFields(classId, specialization.role, level),
id: benchmarkVariantBuildId(classId, specialization.role, specialization.displayName, level),
orientation: specialization.displayName,
talentRanks: allocateCoaTalentRanks(
classId,
catalog,
tree,
specialization.role,
level,
abilitiesByNode,
),
});
}
export interface SelectableBenchmarkBuildCatalogEntry {
readonly id: string;
readonly source: ReturnType<typeof contentCategoryForClass>;
readonly classId: ClassId;
readonly role: PartyRole;
readonly orientation: string;
readonly level: number;
}
/** Cheap metadata-only roster used by interactive tools before a build is selected. */
export function selectableBenchmarkBuildCatalog(
level = CANONICAL_BENCHMARK_LEVEL,
classIds?: readonly ClassId[],
): readonly SelectableBenchmarkBuildCatalogEntry[] {
const safeLevel = clampPlayerLevel(level);
const requested = classIds ? new Set(classIds) : null;
return CLASSES
.map((definition) => definition.id)
.filter((classId) => !requested || requested.has(classId))
.flatMap((classId): readonly SelectableBenchmarkBuildCatalogEntry[] => {
if (isRomClassId(classId)) return partyRolesForClass(classId).map((role) => ({
id: benchmarkBuildId(classId, role, safeLevel),
source: contentCategoryForClass(classId),
classId,
role,
orientation: "Primary",
level: safeLevel,
}));
return specializationsForClass(classId).map((specialization) => ({
id: benchmarkVariantBuildId(classId, specialization.role, specialization.displayName, safeLevel),
source: contentCategoryForClass(classId),
classId,
role: specialization.role,
orientation: specialization.displayName,
level: safeLevel,
}));
});
}
/**
* Builds every user-selectable class/spec/role variant for the balance lab.
* This is intentionally broader than the canonical benchmark cohort above,
* which keeps one representative orientation per class-role pair.
*/
export async function loadSelectableBenchmarkBuilds(
level = CANONICAL_BENCHMARK_LEVEL,
classIds?: readonly ClassId[],
): Promise<BenchmarkBuildDefinition[]> {
const safeLevel = clampPlayerLevel(level);
const requested = classIds ? new Set(classIds) : null;
const selectedClassIds = CLASSES
.map((definition) => definition.id)
.filter((classId) => !requested || requested.has(classId));
const catalogClassIds = selectedClassIds.filter((classId) => (
isCoaClassId(classId)
|| (isBaseClassId(classId) && specializationsForClass(classId).some((specialization) => {
try {
runtimePartyTalentRanksForLevel(classId, specialization.displayName, safeLevel);
return false;
} catch {
return true;
}
}))
));
await preloadAbilityCatalogsForClasses(selectedClassIds);
const catalogs = new Map<ClassId, TalentUiCatalog>(await Promise.all(
catalogClassIds.map(async (classId) => [
classId,
await loadTalentUiCatalog(classId),
] as const),
));
const builds: BenchmarkBuildDefinition[] = [];
for (const classId of selectedClassIds) {
if (isRomClassId(classId)) {
for (const role of partyRolesForClass(classId)) builds.push(romBuild(classId, role, safeLevel));
continue;
}
for (const specialization of specializationsForClass(classId)) {
if (isBaseClassId(classId)) {
builds.push(selectableClassicBuild(specialization, safeLevel, catalogs.get(classId) ?? null));
} else if (isCoaClassId(classId)) {
const catalog = catalogs.get(classId);
if (!catalog) throw new Error(`No loaded CoA talent catalog exists for ${classId}.`);
builds.push(selectableCoaBuild(specialization, safeLevel, catalog));
}
}
}
return builds;
}
/**
* Creates one stable, legal benchmark build for every declared class-role pair.
* A filter changes only membership; output always follows the canonical CLASSES
+73 -11
View File
@@ -1,6 +1,6 @@
import {
abilityAtLevel,
isAbilityUnlocked,
learnedAbilityForBuild,
resourceProfileForClass,
type AbilityDefinition,
} from "../abilityCatalog";
import { aiHintsForAbility, canAbilityCastWhileMoving, targetingRulesForAbility } from "../abilityRules";
@@ -15,8 +15,9 @@ import type { PartyState } from "../partyStore";
import { usePartyStore } from "../partyStore";
import type { PartyRole } from "../partyRoles";
import type { BenchmarkScenarioDefinition } from "./benchmarkTypes";
import { COMBAT_ROTATION_VERSION, evaluateCombatRotation } from "../combatRotation";
export const BENCHMARK_POLICY_ID = "baseline-v3";
export const BENCHMARK_POLICY_ID = `rotation-v4:${COMBAT_ROTATION_VERSION}`;
export interface BenchmarkPolicyDecision {
readonly made: boolean;
@@ -68,12 +69,9 @@ function friendlyTargets(combat: CombatState, party: PartyState): readonly Frien
}
function learnedAbility(ability: AbilityDefinition, combat: CombatState): AbilityDefinition | null {
if (ability.passive || !isAbilityUnlocked(ability, combat.level)) return null;
const learnedRank = ability.talentEntryId === undefined
? undefined
: combat.talentRanks[`coa-entry-${ability.talentEntryId}`] ?? 0;
if (ability.talentEntryId !== undefined && !learnedRank) return null;
const ranked = abilityAtLevel(ability, combat.level, learnedRank);
if (ability.passive) return null;
const ranked = learnedAbilityForBuild(ability, combat.level, combat.talentRanks);
if (!ranked) return null;
if (ranked.effects.every((effect) => effect.kind === "unsupported" || effect.kind === "scripted")) return null;
return ranked;
}
@@ -195,13 +193,77 @@ export class BenchmarkController {
) return { made: false, cast: false, resourceBlocked: false, invalidFailures: 0 };
const targets = friendlyTargets(combat, party);
const candidates = combat.abilities
const availableAbilities = combat.abilities
.map((ability) => learnedAbility(ability, combat))
.filter((ability): ability is AbilityDefinition => ability !== null)
.filter((ability) => !moving || ability.castTimeMs <= 0 || canAbilityCastWhileMoving(ability))
.filter((ability) => !moving || ability.castTimeMs <= 0 || canAbilityCastWhileMoving(ability));
const candidates = availableAbilities
.map((ability) => scoreAbility(ability, role, scenario, combat, targets))
.filter((candidate): candidate is ScoredAbility => candidate !== null)
.sort((left, right) => right.score - left.score || left.ability.id.localeCompare(right.ability.id));
const hostileTarget = combat.selectedTargetId ? combat.mobs[combat.selectedTargetId] : null;
const sharedDecision = evaluateCombatRotation({
now,
classId: combat.classId,
secondaryClassId: combat.secondaryClassId,
specializationId: combat.specializationId,
role,
level: combat.level,
health: combat.health,
maxHealth: combat.maxHealth,
selfId: PLAYER_AGGRO_ID,
resources: combat.resourcePools,
maximumResources: combat.maxResourcePools,
primaryResource: resourceProfileForClass(combat.classId).type,
comboPoints: combat.comboPoints,
comboTargetId: combat.comboTargetId,
runes: combat.deathKnightRunes,
abilities: availableAbilities,
hostileTarget,
friendlyTargets: [
{ id: PLAYER_AGGRO_ID, health: combat.health, maxHealth: combat.maxHealth, fallen: combat.health <= 0 },
...party.members.map((member) => ({
id: member.id,
health: member.health,
maxHealth: member.maxHealth,
fallen: member.health <= 0,
})),
],
hostileCount: scenario.hostileTargets,
targetIsLoose: role === "tank" && Boolean(hostileTarget?.targetActorId && hostileTarget.targetActorId !== PLAYER_AGGRO_ID),
hasDangerousThreat: role !== "tank" && hostileTarget?.targetActorId === PLAYER_AGGRO_ID,
moving,
maintainedAbilityIds: new Set([
...combat.effects.filter((effect) => effect.sourceActorId === PLAYER_AGGRO_ID && effect.remainingTicks > 0)
.map((effect) => effect.sourceAbilityId),
...combat.auras.filter((aura) => aura.sourceId === PLAYER_AGGRO_ID
&& (aura.expiresAt === null || aura.expiresAt > now))
.flatMap((aura) => availableAbilities
.filter((ability) => ability.effects.some((effect) => effect.kind === "apply-aura"
&& effect.aura.id === aura.definition.id))
.map((ability) => ability.id)),
]),
activeAuraExclusiveGroups: new Set(combat.auras
.filter((aura) => aura.sourceId === PLAYER_AGGRO_ID && (aura.expiresAt === null || aura.expiresAt > now))
.flatMap((aura) => aura.definition.exclusiveGroup ? [aura.definition.exclusiveGroup] : [])),
activeSummonAbilityIds: new Set(combat.summons
.filter((summon) => summon.ownerActorId === PLAYER_AGGRO_ID && summon.expiresAt > now)
.map((summon) => summon.sourceAbilityId)),
hasActivePet: combat.summons.some((summon) => {
if (summon.ownerActorId !== PLAYER_AGGRO_ID || summon.expiresAt <= now) return false;
const source = availableAbilities.find((ability) => ability.id === summon.sourceAbilityId);
return source?.effects.some((effect) => effect.kind === "pet"
&& ["summon", "call", "resurrect", "tame"].includes(effect.action)) ?? false;
}),
});
if (sharedDecision) {
const friendlyTarget = sharedDecision.targetId
? targets.find((target) => target.id === sharedDecision.targetId) ?? null
: null;
const existing = candidates.findIndex((candidate) => candidate.ability.id === sharedDecision.ability.id);
if (existing >= 0) candidates.splice(existing, 1);
candidates.unshift({ ability: sharedDecision.ability, score: Number.POSITIVE_INFINITY, friendlyTarget });
}
// Rage healers must begin weapon attacks to fund their heals before allies are injured.
if (role === "healer" && combat.selectedTargetId
+2
View File
@@ -12,6 +12,7 @@ import type {
BenchmarkScenarioDefinition,
} from "./benchmarkTypes";
import { BENCHMARK_POLICY_ID } from "./benchmarkController";
import { COMBAT_ROTATION_VERSION } from "../combatRotation";
interface MutableAbilityMetrics {
castsStarted: number;
@@ -260,6 +261,7 @@ export class BenchmarkRecorder {
schemaVersion: 1,
runId: `${this.#build.id}__${this.#scenario.id}-v${this.#scenario.version}__seed-${this.#seed}`,
policyId: BENCHMARK_POLICY_ID,
rotationVersion: COMBAT_ROTATION_VERSION,
seed: this.#seed,
build: this.#build,
scenario: this.#scenario,
@@ -98,6 +98,7 @@ function matrix(): BenchmarkMatrixReport {
seeds: [1, 2, 3, 4],
durationScale: 1,
policyId: "baseline-v1",
rotationVersion: "playerbots-inspired-v1",
},
scenarios: [scenario],
builds: [build],
@@ -150,6 +151,7 @@ describe("benchmark reporting", () => {
const csv = serializeBenchmarkMatrixCsv(matrix());
expect(csv).toContain("scenario_id,scenario_version,scenario_name");
expect(csv).toContain("policy_id,rotation_version,scenario_id");
expect(csv).toContain('mage-burst,wow,mage,,damage,60,"spell, ""burst"""');
expect(csv).toContain(",dps,3,0,12,20,28\n");
expect(csv.endsWith("\n")).toBe(true);
@@ -164,6 +166,7 @@ describe("benchmark reporting", () => {
expect(markdown).toContain("DPS: 20 [12–28]");
expect(markdown).toContain("1 invalid run(s) are excluded");
expect(markdown).toContain("does not calculate a composite score");
expect(markdown).toContain("rotation: `playerbots-inspired-v1`");
expect(markdown.endsWith("\n")).toBe(true);
expect(markdown.endsWith("\n\n")).toBe(false);
});
+5 -1
View File
@@ -318,6 +318,7 @@ export function serializeBenchmarkMatrixCsv(input: BenchmarkReportInput): string
"seeds",
"duration_scale",
"policy_id",
"rotation_version",
"scenario_id",
"scenario_version",
"scenario_name",
@@ -354,6 +355,7 @@ export function serializeBenchmarkMatrixCsv(input: BenchmarkReportInput): string
summary.cohort.seeds.join(";"),
summary.cohort.durationScale,
summary.cohort.policyId,
summary.cohort.rotationVersion ?? null,
row.scenarioId,
row.scenarioVersion,
row.scenarioName,
@@ -430,7 +432,9 @@ export function renderBenchmarkMatrixMarkdown(input: BenchmarkReportInput): stri
`Generated ${summary.generatedAt} with app ${summary.appVersion}. `
+ `Cohort: level ${summary.cohort.level}, gear \`${summary.cohort.gearPresetId}\`, `
+ `${summary.cohort.seeds.length} seed(s), duration scale ${summary.cohort.durationScale}.`,
`Policy: \`${summary.cohort.policyId}\`.`,
`Policy: \`${summary.cohort.policyId}\`${summary.cohort.rotationVersion
? `; rotation: \`${summary.cohort.rotationVersion}\``
: ""}.`,
"",
`${summary.validRunCount}/${summary.runCount} runs were valid; `
+ `${summary.invalidRunCount} invalid run(s) are excluded from distributions. `
+6
View File
@@ -23,6 +23,7 @@ import type {
BenchmarkRunReport,
BenchmarkScenarioDefinition,
} from "./benchmarkTypes";
import { specializationsForClass } from "../specializationCatalog";
const BENCHMARK_CLOCK_ORIGIN = 1_000_000;
const BENCHMARK_MOB_HEALTH = 1_000_000_000;
@@ -274,9 +275,12 @@ export async function runCombatBenchmark(
let completed = true;
try {
const combat = useCombatStore.getState();
const specializationId = specializationsForClass(build.classId)
.find((specialization) => specialization.displayName === build.orientation)?.id;
combat.initializeCharacter({
classId: build.classId,
secondaryClassId: build.secondaryClassId,
specializationId,
level: build.level,
talentRanks: build.talentRanks,
inventory: build.inventory,
@@ -296,6 +300,8 @@ export async function runCombatBenchmark(
const positions = partyPositions(scenario.partyLayout, party.members.length);
party.members.forEach((member, index) => registerPartyRuntimePosition(member.id, positions[index] ?? [0, 0, 0]));
useCombatStore.getState().synchronizeActors();
useCombatStore.getState().applyDefaultPartyBuffs(now);
useCombatStore.getState().applyPrecombatPreparation(now);
const referenceTank = build.role === "tank"
? { maxHealth: initializedCombat.maxHealth }
+2
View File
@@ -126,6 +126,7 @@ export interface BenchmarkRunReport {
readonly schemaVersion: 1;
readonly runId: string;
readonly policyId: string;
readonly rotationVersion?: string;
readonly seed: number;
readonly build: BenchmarkBuildDefinition;
readonly scenario: BenchmarkScenarioDefinition;
@@ -149,6 +150,7 @@ export interface BenchmarkMatrixReport {
readonly seeds: readonly number[];
readonly durationScale: number;
readonly policyId: string;
readonly rotationVersion?: string;
};
readonly scenarios: readonly BenchmarkScenarioDefinition[];
readonly builds: readonly BenchmarkBuildDefinition[];
@@ -62,6 +62,8 @@ export async function runDungeonBossBenchmark(
combat.setPlayerPosition([-2, 0, 0]);
usePartyStore.getState().initializeParty(seed, build.level, useCombatStore.getState().maxHealth, build.role, 5, build.source);
combat.synchronizeActors();
combat.applyDefaultPartyBuffs(now);
combat.applyPrecombatPreparation(now);
combat.registerMob(bossId, { name: entity.name, boss: true, serverEntry: entity.combat?.serverEntry,
...scaling, attacks: entity.combat?.attacks, xpReward: 0 });
setMobPosition(bossId, [0, 0, 0]);
+32
View File
@@ -0,0 +1,32 @@
import { describe, expect, it } from "vitest";
import { estimateBossPartyPressure, rankBossPartyPressure } from "./bossPressure";
import { DUNGEON_DEFINITIONS } from "./dungeonRegistry";
describe("boss party-pressure audit", () => {
it("produces a finite deterministic ranking for every WoW boss", () => {
const definitions = DUNGEON_DEFINITIONS.filter((dungeon) => dungeon.expansion !== "runewaker");
const first = rankBossPartyPressure(definitions);
const second = rankBossPartyPressure(definitions);
expect(first.length).toBeGreaterThan(200);
expect(second).toEqual(first);
expect(first.every((entry) => Number.isFinite(entry.partyDamagePerSecond)
&& entry.partyDamagePerSecond >= 0)).toBe(true);
expect(first.map((entry) => entry.partyDamage)).toEqual(
[...first.map((entry) => entry.partyDamage)].sort((left, right) => right - left),
);
});
it("counts grouped effects across the party and excludes self-only utility", () => {
const gundrak = DUNGEON_DEFINITIONS.find((dungeon) => dungeon.id === "gundrak")!;
const eckId = Object.entries(gundrak.entities)
.find(([, entity]) => entity.combat?.serverEntry === 29_932)![0];
const estimate = estimateBossPartyPressure(gundrak, eckId);
expect(estimate.boss).toBe("Eck the Ferocious");
expect(estimate.attacks.find((attack) => attack.spellId === 55_837)?.partyDamage).toBe(0);
expect(estimate.attacks.find((attack) => attack.spellId === 55_816)?.partyDamage).toBe(0);
expect(estimate.attacks.find((attack) => attack.spellId === 55_815)).toMatchObject({
target: "random-party",
targetsPerCast: 1,
});
});
});
+151
View File
@@ -0,0 +1,151 @@
import { creatureRuntimeBaseStats } from "./azerothCoreReference";
import { scaledDungeonEnemyLevel } from "./dungeonLevelScaling";
import type { DungeonDefinition } from "./dungeonTypes";
import type {
EnemyAbilityEffect,
EnemyAttackDefinition,
EnemyAtomicAbilityEffect,
} from "./enemyAttacks";
import type { PopulationEntityDefinition } from "./mobPopulation";
export const BOSS_PRESSURE_WINDOW_MS = 60_000;
export const BOSS_PRESSURE_PARTY_SIZE = 5;
export interface BossAttackPressureEstimate {
readonly id: string;
readonly name: string;
readonly spellId?: number;
readonly target: EnemyAttackDefinition["target"];
readonly school: EnemyAttackDefinition["school"];
readonly expectedCasts: number;
readonly targetsPerCast: number;
readonly damagePerTarget: number;
readonly partyDamage: number;
}
export interface BossPartyPressureEstimate {
readonly key: string;
readonly dungeonId: string;
readonly dungeonTitle: string;
readonly boss: string;
readonly serverEntry: number | null;
readonly playerLevel: number;
readonly enemyLevel: number;
readonly windowSeconds: number;
readonly attackDamage: number;
readonly partyDamage: number;
readonly partyDamagePerSecond: number;
readonly tankDamage: number;
readonly randomTargetDamage: number;
readonly groupwideDamage: number;
readonly attacks: readonly BossAttackPressureEstimate[];
}
function midpoint(range: readonly [number, number] | undefined, fallback: number): number {
if (!range) return fallback;
return (Math.max(0, range[0]) + Math.max(0, range[1])) / 2;
}
function expectedCasts(attack: EnemyAttackDefinition, windowMs: number): number {
const initial = midpoint(attack.initialCooldownMs, 450);
if (windowMs < initial) return 0;
const repeat = Math.max(1, midpoint(attack.repeatCooldownMs, attack.cooldownMs));
const casts = 1 + (windowMs - initial) / repeat;
return Math.min(attack.maximumCasts ?? Number.POSITIVE_INFINITY, casts);
}
function atomicDamageMultiplier(effect: EnemyAtomicAbilityEffect): number {
if (effect.kind === "damage") return Math.max(0, effect.multiplier);
if (effect.kind === "leech") return Math.max(0, effect.damageMultiplier);
if (effect.kind === "control") return Math.max(0, effect.damageMultiplier ?? 0);
return 0;
}
function damageMultiplier(attack: EnemyAttackDefinition, effect: EnemyAbilityEffect | undefined): number {
if (!effect) return Math.max(0, attack.damageMultiplier);
if (effect.kind === "composite") {
return effect.effects.reduce((total, entry) => total + atomicDamageMultiplier(entry), 0);
}
return atomicDamageMultiplier(effect);
}
function targetsPerCast(attack: EnemyAttackDefinition, partySize: number): number {
if (attack.target === "nearby-party") return partySize;
if (attack.target === "primary" || attack.target === "random-party") return 1;
return 0;
}
/**
* Comparable direct-damage ceiling for one boss with a grouped party. It uses
* live attack definitions and source creature stats, but deliberately excludes
* armor/resistance, avoidable movement, summons, healing, and mutually exclusive
* phase uptime. Those omissions keep the audit deterministic and make inflated
* schedules or accidental party-wide targeting obvious.
*/
export function estimateBossPartyPressure(
dungeon: DungeonDefinition,
entityId: string,
playerLevel = 80,
windowMs = BOSS_PRESSURE_WINDOW_MS,
partySize = BOSS_PRESSURE_PARTY_SIZE,
): BossPartyPressureEstimate {
const entity: PopulationEntityDefinition | undefined = dungeon.entities[entityId];
if (!entity || entity.kind !== "boss") throw new Error(`${dungeon.id}:${entityId} is not a boss`);
const enemyLevel = scaledDungeonEnemyLevel(playerLevel, true);
const serverEntry = entity.combat?.serverEntry ?? null;
const baseDamage = serverEntry
? creatureRuntimeBaseStats(serverEntry, enemyLevel)?.averageAttackDamage
: undefined;
const attackDamage = (baseDamage ?? (12 + enemyLevel * 2)) * (entity.combat?.damageMultiplier ?? 1);
const attacks = (entity.combat?.attacks ?? []).map((attack): BossAttackPressureEstimate => {
const casts = expectedCasts(attack, windowMs);
const targets = targetsPerCast(attack, partySize);
const perTarget = attackDamage * damageMultiplier(attack, attack.effect);
return {
id: attack.id,
name: attack.name,
...(attack.spellId === undefined ? {} : { spellId: attack.spellId }),
target: attack.target,
school: attack.school,
expectedCasts: casts,
targetsPerCast: targets,
damagePerTarget: perTarget,
partyDamage: casts * targets * perTarget,
};
});
const sum = (target: EnemyAttackDefinition["target"]): number => attacks
.filter((attack) => attack.target === target)
.reduce((total, attack) => total + attack.partyDamage, 0);
const partyDamage = attacks.reduce((total, attack) => total + attack.partyDamage, 0);
return {
key: `${dungeon.id}/${entityId}`,
dungeonId: dungeon.id,
dungeonTitle: dungeon.title,
boss: entity.name,
serverEntry,
playerLevel,
enemyLevel,
windowSeconds: windowMs / 1_000,
attackDamage,
partyDamage,
partyDamagePerSecond: partyDamage / Math.max(1, windowMs / 1_000),
tankDamage: sum("primary"),
randomTargetDamage: sum("random-party"),
groupwideDamage: sum("nearby-party"),
attacks,
};
}
export function rankBossPartyPressure(
definitions: readonly DungeonDefinition[],
playerLevel = 80,
windowMs = BOSS_PRESSURE_WINDOW_MS,
partySize = BOSS_PRESSURE_PARTY_SIZE,
): readonly BossPartyPressureEstimate[] {
return definitions.flatMap((dungeon) => Object.entries(dungeon.entities)
.filter(([, entity]) => entity.kind === "boss")
.map(([entityId]) => estimateBossPartyPressure(dungeon, entityId, playerLevel, windowMs, partySize)))
.sort((left, right) => right.partyDamage - left.partyDamage
|| left.boss.localeCompare(right.boss)
|| left.dungeonId.localeCompare(right.dungeonId));
}
+5 -3
View File
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, it } from "vitest";
import { buildBossTestingCatalog, BOSS_TESTING_CATALOG, createBossTestingRoom, randomTestingBoss } from "./bossTesting";
import { anotherRandomTestingBoss, buildBossTestingCatalog, BOSS_TESTING_CATALOG, createBossTestingRoom, randomTestingBoss } from "./bossTesting";
import { clearBossTestingDungeons, DUNGEON_DEFINITIONS, registerBossTestingDungeon, requireDungeonDefinition } from "./dungeonRegistry";
import { activateDungeonSession, restartActiveDungeonSession } from "./dungeonSession";
import { useGameStore } from "./store";
@@ -76,7 +76,7 @@ describe("Boss Testing", () => {
expect(scale(1, 10).maxHealth).toBe(nativeHealth * bossDifficultyMultipliers(10).health);
const campaign = dungeonEnemyScaling(maraudon, maraudon.entities[celebras.entityId], celebras.spawn.id,
1, maraudon.defaultDifficultyId, 1);
expect(campaign.maxHealth).toBe(900);
expect(campaign.maxHealth).toBe(950);
expect(scale(1).damageMultiplier).toBe(campaign.damageMultiplier);
expect(entity.combat?.attacks).toEqual(maraudon.entities[celebras.entityId].combat?.attacks);
});
@@ -113,7 +113,7 @@ describe("Boss Testing", () => {
* (entity.combat?.healthMultiplier ?? 1),
540,
));
expect(campaign.maxHealth).toBe(Math.max(testing.maxHealth, 900 * 1.8));
expect(campaign.maxHealth).toBe(Math.max(testing.maxHealth, 950 * 1.8));
});
it("recognizes actual boss deaths and awards no progression or collectible rewards", () => {
@@ -138,5 +138,7 @@ describe("Boss Testing", () => {
expect(randomTestingBoss(candidates, () => 0)).toBe(candidates[0]);
expect(randomTestingBoss(candidates, () => 1)).toBe(candidates[1]);
expect(randomTestingBoss(candidates, () => Number.NaN)).toBe(candidates[0]);
expect(anotherRandomTestingBoss(candidates, candidates[0].key, () => 0)).toBe(candidates[1]);
expect(anotherRandomTestingBoss([candidates[0]], candidates[0].key)).toBeNull();
});
});
+8
View File
@@ -42,6 +42,14 @@ export function randomTestingBoss(bosses: readonly TestableBoss[], random: () =>
return bosses[index];
}
export function anotherRandomTestingBoss(
bosses: readonly TestableBoss[],
currentKey: string,
random: () => number = Math.random,
): TestableBoss | null {
return randomTestingBoss(bosses.filter((boss) => boss.key !== currentKey), random);
}
function references(value: unknown, ids: ReadonlySet<string>): boolean {
if (typeof value === "string") return ids.has(value);
if (Array.isArray(value)) return value.some((entry) => references(entry, ids));
+2 -2
View File
@@ -31,8 +31,8 @@ describe("default difficulty CoA healer rotations", () => {
for (const seed of ["class-balance-1", "class-balance-2", "class-balance-3"]) {
const result = await runDungeonBossBenchmark(build, boss.id, seed, true);
samples.push({ level, classId: build.classId, orientation: build.orientation, seed, result });
if (!result.playerAlive || result.allyHealing <= 0 || result.deaths > 0) {
failures.push(`${build.id}:${seed}: ${result.outcome}, ${result.deaths} deaths, ${result.allyHealing} ally healing`);
if (!result.playerAlive || result.healing <= 0 || result.deaths > 0) {
failures.push(`${build.id}:${seed}: ${result.outcome}, ${result.deaths} deaths, ${result.healing} effective healing`);
}
}
}
+6 -2
View File
@@ -49,8 +49,8 @@ export function companionActorFromMember(
cast: actor?.cast ?? member.activeCast,
controlledUntil: actor?.controlledUntil ?? member.controlledUntil,
schoolLockedUntil: actor?.schoolLockedUntil ?? member.schoolLockedUntil,
comboPoints: actor?.comboPoints ?? 0,
comboTargetId: actor?.comboTargetId ?? null,
comboPoints: actor?.comboPoints ?? member.comboPoints,
comboTargetId: actor?.comboTargetId ?? member.comboTargetId,
runes: actor?.runes ?? member.runes,
abilityIds: actor?.abilityIds,
alive: (actor?.health ?? member.health) > 0,
@@ -71,6 +71,8 @@ export function memberFromCompanionActor(member: PartyMember, actor?: CombatActo
maxResource: primary?.maximum ?? member.maxResource,
resourcePools,
lastResourceSpentAt,
comboPoints: actor.comboPoints,
comboTargetId: actor.comboTargetId,
cooldowns: actor.cooldowns,
globalCooldownEndsAt: actor.globalCooldownEndsAt,
activeCast: actor.cast,
@@ -106,6 +108,8 @@ export function companionActorWithMemberRuntime(
cast: member.activeCast ? { ...member.activeCast, targetId: member.activeCastTargetId } : null,
controlledUntil: member.controlledUntil,
schoolLockedUntil: member.schoolLockedUntil,
comboPoints: member.comboPoints,
comboTargetId: member.comboTargetId,
runes: member.runes,
alive: member.health > 0,
});
+56 -1
View File
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import "./abilityCatalog";
import {
characterCombatAnimationForPresentation,
clipsForAnimationIds,
parseWoWAnimationName,
selectCharacterBaseClip,
@@ -8,6 +9,7 @@ import {
selectCharacterVerticalClip,
selectWoWAnimationVariation,
} from "./combatAnimation";
import type { CombatPresentationEvent } from "./combatPresentation";
const clips = [
{ name: "Stand (ID 0 variation 0)" },
@@ -32,6 +34,28 @@ const clips = [
{ name: "HoldBow (ID 109 variation 0)" },
];
function presentationEvent(
overrides: Partial<CombatPresentationEvent> = {},
): CombatPresentationEvent {
return {
id: 1,
sessionId: 1,
occurredAt: 1_000,
phase: "release",
actorGroup: "party",
sourceActorId: "party-1",
targetActorId: "mob-1",
abilityId: "party-auto-attack",
name: "Auto Attack",
source: "fallback",
school: "physical",
delivery: "melee",
origin: [0, 0, 0],
targetPosition: [1, 0, 0],
...overrides,
};
}
describe("WoW character animation selection", () => {
it("parses AnimationData IDs and preserves labels and variations", () => {
expect(parseWoWAnimationName("AttackUnarmed (ID 16 variation 1)")).toEqual({
@@ -108,13 +132,44 @@ describe("WoW character animation selection", () => {
it("maps melee, wound, and terminal death events to native clips", () => {
expect(selectCharacterEventClip(clips, "warrior", { revision: 1, kind: "attack" }))
.toMatchObject({ clip: clips[4], loop: false });
.toMatchObject({ clip: clips[3], loop: false });
expect(selectCharacterEventClip(clips, "warrior", { revision: 2, kind: "wound" }))
.toMatchObject({ clip: clips[7], loop: false });
expect(selectCharacterEventClip(clips, "warrior", { revision: 3, kind: "death" }))
.toMatchObject({ clip: clips[8], terminal: true });
});
it("reserves special melee clips for abilities instead of ordinary weapon swings", () => {
expect(selectCharacterEventClip(clips, "paladin", { revision: 1, kind: "attack" }))
.toMatchObject({ clip: clips[3], loop: false });
});
it("drives party animation only from actual combat presentation phases", () => {
expect(characterCombatAnimationForPresentation(presentationEvent(), 7)).toEqual({
revision: 7,
kind: "attack",
});
expect(characterCombatAnimationForPresentation(presentationEvent({
phase: "cast-start",
abilityId: "paladin-holy-light",
}), 8)).toEqual({
revision: 8,
kind: "ability-start",
abilityId: "paladin-holy-light",
});
expect(characterCombatAnimationForPresentation(presentationEvent({
abilityId: "warrior-mortal-strike",
}), 9)).toEqual({
revision: 9,
kind: "ability-release",
abilityId: "warrior-mortal-strike",
});
expect(characterCombatAnimationForPresentation(presentationEvent({ phase: "impact" }), 10))
.toBeNull();
expect(characterCombatAnimationForPresentation(presentationEvent({ actorGroup: "enemy" }), 11))
.toBeNull();
});
it("uses native RuneWaker spell, bow, and melee clips for RoM characters", () => {
const runewakerClips = [
{ name: "Action - 1h_charge_up" },
+21 -2
View File
@@ -1,6 +1,7 @@
import { baseClassFor, type BaseClassId, type ClassId } from "../app/characterCatalog";
import type { AbilityDefinition } from "./abilityCatalog";
import { abilityAnimationById } from "./abilityAnimationLookup";
import type { CombatPresentationEvent } from "./combatPresentation";
import type { CharacterVerticalMotion } from "./playerJump";
export type CharacterCombatAnimationKind =
@@ -53,8 +54,8 @@ const MELEE_CLASSES = new Set<ClassId>([
]);
const CLASS_ATTACK_IDS: Readonly<Record<BaseClassId, readonly number[]>> = {
warrior: [57, 17, 58, 18, 16, 118],
paladin: [57, 17, 59, 16, 118],
warrior: [17, 18, 16, 118, 57, 58],
paladin: [17, 18, 16, 118, 59, 57],
hunter: [46, 47, 49, 107, 16],
rogue: [85, 88, 87, 117, 57, 17, 95, 16],
priest: [53, 54, 32, 2, 16],
@@ -65,6 +66,24 @@ const CLASS_ATTACK_IDS: Readonly<Record<BaseClassId, readonly number[]>> = {
druid: [53, 54, 32, 118, 16],
};
/** Convert authoritative party combat presentation into one render event. */
export function characterCombatAnimationForPresentation(
event: CombatPresentationEvent,
revision: number,
): CharacterCombatAnimationEvent | null {
if (event.actorGroup !== "party") return null;
if (event.phase === "cast-start") {
return { revision, kind: "ability-start", abilityId: event.abilityId };
}
if (event.phase === "cast-cancel") {
return { revision, kind: "ability-cancel", abilityId: event.abilityId };
}
if (event.phase !== "release") return null;
return event.abilityId === "party-auto-attack"
? { revision, kind: "attack" }
: { revision, kind: "ability-release", abilityId: event.abilityId };
}
/** Parse the names emitted by wow.export without depending on Three.js. */
export function parseWoWAnimationName(name: string): ParsedWoWAnimationName {
const match = name.match(/^\s*(.*?)\s*\(ID\s+(\d+)\s+variation\s+(\d+)\)\s*$/i);
+54 -1
View File
@@ -2,9 +2,11 @@ import { beforeEach, describe, expect, it } from "vitest";
import { PLAYER_AGGRO_ID } from "./aggro";
import { applyAura, type AuraDefinition } from "./combatAuras";
import { useCombatStore } from "./combatStore";
import { abilityById } from "./abilityCatalog";
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
import { clearPartyRuntimeRegistry } from "./partyRuntimeRegistry";
import { usePartyStore } from "./partyStore";
import { createConfiguredPartyMember, usePartyStore } from "./partyStore";
import { runtimePartyTalentRanksForLevel } from "./partyTalentRuntime";
function registerTarget(id = "target", distance = 2): void {
useCombatStore.getState().registerMob(id, { name: id, maxHealth: 1_000, xpReward: 0 });
@@ -99,6 +101,57 @@ describe("named aura combat runtime", () => {
))).toHaveLength(1);
});
it("enters the dungeon with spec maintenance and permanent pets already prepared", () => {
const now = 30_000;
useCombatStore.getState().initializeCharacter({
classId: "hunter",
specializationId: "hunter:beast-mastery",
role: "damage",
level: 80,
inventory: [],
equipment: {},
});
const warlock = createConfiguredPartyMember({
name: "Demo",
classId: "warlock",
specialization: "Demonology",
specializationId: "warlock:demonology",
talentTreeId: "warlock-demonology",
talentRanks: runtimePartyTalentRanksForLevel("warlock", "Demonology", 80),
role: "damage",
}, 80, 0, "precombat-preparation");
usePartyStore.setState({ members: [warlock], playerRole: "damage" });
useCombatStore.getState().synchronizeActors();
const playerResource = useCombatStore.getState().resource;
const companionResource = warlock.resource;
useCombatStore.getState().applyPrecombatPreparation(now);
const prepared = useCombatStore.getState();
expect(prepared.auras).toContainEqual(expect.objectContaining({
targetId: PLAYER_AGGRO_ID,
definition: expect.objectContaining({ exclusiveGroup: "hunter:aspect" }),
}));
expect(prepared.auras).toContainEqual(expect.objectContaining({
targetId: warlock.id,
definition: expect.objectContaining({ exclusiveGroup: "warlock:armor" }),
}));
expect(prepared.summons.some((summon) => summon.ownerActorId === PLAYER_AGGRO_ID
&& abilityById(summon.sourceAbilityId)?.name === "Call Pet")).toBe(true);
expect(prepared.summons.some((summon) => summon.ownerActorId === warlock.id
&& abilityById(summon.sourceAbilityId)?.name === "Summon Felguard")).toBe(true);
expect(prepared.resource).toBe(playerResource);
expect(prepared.globalCooldownEndsAt).toBe(0);
expect(usePartyStore.getState().members[0]).toMatchObject({
resource: companionResource,
globalCooldownEndsAt: 0,
cooldowns: {},
});
const summonIds = prepared.summons.map((summon) => summon.id);
useCombatStore.getState().applyPrecombatPreparation(now + 1_000);
expect(useCombatStore.getState().summons.map((summon) => summon.id)).toEqual(summonIds);
});
it("consumes a named absorb before player health", () => {
const now = Date.now();
expect(useCombatStore.getState().castAbility(
+427
View File
@@ -0,0 +1,427 @@
import { describe, expect, it } from "vitest";
import { BASE_CLASS_IDS } from "../app/characterCatalog";
import {
abilitiesForCharacter,
abilitiesForClass,
learnedAbilityForBuild,
resourceProfileForClass,
type AbilityDefinition,
type ResourceType,
} from "./abilityCatalog";
import {
abilityIsPrecombatPreparation,
abilityHasRotationSemantics,
CLASSIC_ROTATION_PROFILES,
COMBAT_ROTATION_VERSION,
evaluateCombatRotation,
rotationProfileFor,
type RotationContext,
} from "./combatRotation";
import { createDeathKnightRunes } from "./combatActors";
import { aiHintsForAbility } from "./abilityRules";
import { runtimePartyTalentRanksForLevel } from "./partyTalentRuntime";
import { specializationsForClass } from "./specializationCatalog";
const SIGNATURES = Object.freeze({
warrior: "Mortal Strike",
paladin: "Divine Storm",
hunter: "Explosive Shot",
rogue: "Killing Spree",
priest: "Penance",
"death-knight": "Frost Strike",
shaman: "Feral Spirit",
mage: "Arcane Barrage",
warlock: "Haunt",
druid: "Starfall",
} as const);
function context(overrides: Partial<RotationContext> = {}): RotationContext {
const classId = overrides.classId ?? "rogue";
const primaryResource = resourceProfileForClass(classId).type;
return {
now: 1_000,
classId,
specializationId: classId === "rogue" ? "rogue:combat" : null,
role: "damage",
level: 80,
health: 1_000,
maxHealth: 1_000,
resources: { [primaryResource]: 100 },
maximumResources: { [primaryResource]: 100 },
primaryResource,
comboPoints: 0,
comboTargetId: null,
runes: classId === "death-knight" ? createDeathKnightRunes() : [],
abilities: [],
hostileTarget: { id: "target", health: 1_000, maxHealth: 1_000 },
hostileCount: 1,
...overrides,
};
}
function ability(classId: RotationContext["classId"], name: string): AbilityDefinition {
const result = abilitiesForClass(classId).find((candidate) => candidate.name === name);
expect(result, `${classId}:${name}`).toBeDefined();
return result!;
}
function semanticAbility(
id: string,
effects: AbilityDefinition["effects"],
options: Partial<AbilityDefinition> = {},
): AbilityDefinition {
return {
id,
classId: "rom-warrior",
dbcSpellId: 0,
name: id,
unlockLevel: 1,
icon: "",
sigil: "AI",
description: id,
target: "hostile",
range: { min: 0, max: 30 },
castMode: "instant",
castTimeMs: 0,
cooldownMs: 0,
gcdMs: 1_500,
cost: { resource: "rage", amount: 0 },
effects,
...options,
};
}
describe("shared combat rotations", () => {
it("registers exactly one resolvable profile for every classic specialization", () => {
const classicSpecs = BASE_CLASS_IDS.flatMap((classId) => specializationsForClass(classId));
expect(classicSpecs).toHaveLength(31);
expect(CLASSIC_ROTATION_PROFILES).toHaveLength(31);
expect(new Set(CLASSIC_ROTATION_PROFILES.map((entry) => entry.specializationId)).size).toBe(31);
for (const specialization of classicSpecs) {
const rotation = rotationProfileFor(specialization.classId, specialization.id);
expect(rotation?.specializationId, specialization.id).toBe(specialization.id);
for (const name of rotation?.abilityPriority ?? []) {
expect(abilitiesForClass(specialization.classId).some((candidate) => candidate.name === name),
`${specialization.id}:${name}`).toBe(true);
}
}
expect(COMBAT_ROTATION_VERSION).toMatch(/^playerbots-inspired-/);
});
it("prepares only long-lived maintenance effects and permanent pets before combat", () => {
const longBuff = semanticAbility("long-buff", [{
kind: "apply-aura",
aura: { id: "long-buff", name: "Long buff", disposition: "buff", durationMs: 60_000,
modifiers: [{ kind: "stat", stat: "armor", operation: "flat", value: 10 }] },
recipient: "caster",
sourceEffectType: 6,
sourceAuraType: 22,
miscValue: 0,
triggerSpellId: 0,
}], { target: "self" });
const shortBuff = semanticAbility("short-buff", [{
kind: "apply-aura",
aura: { id: "short-buff", name: "Short buff", disposition: "buff", durationMs: 30_000,
modifiers: [{ kind: "stat", stat: "armor", operation: "flat", value: 10 }] },
recipient: "caster",
sourceEffectType: 6,
sourceAuraType: 22,
miscValue: 0,
triggerSpellId: 0,
}], { target: "self" });
const pet = semanticAbility("call-pet", [{ kind: "pet", action: "call", creatureId: 1 }], { target: "self" });
const temporaryPet = semanticAbility("temporary-pet", [{ kind: "pet", action: "summon", creatureId: 2 }], {
target: "self",
cooldownMs: 120_000,
});
expect(abilityIsPrecombatPreparation(longBuff)).toBe(true);
expect(abilityIsPrecombatPreparation(shortBuff)).toBe(false);
expect(abilityIsPrecombatPreparation(pet)).toBe(true);
expect(abilityIsPrecombatPreparation(temporaryPet)).toBe(false);
});
it("gates representative signature abilities from every class by their exact talent node", () => {
for (const [classId, name] of Object.entries(SIGNATURES)) {
const signature = ability(classId as RotationContext["classId"], name);
expect(signature.talentNodeId, `${classId}:${name}`).toMatch(/^talent-\d+$/);
expect(learnedAbilityForBuild(signature, 80, {}), `${classId}:${name}`).toBeNull();
expect(learnedAbilityForBuild(signature, 80, { [signature.talentNodeId!]: 1 }), `${classId}:${name}`).not.toBeNull();
expect(signature.effects.length).toBeGreaterThan(0);
expect(signature.effects.some((effect) => !["unsupported", "scripted", "utility"].includes(effect.kind))).toBe(true);
expect(signature.ranks?.length, `${classId}:${name} ranks`).toBeGreaterThan(0);
expect(signature.ranks?.every((rank) => rank.cost.amount >= 0 && rank.cooldownMs >= 0 && rank.effects.length > 0)).toBe(true);
expect(signature.cooldownMs).toBeGreaterThanOrEqual(0);
expect(signature.cost.amount).toBeGreaterThanOrEqual(0);
}
});
it("keeps specialization talent actions isolated", () => {
const furyTalents = runtimePartyTalentRanksForLevel("warrior", "Fury", 80);
const armsTalents = runtimePartyTalentRanksForLevel("warrior", "Arms", 80);
const bloodthirst = ability("warrior", "Bloodthirst");
const mortalStrike = ability("warrior", "Mortal Strike");
expect(learnedAbilityForBuild(bloodthirst, 80, furyTalents)).not.toBeNull();
expect(learnedAbilityForBuild(mortalStrike, 80, furyTalents)).toBeNull();
expect(learnedAbilityForBuild(mortalStrike, 80, armsTalents)).not.toBeNull();
expect(learnedAbilityForBuild(bloodthirst, 80, armsTalents)).toBeNull();
});
it("uses combo builders before finishers and actual combo points for the decision", () => {
const builder = ability("rogue", "Sinister Strike");
const finisher = ability("rogue", "Eviscerate");
const building = evaluateCombatRotation(context({ abilities: [finisher, builder] }));
expect(building?.ability.id).toBe(builder.id);
const finishing = evaluateCombatRotation(context({
abilities: [builder, finisher],
comboPoints: 5,
comboTargetId: "target",
}));
expect(finishing?.ability.id).toBe(finisher.id);
});
it("reacts before throughput and conserves long offensive cooldowns for durable targets", () => {
const kick = ability("rogue", "Kick");
const strike = ability("rogue", "Sinister Strike");
expect(evaluateCombatRotation(context({
abilities: [strike, kick],
hostileTarget: {
id: "target",
health: 1_000,
maxHealth: 1_000,
activeCast: { interruptible: true },
},
}))?.ability.id).toBe(kick.id);
const bestialWrath = ability("hunter", "Bestial Wrath");
const arcaneShot = ability("hunter", "Arcane Shot");
const hunter = context({
classId: "hunter",
specializationId: "hunter:beast-mastery",
primaryResource: "mana",
resources: { mana: 100 },
maximumResources: { mana: 100 },
abilities: [bestialWrath, arcaneShot],
});
expect(evaluateCombatRotation(hunter)?.ability.id).toBe(arcaneShot.id);
expect(evaluateCombatRotation({ ...hunter, hostileTarget: { id: "boss", health: 10_000, maxHealth: 10_000, boss: true } })?.ability.id)
.toBe(bestialWrath.id);
});
it("uses healer triage bands and area healing for three injured allies", () => {
const flash = ability("priest", "Flash Heal");
const prayer = ability("priest", "Prayer of Healing");
const hostileLeech = semanticAbility("hostile-leech", [
{ kind: "damage", coefficient: 1 },
{ kind: "heal", coefficient: 1, recipient: "caster" },
]);
const decision = evaluateCombatRotation(context({
classId: "priest",
specializationId: "priest:holy",
role: "healer",
primaryResource: "mana",
resources: { mana: 1_000 },
maximumResources: { mana: 1_000 },
selfId: "self",
abilities: [flash, prayer, hostileLeech],
friendlyTargets: [
{ id: "a", health: 250, maxHealth: 1_000 },
{ id: "b", health: 500, maxHealth: 1_000 },
{ id: "c", health: 700, maxHealth: 1_000 },
],
}));
expect(decision?.ability.id).toBe(prayer.id);
expect(decision?.targetId).toBe("a");
});
it("includes RoM secondary general and elite skills in shared-policy candidates", () => {
const primaryOnly = abilitiesForCharacter("rom-warrior", null);
const dualClass = abilitiesForCharacter("rom-warrior", "rom-scout");
expect(dualClass.length).toBeGreaterThan(primaryOnly.length);
expect(dualClass.some((candidate) => candidate.secondaryClassId === "rom-scout")).toBe(true);
const secondaryGeneral = dualClass.find((candidate) => candidate.classId === "rom-scout"
&& !candidate.secondaryClassId && !candidate.passive && abilityHasRotationSemantics(candidate)
&& aiHintsForAbility(candidate).categories.some((category) => ["damage", "summon", "buff"].includes(category)));
const elite = dualClass.find((candidate) => candidate.secondaryClassId === "rom-scout"
&& !candidate.passive && abilityHasRotationSemantics(candidate)
&& aiHintsForAbility(candidate).categories.some((category) => ["damage", "summon", "buff"].includes(category)));
expect(secondaryGeneral).toBeDefined();
expect(elite).toBeDefined();
const dualContext = context({
classId: "rom-warrior",
secondaryClassId: "rom-scout",
specializationId: null,
primaryResource: "rage",
resources: { rage: 100, focus: 100 },
maximumResources: { rage: 100, focus: 100 },
hostileTarget: { id: "boss", health: 20_000, maxHealth: 20_000, boss: true },
});
expect(evaluateCombatRotation({ ...dualContext, abilities: [secondaryGeneral!] })?.ability.id).toBe(secondaryGeneral!.id);
expect(evaluateCombatRotation({ ...dualContext, abilities: [elite!] })?.ability.id).toBe(elite!.id);
});
it("handles execute, cleave/AoE thresholds, and maintained effects", () => {
const strike = semanticAbility("strike", [{ kind: "damage", coefficient: 1 }]);
const execute = semanticAbility("execute", [{ kind: "execute", coefficient: 1 }]);
const area = semanticAbility("area", [{ kind: "damage", coefficient: 1 }], { radius: 8 });
const debuff = semanticAbility("debuff", [{
kind: "apply-aura",
aura: { id: "test-debuff", name: "Test", disposition: "debuff", durationMs: 10_000,
modifiers: [{ kind: "damage", direction: "taken", operation: "percent", value: 0.1 }] },
sourceEffectType: 6,
sourceAuraType: 4,
miscValue: 0,
triggerSpellId: 0,
}]);
const base = context({ classId: "rom-warrior", specializationId: null, primaryResource: "rage",
resources: { rage: 100 }, maximumResources: { rage: 100 } });
expect(evaluateCombatRotation({ ...base, abilities: [strike, execute] })?.ability.id).toBe("strike");
expect(evaluateCombatRotation({ ...base, abilities: [strike, execute],
hostileTarget: { id: "target", health: 199, maxHealth: 1_000 } })?.ability.id).toBe("execute");
expect(evaluateCombatRotation({ ...base, abilities: [strike, area], hostileCount: 1 })?.ability.id).toBe("strike");
expect(evaluateCombatRotation({ ...base, abilities: [strike, area], hostileCount: 2 })?.ability.id).toBe("area");
expect(evaluateCombatRotation({ ...base, abilities: [strike, debuff] })?.ability.id).toBe("debuff");
expect(evaluateCombatRotation({ ...base, abilities: [strike, debuff], maintainedAbilityIds: new Set([debuff.id]) })?.ability.id)
.toBe("strike");
});
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", [
{ kind: "heal", coefficient: 1 },
{
kind: "apply-aura",
aura: {
id: "prayer-hot",
name: "Prayer",
disposition: "buff",
durationMs: 21_000,
procs: [{ id: "prayer-proc", triggers: "periodic", chance: 1,
action: { kind: "heal", amount: 10, target: "aura-target" } }],
},
sourceEffectType: 6,
sourceAuraType: 23,
miscValue: 0,
triggerSpellId: 1,
},
], { target: "friendly" });
const base = context({ classId: "venomancer", specializationId: null, abilities: [prayer, strike] });
expect(evaluateCombatRotation(base)?.ability.id).toBe(strike.id);
expect(evaluateCombatRotation({ ...base, friendlyTargets: [{ id: "ally", health: 50, maxHealth: 100 }] }))
.toMatchObject({ ability: { id: prayer.id }, targetId: "ally" });
});
it("handles movement, threat recovery, defenses, resources, pets, and DK runes", () => {
const fireball = ability("mage", "Fireball");
const fireBlast = ability("mage", "Fire Blast");
const mage = context({ classId: "mage", specializationId: "mage:fire", primaryResource: "mana",
resources: { mana: 100 }, maximumResources: { mana: 100 }, abilities: [fireball, fireBlast] });
expect(evaluateCombatRotation(mage)?.ability.id).toBe(fireball.id);
expect(evaluateCombatRotation({ ...mage, moving: true })?.ability.id).toBe(fireBlast.id);
const fade = ability("priest", "Fade");
const mindBlast = ability("priest", "Mind Blast");
const priest = context({ classId: "priest", specializationId: "priest:shadow", primaryResource: "mana",
resources: { mana: 100 }, maximumResources: { mana: 100 }, abilities: [fade, mindBlast] });
expect(evaluateCombatRotation(priest)?.ability.id).toBe(mindBlast.id);
expect(evaluateCombatRotation({ ...priest, hasDangerousThreat: true })?.ability.id).toBe(fade.id);
const shield = semanticAbility("shield", [{ kind: "shield", coefficient: 1, durationMs: 5_000 }], { target: "self" });
const fallback = semanticAbility("fallback", [{ kind: "damage", coefficient: 1 }]);
const generator = semanticAbility("generator", [{ kind: "resource", amount: 30, resource: "rage" }], { target: "self" });
const summon = semanticAbility("summon", [{ kind: "summon", creatureId: 1, coefficient: 0.3, durationMs: 30_000 }],
{ target: "self" });
const semantic = context({ classId: "rom-warrior", specializationId: null, primaryResource: "rage",
resources: { rage: 10 }, maximumResources: { rage: 100 }, abilities: [fallback, shield, generator, summon],
activeSummonAbilityIds: new Set([summon.id]) });
expect(evaluateCombatRotation(semantic)?.ability.id).toBe(generator.id);
expect(evaluateCombatRotation({ ...semantic, resources: { rage: 100 }, health: 300,
activeSummonAbilityIds: new Set([summon.id]) })?.ability.id).toBe(shield.id);
expect(evaluateCombatRotation({ ...semantic, resources: { rage: 100 }, health: 1_000,
activeSummonAbilityIds: new Set([summon.id]) })?.ability.id).toBe(fallback.id);
const icyTouch = ability("death-knight", "Icy Touch");
const deathCoil = ability("death-knight", "Death Coil");
const dk = context({ classId: "death-knight", specializationId: "death-knight:unholy",
primaryResource: "runic-power", resources: { "runic-power": 100 }, maximumResources: { "runic-power": 100 },
abilities: [icyTouch, deathCoil], runes: createDeathKnightRunes(0) });
expect(evaluateCombatRotation(dk)?.ability.id).toBe(icyTouch.id);
expect(evaluateCombatRotation({ ...dk, runes: dk.runes.map((rune) => (
rune.type === "frost" ? { ...rune, readyAt: 10_000 } : rune
)) })?.ability.id).toBe(deathCoil.id);
});
it("uses Rockbiter as the low-level Shaman maintenance fallback", () => {
const rockbiter = learnedAbilityForBuild(ability("shaman", "Rockbiter Weapon"), 1, {})!;
const lightningBolt = learnedAbilityForBuild(ability("shaman", "Lightning Bolt"), 1, {})!;
const shaman = context({
classId: "shaman",
specializationId: "shaman:elemental",
level: 1,
primaryResource: "mana",
resources: { mana: 100 },
maximumResources: { mana: 100 },
abilities: [rockbiter, lightningBolt],
maintainedAbilityIds: new Set(),
activeAuraExclusiveGroups: new Set(),
});
expect(evaluateCombatRotation(shaman)?.ability.id).toBe(rockbiter.id);
expect(evaluateCombatRotation({ ...shaman, maintainedAbilityIds: new Set([rockbiter.id]) })?.ability.id)
.toBe(lightningBolt.id);
});
it("enters a learned Death Knight presence before attacking", () => {
const bloodPresence = learnedAbilityForBuild(ability("death-knight", "Blood Presence"), 1, {})!;
const icyTouch = learnedAbilityForBuild(ability("death-knight", "Icy Touch"), 1, {})!;
expect(bloodPresence.effects.some((effect) => effect.kind === "heal")).toBe(false);
const deathKnight = context({
classId: "death-knight",
specializationId: "death-knight:blood",
role: "tank",
level: 1,
primaryResource: "runic-power",
resources: { "runic-power": 0 },
maximumResources: { "runic-power": 100 },
runes: createDeathKnightRunes(),
abilities: [bloodPresence, icyTouch],
maintainedAbilityIds: new Set(),
activeAuraExclusiveGroups: new Set(),
});
expect(evaluateCombatRotation(deathKnight)?.ability.id).toBe(bloodPresence.id);
});
it("summons one real pet and ignores noncombat utility summons", () => {
const shadowBolt = ability("warlock", "Shadow Bolt");
const imp = ability("warlock", "Summon Imp");
const eye = ability("warlock", "Eye of Kilrogg");
const warlock = context({
classId: "warlock",
specializationId: null,
primaryResource: "mana",
resources: { mana: 100 },
maximumResources: { mana: 100 },
abilities: [shadowBolt, imp, eye],
activeSummonAbilityIds: new Set(),
hasActivePet: false,
});
expect(evaluateCombatRotation(warlock)?.ability.id).toBe(imp.id);
expect(evaluateCombatRotation({ ...warlock, hasActivePet: true })?.ability.id).toBe(shadowBolt.id);
expect(evaluateCombatRotation({ ...warlock, abilities: [shadowBolt, eye] })?.ability.id).toBe(shadowBolt.id);
});
it("is consumer-independent for equivalent normalized state", () => {
const abilities = [ability("mage", "Fireball"), ability("mage", "Fire Blast")];
const normalized = context({
classId: "mage",
specializationId: "mage:fire",
primaryResource: "mana" as ResourceType,
resources: { mana: 100 },
maximumResources: { mana: 100 },
abilities,
});
const companionDecision = evaluateCombatRotation(normalized);
const benchmarkDecision = evaluateCombatRotation({ ...normalized });
expect(benchmarkDecision).toEqual(companionDecision);
});
});
+484
View File
@@ -0,0 +1,484 @@
import type { ClassId } from "../app/characterCatalog";
import type { AbilityDefinition, ResourceType } from "./abilityCatalog";
import { aiHintsForAbility, canAbilityCastWhileMoving, runeCostForAbility } from "./abilityRules";
import type { DeathKnightRuneState } from "./combatActors";
import { channelAmountEffects } from "./combatChannelAmounts";
import { spendDeathKnightRunes } from "./combatResources";
import { healingAbilityCanAidOthers } from "./healingAbilityRules";
import type { PartyRole } from "./partyRoles";
export const COMBAT_ROTATION_VERSION = "playerbots-inspired-v1";
export interface RotationProfile {
readonly id: string;
readonly specializationId: string;
readonly classId: ClassId;
readonly abilityPriority: readonly string[];
}
export interface RotationFriendlyTarget {
readonly id: string;
readonly health: number;
readonly maxHealth: number;
readonly needsDispel?: boolean;
readonly fallen?: boolean;
}
export interface RotationHostileTarget {
readonly id: string;
readonly health: number;
readonly maxHealth: number;
readonly boss?: boolean;
readonly healthMultiplier?: number;
readonly activeCast?: { readonly interruptible?: boolean } | null;
}
export interface RotationContext {
readonly now: number;
readonly classId: ClassId;
readonly secondaryClassId?: ClassId | null;
readonly specializationId?: string | null;
readonly specialization?: string | null;
readonly role: PartyRole;
readonly level: number;
readonly health: number;
readonly maxHealth: number;
readonly selfId?: string;
readonly resources: Readonly<Partial<Record<ResourceType, number>>>;
readonly maximumResources: Readonly<Partial<Record<ResourceType, number>>>;
readonly primaryResource: ResourceType;
readonly comboPoints: number;
readonly comboTargetId: string | null;
readonly runes: readonly DeathKnightRuneState[];
readonly abilities: readonly AbilityDefinition[];
readonly hostileTarget: RotationHostileTarget | null;
readonly friendlyTargets?: readonly RotationFriendlyTarget[];
readonly hostileCount?: number;
readonly targetIsLoose?: boolean;
readonly hasDangerousThreat?: boolean;
readonly requiredControl?: boolean;
readonly moving?: boolean;
readonly maintainedAbilityIds?: ReadonlySet<string>;
readonly activeAuraExclusiveGroups?: ReadonlySet<string>;
readonly activeSummonAbilityIds?: ReadonlySet<string>;
readonly hasActivePet?: boolean;
readonly canUseAbility?: (ability: AbilityDefinition) => boolean;
}
export interface RotationDecision {
readonly ability: AbilityDefinition;
readonly targetId: string | null;
readonly profileId: string;
readonly ruleId: string;
readonly reason: string;
}
const profile = (
specializationId: string,
classId: ClassId,
abilityPriority: readonly string[],
): RotationProfile => Object.freeze({
id: `classic:${specializationId}`,
specializationId,
classId,
abilityPriority: Object.freeze(abilityPriority),
});
/** 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", "Mortal Strike", "Rend", "Overpower", "Execute", "Slam", "Heroic Strike"]),
profile("warrior:fury", "warrior", ["Berserker Stance", "Battle Stance", "Bloodthirst", "Whirlwind", "Execute", "Sunder Armor", "Heroic Strike"]),
profile("warrior:protection", "warrior", ["Defensive Stance", "Battle Stance", "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"]),
profile("paladin:protection", "paladin", ["Devotion Aura", "Blessing of Might", "Seal of Vengeance", "Seal of Corruption", "Seal of Wisdom", "Seal of Righteousness", "Hammer of the Righteous", "Shield of Righteousness", "Holy Shield", "Consecration", "Judgement of Wisdom"]),
profile("paladin:retribution", "paladin", ["Retribution Aura", "Devotion Aura", "Blessing of Might", "Seal of Command", "Seal of Vengeance", "Seal of Corruption", "Seal of Righteousness", "Divine Storm", "Crusader Strike", "Judgement of Wisdom", "Consecration", "Exorcism", "Hammer of Wrath"]),
profile("hunter:beast-mastery", "hunter", ["Aspect of the Dragonhawk", "Aspect of the Hawk", "Call Pet", "Kill Command", "Bestial Wrath", "Serpent Sting", "Arcane Shot", "Multi-Shot", "Steady Shot"]),
profile("hunter:marksmanship", "hunter", ["Aspect of the Dragonhawk", "Aspect of the Hawk", "Call Pet", "Kill Shot", "Chimera Shot", "Aimed Shot", "Serpent Sting", "Arcane Shot", "Steady Shot"]),
profile("hunter:survival", "hunter", ["Aspect of the Dragonhawk", "Aspect of the Hawk", "Call Pet", "Kill Shot", "Explosive Shot", "Black Arrow", "Serpent Sting", "Aimed Shot", "Arcane Shot", "Steady Shot"]),
profile("rogue:assassination", "rogue", ["Envenom", "Rupture", "Mutilate", "Eviscerate", "Sinister Strike"]),
profile("rogue:combat", "rogue", ["Killing Spree", "Blade Flurry", "Eviscerate", "Sinister Strike"]),
profile("rogue:subtlety", "rogue", ["Shadow Dance", "Rupture", "Eviscerate", "Hemorrhage", "Backstab"]),
profile("priest:discipline", "priest", ["Inner Fire", "Penance", "Power Word: Shield", "Flash Heal", "Greater Heal", "Renew"]),
profile("priest:holy", "priest", ["Inner Fire", "Circle of Healing", "Prayer of Mending", "Flash Heal", "Greater Heal", "Renew"]),
profile("priest:shadow", "priest", ["Shadowform", "Inner Fire", "Vampiric Touch", "Devouring Plague", "Shadow Word: Pain", "Mind Blast", "Mind Flay", "Shadow Word: Death"]),
profile("death-knight:blood", "death-knight", ["Frost Presence", "Blood Presence", "Heart Strike", "Death Strike", "Icy Touch", "Plague Strike", "Death Coil"]),
profile("death-knight:frost", "death-knight", ["Blood Presence", "Howling Blast", "Obliterate", "Frost Strike", "Icy Touch", "Plague Strike"]),
profile("death-knight:unholy", "death-knight", ["Unholy Presence", "Blood Presence", "Raise Dead", "Scourge Strike", "Death and Decay", "Plague Strike", "Icy Touch", "Death Coil"]),
profile("shaman:elemental", "shaman", ["Flametongue Weapon", "Rockbiter Weapon", "Lightning Shield", "Flame Shock", "Lava Burst", "Chain Lightning", "Lightning Bolt"]),
profile("shaman:enhancement", "shaman", ["Windfury Weapon", "Rockbiter Weapon", "Lightning Shield", "Feral Spirit", "Stormstrike", "Lava Lash", "Earth Shock", "Lightning Bolt"]),
profile("shaman:restoration", "shaman", ["Water Shield", "Rockbiter Weapon", "Lightning Shield", "Riptide", "Chain Heal", "Lesser Healing Wave", "Healing Wave", "Earth Shield"]),
profile("mage:arcane", "mage", ["Mage Armor", "Ice Armor", "Frost Armor", "Arcane Barrage", "Arcane Blast", "Arcane Missiles"]),
profile("mage:fire", "mage", ["Molten Armor", "Mage Armor", "Ice Armor", "Frost Armor", "Living Bomb", "Pyroblast", "Fireball", "Scorch", "Fire Blast"]),
profile("mage:frost", "mage", ["Frost Armor", "Ice Armor", "Mage Armor", "Deep Freeze", "Frostbolt", "Ice Lance", "Cone of Cold"]),
profile("warlock:affliction", "warlock", ["Fel Armor", "Demon Armor", "Demon Skin", "Summon Felhunter", "Summon Voidwalker", "Summon Imp", "Haunt", "Unstable Affliction", "Corruption", "Curse of Agony", "Drain Soul", "Shadow Bolt"]),
profile("warlock:demonology", "warlock", ["Fel Armor", "Demon Armor", "Demon Skin", "Summon Felguard", "Summon Voidwalker", "Summon Imp", "Metamorphosis", "Immolate", "Corruption", "Shadow Bolt"]),
profile("warlock:destruction", "warlock", ["Fel Armor", "Demon Armor", "Demon Skin", "Summon Imp", "Chaos Bolt", "Conflagrate", "Immolate", "Incinerate", "Shadow Bolt"]),
profile("druid:balance", "druid", ["Moonkin Form", "Starfall", "Moonfire", "Insect Swarm", "Starfire", "Wrath"]),
profile("druid:feral-bear", "druid", ["Dire Bear Form", "Bear Form", "Mangle (Bear)", "Lacerate", "Swipe (Bear)", "Maul"]),
profile("druid:feral-cat", "druid", ["Cat Form", "Rip", "Savage Roar", "Mangle (Cat)", "Rake", "Shred", "Claw"]),
profile("druid:restoration", "druid", ["Tree of Life", "Wild Growth", "Swiftmend", "Lifebloom", "Rejuvenation", "Regrowth", "Healing Touch"]),
]);
const PROFILE_BY_SPECIALIZATION = new Map(
CLASSIC_ROTATION_PROFILES.map((entry) => [entry.specializationId, entry]),
);
function slug(value: string): string {
return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
}
export function rotationProfileFor(
classId: ClassId,
specializationId?: string | null,
specialization?: string | null,
): RotationProfile | null {
const explicit = specializationId ? PROFILE_BY_SPECIALIZATION.get(specializationId) : null;
if (explicit?.classId === classId) return explicit;
const inferred = specialization ? PROFILE_BY_SPECIALIZATION.get(`${classId}:${slug(specialization)}`) : null;
return inferred?.classId === classId ? inferred : null;
}
function throughput(ability: AbilityDefinition, comboPoints: number): number {
return ability.effects.reduce((total, effect) => {
if (["damage", "heal", "shield", "dot", "hot", "execute"].includes(effect.kind) && "coefficient" in effect) {
const basePoints = "basePoints" in effect ? effect.basePoints ?? 0 : 0;
return total + Math.max(0, effect.coefficient) * 10 + Math.max(0, basePoints) * 0.01;
}
if (effect.kind === "finisher-damage") {
return total + (effect.baseCoefficient + effect.perComboCoefficient * comboPoints) * 10;
}
return total;
}, 0);
}
function isComboFinisher(ability: AbilityDefinition): boolean {
return ability.effects.some((effect) => effect.kind === "finisher-damage"
|| (effect.kind === "combo" && effect.amount < 0));
}
function isComboBuilder(ability: AbilityDefinition): boolean {
return ability.effects.some((effect) => effect.kind === "combo" && effect.amount > 0);
}
function isPetSummon(ability: AbilityDefinition): boolean {
return ability.effects.some((effect) => effect.kind === "pet"
&& ["summon", "call", "resurrect", "tame"].includes(effect.action));
}
export const PRECOMBAT_MINIMUM_BUFF_DURATION_MS = 60_000;
function isPersistentPetPreparation(
ability: AbilityDefinition,
effect: Extract<AbilityDefinition["effects"][number], { kind: "pet" }>,
): boolean {
if (["call", "resurrect", "tame"].includes(effect.action)) return true;
return effect.action === "summon" && ability.cooldownMs < PRECOMBAT_MINIMUM_BUFF_DURATION_MS;
}
/** Long-lived self maintenance and permanent pets that should exist before the first pull. */
export function precombatPreparationEffects(ability: AbilityDefinition): readonly AbilityDefinition["effects"][number][] {
return ability.effects.filter((effect) => (
effect.kind === "pet"
? isPersistentPetPreparation(ability, effect)
: effect.kind === "apply-aura"
&& effect.aura.disposition === "buff"
&& (effect.aura.durationMs === null || effect.aura.durationMs >= PRECOMBAT_MINIMUM_BUFF_DURATION_MS)
));
}
export function abilityIsPrecombatPreparation(ability: AbilityDefinition): boolean {
return !ability.passive && precombatPreparationEffects(ability).length > 0;
}
function isOffensiveSummon(ability: AbilityDefinition): boolean {
return ability.effects.some((effect) => effect.kind === "summon" && effect.coefficient >= 0.15);
}
function isCombatMaintenanceAura(ability: AbilityDefinition): boolean {
return ability.effects.some((effect) => effect.kind === "apply-aura"
&& effect.aura.disposition === "buff"
&& (effect.aura.durationMs === null || effect.aura.durationMs >= 15_000)
&& Boolean(
effect.aura.modifiers?.length
|| effect.aura.absorbs?.length
|| effect.aura.procs?.length
|| effect.aura.controlImmunities?.length
|| effect.aura.reflectSpellChance
|| effect.aura.redirectDamagePercent
|| effect.aura.utilityTags?.includes("weapon-enchant"),
));
}
export function abilityHasRotationSemantics(ability: AbilityDefinition): boolean {
return ability.effects.some((effect) => {
if (effect.kind === "unsupported" || effect.kind === "scripted" || effect.kind === "utility") return false;
if (effect.kind !== "apply-aura") return true;
return Boolean(
effect.control
|| effect.aura.modifiers?.length
|| effect.aura.absorbs?.length
|| effect.aura.procs?.length
|| effect.aura.utilityTags?.length
|| effect.aura.controlImmunities?.length
|| effect.aura.reflectSpellChance
|| effect.aura.redirectDamagePercent,
);
});
}
function targetForAbility(
ability: AbilityDefinition,
context: RotationContext,
lowest: RotationFriendlyTarget | null,
): string | null {
const categories = new Set(aiHintsForAbility(ability).categories);
const casterOnly = ability.target === "self" || (ability.effects.some((effect) => (
"recipient" in effect && effect.recipient === "caster"
)) && !ability.effects.some((effect) => (
"recipient" in effect && effect.recipient === "ability-target"
)));
const hostileAction = (["damage", "execute", "debuff", "control", "interrupt", "taunt"] as const)
.some((category) => categories.has(category));
if (casterOnly && !hostileAction) return context.selfId ?? null;
if (ability.target === "friendly") return lowest?.id ?? null;
if (ability.target === "any" && lowest && aiHintsForAbility(ability).categories.some((category) => (
category === "heal" || category === "dispel" || category === "resurrection" || category === "mitigation"
))) return lowest.id;
// Self-centered hostile area skills (for example Thunder Clap) still need an
// enemy anchor so execution can enumerate targets around the caster.
if (ability.target === "self" && (ability.radius ?? 0) > 0
&& (["damage", "execute", "debuff", "control", "interrupt", "taunt"] as const)
.some((category) => categories.has(category))) {
return context.hostileTarget?.id ?? null;
}
if (ability.target === "hostile" || ability.target === "any") return context.hostileTarget?.id ?? null;
return null;
}
interface ScoredRotationAbility {
readonly ability: AbilityDefinition;
readonly score: number;
readonly ruleId: string;
readonly reason: string;
readonly targetId: string | null;
}
function scoreAbility(
ability: AbilityDefinition,
context: RotationContext,
rotationProfile: RotationProfile | null,
lowest: RotationFriendlyTarget | null,
): ScoredRotationAbility | null {
const hints = aiHintsForAbility(ability);
const categories = new Set(hints.categories);
const targetHealth = context.hostileTarget
? context.hostileTarget.health / Math.max(1, context.hostileTarget.maxHealth)
: 1;
const selfHealth = context.health / Math.max(1, context.maxHealth);
const lowestHealth = lowest ? lowest.health / Math.max(1, lowest.maxHealth) : 1;
const injured = (context.friendlyTargets ?? []).filter((target) => (
!target.fallen && target.health > 0 && target.health < target.maxHealth * 0.9
));
const resource = context.resources[context.primaryResource] ?? 0;
const maximumResource = context.maximumResources[context.primaryResource] ?? 0;
const resourceFraction = maximumResource > 0 ? resource / maximumResource : 1;
const maintained = (context.maintainedAbilityIds?.has(ability.id) ?? false)
|| ability.effects.some((effect) => effect.kind === "apply-aura"
&& Boolean(effect.aura.exclusiveGroup
&& context.activeAuraExclusiveGroups?.has(effect.aura.exclusiveGroup)));
const activeSummon = context.activeSummonAbilityIds?.has(ability.id) ?? false;
const petSummon = isPetSummon(ability);
const offensiveSummon = isOffensiveSummon(ability);
const maintenanceBuff = isCombatMaintenanceAura(ability);
const channelAmounts = ability.castMode === "channel" ? channelAmountEffects(ability, 1) : [];
const damaging = categories.has("damage") || categories.has("execute")
|| channelAmounts.some(({ effect }) => effect.kind === "damage" || effect.kind === "dot");
const healingCategory = categories.has("heal") || categories.has("emergency-heal") || categories.has("area-heal");
const defensive = categories.has("mitigation") || categories.has("heal");
const offensiveMajorCooldown = ability.cooldownMs >= 60_000 && !defensive
&& (damaging || categories.has("buff") || categories.has("summon") || categories.has("pet"));
const durableTarget = Boolean(
context.hostileTarget?.boss
|| (context.hostileTarget?.healthMultiplier ?? 1) > 1
|| (context.hostileTarget?.maxHealth ?? 0) >= context.maxHealth * 5,
);
const profileIndex = rotationProfile?.abilityPriority.findIndex((name) => name === ability.name) ?? -1;
if (categories.has("resurrection")) {
const fallen = (context.friendlyTargets ?? []).find((target) => target.fallen);
return fallen ? { ability, score: 20_000, ruleId: "reactive-resurrection", reason: "fallen ally", targetId: fallen.id } : null;
}
if (categories.has("dispel")) {
const dispel = (context.friendlyTargets ?? []).find((target) => target.needsDispel);
if (dispel) return { ability, score: 19_000, ruleId: "reactive-dispel", reason: "ally needs dispel", targetId: dispel.id };
if (!damaging) return null;
}
if (categories.has("interrupt")) {
if (!context.hostileTarget?.activeCast?.interruptible) {
if (!damaging) return null;
} else {
return { ability, score: 18_000, ruleId: "reactive-interrupt", reason: "interruptible enemy cast", targetId: context.hostileTarget.id };
}
}
if (categories.has("taunt")) {
if (context.role !== "tank" || !context.targetIsLoose) return null;
return { ability, score: 17_000, ruleId: "reactive-taunt", reason: "loose hostile target", targetId: context.hostileTarget?.id ?? null };
}
if (categories.has("threat") && !damaging && !maintenanceBuff) {
if (context.role === "tank" || !context.hasDangerousThreat) return null;
return {
ability,
score: 15_000,
ruleId: "reactive-threat-drop",
reason: "non-tank has enemy attention",
targetId: ability.target === "self" ? null : context.hostileTarget?.id ?? null,
};
}
if (categories.has("control") && !context.requiredControl && !damaging) return null;
// A friendly heal without an injured recipient must never fall through to
// the hostile target. Hybrid damage builds otherwise refresh HoTs on enemies
// forever because those auras also look like maintenance buffs.
const actionableHealing = healingCategory
&& !(maintenanceBuff && categories.has("resource"))
&& !petSummon
&& !offensiveSummon;
if (actionableHealing && !damaging) {
const healingTargetHealth = ability.target === "self" ? selfHealth : lowestHealth;
if ((ability.target === "friendly" || ability.target === "any") && !lowest) return null;
if (healingTargetHealth >= 0.9) return null;
}
// Pet stance and recall buttons require an explicit positioning/order intent;
// treating them as maintenance actions otherwise starves real attacks.
if (categories.has("pet") && !petSummon && !categories.has("summon") && !damaging
&& !categories.has("buff") && !categories.has("heal")) return null;
if (petSummon && context.hasActivePet) return null;
// Scouting objects, portals, ritual objects, and other zero-pressure DBC
// summons are valid player buttons but are not autonomous combat actions.
if (categories.has("summon") && !damaging && !petSummon && !offensiveSummon && !maintenanceBuff) return null;
if (categories.has("buff") && !damaging && !defensive && !maintenanceBuff
&& !petSummon && !offensiveSummon) return null;
if (categories.has("movement") && !damaging && !context.moving) return null;
if (context.requiredControl && categories.has("control")) {
return { ability, score: 16_000, ruleId: "reactive-control", reason: "encounter requested control", targetId: context.hostileTarget?.id ?? null };
}
if (offensiveMajorCooldown && !durableTarget) return null;
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;
if (isComboFinisher(ability) && (context.comboPoints < 4 || context.comboTargetId !== context.hostileTarget?.id)) return null;
if (isComboBuilder(ability) && context.comboPoints >= 5 && context.comboTargetId === context.hostileTarget?.id) return null;
let score = hints.priority ?? 0;
let ruleId = "semantic-fallback";
let reason = "shared semantic priority";
const maintenanceScore = rotationProfile === null ? 5_200 : profileIndex >= 0 ? 7_000 : 2_000;
const summonScore = rotationProfile === null ? 4_800 : profileIndex >= 0 ? 6_500 : 1_800;
if (profileIndex >= 0) {
score += 4_000 - profileIndex * 120;
ruleId = `profile-${slug(ability.name)}`;
reason = `${rotationProfile!.specializationId} priority ${profileIndex + 1}`;
}
if (context.role === "healer") {
const healing = actionableHealing
&& (healingAbilityCanAidOthers(ability) || lowest?.id === context.selfId);
if (!healing && !categories.has("mitigation") && !damaging && !categories.has("resource")
&& !maintenanceBuff && !petSummon && !offensiveSummon) return null;
if (healing) {
if (!lowest || lowestHealth >= 0.9) return null;
score += lowestHealth < 0.3 ? 7_000 : lowestHealth < 0.6 ? 5_000 : 3_000;
if (categories.has("emergency-heal")) score += lowestHealth < 0.3 ? 1_500 : -1_000;
if (categories.has("area-heal")) score += injured.length >= 3 ? 6_500 : -1_200;
score += Math.min(500, throughput(ability, context.comboPoints) * 10);
} else if (categories.has("mitigation")) {
if (lowestHealth >= 0.8 && selfHealth >= 0.8) return null;
score += 2_500;
} else if (maintenanceBuff) {
score += context.maintainedAbilityIds === undefined ? 1_600 : maintenanceScore;
ruleId = "maintenance-buff";
reason = "missing maintenance buff";
} else if (petSummon || offensiveSummon) {
score += context.activeSummonAbilityIds === undefined ? 1_400 : summonScore;
ruleId = "maintenance-summon";
reason = "missing pet or summon";
} else if (damaging) {
score += injured.length ? -3_000 : 300;
}
} else {
if (categories.has("mitigation")) {
if (!maintenanceBuff && selfHealth >= 0.55) return null;
if (selfHealth < 0.55) score += selfHealth < 0.35 ? 8_000 : 3_000;
}
if (categories.has("execute")) {
if (targetHealth > 0.2) return null;
score += 6_000;
ruleId = "execute";
reason = "target below twenty percent";
}
if (categories.has("debuff")) score += 2_000;
if (categories.has("buff")) {
score += maintenanceBuff && context.maintainedAbilityIds !== undefined
? maintenanceScore
: 1_600;
if (!damaging && !defensive && maintenanceBuff) {
ruleId = "maintenance-buff";
reason = "missing maintenance buff";
}
}
if (petSummon || offensiveSummon) {
score += damaging || context.activeSummonAbilityIds === undefined
? 1_400
: summonScore;
if (!damaging) {
ruleId = "maintenance-summon";
reason = "missing pet or summon";
}
}
if (categories.has("resource")) score += resourceFraction < 0.3 ? 2_600 : -1_000;
if (damaging) score += 1_000 + throughput(ability, context.comboPoints) * 10;
if (isComboFinisher(ability)) score += 2_400;
if (isComboBuilder(ability)) score += context.comboPoints < 4 ? 1_400 : 0;
if ((context.hostileCount ?? 1) >= 3) score += categories.has("area-damage") ? 2_000 : -300;
else if ((context.hostileCount ?? 1) === 2) score += categories.has("area-damage") ? 900 : 0;
else if (categories.has("area-damage")) score -= 300;
}
if (score <= 0) return null;
return { ability, score, ruleId, reason, targetId: targetForAbility(ability, context, lowest) };
}
export function evaluateCombatRotation(context: RotationContext): RotationDecision | null {
const rotationProfile = rotationProfileFor(
context.classId,
context.specializationId,
context.specialization,
);
const lowest = [...(context.friendlyTargets ?? [])]
.filter((target) => target.health > 0 && !target.fallen)
.sort((left, right) => (
left.health / Math.max(1, left.maxHealth) - right.health / Math.max(1, right.maxHealth)
|| left.id.localeCompare(right.id)
))[0] ?? null;
const decision = context.abilities
.filter((ability) => !ability.passive)
.filter(abilityHasRotationSemantics)
.filter((ability) => !context.moving || ability.castTimeMs <= 0 || canAbilityCastWhileMoving(ability))
.filter((ability) => {
if (context.classId !== "death-knight") return true;
const runeCost = runeCostForAbility(ability);
return !runeCost || spendDeathKnightRunes(context.runes, runeCost, context.now) !== null;
})
.filter((ability) => context.canUseAbility?.(ability) ?? true)
.map((ability) => scoreAbility(ability, context, rotationProfile, lowest))
.filter((candidate): candidate is ScoredRotationAbility => candidate !== null)
.sort((left, right) => right.score - left.score
|| left.ability.cooldownMs - right.ability.cooldownMs
|| left.ability.id.localeCompare(right.ability.id))[0];
return decision ? {
ability: decision.ability,
targetId: decision.targetId,
profileId: rotationProfile?.id ?? `semantic:${context.classId}`,
ruleId: decision.ruleId,
reason: decision.reason,
} : null;
}
Loaded 100 of 151 files, more files were not shown because too many files have changed in this diff. Show more