Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
38b3d4d160 | ||
|
|
2dbb0acfc1 | ||
|
|
d6976e6ef6 | ||
|
|
d0a02487db | ||
|
|
dd583023ac | ||
|
|
79153ca40d | ||
|
|
717d12e5af | ||
|
|
612170afd0 | ||
|
|
39425f2494 | ||
|
|
2162039b49 | ||
|
|
9ff574ac0b |
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -3436,3 +3436,186 @@ 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/`.
|
||||
|
||||
## Balance Lab deterministic class/spec coverage (2026-09-17)
|
||||
|
||||
- Automatic sweeps now offer **Random parties**, **All specs**, and **Specific
|
||||
specs**. All-spec mode derives the minimum valid 1-tank/1-healer/3-damage
|
||||
party set that covers every available build. Specific mode guarantees every
|
||||
selected build appears and uses distinct compatible fillers for missing
|
||||
roles; selected builds are not reused as fillers when alternatives exist.
|
||||
- A real level-20 WoW all-spec sweep against Eck the Ferocious generated eight
|
||||
distinct parties and placed all 31 catalog builds in `suite-report.json`
|
||||
with zero missing. The combined catalog audit found 70 CoA builds requiring
|
||||
16 coverage parties and 16 RoM builds requiring four; every source exposes
|
||||
enough tank, healer, and damage builds for valid coverage parties.
|
||||
- Eight Python controller tests, nine Balance Lab service tests, two server
|
||||
integration tests, and the production build passed. Production Edge
|
||||
automation verified the All Specs estimate, selected Paladin Holy through the
|
||||
Specific Specs picker, completed the server job, rendered all seven overview
|
||||
graphs, and passed desktop plus 480×900 overflow checks without browser
|
||||
errors. Evidence is in `playtest-artifacts/balance-lab/02-server-job-config.png`
|
||||
and `playtest-artifacts/balance-lab/03-server-job-complete.png`.
|
||||
|
||||
## Balance Lab class/spec ability damage drill-down (2026-09-17)
|
||||
|
||||
- Every class/spec row now exposes **View ability damage** when ability telemetry
|
||||
exists. It opens the Abilities tab on that exact source/build, clears
|
||||
conflicting search and role filters, and defaults to abilities with positive
|
||||
effective damage sorted highest first.
|
||||
- The drill-down graph and table retain ability name/ID, casts, casts per minute,
|
||||
hits, effective/raw damage, damage share, DPS, and damage per hit. Healing,
|
||||
overheal, absorbs, usage, and unused-ability filters remain available.
|
||||
- The production build passed. A real five-second level-20 WoW run against Eck
|
||||
the Ferocious opened a Death Knight Unholy row through the new action and
|
||||
verified positive effective ability damage plus the expected cast, damage
|
||||
share, and DPS columns. Desktop and 480×900 checks passed with no browser
|
||||
errors. Evidence is in
|
||||
`playtest-artifacts/balance-lab/04b-ability-breakdown.png` and
|
||||
`playtest-artifacts/balance-lab/06-mobile-abilities.png`.
|
||||
|
||||
## WoW full-spec mechanics and rotation pass (2026-09-18)
|
||||
|
||||
- `playerbots-inspired-v3` now gives every one of the 31 Wrath specs a legal,
|
||||
level-scaled talent plan and executable profile. The pass repaired stale
|
||||
talent prerequisites, companion Rage/Runic Power persistence, short melee
|
||||
reach, warrior rage banking, and explicit profile ordering.
|
||||
- Scripted payloads now make Bloodthirst, Bloodrage, Execute, Mutilate, Killing
|
||||
Spree, Penance, Feral Spirit, Conflagrate, and Earth Shield mechanically
|
||||
meaningful. Frost/Unholy disease setup, Arms Rend/Mortal Strike order, Arcane
|
||||
Barrage, and healer maintenance behavior have direct regression coverage.
|
||||
- The exhaustive level-80 headless audit completed 2,672 fights with no
|
||||
simulation failures under `artifacts/balance-fixes-20260918-level80/`.
|
||||
The final 40-run Stockades confirmation under
|
||||
`artifacts/balance-fixes-20260918-stockade-level80-final/` exercised all 31
|
||||
specs: Feral Spirit, Bloodrage, and Earth Shield were active in every eligible
|
||||
run, while the repaired damage signatures produced damage in their applicable
|
||||
runs. Low-level boss floors were recalibrated so ordinary/final Stockades
|
||||
encounters still retain their 25/45-second mechanic windows. These are
|
||||
structured combat checks, so no scene screenshot is applicable. Remaining
|
||||
cross-spec DPS/HPS outliers require a separate coefficient-tuning pass.
|
||||
@@ -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
|
||||
|
||||
@@ -18,6 +18,14 @@ approval before it commits, pushes `main`, builds the signed APK, and publishes
|
||||
the Gitea release. Signing passwords and the Gitea token are passed to the
|
||||
child process in memory rather than on its command line.
|
||||
|
||||
If a content, server, normal-application, or shelved-application test command
|
||||
fails, the visual manager pauses with the failed command, exit code, and recent
|
||||
test output. **Stop Release** remains the safe default, but **Continue Anyway**
|
||||
allows an intentional override and records that decision in the release log.
|
||||
Build, content-publication, Git, signing, and APK-publication failures are not
|
||||
overridable and still stop the workflow. The manual procedure below remains
|
||||
fail-closed.
|
||||
|
||||
Before changing versions or running the longer test/build sequence, the manager
|
||||
runs an isolated release-candidate check. It validates tracked and untracked
|
||||
files without changing the real Git index, so extra blank lines at EOF and
|
||||
@@ -651,14 +659,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
|
||||
|
||||
@@ -0,0 +1,415 @@
|
||||
# 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. random parties, guaranteed coverage of all class/spec builds, or a
|
||||
searchable selection of specific class/spec builds;
|
||||
5. how many unique 1-tank/1-healer/3-damage configurations to generate per
|
||||
source when random selection is used;
|
||||
6. all bosses, WoW bosses, RoM bosses, or a searchable multi-boss selection;
|
||||
7. how many deterministic simulations to run for every party/boss pairing; and
|
||||
8. the maximum duration of each fight.
|
||||
|
||||
Automatic class/spec selection has three strategies:
|
||||
|
||||
- **Random parties** preserves the original seeded behavior and generates the
|
||||
requested number of unique parties per source.
|
||||
- **All specs** creates the minimum deterministic set of valid parties needed
|
||||
to include every available class/spec build for the selected WoW, CoA, or
|
||||
RoM source at least once.
|
||||
- **Specific specs** treats each selected build as required coverage and fills
|
||||
the remaining tank, healer, and damage slots with distinct compatible builds.
|
||||
When alternatives exist, required builds are not reused as fillers.
|
||||
|
||||
Coverage parties always contain one tank, one healer, and three different
|
||||
damage builds. Their composition is deterministic for the party seed, and no
|
||||
build is duplicated inside one party.
|
||||
|
||||
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, class/spec selection strategy, a searchable spec picker, simulations per
|
||||
party/boss, duration, random seed, boss category, and a searchable multi-boss
|
||||
picker. Random mode exposes the requested parties per source; coverage modes
|
||||
show the derived minimum party count before submission.
|
||||
|
||||
**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, source/role-median tuning flags, and a direct ability-damage
|
||||
drill-down for every build; 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. **View ability damage** on a class/spec row opens the
|
||||
Abilities tab with that exact build selected, damaging abilities ranked by
|
||||
effective damage, and its casts, hits, DPS, damage share, and damage-per-hit
|
||||
details visible in the table. **Show all specs** clears that drill-down.
|
||||
|
||||
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.
|
||||
- Member and ability `absorbs` count shield capacity granted by both direct
|
||||
shield effects and named absorb auras such as Power Word: Shield. Member
|
||||
`deathEvents` counts each alive-to-dead transition, so a member that is
|
||||
resurrected and defeated again contributes another death.
|
||||
- Ability `activeRuns` counts simulations with at least one cast, completed
|
||||
non-throughput release, hit, damage, healing, overheal, or absorb event.
|
||||
Completed resource, aura, pet, and summon actions are recorded as casts even
|
||||
though they create no immediate throughput 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
|
||||
and non-throughput actions.
|
||||
- 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.
|
||||
|
||||
Companion builds initialize every resource pool required by their learned
|
||||
abilities, not only the class's primary pool. Secondary Energy and Focus
|
||||
regenerate, and any companion with a Rage pool gains Rage from landed weapon
|
||||
attacks; this is what makes druid Cat and Bear rotations executable while Mana
|
||||
remains the class's primary resource. A completed companion cast resolves the
|
||||
member's currently learned rank before applying its effects, so cast-time spell
|
||||
damage and healing cannot fall back to rank one.
|
||||
|
||||
`playerbots-inspired-v4` keeps level-10-and-up non-healer builds on their
|
||||
specialization's authored damage priority whenever that build knows a profile
|
||||
damage action. Reactive interrupts, taunts, control, and defenses still take
|
||||
precedence. Bear and Cat actions are isolated to their matching Feral specs;
|
||||
Overpower, Revenge, and Victory Rush require an explicitly modeled trigger;
|
||||
and Drain Soul, Hammer of Wrath, and Kill Shot obey their target-health gates.
|
||||
Off-profile control, interrupt, and debuff buttons cannot consume ordinary
|
||||
rotation GCDs after their reactive conditions have been checked. Incidental DBC
|
||||
aura categories cannot outrank explicit profile actions. Rage and Runic Power
|
||||
generated during an action persist without erasing that action's cooldown/GCD;
|
||||
short-range melee abilities use companion combat reach; and pets move every
|
||||
combat tick while retaining their own attack cadence.
|
||||
|
||||
## Code map
|
||||
|
||||
- `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, all learned-ability resource
|
||||
pools, 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.
|
||||
@@ -0,0 +1,75 @@
|
||||
# Combat rotation matrix
|
||||
|
||||
Healer Man uses AzerothCore playerbots commit `531282e` as a read-only behavioral reference. The complete historical checkout is available locally at `../wow-pvt-server-backups-2/azerothcore-wotlk/modules/mod-playerbots`; do not modify it or treat it as the active project. The implementation is an independent TypeScript policy over Healer Man's executable catalog, not a port of playerbots C++.
|
||||
|
||||
The most useful upstream starting points are `src/Ai/Class/<Class>/Strategy/` for actions, triggers, and relative priorities; `conf/playerbots.conf.dist` for the level-60 and level-80 PvE talent build links; `src/PlayerbotAIConfig.cpp` for parsing those links; and `src/Bot/Factory/PlayerbotFactory.cpp` plus `src/Mgr/Talent/Talentspec.cpp` for level-aware talent application. Translate these inputs through Healer Man's own talent legality, executable-effect, resource, encounter-state, and deterministic-simulation rules rather than copying C++ behavior literally.
|
||||
|
||||
| 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. |
|
||||
|
||||
`playerbots-inspired-v4` also generates legal level-scaled talent plans for all
|
||||
31 classic specializations. Each level-80 plan spends 51 points in the selected
|
||||
tree and 20 in a compatible secondary tree, prefers executable active talents
|
||||
and their prerequisites, and sanitizes obsolete imported prerequisite links.
|
||||
Runtime resource generation is actor-authoritative without erasing the action's
|
||||
cooldown/GCD, and companion melee abilities share the formation combat-reach
|
||||
leeway already used by weapon swings.
|
||||
|
||||
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; Bloodrage; Rend; Mortal Strike; Bladestorm; Overpower; Execute; Slam; Heroic Strike |
|
||||
| Warrior | Fury | Berserker/Battle Stance; Bloodrage; Bloodthirst; Whirlwind; Execute; Sunder Armor; Heroic Strike |
|
||||
| Warrior | Protection | Defensive/Battle Stance; Bloodrage; Shield Slam; Revenge; Devastate; Thunder Clap; Sunder Armor; Heroic Strike |
|
||||
| Paladin | Holy | Concentration Aura; Blessing of Might; Holy Shock; Flash of Light; Holy Light; Sacred Shield; Judgement of Light |
|
||||
| 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; Icy Touch; Plague Strike; Howling Blast; Frost Strike; Obliterate |
|
||||
| Death Knight | Unholy | Unholy Presence (Blood Presence fallback); Raise Dead; Plague Strike; Icy Touch; Scourge Strike; Death Coil; Death and Decay |
|
||||
| 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.
|
||||
@@ -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,
|
||||
|
||||
@@ -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`.
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "healer-man",
|
||||
"version": "0.1.45",
|
||||
"version": "0.1.55",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "healer-man",
|
||||
"version": "0.1.45",
|
||||
"version": "0.1.55",
|
||||
"dependencies": {
|
||||
"@capacitor/android": "8.4.1",
|
||||
"@capacitor/core": "8.4.1",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "healer-man",
|
||||
"private": true,
|
||||
"version": "0.1.45",
|
||||
"version": "0.1.55",
|
||||
"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",
|
||||
|
||||
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"versionName": "0.1.45",
|
||||
"versionCode": 1045
|
||||
"versionName": "0.1.55",
|
||||
"versionCode": 1055
|
||||
}
|
||||
@@ -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 };
|
||||
|
||||
@@ -0,0 +1,670 @@
|
||||
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 {
|
||||
abilityById,
|
||||
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 { subscribeCombatPresentation } from "../../src/game/combatPresentation";
|
||||
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 abilityProducesAttributedCombatEvent(abilityId: string | undefined): boolean {
|
||||
const ability = abilityId ? abilityById(abilityId) : null;
|
||||
return ability?.effects.some((effect) => [
|
||||
"damage", "dot", "finisher-damage", "execute", "heal", "hot", "shield", "resurrection",
|
||||
].includes(effect.kind)) ?? true;
|
||||
}
|
||||
|
||||
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 actorAlive = new Map<string, boolean>();
|
||||
const bossAbilities = new Map<string, MutableAbilityMetrics>();
|
||||
// Instant resource, aura, pet, and summon actions do not produce the damage,
|
||||
// healing, absorb, or cast events used by the original report collector.
|
||||
// Count their completed release once so Balance Lab does not label working
|
||||
// actions such as Bloodrage and Feral Spirit as available-but-unused.
|
||||
const stopPresentation = subscribeCombatPresentation((message) => {
|
||||
if (message.kind !== "event" || message.event.phase !== "release") return;
|
||||
const source = byActorId.get(message.event.sourceActorId);
|
||||
if (!source || abilityProducesAttributedCombatEvent(message.event.abilityId)) return;
|
||||
source.casts += 1;
|
||||
abilityMetrics(source, message.event.abilityId).casts += 1;
|
||||
});
|
||||
const stopEvents = subscribeCombatEvents((event) => {
|
||||
const source = byActorId.get(event.sourceActorId);
|
||||
const target = byActorId.get(event.targetActorId);
|
||||
if (event.kind === "cast" && source) {
|
||||
if (!abilityProducesAttributedCombatEvent(event.abilityId)) return;
|
||||
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;
|
||||
}
|
||||
});
|
||||
|
||||
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();
|
||||
for (const metrics of memberMetrics) {
|
||||
actorAlive.set(metrics.actorId, currentHealth(metrics.actorId)[0] > 0);
|
||||
}
|
||||
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);
|
||||
const alive = health > 0;
|
||||
if ((actorAlive.get(metrics.actorId) ?? alive) && !alive) metrics.deathEvents += 1;
|
||||
actorAlive.set(metrics.actorId, alive);
|
||||
metrics.minimumHealthPercent = Math.min(
|
||||
metrics.minimumHealthPercent,
|
||||
maxHealth > 0 ? Math.max(0, health / maxHealth) : 0,
|
||||
);
|
||||
}
|
||||
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 {
|
||||
stopPresentation();
|
||||
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;
|
||||
});
|
||||
@@ -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)}.`);
|
||||
@@ -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) => (
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
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('Class/spec selection').selectOption('all');
|
||||
assert(Number(await page.getByLabel('Coverage parties').inputValue()) > 1, 'all-spec coverage should derive multiple parties');
|
||||
await page.getByLabel('Class/spec selection').selectOption('specific');
|
||||
await page.getByLabel('Search Balance Lab class specs').fill('Paladin Holy');
|
||||
await page.getByRole('checkbox', { name: /Paladin · Holy/ }).check();
|
||||
assert.equal(await page.getByLabel('Coverage parties').inputValue(), '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(), 7);
|
||||
assert.match(await page.locator('.balance-results-chart').filter({ hasText: 'Boss damage to party' }).innerText(), /Eck the Ferocious/);
|
||||
await capture('04-run-analytics');
|
||||
|
||||
await 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');
|
||||
const damageSpecRow = page.locator('.balance-results-table-panel tbody tr').filter({
|
||||
has: page.locator('td:nth-child(3)').getByText('damage', { exact: true }),
|
||||
}).first();
|
||||
const damageSpecClass = await damageSpecRow.locator('td:first-child strong').innerText();
|
||||
const damageSpecName = await damageSpecRow.locator('td:first-child small').innerText();
|
||||
await damageSpecRow.getByRole('button', { name: `View ${damageSpecClass} ${damageSpecName} ability damage` }).click();
|
||||
await page.getByRole('heading', { name: `${damageSpecClass} · ${damageSpecName} ability damage` }).waitFor();
|
||||
assert.notEqual(await page.getByLabel('Filter ability class spec').inputValue(), 'all');
|
||||
assert.equal(await page.getByLabel('Filter ability usage').inputValue(), 'damage');
|
||||
const abilityRows = page.locator('.balance-results-abilities tbody tr');
|
||||
assert(await abilityRows.count() > 0, 'spec drill-down should show abilities that dealt damage');
|
||||
assert(Number((await abilityRows.first().locator('td:nth-child(12)').innerText()).replaceAll(',', '')) > 0, 'drilled ability rows should include effective damage');
|
||||
const abilityHeaders = await page.locator('.balance-results-abilities thead').innerText();
|
||||
assert.match(abilityHeaders, /CASTS/);
|
||||
assert.match(abilityHeaders, /EFFECTIVE DAMAGE/);
|
||||
assert.match(abilityHeaders, /DAMAGE SHARE/);
|
||||
assert.match(abilityHeaders, /DPS/);
|
||||
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;
|
||||
});
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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;
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Visual, fail-closed release manager for Healer Man.
|
||||
"""Visual release manager for Healer Man with explicit safety gates.
|
||||
|
||||
The GUI intentionally leaves the TrueNAS portion as copyable shell commands:
|
||||
the app must be stopped and started from the TrueNAS Apps UI around that block.
|
||||
@@ -18,6 +18,7 @@ import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import webbrowser
|
||||
from collections import deque
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Callable, Iterable, Sequence
|
||||
@@ -59,6 +60,17 @@ FORBIDDEN_SUFFIXES = {
|
||||
ALLOWED_GENERATED_FILES = {
|
||||
"public/data/healer-man-loot.sqlite",
|
||||
}
|
||||
OVERRIDABLE_TEST_LABELS = frozenset(
|
||||
{
|
||||
"Content tests",
|
||||
"Server tests",
|
||||
"Application tests",
|
||||
"Shelved-mode application tests",
|
||||
}
|
||||
)
|
||||
ANSI_ESCAPE_PATTERN = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]")
|
||||
FAILURE_OUTPUT_LINE_LIMIT = 160
|
||||
FAILURE_OUTPUT_CHARACTER_LIMIT = 20_000
|
||||
|
||||
|
||||
class ReleaseError(RuntimeError):
|
||||
@@ -69,6 +81,23 @@ class ReleaseCancelled(ReleaseError):
|
||||
"""The user cancelled an active release."""
|
||||
|
||||
|
||||
class ReleaseCommandError(ReleaseError):
|
||||
"""A release subprocess failed and retained its output for review."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
label: str,
|
||||
command: Sequence[str],
|
||||
return_code: int,
|
||||
output: str,
|
||||
) -> None:
|
||||
super().__init__(f"{label} failed with exit code {return_code}.")
|
||||
self.label = label
|
||||
self.command = tuple(command)
|
||||
self.return_code = return_code
|
||||
self.output = output
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ReleaseVersion:
|
||||
version_name: str
|
||||
@@ -88,6 +117,36 @@ def full_release_checks(npm: str) -> tuple[tuple[list[str], str], ...]:
|
||||
)
|
||||
|
||||
|
||||
def can_override_release_check(label: str) -> bool:
|
||||
"""Return whether the interactive release flow may continue after a failure."""
|
||||
return label in OVERRIDABLE_TEST_LABELS
|
||||
|
||||
|
||||
def format_failed_test_warning(failure: ReleaseCommandError) -> str:
|
||||
"""Build the detailed warning shown before overriding a failed test command."""
|
||||
clean_output = ANSI_ESCAPE_PATTERN.sub("", failure.output).strip()
|
||||
output_lines = clean_output.splitlines()
|
||||
omitted_lines = max(0, len(output_lines) - FAILURE_OUTPUT_LINE_LIMIT)
|
||||
if omitted_lines:
|
||||
output_lines = output_lines[-FAILURE_OUTPUT_LINE_LIMIT:]
|
||||
recent_output = "\n".join(output_lines)
|
||||
if len(recent_output) > FAILURE_OUTPUT_CHARACTER_LIMIT:
|
||||
recent_output = recent_output[-FAILURE_OUTPUT_CHARACTER_LIMIT:]
|
||||
recent_output = "… earlier output omitted …\n" + recent_output
|
||||
elif omitted_lines:
|
||||
recent_output = f"… {omitted_lines} earlier line(s) omitted …\n" + recent_output
|
||||
if not recent_output:
|
||||
recent_output = "(The command produced no output.)"
|
||||
command = subprocess.list2cmdline(list(failure.command))
|
||||
return (
|
||||
f"{failure.label} failed with exit code {failure.return_code}.\n\n"
|
||||
f"Command:\n{command}\n\n"
|
||||
f"Recent output:\n{recent_output}\n\n"
|
||||
"Continuing can publish a release with known test failures. "
|
||||
"Only continue if you reviewed the failure and accept that risk."
|
||||
)
|
||||
|
||||
|
||||
def parse_semver(value: str) -> tuple[int, int, int]:
|
||||
match = SEMVER_PATTERN.fullmatch(value.strip())
|
||||
if not match:
|
||||
@@ -1116,7 +1175,25 @@ def launch_gui(*, smoke_test: bool = False) -> int:
|
||||
)
|
||||
|
||||
for command, label in full_release_checks(npm):
|
||||
try:
|
||||
self._run_command(command, label)
|
||||
except ReleaseCommandError as failure:
|
||||
if not can_override_release_check(label):
|
||||
raise
|
||||
approved = self._request_approval(
|
||||
format_failed_test_warning(failure),
|
||||
title=f"Warning: {label} failed",
|
||||
heading="Tests failed — continue the release?",
|
||||
approve_label="Continue Anyway",
|
||||
cancel_label="Stop Release",
|
||||
)
|
||||
if not approved:
|
||||
raise ReleaseCancelled(
|
||||
f"Release stopped because {label} failed."
|
||||
) from failure
|
||||
self._log_event(
|
||||
f"⚠ Continuing after failed {label}; override approved by user."
|
||||
)
|
||||
self._run_command(["git", "add", "-A"], "Stage release")
|
||||
staged = True
|
||||
|
||||
@@ -1267,31 +1344,66 @@ def launch_gui(*, smoke_test: bool = False) -> int:
|
||||
)
|
||||
self.current_process = process
|
||||
assert process.stdout is not None
|
||||
output_lines: deque[str] = deque(maxlen=FAILURE_OUTPUT_LINE_LIMIT)
|
||||
try:
|
||||
for line in process.stdout:
|
||||
self._log_event(line.rstrip("\n"))
|
||||
rendered = line.rstrip("\n")
|
||||
output_lines.append(rendered)
|
||||
self._log_event(rendered)
|
||||
return_code = process.wait()
|
||||
finally:
|
||||
self.current_process = None
|
||||
if self.cancel_event.is_set():
|
||||
raise ReleaseCancelled("Release cancelled.")
|
||||
if return_code != 0:
|
||||
raise ReleaseError(f"{label} failed with exit code {return_code}.")
|
||||
raise ReleaseCommandError(
|
||||
label,
|
||||
args,
|
||||
return_code,
|
||||
"\n".join(output_lines),
|
||||
)
|
||||
self._log_event(f"✓ {label} passed")
|
||||
|
||||
def _request_approval(self, review: str) -> bool:
|
||||
def _request_approval(
|
||||
self,
|
||||
review: str,
|
||||
*,
|
||||
title: str = "Review staged release",
|
||||
heading: str = "Inspect every staged path before publishing",
|
||||
approve_label: str = "Commit, Push, and Publish",
|
||||
cancel_label: str = "Cancel release",
|
||||
) -> bool:
|
||||
response: dict[str, bool] = {}
|
||||
ready = threading.Event()
|
||||
self.events.put(("approval", review, response, ready))
|
||||
self.events.put(
|
||||
(
|
||||
"approval",
|
||||
review,
|
||||
title,
|
||||
heading,
|
||||
approve_label,
|
||||
cancel_label,
|
||||
response,
|
||||
ready,
|
||||
)
|
||||
)
|
||||
while not ready.wait(0.2):
|
||||
if self.cancel_event.is_set():
|
||||
return False
|
||||
return response.get("approved", False)
|
||||
|
||||
def _show_staged_review(self, review: str) -> bool:
|
||||
def _show_approval(
|
||||
self,
|
||||
review: str,
|
||||
*,
|
||||
title: str,
|
||||
heading: str,
|
||||
approve_label: str,
|
||||
cancel_label: str,
|
||||
) -> bool:
|
||||
decision = {"approved": False}
|
||||
dialog = tk.Toplevel(self.root)
|
||||
dialog.title("Review staged release")
|
||||
dialog.title(title)
|
||||
dialog.geometry("820x640")
|
||||
dialog.minsize(640, 420)
|
||||
dialog.configure(bg=self.BG)
|
||||
@@ -1301,7 +1413,7 @@ def launch_gui(*, smoke_test: bool = False) -> int:
|
||||
dialog.columnconfigure(0, weight=1)
|
||||
ttk.Label(
|
||||
dialog,
|
||||
text="Inspect every staged path before publishing",
|
||||
text=heading,
|
||||
style="Title.TLabel",
|
||||
).grid(row=0, column=0, sticky="w", padx=18, pady=(16, 10))
|
||||
review_text = scrolledtext.ScrolledText(
|
||||
@@ -1326,12 +1438,12 @@ def launch_gui(*, smoke_test: bool = False) -> int:
|
||||
dialog.grab_release()
|
||||
dialog.destroy()
|
||||
|
||||
ttk.Button(buttons, text="Cancel release", command=lambda: finish(False)).pack(
|
||||
ttk.Button(buttons, text=cancel_label, command=lambda: finish(False)).pack(
|
||||
side="left"
|
||||
)
|
||||
ttk.Button(
|
||||
buttons,
|
||||
text="Commit, Push, and Publish",
|
||||
text=approve_label,
|
||||
style="Accent.TButton",
|
||||
command=lambda: finish(True),
|
||||
).pack(side="right")
|
||||
@@ -1375,8 +1487,15 @@ def launch_gui(*, smoke_test: bool = False) -> int:
|
||||
if kind == "log":
|
||||
self._append_log(str(event[1]))
|
||||
elif kind == "approval":
|
||||
review, response, ready = event[1], event[2], event[3]
|
||||
approved = self._show_staged_review(str(review))
|
||||
review, title, heading, approve_label, cancel_label = event[1:6]
|
||||
response, ready = event[6], event[7]
|
||||
approved = self._show_approval(
|
||||
str(review),
|
||||
title=str(title),
|
||||
heading=str(heading),
|
||||
approve_label=str(approve_label),
|
||||
cancel_label=str(cancel_label),
|
||||
)
|
||||
assert isinstance(response, dict)
|
||||
response["approved"] = approved
|
||||
assert isinstance(ready, threading.Event)
|
||||
|
||||
@@ -54,6 +54,39 @@ class SafetyTests(unittest.TestCase):
|
||||
self.assertLess(publish_index, commands.index(["npm", "run", "build"]))
|
||||
self.assertLess(publish_index, commands.index(["npm", "run", "android:sync"]))
|
||||
|
||||
def test_only_test_failures_may_be_overridden(self):
|
||||
checks = release_manager.full_release_checks("npm")
|
||||
overridable = {
|
||||
label for _command, label in checks
|
||||
if release_manager.can_override_release_check(label)
|
||||
}
|
||||
self.assertEqual(
|
||||
overridable,
|
||||
{
|
||||
"Content tests",
|
||||
"Server tests",
|
||||
"Application tests",
|
||||
"Shelved-mode application tests",
|
||||
},
|
||||
)
|
||||
self.assertFalse(release_manager.can_override_release_check("Production build"))
|
||||
self.assertFalse(release_manager.can_override_release_check("Push main"))
|
||||
|
||||
def test_failed_test_warning_includes_command_and_specific_output(self):
|
||||
failure = release_manager.ReleaseCommandError(
|
||||
"Application tests",
|
||||
["npm", "test"],
|
||||
1,
|
||||
"\x1b[31mFAIL\x1b[39m src/game/example.test.ts\n"
|
||||
"Error: expected 2 to be 3",
|
||||
)
|
||||
warning = release_manager.format_failed_test_warning(failure)
|
||||
self.assertIn("Application tests failed with exit code 1", warning)
|
||||
self.assertIn("npm test", warning)
|
||||
self.assertIn("FAIL src/game/example.test.ts", warning)
|
||||
self.assertIn("Error: expected 2 to be 3", warning)
|
||||
self.assertNotIn("\x1b", warning)
|
||||
|
||||
|
||||
class ReleaseCandidateTests(unittest.TestCase):
|
||||
def test_checks_untracked_files_without_changing_the_real_index(self):
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import unittest
|
||||
|
||||
from scripts.balance_lab import (
|
||||
aggregate_class_abilities,
|
||||
aggregate_class_specs,
|
||||
automatic_parties,
|
||||
coverage_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_coverage_parties_include_every_build_with_minimum_unique_parties(self) -> None:
|
||||
builds = [
|
||||
{"id": "tank-a", "source": "wow", "role": "tank"},
|
||||
{"id": "tank-b", "source": "wow", "role": "tank"},
|
||||
{"id": "healer-a", "source": "wow", "role": "healer"},
|
||||
{"id": "healer-b", "source": "wow", "role": "healer"},
|
||||
*({"id": f"damage-{index}", "source": "wow", "role": "damage"} for index in range(7)),
|
||||
]
|
||||
first = coverage_parties(builds, ("wow",), None, 19)
|
||||
second = coverage_parties(builds, ("wow",), None, 19)
|
||||
self.assertEqual(first, second)
|
||||
self.assertEqual(len(first), 3)
|
||||
covered = {member["id"] for configuration in first for member in configuration["party"]}
|
||||
self.assertEqual(covered, {build["id"] for build in builds})
|
||||
signatures = {tuple(member["id"] for member in configuration["party"]) for configuration in first}
|
||||
self.assertEqual(len(signatures), 3)
|
||||
for configuration in first:
|
||||
self.assertEqual([member["role"] for member in configuration["party"]], [
|
||||
"tank", "healer", "damage", "damage", "damage",
|
||||
])
|
||||
self.assertEqual(len({member["id"] for member in configuration["party"]}), 5)
|
||||
|
||||
def test_specific_coverage_uses_fillers_but_guarantees_selected_builds(self) -> None:
|
||||
builds = [
|
||||
{"id": "tank-a", "source": "wow", "role": "tank"},
|
||||
{"id": "tank-b", "source": "wow", "role": "tank"},
|
||||
{"id": "healer-a", "source": "wow", "role": "healer"},
|
||||
{"id": "healer-b", "source": "wow", "role": "healer"},
|
||||
*({"id": f"damage-{index}", "source": "wow", "role": "damage"} for index in range(5)),
|
||||
]
|
||||
selected = ["tank-a", "tank-b", "damage-4"]
|
||||
configurations = coverage_parties(builds, ("wow",), selected, 7)
|
||||
self.assertEqual(len(configurations), 2)
|
||||
observed = [member["id"] for configuration in configurations for member in configuration["party"]]
|
||||
covered = set(observed)
|
||||
self.assertTrue(set(selected).issubset(covered))
|
||||
self.assertTrue(all(observed.count(build_id) == 1 for build_id in selected))
|
||||
|
||||
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()
|
||||
@@ -0,0 +1,500 @@
|
||||
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 SPEC_SELECTIONS = new Set(["random", "all", "specific"]);
|
||||
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();
|
||||
const specSelection = String(input?.specSelection ?? "random").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");
|
||||
}
|
||||
if (!SPEC_SELECTIONS.has(specSelection)) {
|
||||
throw new GameDatabaseError("Class/spec selection must be random, all, or specific.", 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 rawBuildIds = Array.isArray(input?.selectedBuildIds) ? input.selectedBuildIds : [];
|
||||
if (rawBuildIds.length > 200) {
|
||||
throw new GameDatabaseError("Select no more than 200 class/spec builds.", 400, "invalid_balance_lab_job");
|
||||
}
|
||||
const selectedBuildIds = [...new Set(rawBuildIds.map((value) => String(value).trim()).filter(Boolean))];
|
||||
if (selectedBuildIds.some((id) => id.length > 240 || !/^[A-Za-z0-9._:/-]+$/.test(id))) {
|
||||
throw new GameDatabaseError("A selected class/spec build ID is invalid.", 400, "invalid_balance_lab_job");
|
||||
}
|
||||
if (specSelection === "specific" && selectedBuildIds.length === 0) {
|
||||
throw new GameDatabaseError("Select at least one class/spec build.", 400, "invalid_balance_lab_job");
|
||||
}
|
||||
const spec = Object.freeze({
|
||||
source,
|
||||
specSelection,
|
||||
selectedBuildIds: Object.freeze(specSelection === "specific" ? selectedBuildIds : []),
|
||||
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 partyEstimate = spec.specSelection === "random"
|
||||
? sourceCount * spec.partyConfigurations
|
||||
: spec.specSelection === "specific"
|
||||
? Math.max(spec.partyConfigurations, spec.selectedBuildIds.length)
|
||||
: Math.max(spec.partyConfigurations, spec.source === "all" ? 100 : 50);
|
||||
const estimatedRuns = partyEstimate * 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",
|
||||
"--spec-selection", spec.specSelection,
|
||||
"--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 buildId of spec.selectedBuildIds) args.push("--spec", buildId);
|
||||
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 expectedPartyConfigurations(spec) {
|
||||
return spec.specSelection === "random"
|
||||
? spec.partyConfigurations * (spec.source === "all" ? 3 : 1)
|
||||
: spec.partyConfigurations;
|
||||
}
|
||||
|
||||
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,
|
||||
specSelection: legacyConfig.specSelection ?? "random",
|
||||
selectedBuildIds: legacyConfig.selectedBuildIds ?? [],
|
||||
level: legacyConfig.level,
|
||||
partyConfigurations: legacyConfig.specSelection && legacyConfig.specSelection !== "random"
|
||||
? legacyConfig.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 restoredSummary = hasReport ? reportSummary(outputDir) : null;
|
||||
const totalParties = restoredSummary?.partyConfigurations || expectedPartyConfigurations(spec);
|
||||
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: restoredSummary,
|
||||
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 = expectedPartyConfigurations(spec);
|
||||
const job = {
|
||||
id,
|
||||
status: "queued",
|
||||
createdAt: new Date().toISOString(),
|
||||
startedAt: null,
|
||||
completedAt: null,
|
||||
requestedBy: account.username,
|
||||
spec,
|
||||
progress: {
|
||||
party: 0,
|
||||
partiesCompleted: 0,
|
||||
partiesTotal: totalParties,
|
||||
simulationsCompleted: 0,
|
||||
simulationsTotal: 0,
|
||||
},
|
||||
artifacts: [],
|
||||
error: null,
|
||||
logTail: [],
|
||||
summary: null,
|
||||
outputDir,
|
||||
};
|
||||
jobs.set(id, job);
|
||||
persistJob(job);
|
||||
queue.push(job);
|
||||
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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
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",
|
||||
specSelection: "specific",
|
||||
selectedBuildIds: ["balance-wow-priest-healer-holy-level-40", "balance-wow-priest-healer-holy-level-40"],
|
||||
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.equal(spec.specSelection, "specific");
|
||||
assert.deepEqual(spec.selectedBuildIds, ["balance-wow-priest-healer-holy-level-40"]);
|
||||
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/,
|
||||
);
|
||||
assert.throws(
|
||||
() => normalizeBalanceLabJobSpec({ source: "wow", specSelection: "specific", selectedBuildIds: [] }),
|
||||
/Select at least one class\/spec build/,
|
||||
);
|
||||
});
|
||||
|
||||
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"],
|
||||
specSelection: "specific", selectedBuildIds: ["balance-rom-mage-damage-primary-level-60"],
|
||||
});
|
||||
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("specific"), true);
|
||||
assert.equal(command.args.includes("balance-rom-mage-damage-primary-level-60"), 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 });
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
@@ -43,7 +44,7 @@ async function derivePassword(password, salt) {
|
||||
return Buffer.from(await scrypt(password, salt, 64));
|
||||
}
|
||||
|
||||
const GM_USERNAME_KEYS = new Set(["phenom"]);
|
||||
const GM_USERNAME_KEYS = new Set(["phenom", "codex_dev_gm"]);
|
||||
|
||||
function accountRoles(username) {
|
||||
return GM_USERNAME_KEYS.has(String(username ?? "").trim().toLowerCase()) ? ["gm"] : [];
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
@@ -54,12 +56,15 @@ test("persists opaque cloud roster data with increasing revisions", () => withDa
|
||||
assert.equal(database.getCloudSave(registered.account.id).data.characters[0].name, "Mendbetter");
|
||||
}));
|
||||
|
||||
test("derives GM authority from the hardcoded Phenom account", () => withDatabase(async (database) => {
|
||||
test("derives GM authority from the hardcoded developer accounts", () => withDatabase(async (database) => {
|
||||
const gm = await database.register("Phenom", "storm-admin");
|
||||
const developerGm = await database.register("codex_dev_gm", "developer-admin");
|
||||
const player = await database.register("Ordinary", "storm-player");
|
||||
assert.deepEqual(gm.account.roles, ["gm"]);
|
||||
assert.deepEqual((await database.login("pHeNoM", "storm-admin")).account.roles, ["gm"]);
|
||||
assert.deepEqual(developerGm.account.roles, ["gm"]);
|
||||
assert.equal(database.isGameMaster(database.authenticate(gm.token)), true);
|
||||
assert.equal(database.isGameMaster(database.authenticate(developerGm.token)), true);
|
||||
assert.equal(database.isGameMaster(database.authenticate(player.token)), false);
|
||||
assert.equal(database.isGameMaster({ ...player.account, roles: ["gm"] }), false);
|
||||
}));
|
||||
@@ -137,6 +142,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");
|
||||
|
||||
@@ -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 };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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 />}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
import { onlineApiOrigin, requestOnlineJson } from "./onlineAccountClient";
|
||||
|
||||
export type BalanceLabPartySource = "wow" | "coa" | "rom" | "all";
|
||||
export type BalanceLabBossCategory = "all" | "wow" | "rom";
|
||||
export type BalanceLabSpecSelection = "random" | "all" | "specific";
|
||||
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 specSelection?: BalanceLabSpecSelection;
|
||||
readonly selectedBuildIds?: readonly string[];
|
||||
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();
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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"));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,22 +125,23 @@ export function CombatBridge() {
|
||||
let previous = performance.now();
|
||||
let timer: number | null = null;
|
||||
|
||||
const advance = () => {
|
||||
const now = performance.now();
|
||||
const delta = Math.min(0.25, Math.max(0, (now - previous) / 1_000));
|
||||
previous = now;
|
||||
const advanceOneTick = (delta: number, epochNow: number, spectator: boolean) => {
|
||||
const game = useGameStore.getState();
|
||||
const sharedOnline = onlineSessionIsActive();
|
||||
if ((!game.paused || sharedOnline) && (!game.companionOpen || sharedOnline)) {
|
||||
const epochNow = Date.now();
|
||||
const simulation = useDungeonSimulationStore.getState();
|
||||
const combat = useCombatStore.getState();
|
||||
combat.setPlayerPosition(game.playerPosition);
|
||||
combat.tick(delta, epochNow);
|
||||
if (onlineSessionOwnsWorldSimulation() && game.gameMode === "dungeon") {
|
||||
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();
|
||||
@@ -159,16 +164,41 @@ export function CombatBridge() {
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const advance = () => {
|
||||
const now = performance.now();
|
||||
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)) {
|
||||
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);
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,6 +30,7 @@ export function PartyBridge() {
|
||||
&& onlineActivity.type === (gameMode === "manastorm" ? "manastorm" : "dungeon")
|
||||
? onlineGroupAiPartySize(onlineActivity.partySize)
|
||||
: requestedPartySize;
|
||||
if (simulationMode === "play") {
|
||||
party.initializeParty(
|
||||
character.id,
|
||||
combat.level,
|
||||
@@ -35,11 +39,15 @@ export function PartyBridge() {
|
||||
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]);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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]) });
|
||||
}
|
||||
@@ -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 } },
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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,81 @@ 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,
|
||||
knownDamagingAbilityNames: new Set(availableAbilities.filter((ability) => {
|
||||
const categories = aiHintsForAbility(ability).categories;
|
||||
return categories.includes("damage") || categories.includes("execute");
|
||||
}).map((ability) => ability.name)),
|
||||
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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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. `
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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(1050);
|
||||
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, 1050 * 1.9));
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,10 @@ import { deriveCharacterStats } from "./wotlkStats";
|
||||
|
||||
export function companionResourceMaximum(member: PartyMember, id: string): number {
|
||||
return id === member.resourceType ? member.maxResource : id === "mana" && !isRomClassId(member.classId)
|
||||
? deriveCharacterStats(baseClassFor(member.classId), member.level, member.gearStats, member.raceId).mana : 100;
|
||||
? deriveCharacterStats(baseClassFor(member.classId), member.level, member.gearStats, member.raceId).mana
|
||||
: id === "nature-power" ? 10
|
||||
: id === "psi" ? 6
|
||||
: 100;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -49,8 +52,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 +74,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 +111,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,
|
||||
});
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -2,9 +2,12 @@ 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";
|
||||
import { subscribeCombatEvents, type StructuredCombatEvent } from "./combatEvents";
|
||||
|
||||
function registerTarget(id = "target", distance = 2): void {
|
||||
useCombatStore.getState().registerMob(id, { name: id, maxHealth: 1_000, xpReward: 0 });
|
||||
@@ -99,18 +102,85 @@ 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);
|
||||
|
||||
registerTarget("pet-target");
|
||||
useCombatStore.getState().damageMob("pet-target", 1, now + 1_100);
|
||||
const beforePetAttack = useCombatStore.getState().mobs["pet-target"].health;
|
||||
useCombatStore.getState().tick(0.1, now + 2_100);
|
||||
expect(useCombatStore.getState().mobs["pet-target"].health).toBeLessThan(beforePetAttack);
|
||||
});
|
||||
|
||||
it("consumes a named absorb before player health", () => {
|
||||
const now = Date.now();
|
||||
const events: StructuredCombatEvent[] = [];
|
||||
const stop = subscribeCombatEvents((event) => events.push(event));
|
||||
expect(useCombatStore.getState().castAbility(
|
||||
"wow335-priest-power-word-shield",
|
||||
undefined,
|
||||
now,
|
||||
).ok).toBe(true);
|
||||
stop();
|
||||
const beforeHealth = useCombatStore.getState().health;
|
||||
const beforeAbsorb = useCombatStore.getState().auras
|
||||
.flatMap((aura) => aura.absorbs)
|
||||
.reduce((total, absorb) => total + absorb.remaining, 0);
|
||||
expect(beforeAbsorb).toBeGreaterThan(0);
|
||||
expect(events).toContainEqual(expect.objectContaining({
|
||||
kind: "absorb",
|
||||
sourceActorId: PLAYER_AGGRO_ID,
|
||||
targetActorId: PLAYER_AGGRO_ID,
|
||||
abilityId: "wow335-priest-power-word-shield",
|
||||
effectiveAmount: beforeAbsorb,
|
||||
}));
|
||||
expect(useCombatStore.getState().auras.find((aura) => aura.absorbs.length)?.definition.icon).toBe(
|
||||
useCombatStore.getState().abilities.find((ability) => (
|
||||
ability.id === "wow335-priest-power-word-shield"
|
||||
|
||||