Compare commits
25
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a641a6e948 | ||
|
|
7449abdad2 | ||
|
|
0206fda9e2 | ||
|
|
c8b3a3d95a | ||
|
|
8e95480d4f | ||
|
|
f4a7d33f74 | ||
|
|
d3cc27a54a | ||
|
|
189b94d6b7 | ||
|
|
f90ba7d989 | ||
|
|
fdb6739f8d | ||
|
|
f864b5aea7 | ||
|
|
97ee0c13f9 | ||
|
|
3e30af4c98 | ||
|
|
246b4832b0 | ||
|
|
14e105642c | ||
|
|
8b9c95b24f | ||
|
|
daa417ec17 | ||
|
|
cc3b2fa4d5 | ||
|
|
1fedb9368d | ||
|
|
f8314e7de1 | ||
|
|
a05a79eb83 | ||
|
|
38b3d4d160 | ||
|
|
2dbb0acfc1 | ||
|
|
d6976e6ef6 | ||
|
|
d0a02487db |
No files matched your search
@@ -54,6 +54,13 @@ These rules apply to every change under this project root.
|
||||
change is a separate external mutation; do not close a report merely because
|
||||
a local fix passes.
|
||||
|
||||
## Active balance certification scope
|
||||
|
||||
- Automated balance, bracket, seat-sweep, and benchmark validation is Ready-only until the project owner promotes more specializations. The current Ready set is Protection Warrior, Discipline Priest, Marksmanship Hunter, Fire Mage, and Assassination Rogue.
|
||||
- Do not run the full repository test suite or all-class WoW/CoA/RoM balance sweeps for routine balance work. Use `npm run test:ready-five` plus the smallest directly affected focused tests.
|
||||
- CoA specializations and RoM class/role builds are Needs Testing. They may be inspected in an explicit targeted diagnostic, but must not enter random, all-eligible, regression, bracket, or NPC seat-audit runs.
|
||||
- A broader balance run requires an explicit user request or a readiness-policy promotion. Record that expanded scope in the task handoff.
|
||||
|
||||
## Playable characters and equipment
|
||||
|
||||
- `src/avatar/CharacterModel.tsx` is the shared player, party, roster, and character-creation rendering path. Never replace it with a screen-specific loader.
|
||||
|
||||
+18
-10
@@ -131,8 +131,8 @@ services:
|
||||
command: >-
|
||||
sh -lc "apt-get update && apt-get install -y --no-install-recommends python3 && rm -rf /var/lib/apt/lists/* && python3 --version && npm ci --include=dev && npm run db:init && npm run content:publish && npm run build && npm start"
|
||||
environment:
|
||||
BALANCE_LAB_MAX_CONCURRENT: "1"
|
||||
BALANCE_LAB_MAX_QUEUED: "8"
|
||||
BALANCE_LAB_MAX_CONCURRENT: "4"
|
||||
BALANCE_LAB_MAX_QUEUED: "100"
|
||||
BALANCE_LAB_PYTHON: "python3"
|
||||
CORS_ORIGINS: "https://iwanttoheal.phenomrom.com,capacitor://localhost,http://localhost,https://localhost"
|
||||
CONTENT_DIR: /app/content
|
||||
@@ -184,8 +184,8 @@ and add or correct these environment values:
|
||||
|
||||
```yaml
|
||||
BALANCE_LAB_PYTHON: "python3"
|
||||
BALANCE_LAB_MAX_CONCURRENT: "1"
|
||||
BALANCE_LAB_MAX_QUEUED: "8"
|
||||
BALANCE_LAB_MAX_CONCURRENT: "4"
|
||||
BALANCE_LAB_MAX_QUEUED: "100"
|
||||
```
|
||||
|
||||
The app stores job reports in
|
||||
@@ -199,6 +199,13 @@ job returns as failed after restart, while any job with a complete
|
||||
unless the server has enough spare CPU for multiple full combat sweeps. All
|
||||
Balance Lab routes independently require a server-authorized GM account.
|
||||
|
||||
Balance Lab's one-shot `vite-node` workers run in the dedicated `balance-lab`
|
||||
Vite mode, which disables the public directory and ignores all filesystem-watch
|
||||
paths. This is required for parallel sweeps: without it, concurrent workers can
|
||||
exhaust Linux's file-watcher limit while scanning the shipped asset tree and
|
||||
fail immediately with `ENOSPC`. Do not use a larger inotify allowance as the
|
||||
primary fix; deploy the current runtime configuration.
|
||||
|
||||
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
|
||||
@@ -224,12 +231,13 @@ schema-compatible with Healer Man and must not be copied over the new one.
|
||||
|
||||
## Update workflow
|
||||
|
||||
For a code update that includes a new APK, the visual workflow in
|
||||
`RELEASE_RUNBOOK.md` is recommended. For a manual release, choose the next
|
||||
version first. The example below upgrades `0.1.2` to `0.1.3`; replace `1003` if
|
||||
it is not greater than every prior Android `versionCode`. Then run these
|
||||
commands in **Windows PowerShell**
|
||||
on the development PC. Stop if any test or build fails:
|
||||
The visual release manager described in `RELEASE_RUNBOOK.md` defaults to **Git
|
||||
+ TrueNAS only**, which commits and pushes a server update without changing the
|
||||
Android version or publishing an APK. Clear that toggle when a code update also
|
||||
requires a new APK. For a manual APK release, choose the next version first.
|
||||
The example below upgrades `0.1.2` to `0.1.3`; replace `1003` if it is not
|
||||
greater than every prior Android `versionCode`. Then run these commands in
|
||||
**Windows PowerShell** on the development PC. Stop if any test or build fails:
|
||||
|
||||
```powershell
|
||||
Set-Location F:\Projects\HealerMan
|
||||
|
||||
@@ -3581,3 +3581,242 @@ Vite's existing large-chunk advisory remains informational.
|
||||
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.
|
||||
|
||||
## Balance Lab level batches and parallel workers (2026-09-19)
|
||||
|
||||
- The GM setup now switches between one level and all level brackets. The three
|
||||
schedules expose 9 jobs (level 1 plus every 10), 17 jobs (level 1 plus every
|
||||
5), or 80 jobs (every level). Every-boss, source-specific boss, and explicit
|
||||
boss selection remain available, and the estimate totals all scheduled jobs.
|
||||
- A browser-driven nine-level exploratory submission created nine persisted
|
||||
jobs in one request, started four independent Python/TypeScript processes,
|
||||
and completed the first four level reports before the automation time limit;
|
||||
the test server then stopped the remaining isolated test jobs. Server tests
|
||||
separately prove parallel worker utilization, queued overflow, and all-or-
|
||||
nothing rejection when a complete batch will not fit.
|
||||
- The final production-build smoke pass verified the 9/17/80 schedule controls,
|
||||
worker/queue status, specific Paladin Holy and Eck selection, one complete
|
||||
real level-20 report, all report/ability analytics, and 480x900 layout with no
|
||||
browser errors. Evidence is in
|
||||
`playtest-artifacts/balance-lab/02-level-batch-config.png`,
|
||||
`playtest-artifacts/balance-lab/04-server-job-complete.png`, and
|
||||
`playtest-artifacts/balance-lab/06-mobile-analytics.png`.
|
||||
|
||||
## Balance Lab parallel watcher isolation (2026-09-19)
|
||||
|
||||
- A production all-WoW, every-10-level Stockades batch completed only the level
|
||||
1 and level 80 jobs. Levels 10 through 70 failed during startup before a
|
||||
party ran. Every failed job recorded `ENOSPC: System limit for number of file
|
||||
watchers reached` beneath Vite's watcher stack, while the Balance Lab runtime
|
||||
reported zero active jobs and normal queue capacity after the failures.
|
||||
- Each parallel Python worker had launched its own `vite-node` process. The
|
||||
inherited Vite development configuration watched the large shipped public
|
||||
asset tree, so ten simultaneous jobs exhausted the container's Linux watcher
|
||||
allowance. Which two levels completed was a startup resource race, not a
|
||||
class, level, boss, or combat-model failure.
|
||||
- The headless bridge now launches with `--mode balance-lab`. That Vite mode
|
||||
sets `publicDir: false` and ignores every watch path, eliminating hot-reload
|
||||
watchers that one-shot simulations do not use. Server job failures also
|
||||
translate the known watcher-limit signature into a specific GM-facing error.
|
||||
- The Python controller tests, all 14 Balance Lab service tests, the complete
|
||||
84-test server suite, and the 62-test benchmark/party suite passed. A resolved
|
||||
Vite configuration check returned an empty public directory and
|
||||
`watch.ignored: ["**/*"]`; a real level-10 WoW catalog invocation completed
|
||||
through the new mode and wrote its 72,723-byte catalog. The production build
|
||||
passed, and the authenticated GM smoke flow completed a real server job,
|
||||
loaded its analytics and ability breakdown, and passed desktop/mobile checks.
|
||||
|
||||
## Default Git and TrueNAS release path (2026-09-19)
|
||||
|
||||
- The visual release manager now starts with **Git + TrueNAS only** enabled.
|
||||
This mode retains the candidate check, content/server/application tests,
|
||||
content publication, production build, staged-file review, commit,
|
||||
remote-main verification, and pinned TrueNAS deployment commands.
|
||||
- The default path does not bump package or Android versions, request signing
|
||||
credentials, run Android sync, build an APK, or publish a Gitea release.
|
||||
Clearing the toggle restores the existing full APK workflow; **Publish
|
||||
Current APK Only** remains available for retrying publication from an already
|
||||
versioned and pushed commit.
|
||||
- **Run test suites** is independently enabled by default. Clearing it skips
|
||||
only the content, server, normal-application, and shelved-application test
|
||||
commands. The candidate check, content publication, production build,
|
||||
staged review, Git verification, and selected deployment/APK stages remain.
|
||||
Both the log and final staged review identify the skipped suites.
|
||||
- All 12 release-manager unit tests and Python compilation passed. The real
|
||||
Windows Tk smoke launch also initialized the default-on mode and closed
|
||||
cleanly.
|
||||
|
||||
## Thor character shell redesign (2026-09-20)
|
||||
|
||||
- The `960 x 540` top-display roster now keeps its character stage on the left,
|
||||
a 300-pixel saved-character rail on the right, and the primary Continue action
|
||||
inside a 62-pixel bottom bar. Both creator steps retain the shared live model
|
||||
preview and fit without document overflow.
|
||||
- All 47 avatar/rendering tests and the full production build passed. Browser
|
||||
automation created a Human Priest, exercised both creator steps, and verified
|
||||
the roster geometry without page errors. Evidence is in
|
||||
`playtest-artifacts/character-shell/01-creator-path.png`,
|
||||
`playtest-artifacts/character-shell/02-creator-appearance.png`, and
|
||||
`playtest-artifacts/character-shell/03-character-select.png`.
|
||||
- A production Wailing Caverns smoke run rendered the player and all four party
|
||||
members with intact bodies and starter weapon silhouettes. Evidence is in
|
||||
`playtest-artifacts/character-shell/04-in-dungeon.png`.
|
||||
|
||||
## Thor main-menu redesign (2026-09-20)
|
||||
|
||||
- The `960 x 540` top display now gives the destination grid the wide left pane
|
||||
and uses a 288-pixel right rail for the selected character's level, name,
|
||||
class, and specialization. The character-model preview no longer mounts on
|
||||
this screen; authenticated sessions open the online-group invite panel below
|
||||
the identity card.
|
||||
- A live browser pass measured a full `960 x 540` surface with no horizontal or
|
||||
document overflow. The destination pane scrolls independently, the identity
|
||||
card remains fully visible, no character-preview node mounts, and all removed
|
||||
introductory copy is absent. Nineteen focused tests and the production build
|
||||
passed.
|
||||
- The destinations are now split into a dedicated Adventures card for Dungeons
|
||||
and Randomized Bite-Sized plus a secondary Character & Collection card. Boss
|
||||
testing, companion readiness, reports, and the authorized GM/server tools now
|
||||
live behind one controller-navigable GM Menu dialog; unauthorized accounts
|
||||
see GM Tools and Balance Lab as locked rather than losing the menu structure.
|
||||
|
||||
## Discipline Priest Holy Nova (2026-09-22)
|
||||
|
||||
- A level-80 Priest cast Holy Nova in a built Wailing Caverns scene. The live
|
||||
cast restored 770 health to the injured player. At 400 ms, one Nova burst was
|
||||
visible; by 850 ms, it had ended. Screenshots are in
|
||||
`playtest-artifacts/holy-nova/holy-nova-100ms.png`,
|
||||
`playtest-artifacts/holy-nova/holy-nova-400ms.png`, and
|
||||
`playtest-artifacts/holy-nova/holy-nova-850ms.png`.
|
||||
- The nearby-enemy damage and nearby-ally healing paths passed the focused
|
||||
combat-store test. The four focused suites, Ready-five certification,
|
||||
TypeScript build, production build, and release candidate check passed.
|
||||
|
||||
## RoM primary five validation (2026-09-22)
|
||||
|
||||
- A fresh local account created a Human RoM Rogue. Character creation showed
|
||||
level-based skill unlocks. The roster preview rendered the complete character
|
||||
body with a starter weapon silhouette after the 3D canvas loaded.
|
||||
- The same Rogue entered a live Wailing Caverns scene as DPS. The player and
|
||||
companions rendered bodies and equipped weapon or staff silhouettes; the
|
||||
browser reported no page errors. The left-hand Rogue dagger is not clear in
|
||||
these camera angles, so this is a body/equipment-presence check rather than
|
||||
visual proof of dual daggers. The resolved virtual loadout test checks both
|
||||
dagger slots.
|
||||
- Evidence: `artifacts/playtest/rom-five/roster-preview.png` and
|
||||
`artifacts/playtest/rom-five/dungeon-rogue.png`. The in-dungeon Skills and
|
||||
Spellbook panels also showed level unlocks without numbered skill ranks;
|
||||
evidence is `skills-no-ranks.png` and `spellbook-no-ranks.png` in that same
|
||||
directory. The repeatable flow is `node scripts/playtest-rom-five.cjs`.
|
||||
- At 1440 x 900 the dual-display "Roster details" toggle overlays the roster
|
||||
Continue button. The playtest used a programmatic click to continue; this
|
||||
existing layout collision should be addressed in the shell UI.
|
||||
|
||||
## Named RoM class pairs (2026-09-23)
|
||||
|
||||
- A fresh local account created a male RoM Dwarf Mage. Its live creation and
|
||||
roster previews rendered the complete body and starter staff silhouette.
|
||||
Evidence: `artifacts/playtest/rom-five/dwarf-mage-creator.png` and
|
||||
`artifacts/playtest/rom-five/dwarf-mage-roster.png`.
|
||||
- The same session entered Wailing Caverns with a RoM Rogue. The party frames
|
||||
showed paired-cohort NPC names Serenna, Caelan, and Veyra for that seed; the
|
||||
player and companions rendered bodies and weapon or staff silhouettes in the
|
||||
scene. The browser reported no page errors. Evidence:
|
||||
`artifacts/playtest/rom-five/dungeon-rogue.png`. The companion pair and race
|
||||
assignments, including Durnik as a dwarf, are covered by
|
||||
`src/game/romFiveValidation.test.ts`; this level-1 scene does not exercise
|
||||
the level-10 secondary-class unlock or elite-skill mechanics.
|
||||
|
||||
## RoM leveling loot and package catalog (2026-09-23)
|
||||
|
||||
- Rebuilt the client and reran `node scripts/playtest-rom-five.cjs` after the
|
||||
leveling boss-loot rule and source package catalog changes. The live Rogue
|
||||
roster preview showed the complete body and starter blade silhouette;
|
||||
the in-dungeon scene showed the player and companion bodies with weapon,
|
||||
shield, and staff silhouettes. No browser page errors occurred. Evidence:
|
||||
`artifacts/playtest/rom-five/roster-preview.png` and
|
||||
`artifacts/playtest/rom-five/dungeon-rogue.png`.
|
||||
- This level-1 scene checks rendering. Focused boss-card, SQL loot, RuneWaker
|
||||
loot, and RoM class tests check that leveling boss item drops are empty,
|
||||
cards remain granted, and secondary stat growth uses the primary level.
|
||||
Source package contents are cataloged as unverified and were not granted.
|
||||
|
||||
## RoM heirloom reference atlas (2026-09-23)
|
||||
|
||||
- Rebuilt the client and reran `node scripts/playtest-rom-five.cjs` after adding
|
||||
the source-backed 1–79 native-stat references. The live Rogue roster showed
|
||||
its body and starter blade; the Dwarf Mage roster showed its body and staff.
|
||||
Wailing Caverns showed the Rogue player and companion bodies with equipped
|
||||
blade, shield, and staff silhouettes. No browser page errors occurred.
|
||||
Evidence: `artifacts/playtest/rom-five/roster-preview.png`,
|
||||
`dwarf-mage-roster.png`, and `dungeon-rogue.png` in the same directory.
|
||||
- These references are not equipped by the runtime, so this level-1 visual check
|
||||
confirms the existing character path still renders. The reference suite
|
||||
verifies the five class curves and source IDs at every level 1–79.
|
||||
|
||||
## Five-class RoM hybrid equipment (2026-09-23)
|
||||
|
||||
- Rebuilt and reran `node scripts/playtest-rom-five.cjs` after changing the five
|
||||
active heirloom layouts to legal RoM weapon combinations with WoW combat
|
||||
stats. The Rogue and Dwarf Mage roster previews showed complete bodies and
|
||||
starter blade/staff silhouettes. Wailing Caverns showed the player and
|
||||
companion bodies with visible blade, mace, shield, and staff silhouettes;
|
||||
there were no browser page errors. Evidence: `artifacts/playtest/rom-five/`
|
||||
`roster-preview.png`, `dwarf-mage-roster.png`, and `dungeon-rogue.png`.
|
||||
- At level 1, the screenshot does not confirm each member's full inventory or
|
||||
the level-10 secondary unlock. `src/game/romFiveValidation.test.ts` checks
|
||||
every hybrid weapon slot for legality and stat growth at levels 1, 20, 40,
|
||||
60, and 79. Targeted Shadoj reports at levels 20, 60, and 79 are under
|
||||
`artifacts/balance-lab/rom-hybrid-l*-shadoj/`; they exposed low Mage damage
|
||||
and a level-79 Priest death in each seed, so balance remains Needs Testing.
|
||||
|
||||
## RoM independent weapon slots (2026-09-23)
|
||||
|
||||
- Rebuilt and ran `node scripts/playtest-rom-five.cjs` after adding the trained
|
||||
off-hand and ranged heirlooms. The Rogue roster preview and Wailing Caverns
|
||||
scene showed a complete character body and starter blade; companion bodies,
|
||||
shields, and weapons remained visible. The Dwarf Mage roster still showed its
|
||||
body and two-hand staff. The playtest reported no browser page errors.
|
||||
Evidence: `artifacts/playtest/rom-five/roster-preview.png`,
|
||||
`dungeon-rogue.png`, and `dwarf-mage-roster.png` in the same directory.
|
||||
- These level-1 screenshots do not show the later training unlocks. Focused
|
||||
equipment tests verify the Knight/Scout bow at level 10, Rogue projectile at
|
||||
level 12, and Scout/Rogue and Rogue off-hand weapons at level 16.
|
||||
@@ -37,12 +37,12 @@ backup, and Gitea APK release procedures are documented in
|
||||
Verification:
|
||||
|
||||
```powershell
|
||||
npm test
|
||||
npm run test:ready-five
|
||||
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
|
||||
multi-party 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
|
||||
@@ -51,6 +51,17 @@ 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.
|
||||
|
||||
Automatic balance certification is Ready-only: Protection Warrior, Discipline
|
||||
Priest, Marksmanship Hunter, Fire Mage, and Assassination Rogue. Every other
|
||||
WoW spec plus every CoA and RoM entry is Needs Testing and excluded from broad
|
||||
bracket, regression, and NPC seat-audit runs.
|
||||
|
||||
Authenticated GMs can open **Ability & Talent Tuning** from the main-menu GM
|
||||
Menu. The local sandbox supplies searchable ability and talent catalogs plus
|
||||
per-device runtime overrides for cooldowns, cast times, costs, damage, healing,
|
||||
durations, area reach, and supported passive talent modifiers. See
|
||||
[`docs/BALANCE_TUNING.md`](docs/BALANCE_TUNING.md).
|
||||
|
||||
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
|
||||
|
||||
+30
-8
@@ -11,12 +11,34 @@ On the development PC, double-click `scripts\release-manager.cmd` or run:
|
||||
npm run release:gui
|
||||
```
|
||||
|
||||
The release manager suggests the next semantic patch version and a strictly
|
||||
increasing Android `versionCode`, runs the complete validation suite, and
|
||||
stages the release. It then shows the exact staged paths and diff summary for
|
||||
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.
|
||||
The release manager opens with **Git + TrueNAS only** enabled. In this default
|
||||
mode it runs the server validation and production build, shows the exact staged
|
||||
paths and diff summary for approval, commits, pushes `main`, and pins the
|
||||
TrueNAS **Deploy** tab to the new commit. It does not change `package.json` or
|
||||
`release-version.json`, run Android sync, read signing credentials, build an
|
||||
APK, or create a Gitea APK release.
|
||||
|
||||
Clear **Git + TrueNAS only** when the update also needs a new Android release.
|
||||
APK mode suggests the next semantic version and a strictly increasing Android
|
||||
`versionCode`, then performs Android sync and publishes the signed APK after the
|
||||
Git push. Signing passwords and the Gitea token are passed to the child process
|
||||
in memory rather than on its command line.
|
||||
|
||||
**Run test suites** is also enabled by default. Clear it for a narrowly scoped
|
||||
update that does not need the content, server, normal-application, or
|
||||
shelved-application test suites. The manager records the skip in its log and
|
||||
staged-review dialog. This toggle does not skip the isolated release-candidate
|
||||
check, content publication, production build, staged whitespace/secret review,
|
||||
Git push verification, or any Android stages selected by APK mode.
|
||||
|
||||
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. Android sync, signing, and APK
|
||||
publication are omitted entirely in Git + TrueNAS mode. 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
|
||||
@@ -653,8 +675,8 @@ services:
|
||||
command: >-
|
||||
sh -lc "apt-get update && apt-get install -y --no-install-recommends python3 && rm -rf /var/lib/apt/lists/* && python3 --version && npm ci --include=dev && npm run db:init && npm run content:publish && npm run build && npm start"
|
||||
environment:
|
||||
BALANCE_LAB_MAX_CONCURRENT: "1"
|
||||
BALANCE_LAB_MAX_QUEUED: "8"
|
||||
BALANCE_LAB_MAX_CONCURRENT: "4"
|
||||
BALANCE_LAB_MAX_QUEUED: "100"
|
||||
BALANCE_LAB_PYTHON: "python3"
|
||||
CORS_ORIGINS: "https://iwanttoheal.phenomrom.com,capacitor://localhost,http://localhost,https://localhost"
|
||||
CONTENT_DIR: /app/content
|
||||
|
||||
+304
-45
@@ -23,10 +23,11 @@ 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;
|
||||
2. a WoW, CoA, or RoM party catalog; automatic mode only uses entries marked
|
||||
Ready (plus Beta when explicitly enabled);
|
||||
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;
|
||||
4. random Ready parties, guaranteed coverage of all Ready class/spec builds,
|
||||
or a searchable targeted selection that may include Needs Testing 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;
|
||||
@@ -35,11 +36,14 @@ The prompt selects:
|
||||
|
||||
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.
|
||||
- **Random parties** preserves the original seeded behavior and generates up
|
||||
to the requested number of unique parties per source. If a Ready/Beta pool
|
||||
can form fewer unique 1/1/3 rosters, Balance Lab runs each available roster
|
||||
once and reports the cap instead of failing or duplicating identical data.
|
||||
- **All Ready specs** creates the minimum deterministic set of valid parties
|
||||
needed to include every Ready build for the selected source at least once.
|
||||
At present this is the five certified WoW specs; CoA and RoM have no Ready
|
||||
entries and therefore do not participate in automatic runs.
|
||||
- **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.
|
||||
@@ -50,21 +54,49 @@ 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.
|
||||
`--active-slot` to change it. Use `--seat-sweep` to run the same composition
|
||||
five times with each seat active exactly once. Seat sweeps produce dedicated
|
||||
`npc_class_specs_across_parties.csv` and
|
||||
`npc_class_abilities_across_parties.csv` artifacts, so companion comparisons
|
||||
exclude the active benchmark controller.
|
||||
|
||||
Classic specializations expose two named, non-destructive talent profiles: the
|
||||
default PvE build translated from the pinned AzerothCore Playerbots
|
||||
configuration at commit `531282e`, and the prior Healer Man executable-first
|
||||
build. Each profile has a stable generated character name and unique build ID.
|
||||
Adding another profile therefore does not overwrite either existing build.
|
||||
Random parties and unselected filler seats use the declared default profile;
|
||||
named alternatives remain available to specific selection and full coverage.
|
||||
Run `npm run talents:generate` after intentionally changing the Playerbots
|
||||
mapping or source; the checked-in snapshot keeps production independent from
|
||||
the historical checkout. Runtime selection mirrors PlayerbotFactory: it starts
|
||||
with the closest configured link at or below the tested level (or the first
|
||||
future link at low levels), then consumes later links if talent points remain;
|
||||
level 80 starts from the level-80 link. Profile metadata records the applicable
|
||||
source point count, ranks retained after mapping to the imported Healer Man
|
||||
talent catalog, and legal fallback points at the tested level. This makes
|
||||
patched-tree mismatch visible instead of presenting the translation as a
|
||||
literal Blizzard build.
|
||||
|
||||
## 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.
|
||||
a single level or an all-brackets schedule, class/spec selection strategy, a
|
||||
searchable spec picker, simulations per party/boss, duration, random seed, boss
|
||||
category, and a searchable multi-boss picker. The bracket schedules cover level
|
||||
1 plus every 10 levels, level 1 plus every 5 levels, or every level from 1
|
||||
through 80. Random mode exposes the requested parties per source; coverage
|
||||
modes show the derived minimum party count before submission.
|
||||
The optional **NPC seat audit** multiplies each generated composition across all
|
||||
five active-seat assignments and makes in-app class/ability analysis prefer the
|
||||
NPC-only report tables.
|
||||
|
||||
**Run on server** sends only that allow-listed configuration to the authenticated
|
||||
GM API. The server places it in a one-at-a-time queue, launches the same Python
|
||||
controller and live TypeScript runtime used by the command line, and writes
|
||||
artifacts under:
|
||||
**Run on server** sends one allow-listed configuration to the authenticated GM
|
||||
API. **Run all level brackets** submits the complete schedule atomically and
|
||||
creates one independently persisted job and report per level. The bounded worker
|
||||
pool runs several jobs at the same time, launches the same Python controller and
|
||||
live TypeScript runtime used by the command line, and writes artifacts under:
|
||||
|
||||
```text
|
||||
DATA_DIR/balance-lab/jobs/<job-id>/
|
||||
@@ -106,7 +138,8 @@ The dashboard includes:
|
||||
defeat/wipe rates, deaths, and health;
|
||||
- complete class/spec tables with total and boss DPS, healing done/received per
|
||||
second, DTPS, absolute damage taken, healing done/received and absorb totals,
|
||||
deaths, and source/role-median tuning flags; and
|
||||
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,
|
||||
@@ -118,7 +151,10 @@ 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.
|
||||
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:
|
||||
|
||||
@@ -135,16 +171,169 @@ command installs `python3`, prints `python3 --version` during startup, and sets
|
||||
`BALANCE_LAB_PYTHON=python3`; use the exact YAML in `DEPLOYMENT.md` rather than
|
||||
the older Node-only command.
|
||||
|
||||
`BALANCE_LAB_MAX_CONCURRENT` defaults to `1` and is capped at four.
|
||||
`BALANCE_LAB_MAX_QUEUED` defaults to eight and is capped at 50. Raising
|
||||
concurrency permits parallel server processes but can make both the game server
|
||||
and simulations less responsive. Requests are capped before launch, subprocess
|
||||
`BALANCE_LAB_MAX_CONCURRENT` defaults to four and is capped at 16.
|
||||
`BALANCE_LAB_MAX_QUEUED` defaults to 100 and is capped at 200, which leaves room
|
||||
for an 80-job every-level sweep. Raising concurrency permits more parallel
|
||||
server processes but can make both the game server and simulations less
|
||||
responsive. A batch contains at most 80 jobs and is validated against the
|
||||
remaining queue capacity before any of its jobs are created. Subprocess
|
||||
arguments are passed as an array without a shell, artifact names are
|
||||
allow-listed, and every job and artifact endpoint authenticates the account's
|
||||
server-issued GM role.
|
||||
|
||||
Each Python worker starts the TypeScript bridge with `vite-node --mode
|
||||
balance-lab`. That dedicated mode disables Vite's public directory and ignores
|
||||
all filesystem-watch paths because a headless, one-shot simulation does not
|
||||
need hot reload. Keep this isolation in place when changing `vite.config.ts`:
|
||||
parallel workers must not consume one watcher per shipped asset. If an older
|
||||
deployment exhausts the host watcher limit, the job now reports that specific
|
||||
startup failure instead of only the subprocess exit code; deploy the no-watch
|
||||
runtime fix rather than increasing the host's watcher allowance.
|
||||
|
||||
## Local graphical runs
|
||||
|
||||
On Windows, double-click `scripts/Launch Balance Lab GUI.cmd`. The launcher
|
||||
starts the local Balance Lab service, opens the existing Balance Lab screen in
|
||||
the default browser, and keeps the worker service alive in its command window.
|
||||
Leave that window open until queued simulations finish. The equivalent terminal
|
||||
command is:
|
||||
|
||||
```powershell
|
||||
npm run balance:gui
|
||||
```
|
||||
|
||||
This development-only mode binds Vite to `127.0.0.1`, accepts API requests only
|
||||
from the loopback interface, and does not require an online account or GM login.
|
||||
It uses the same Python controller, TypeScript combat runtime, configuration
|
||||
form, progress UI, report dashboard, and artifact downloads as the server GM
|
||||
tool. Job history and reports persist beneath
|
||||
`artifacts/balance-lab-gui-runtime`; closing the browser does not stop work, but
|
||||
closing the launcher does. Production and ordinary development modes do not
|
||||
expose these unauthenticated local routes.
|
||||
|
||||
Use **All brackets** in the GUI for every-10-level, every-5-level, or every-level
|
||||
runs. Use **Run locally** for one level. The runtime status at the top confirms
|
||||
that Python is available before a run is submitted.
|
||||
|
||||
## Repeatable command-line runs
|
||||
|
||||
Run the same every-10-level schedule used by the GM screen entirely on the
|
||||
local development machine:
|
||||
|
||||
```powershell
|
||||
npm run balance:lab:brackets
|
||||
```
|
||||
|
||||
The bracket runner launches the existing Python controller and live TypeScript
|
||||
runtime; it does not contain a second combat implementation. By default it
|
||||
tests every WoW specialization at levels 1, 10, 20, and so on through 80, uses
|
||||
three seeds, allows 80 seconds per fight, and runs up to four level processes
|
||||
concurrently. Normal Balance Lab options can narrow the run:
|
||||
|
||||
```powershell
|
||||
npm run balance:lab:brackets -- `
|
||||
--levels 40,50,60,70,80 `
|
||||
--simulations-per-party 5 `
|
||||
--boss "stormwind-stockade/entry-1717" `
|
||||
--boss "stormwind-stockade/entry-1663"
|
||||
```
|
||||
|
||||
Use `--step 5` or `--step 1` for the other GM schedules, `--jobs 1` when the
|
||||
machine should remain responsive, and `--output-dir <path>` for a stable
|
||||
comparison location. `npm run balance:lab:regression -- <options>` runs the
|
||||
same suite but returns a failing exit code when configured pressure or
|
||||
durability limits are exceeded. The default checks flag boss durability below
|
||||
20 or above 70 boss-damage seconds, bracket-to-bracket boss-health growth more
|
||||
than 1.5 times party boss-DPS growth once the preceding bracket reaches that
|
||||
minimum window, missing incoming damage, and local raw runs where healers are
|
||||
not required often enough or answer fewer than 60% of five-second pressure
|
||||
windows.
|
||||
|
||||
The bracket root contains `bracket-summary.json`, `.csv`, and `.md`. These show
|
||||
boss health beside direct boss DPS, incoming DTPS, effective healer HPS,
|
||||
mitigation, healing coverage, five-second pressure windows answered by healer
|
||||
throughput, outcomes, and growth relative to the previous tested level.
|
||||
`heirloom-summary.json` and `.csv` record the resolved item level,
|
||||
slot count, attributes, ratings, armor, and weapon DPS for every tested build.
|
||||
Existing reports can be re-analyzed without rerunning combat:
|
||||
|
||||
```powershell
|
||||
python scripts/balance_lab_brackets.py --analyze-existing artifacts/balance-lab-brackets/<run>
|
||||
```
|
||||
|
||||
Balance Lab builds use the `virtual-heirloom` preset. Empty persisted inventory
|
||||
and equipment assignments mean that no earned drops override the virtual set;
|
||||
they do not mean that the character is ungeared. Virtual heirlooms fill every
|
||||
compatible slot with specialization-specific synthetic stats, scale through
|
||||
level 79, and remain at item level 79 for every party member at level 80. They
|
||||
are not literal Blizzard heirloom item templates: their custom stat budget is
|
||||
normalized slightly above the best compatible same-level item in the authored
|
||||
WotLK-derived loot catalog. The saved catalog and heirloom summary make that
|
||||
resolved loadout explicit for each local run.
|
||||
|
||||
Damage and healer heirloom profiles include both critical-strike rating and
|
||||
haste rating. The WotLK stat resolver converts those ratings at the simulated
|
||||
character level into the appropriate melee, ranged, or spell percentages.
|
||||
Protection tank heirlooms intentionally spend that budget on defense,
|
||||
avoidance, block, stamina, and expertise instead.
|
||||
|
||||
Run selected brackets as a companion-only seat audit:
|
||||
|
||||
```powershell
|
||||
npm run balance:lab:npc-audit -- --levels 20,40,60,80 --simulations-per-party 1
|
||||
```
|
||||
|
||||
NPC raw rows include the named talent build and character, selected rotation
|
||||
profile/rule/reason counts, profile priority for every available ability,
|
||||
missing profile actions, GCD uptime, and sampled resource starvation/capping.
|
||||
Use these diagnostics before changing class coefficients.
|
||||
|
||||
## Companion cooldown policy
|
||||
|
||||
GM Tools includes a revisioned **Companion cooldown policy** editor. Generic
|
||||
rules control normal defensive, emergency defensive, healer mitigation, and
|
||||
major offensive cooldown thresholds. Mechanic rules bind an authored boss
|
||||
attack to tank-only or party-wide pre-emptive mitigation with configurable lead
|
||||
time and minimum defensive cooldown length.
|
||||
|
||||
Clients load the published policy from `/api/companion-cooldown-policy`; GM
|
||||
reads/writes use `/api/gm/companion-cooldown-policy`. Server Balance Lab jobs
|
||||
snapshot the published policy before launch. Local runs use built-in defaults
|
||||
unless `--cooldown-policy` points to a JSON policy file.
|
||||
|
||||
## Companion specialization readiness
|
||||
|
||||
The readiness catalog shows every WoW and CoA specialization plus each RoM
|
||||
class/role profile as Ready, Beta, or Needs Testing. The revisioned GM policy
|
||||
currently publishes the 31 WoW specialization statuses; all CoA and RoM entries
|
||||
are fixed at Needs Testing until their certification work begins. Shipped defaults certify only Protection
|
||||
Warrior/Aldren, Discipline Priest/Quorin, Marksmanship Hunter/Liora, Fire
|
||||
Mage/Xavian, and Assassination Rogue/Orin. The main menu, Dungeon setup, and
|
||||
Balance Lab link to the full Companion Readiness catalog. Players can inspect
|
||||
it; authenticated GMs can edit and atomically publish the complete policy.
|
||||
Published policies must retain at least one Ready tank, one Ready healer, and
|
||||
three distinct Ready damage specializations.
|
||||
|
||||
Random and all-eligible runs use Ready specs by default across every source.
|
||||
Because no CoA or RoM entry is currently Ready, those catalogs contribute no
|
||||
automatic parties. **Include Beta Specs** adds Beta specs and is saved per
|
||||
device; explicit targeted diagnostics may still select a Needs Testing spec,
|
||||
but `--ready-only` rejects those targets for certification and regression runs.
|
||||
Filler seats always come from the eligible Ready/Beta pool.
|
||||
Server jobs snapshot the published policy and beta choice. Local GUI runs use
|
||||
the revisioned loopback policy saved under
|
||||
`artifacts/balance-lab-gui-runtime`; corrupt or missing policy files fall back
|
||||
to shipped defaults.
|
||||
|
||||
CLI parity is available through `--ready-only`, `--include-beta-specs`, and
|
||||
`--companion-readiness-policy <json>`. The JSON may be the published envelope
|
||||
or its policy config.
|
||||
|
||||
The standard bracket, regression, and NPC-audit npm commands pass
|
||||
`--ready-only`. For focused code validation use `npm run test:ready-five`; do
|
||||
not use a full-repository or all-class balance sweep unless the project owner
|
||||
explicitly expands the certification scope.
|
||||
|
||||
List exact party build IDs at a level:
|
||||
|
||||
```powershell
|
||||
@@ -176,7 +365,8 @@ python scripts/balance_lab.py `
|
||||
--boss-category all
|
||||
```
|
||||
|
||||
Generate ten parties for each of WoW, CoA, and RoM, but test only RoM bosses:
|
||||
Run every currently Ready source against only RoM bosses. Until a CoA or RoM
|
||||
build is promoted, `--source all` still produces only the five Ready WoW specs:
|
||||
|
||||
```powershell
|
||||
python scripts/balance_lab.py `
|
||||
@@ -256,6 +446,8 @@ under `parties/<party-id>/` and adds these suite-level files:
|
||||
| `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. |
|
||||
| `npc_class_specs_across_parties.csv` | NPC-only class/spec results, excluding each run's active benchmark-controlled seat. |
|
||||
| `npc_class_abilities_across_parties.csv` | NPC-only ability, rotation-priority, decision-count, and missing-profile-action results. |
|
||||
| `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. |
|
||||
@@ -278,18 +470,26 @@ under `parties/<party-id>/` and adds these suite-level files:
|
||||
`totalHealing` and `totalHealingReceived` are the corresponding absolute
|
||||
values across the included runs. Reports created before target attribution
|
||||
was added remain readable but require a rerun to populate healing received.
|
||||
- Ability `activeRuns` counts simulations with at least one cast, hit, damage,
|
||||
healing, overheal, or absorb event. `usageRate` divides that by every run for
|
||||
that build; `damageShare` divides the ability's effective damage by the
|
||||
class/spec's total effective damage. `available · unused` means the ability
|
||||
was learned and executable at the tested level/talent setup but emitted no
|
||||
combat event. This is a review signal, not proof of an AI defect: resurrection,
|
||||
- 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.
|
||||
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
|
||||
@@ -304,13 +504,24 @@ 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.
|
||||
|
||||
Shared curves remain the baseline, but confirmed encounter outliers belong in
|
||||
`src/game/bossBalanceOverrides.ts`. Each source-controlled entry identifies one
|
||||
dungeon boss and may apply at one exact level, across a bounded level bracket,
|
||||
or at every level. Health and damage multipliers are independent and compose;
|
||||
isolated `boss-testing:` rooms resolve back to their source dungeon so the same
|
||||
exception is exercised by Balance Lab and live dungeon runs. Record the audit
|
||||
evidence in the entry reason and prefer this layer over another global curve
|
||||
change when the problem is confined to particular encounters.
|
||||
|
||||
## 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.
|
||||
every supported RoM class-role pair, but automatic certification is Ready-only.
|
||||
Needs Testing entries remain selectable solely for an intentional targeted
|
||||
diagnostic. 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
|
||||
@@ -325,6 +536,47 @@ 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-v6-reactive-ready-five` 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 and Victory Rush require an explicitly modeled trigger, while
|
||||
Protection companions derive Revenge's five-second trigger from incoming
|
||||
dodge, parry, and block combat events; 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. Untargeted cooldown
|
||||
resets exclude the source ability, Discipline mitigation/absorbs precede direct
|
||||
triage and moderate injuries receive a pending-HoT-aware periodic-heal pass,
|
||||
and Assassination banks Energy for Mutilate while using Envenom as its primary
|
||||
four-plus-point finisher, matching the pinned Playerbots strategy. Rogue weapon
|
||||
hits now execute the maintained Instant/Deadly coatings: Deadly Poison stacks a
|
||||
12-second DoT to five source-specific doses, Envenom uses one dose per combo
|
||||
point for Nature damage and grants its temporary poison-frequency buff, Mutilate
|
||||
offers two poison application opportunities, and Shiv guarantees the off-hand
|
||||
Deadly application at four-plus combo points only while the pending Envenom
|
||||
lacks enough doses. Envenom normally consumes the used doses; Orin's pinned 3/3
|
||||
Master Poisoner build preserves them 100% of the time as specified by the
|
||||
talent.
|
||||
Ready DPS pack logic is deliberately thresholded and cluster-aware:
|
||||
Marksmanship uses Multi-Shot at two nearby engaged targets and Volley at three,
|
||||
while Fire uses Flamestrike and Assassination uses Fan of Knives at three.
|
||||
Volley's six Arcane pulses are recovered from its DBC trigger child, Fan of
|
||||
Knives is centered on the Rogue, active ground effects are not replaced early,
|
||||
and companion AoE does not pull unengaged enemies.
|
||||
|
||||
## Code map
|
||||
|
||||
- `scripts/balance_lab.py`: prompts, command-line parsing, random/manual/automatic
|
||||
@@ -333,24 +585,30 @@ noting that control-policy difference.
|
||||
- `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
|
||||
run-history selection, progress display, report loading, and server or local
|
||||
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.
|
||||
- `src/app/balanceLabClient.ts`: typed Balance Lab API client with authenticated
|
||||
server and loopback-local execution targets.
|
||||
- `server/balance-lab-jobs.mjs`: validated single and batch job specifications,
|
||||
bounded parallel process queue, progress parsing, `job.json`
|
||||
persistence/startup restoration, report-summary extraction, and artifact
|
||||
allow-list.
|
||||
- `server/local-balance-lab-api.mjs`: loopback-only, no-login development API
|
||||
that owns the local job service and persists GUI runs under `artifacts/`.
|
||||
- `server/server.mjs`: GM-authenticated runtime-preflight, job, status, and
|
||||
artifact HTTP routes.
|
||||
- `src/game/benchmark/benchmarkBuilds.ts`: selectable spec builds and legal
|
||||
talent allocation. The older canonical cohort remains unchanged.
|
||||
- `src/game/partyStore.ts`: `createConfiguredPartyMember`, the narrow factory
|
||||
that preserves production stats, equipment, resources, and appearance for an
|
||||
exact companion.
|
||||
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.
|
||||
- `src/game/bossBalanceOverrides.ts`: audited boss-specific health and damage
|
||||
exceptions, optionally scoped to an exact level or level bracket.
|
||||
|
||||
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`
|
||||
@@ -362,10 +620,11 @@ 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 -m py_compile scripts/balance_lab_brackets.py
|
||||
python -m unittest scripts.test_balance_lab scripts.test_balance_lab_brackets
|
||||
node --test server/local-balance-lab-api.test.mjs 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 test:ready-five
|
||||
npm run build
|
||||
npm run balance:lab:playtest
|
||||
npm run release:check
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
# Ability and Talent Tuning
|
||||
|
||||
The main-menu **GM Menu** exposes **Ability & Talent Tuning** to authenticated
|
||||
GMs. It is a full-screen DOM tool intended for fast local combat iteration. The
|
||||
catalog can be searched and filtered by class, and the selected entry exposes
|
||||
its source values beside editable runtime percentages.
|
||||
|
||||
## Ability overrides
|
||||
|
||||
Ability overrides are keyed by stable ability ID. Each field is expressed as a
|
||||
percentage of the source catalog value: `100` preserves the source, `50` halves
|
||||
it, and `200` doubles it. The editor supports:
|
||||
|
||||
- cooldown;
|
||||
- cast or channel time;
|
||||
- resource cost;
|
||||
- direct, periodic, finisher, and summon damage;
|
||||
- direct, periodic, and absorb healing;
|
||||
- aura, control, summon, and periodic duration;
|
||||
- area radius, chain reach, and ground reach.
|
||||
|
||||
`src/game/balanceTuning.ts` normalizes and applies the policy.
|
||||
`src/game/abilityCatalog.ts` retains the authored catalog separately and
|
||||
rebuilds its live definitions whenever the GM applies a draft. This prevents
|
||||
percentage changes from compounding when the editor is opened repeatedly.
|
||||
|
||||
## Talent overrides
|
||||
|
||||
Talent overrides can scale every supported passive modifier parsed from a
|
||||
talent tooltip. They can also add a signed percentage-point modifier per learned
|
||||
rank for damage, healing, critical chance, haste, costs, cooldowns, durations,
|
||||
range, threat, attributes, defenses, or resources.
|
||||
|
||||
On-use talents are not passive modifiers. Inner Focus is explicitly excluded
|
||||
from passive tooltip parsing. Activating its ability grants a temporary aura;
|
||||
the next direct single-target or area heal costs no resource, gains 25
|
||||
percentage points of critical chance, keeps its ordinary cast time, and consumes
|
||||
the aura. Tune its cooldown and aura duration from the Abilities tab.
|
||||
|
||||
## Persistence and scope
|
||||
|
||||
The normalized policy is stored per device under
|
||||
`healer-man:balance-tuning:v1`. Applying it updates player abilities, NPC
|
||||
companion abilities, and client-side test runs that use the live browser
|
||||
catalog. **Reset ability**, **Reset talent**, and **Reset all** remove overrides
|
||||
without changing authored or generated source data.
|
||||
|
||||
This first editor is intentionally a local sandbox. Server Balance Lab jobs run
|
||||
in a separate process and do not read browser local storage; permanent balance
|
||||
changes should still be moved into authored source data after local validation.
|
||||
|
||||
## Heirloom ratings
|
||||
|
||||
Virtual heirlooms include specialization-aware critical-strike and haste
|
||||
ratings for strength, agility, ranged, spell-damage, and healer profiles.
|
||||
Protection tank gear instead prioritizes defense, avoidance, block, stamina,
|
||||
and expertise. `src/game/wotlkStats.ts` converts the ratings at the character's
|
||||
level into melee, ranged, and spell critical/haste percentages. Both player and
|
||||
party heirlooms continue to cap at item level 79.
|
||||
|
||||
## Validation
|
||||
|
||||
```powershell
|
||||
npx vitest run src/game/balanceTuning.test.ts src/game/talentModifiers.test.ts src/game/combatStore.test.ts src/game/heirloomEquipment.test.ts
|
||||
npm run build
|
||||
npm run release:check
|
||||
```
|
||||
@@ -1,6 +1,8 @@
|
||||
# Combat rotation matrix
|
||||
|
||||
Healer Man uses AzerothCore playerbots commit `531282e` as a read-only behavioral reference. The implementation is an independent TypeScript policy over Healer Man's executable catalog, not a port of playerbots C++.
|
||||
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 |
|
||||
| --- | --- |
|
||||
@@ -9,13 +11,21 @@ Healer Man uses AzerothCore playerbots commit `531282e` as a read-only behaviora
|
||||
| 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. |
|
||||
| Threat | Tanks interrupt first and taunt a hostile that is not attacking them; non-tanks drop or redirect dangerous threat before continuing their profile. Threat effects update every engaged enemy and immediately resolve its next target. |
|
||||
| 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. |
|
||||
| Defense | Mitigation is used below 55% actor health and receives emergency priority below 35%. Assassination uses Evasion for boss aggro, low-health melee pressure, or a telegraphed strong melee hit; Vanish resets threat for boss aggro or near-death escape. |
|
||||
| Resources | Low-resource generators are favored below 30%; spenders still have to pass runtime cost and rune validation. Arms banks Rage for Mortal Strike and Assassination banks Energy for Mutilate instead of starving those signature actions with a cheaper filler. |
|
||||
| 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-v6-reactive-ready-five` 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
|
||||
@@ -24,29 +34,29 @@ The leftmost available action wins after reactive rules, learned-talent gating,
|
||||
|
||||
| Class | Specialization | Maintenance and core priority |
|
||||
| --- | --- | --- |
|
||||
| Warrior | Arms | Battle Stance; Mortal Strike; Rend; Overpower; Execute; Slam; Heroic Strike |
|
||||
| Warrior | Fury | Berserker Stance; Bloodthirst; Whirlwind; Execute; Sunder Armor; Heroic Strike |
|
||||
| Warrior | Protection | Defensive Stance; Shield Slam; Revenge; Devastate; Thunder Clap; Sunder Armor; Heroic Strike |
|
||||
| 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 Stance; Shield Block/Last Stand/Shield Wall/Enraged Regeneration; Taunt; Shield Bash; 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 | Marksmanship | Aspect of the Dragonhawk/Hawk; Call Pet; Misdirection; Rapid Fire; Readiness; Kill Shot; Volley (3+); Multi-Shot (2+); Chimera Shot; Aimed Shot; Serpent Sting; Arcane Shot; Steady Shot; Silencing Shot; Disengage; Feign Death |
|
||||
| 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 | Assassination | Deadly/Instant Poison; Tricks of the Trade; Cold Blood; Fan of Knives (3+); Envenom; Shiv; Rupture; Mutilate; Eviscerate; Sinister Strike; Kick; Evasion; Cloak of Shadows; Vanish |
|
||||
| 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 | Discipline | Inner Fire; Pain Suppression; Inner Focus; Power Infusion; Shadowfiend; Divine Hymn; Penance; Power Word: Shield; Flash/Greater Heal; Renew; Dispel Magic; Cure Disease; Resurrection |
|
||||
| Priest | Holy | Inner Fire; Circle of Healing; Prayer of Mending; Flash Heal; Greater Heal; Renew |
|
||||
| Priest | Shadow | Shadowform; Inner Fire; Vampiric Touch; Devouring Plague; Shadow Word: Pain; Mind Blast; Mind Flay; Shadow Word: Death |
|
||||
| Death Knight | Blood | Frost Presence (Blood Presence fallback); Heart Strike; Death Strike; Icy Touch; Plague Strike; Death Coil |
|
||||
| Death Knight | Frost | Blood Presence; Howling Blast; Obliterate; Frost Strike; Icy Touch; Plague Strike |
|
||||
| Death Knight | Unholy | Unholy Presence (Blood Presence fallback); Raise Dead; Scourge Strike; Death and Decay; Plague Strike; Icy Touch; Death Coil |
|
||||
| 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 | Fire | Molten Armor; Combustion; Mirror Image; Flamestrike (3+); Living Bomb; Fireball; Scorch; Fire Blast; Counterspell; Blink; Ice Block; Fire/Frost Ward |
|
||||
| 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 |
|
||||
@@ -63,3 +73,64 @@ 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.
|
||||
|
||||
## Companion readiness gate
|
||||
|
||||
The shared WoW NPC policy begins with Protection Warrior (Aldren), Discipline
|
||||
Priest (Quorin), Marksmanship Hunter (Liora), Fire Mage (Xavian), and
|
||||
Assassination Rogue (Orin) as Ready. Every other WoW specialization begins as
|
||||
Needs Testing; Beta is an explicit GM-published opt-in. Every CoA specialization
|
||||
and RoM class/role profile is also shown as Needs Testing. Automatic balance,
|
||||
bracket, regression, and seat-audit scopes exclude Needs Testing entries.
|
||||
Player character choices remain unrestricted, and an explicit targeted
|
||||
diagnostic can still inspect a Needs Testing build without adding it to the
|
||||
certified pool.
|
||||
|
||||
Ready is a certification result, not a tuning label. Its profile must resolve a
|
||||
legal Playerbots-derived talent allocation and learned ability ranks at each
|
||||
level bracket; preserve its generated identity, resources, gear, and precombat
|
||||
setup; execute every conditionally required action; react correctly to threat,
|
||||
healing, dispels, interrupts, movement, and boss mechanics; and demonstrate
|
||||
major offensive and defensive cooldown decisions without unexplained idle
|
||||
windows. Stock abilities whose Talent.dbc node was removed by the patched
|
||||
client (currently Devastate, Shadowstep, and Lava Lash) use their retained
|
||||
Spell.dbc source level rather than an impossible dangling talent gate.
|
||||
|
||||
The Ready-five runtime gives healer mitigation its own triage pass before
|
||||
direct healing: Discipline uses Pain Suppression at the defensive threshold,
|
||||
Power Word: Shield as named absorb capacity, and Renew on moderately injured
|
||||
targets without replacing a pending HoT. Inner Focus is reserved for an ally
|
||||
at or below 40% health or at least two allies at or below 60%, then empowers
|
||||
the next direct single-target or area heal with a free cost and 25 percentage
|
||||
points of critical chance. Player Inner Focus uses the same one-heal aura and
|
||||
retains the chosen heal's normal cast time; its activation tooltip is excluded
|
||||
from permanent passive talent parsing. Protection Revenge is enabled for a
|
||||
five-second window after an authoritative incoming dodge, parry, or block.
|
||||
Untargeted cooldown resets affect other abilities only, so Readiness cannot
|
||||
reset itself and monopolize the Hunter rotation. Blink and Disengage create
|
||||
distance from threat, low-health danger, or an imminent strong melee hit;
|
||||
Fire Blast is the instant Fire action available while moving.
|
||||
|
||||
Ready DPS AoE uses local engaged packs rather than every hostile in the
|
||||
encounter. Marksmanship uses Multi-Shot when two targets fit its three-target
|
||||
chain and channels Volley into clustered packs of three or more. Fire uses
|
||||
Flamestrike at three or more targets and allows its ground fire to finish
|
||||
ticking before recasting. Assassination uses the full-circle Fan of Knives at
|
||||
three or more targets. Companion AoE never counts or damages unengaged packs.
|
||||
|
||||
Assassination treats Envenom as its primary four-plus-combo-point finisher,
|
||||
matching the pinned Playerbots Assassination trigger, and banks Energy rather
|
||||
than spending those points on cheaper Rupture while Envenom becomes affordable.
|
||||
Rupture remains a bleed fallback rather than a required maintenance action.
|
||||
Instant Poison now deals rank- and attack-power-scaled Nature damage. Deadly
|
||||
Poison applies a source-specific, refreshable 12-second DoT that scales from one
|
||||
to five doses. Envenom uses up to one dose per combo point, normally consumes
|
||||
those doses, deals Nature damage per used dose, and grants its 1 + combo-point
|
||||
second buff: 75% more frequent Instant Poison applications and 15 percentage
|
||||
points more Deadly Poison application chance. Mutilate supplies two poison
|
||||
opportunities. Shiv
|
||||
models its off-hand strike by guaranteeing a Deadly Poison application and is
|
||||
eligible at four-plus combo points only while the target lacks enough doses for
|
||||
the pending Envenom. The pinned Assassination build's 3/3 Master Poisoner gives
|
||||
Envenom a 100% chance to
|
||||
preserve the doses while retaining their damage scaling.
|
||||
@@ -186,8 +186,8 @@ Run focused tests while iterating, then use the relevant final checks:
|
||||
# Required after authored dungeon mechanics change
|
||||
npm run dungeon:catalog
|
||||
|
||||
# Combat or shared runtime changes
|
||||
npm test
|
||||
# Combat or shared runtime changes (current certified companion scope)
|
||||
npm run test:ready-five
|
||||
|
||||
# Report API/server changes
|
||||
npm run server:test
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
# RoM five class validation
|
||||
|
||||
Status: **Needs Testing**. This is a targeted diagnostic for Priest healer, Knight tank, Scout DPS, Rogue DPS, and Mage DPS. The accepted pair elite skills have focused source and runtime checks, with specific unresolved mechanics listed below.
|
||||
|
||||
## Cohort and rules
|
||||
|
||||
The five named builds and live NPCs use these race-legal pairings. Their secondary classes activate at level 10 and always share their primary class's level. RoM has no talent allocation; ordinary skills unlock by level, and the generated source's internal level variants do not create a player-facing spell-rank choice.
|
||||
|
||||
| Role | Class pair | NPC name | Race |
|
||||
| --- | --- | --- | --- |
|
||||
| Tank | Knight / Scout | Rorik | Human |
|
||||
| Healer | Priest / Knight | Serenna | Human |
|
||||
| DPS | Rogue / Warrior | Veyra | Human |
|
||||
| DPS | Scout / Rogue | Caelan | Elf |
|
||||
| DPS | Mage / Warlock | Durnik | Dwarf |
|
||||
|
||||
`src/game/romFiveIdentities.ts` owns these identities for the live NPC templates and targeted canonical builds. The manual party uses Priest as the active healer and the other four classes as companions. This explicit cohort does not change Ready-only random parties, bracket certification, or the companion readiness registry. Elite skills can appear in a paired kit through the existing RoM catalog; focused checks now cover their translated effects, selected live casts, and source-conditional procs.
|
||||
|
||||
`npm run test:rom-five` checks the named pairings, legal races, level-10 unlock, role-specific hybrid heirloom layouts and level growth, ordinary and pair-elite ability coverage, and focused runtime mechanics. Knight is a tank seat in RoM generated parties. The equipment model has separate main-hand, off-hand, and ranged positions. Knight/Scout uses mace, shield, and bow from level 10; Priest/Knight uses mace and shield; Scout/Rogue uses dagger and bow, gaining an off-hand dagger at level 16; Rogue/Warrior gains a projectile at level 12 and an off-hand dagger at level 16; Mage/Warlock uses a two-handed staff. Priest and Mage retain empty ranged positions because their accepted pairings do not grant a ranged weapon. Their equipment uses the same level budget and WoW-shaped fields as the WoW heirlooms, allocated by RoM role. `src/game/wotlkStats.ts` gives these five WoW-equivalent base attribute, HP, and Mana curves with a 10% same-level secondary contribution from level 10. Other RoM classes retain native growth. Mage/Warlock is a RoM dwarf with the Human Mage Wrath curve as its base reference because Dwarf Mage has no Wrath 3.3.5 row.
|
||||
|
||||
## Findings and fixes in this pass
|
||||
|
||||
- Player and companion casts now respect source out-of-combat restrictions for Resurrection and Rogue openers. Sneak Attack and Blind Spot carry behind-target rules for spatial combat.
|
||||
- Rogue Throw executes one projectile hit instead of two identical source attack components. Scout Shot executes its primary hit instead of five alternate attack components and unrelated target stat effects.
|
||||
- RoM character creation, Skills, and Spellbook screens present level-based skill unlocks without talent-point or spell-rank choices.
|
||||
- The RoM Knight party template is assigned to the tank pool rather than the damage pool.
|
||||
- Conditional combat payloads now resolve once per cast for both player and companion actors. Knight Holy Seals cap at four on each target, their bleed follows the stack count, and Punishment, Threaten, Shock, and Truth Shield Bash use the corresponding seal branch. Punishment consumes the seal bleed. Rogue Low Blow, Wound Attack, Shadowstab, and Sneak Attack use active self-applied bleeds and the equipped main-hand weapon; Wound Attack no longer invents a bleed. Mage Electric Explosion executes either its normal or Charged hit.
|
||||
- Rogue Premeditation now guarantees the next offensive critical hit and increases its critical power. Poison supplies the Poisonous state needed by Numbing Dagger. Vanish permits an out-of-combat Rogue opener while its stealth state lasts. Shadow Step moves behind the target; Knight Charge closes to melee range.
|
||||
- Priest Regenerate has one heal-over-time effect. Holy Candle and Scout Blood Arrow drain the caster's HP on their periodic ticks. Mage Flame and Fireball execute one hit, Meteor Shower five, and Thunderstorm three. Source helper summons and zero-damage effects no longer create extra combat actions for these skills.
|
||||
- Scout Lasso, Rogue Numbing Dagger, and Mage Static Field apply one break-on-damage control effect to one target at a time. Scout Joint Blow only slows; Mage Discharge applies its stun without unrelated control effects. Knight Shield of Discipline absorbs the documented damage types and reflects a spell hit after the caster is struck, not on cast. Shield-dependent Knight skills require an equipped shield.
|
||||
|
||||
## Accepted pair skill checks
|
||||
|
||||
`src/game/romPriestKnightOverrides.ts`, `src/game/romScoutRogueOverrides.ts`, and `src/game/romMageWarlockOverrides.ts` specialize the five accepted pairs from source components. `src/game/romCombatMechanics.ts` selects their conditional cast effects; `src/game/combatStore.ts` handles proc actions, damage schools, conditional cooldowns, line and chain targeting, and timed effects. `src/game/partyRuntime.ts` installs learned NPC passive auras on their first cast and passes passive and target tags into the shared selector. The three pair validation files are part of `npm run test:rom-five`; the focused run passed 228 tests across eleven files on 2026-09-23. These include direct healing, area healing and damage, taunts, resource changes, offensive and defensive auras, crowd control, source-conditional skill checks, gear-slot unlocks, and selected live player/companion casts. Pure effect tests are evidence for translation and selection, not proof that every encounter timing is correct.
|
||||
|
||||
The hidden source references are now executable through their parent skills where RuneWaker treats them as replacements: Priest/Knight Wave Armor `499967`, Knight/Scout Holy Chains `499925` and Blood Arrow `499924`, Scout/Rogue Reflected Shot `499535` and Wrist Attack `499537`, Rogue/Warrior Poison Spray `499477` and Whirlwind `499481`, and Mage/Warlock Soul Pain `498746`, Plasma Arrow `498749`, Elements of Pride `498755`/`498756`, and Elemental Extraction `499812`/`499851`. Priest/Knight Devotion Halo `499966` is a separate level-70 button: it gives the whole party Wisdom and reduces other members' actual Mana spending. Poisonous Spit supplies its own Poison Powder and Poison Spray consumes the selected target's Infection poison before spreading its upgraded poison. Toxic Splash can grant Rage on Infection. The source-only Holy Chains trigger `499910` has no measurable player-facing payload in the extracted data, so no extra effect is invented.
|
||||
|
||||
The RuneWaker NPC object database now supplies Undead and Demon family labels through `src/game/romCreatureFamilies.ts`. Base Holy Chains targets those families; Evil Shackles opens living targets with a shorter root. Numbing Dagger rejects known Undead targets. Unknown or unregistered NPC families remain permissive. Ammo, Poison Powder, Rogue Throw projectiles, and other required bag reagents are treated as an unlimited, level-appropriate supply; casts do not consume an inventory stack.
|
||||
|
||||
Remaining source and runtime gaps:
|
||||
|
||||
1. Some source proc chances and magnitudes still use explicit hybrid estimates where the extracted source does not provide a direct WoW-stat conversion. NPC proc execution and resource side effects need more end-to-end coverage across encounters.
|
||||
2. Rogue/Warrior One-Hand Axe Mastery does not benefit the current dual-dagger loadout. The newly equipped bow, projectile, and off-hand slots change the level-12-and-up gear budget, so previous balance diagnostics need repeating before promotion.
|
||||
3. Mage/Warlock damage remains low relative to Scout and Rogue. Check its AI rotation, cast cadence, and WoW-equivalent scaling before adjusting encounter tuning or promoting RoM.
|
||||
|
||||
## Combat diagnostic
|
||||
|
||||
The following historical runs predate secondary-class assignment. They used `scripts/balance_lab.py` with `--source rom --party-mode manual`, exactly the five canonical build IDs for that level, and 180-second fights. Reports are under `artifacts/balance-lab/rom-five-primary-*`.
|
||||
|
||||
| Level | Encounter set | Runs defeated | Death events | Median time to defeat |
|
||||
| ---: | --- | ---: | ---: | ---: |
|
||||
| 20 | Cayus, two seeds | 2/2 | 0 | 70.2 s |
|
||||
| 40 | Cayus, two seeds | 2/2 | 0 | 79.4 s |
|
||||
| 60 | All 62 RoM bosses, one seed | 62/62 | 0 | 101.5 s |
|
||||
| 60 | Shadoj, three seeds | 3/3 | 0 | 106.6 s |
|
||||
| 80 | Cayus, two seeds | 2/2 | 0 | 122.0 s |
|
||||
|
||||
The table above also predates the conditional mechanics changes. The level-60 all-boss report measured approximately 1,036 Knight tank DPS, 831 Rogue DPS, 374 Mage DPS, 223 Scout DPS, and 35 Priest HPS. These are screening measurements, not balance targets: this cohort has only one healer seat, one tank seat, and one seed per boss. Ten bosses dealt no effective damage and therefore did not exercise healing.
|
||||
|
||||
After the mechanics corrections, the targeted Shadoj run at level 60 with the same five manual builds and seeds 1, 2, and 3 defeated the boss in 3/3 fights with no deaths. Its mean defeat time was 115.7 seconds. The report at `artifacts/balance-lab/rom-five-primary-l60-mechanics-final/` measured about 1,186 Rogue DPS, 750 Knight DPS, 237 Scout DPS, 186 Mage DPS, and 334 Priest HPS. The changed output is expected from removing duplicate hits and enforcing source conditions; it is a diagnostic, not a promotion or tuning target.
|
||||
|
||||
## Before Ready promotion
|
||||
|
||||
1. Resolve the persistent damage spread: Rogue is now far ahead of Scout and Mage, while Knight tank damage remains above both. Review source formulas, resource cadence, and rotations before tuning coefficients.
|
||||
2. Expand source-row and live NPC-cast audits beyond these corrected skills. Unknown NPC family IDs are permissive until their source records are classified; basic semantic coverage does not prove full tooltip parity.
|
||||
3. Exercise behind-target requirements in a live spatial scene. The headless balance runner has no spatial target provider, so its Rogue damage can include casts that a live scene would reject.
|
||||
4. Validate Priest throughput against encounters that actually demand sustained healing, and run RoM NPC seat tests after the mechanics above are resolved. The current manual run leaves Priest player-controlled.
|
||||
5. Verify the level-16 Rogue off-hand dagger visually from an unobstructed angle. The level-1 roster and dungeon screenshots in `artifacts/playtest/rom-five/` confirm rendered bodies and starter weapon silhouettes; they cannot show the later off-hand unlock. Keep RoM entries at Needs Testing until the full class mechanics and role checks pass.
|
||||
|
||||
The RoM source catalog and generated data remain authoritative for ability descriptions and components. The older `rom-five-primary-*` results predate paired builds. The later `rom-hybrid-*` and `rom-hybrid-wowbase-*` manual reports contain the named secondary classes even though the report's specialization column reads "Primary" for the primary orientation.
|
||||
|
||||
The chosen gameplay model uses a WoW-stat hybrid instead of requiring native RoM equipment conversion for Ready promotion. `docs/ROM_STATS_AND_GEARING.md` records the source coefficients, active hybrid rules, remaining balance risks, and the players-only RoM online group boundary. Initial three-seed Shadoj screens at levels 20, 60, and 79 defeated the boss without a wipe, but Mage damage was far below the other two DPS builds and the Priest died once in each level-79 run. Those one-encounter results do not establish balance. Leveling bosses award cards but no items. `docs/ROM_LEVELING_GEAR_PACKAGES.md` records the source-confirmed package families; exact historical contents remain unverified and would need a WoW-stat translation before grant.
|
||||
|
||||
After the WoW-equivalent base change, one-seed manual Shadoj screens at levels 20, 60, and 79 defeated the boss with zero deaths. At level 60, Mage dealt about 185 DPS versus Scout 971 and Rogue 1,026; at level 79 the corresponding values were about 242, 1,418, and 1,538. Reports are under `artifacts/balance-lab/rom-hybrid-wowbase-l20-shadoj`, `rom-hybrid-wowbase-l60-shadoj`, and `rom-hybrid-wowbase-l79-shadoj`. These narrow screens do not certify class or dungeon balance.
|
||||
@@ -0,0 +1,41 @@
|
||||
# RoM level 1–79 heirloom reference
|
||||
|
||||
Status: **source-backed design data, separate from equipped hybrid heirlooms**. `scripts/runewaker-pipeline/export-rom-heirloom-reference.mjs` generates `src/game/generated/romHeirloomReference.json` from the sibling RuneWaker client snapshot, or from `RUNEWAKER_ROOT`. Run `node scripts/runewaker-pipeline/export-rom-heirloom-reference.mjs` after refreshing `src/game/generated/romLevelingGearPackages.json`. `src/game/romHeirloomReference.ts` provides typed level and item lookups. The 5 × 79 curves cover the five selected primary classes; their secondary class always shares the primary level.
|
||||
|
||||
## Evidence and scope
|
||||
|
||||
The [RoM Item Sets index](https://runesofmagic.fandom.com/wiki/Category:Item_Sets) and [Leveling Packages index](https://runesofmagic.fandom.com/wiki/Category:Leveling_Packages) identify leveling families. The [Issac](https://runesofmagic.fandom.com/wiki/Issac%27s_%28Item_Set%29), [Savage Lands](https://runesofmagic.fandom.com/wiki/Savage_Lands_%28Item_Set%29), [Lance's Support](https://runesofmagic.fandom.com/wiki/Lance%27s_Support), and [Cloth Armor Package (Multi)](https://runesofmagic.fandom.com/wiki/Cloth_Armor_Package_%28Multi%29) pages connect sets with the level-50, 55, 60, and 65 rewards. `docs/ROM_LEVELING_GEAR_PACKAGES.md` records the quest/package IDs and distinguishes wiki claims from the client package links that remain undecoded.
|
||||
|
||||
The local client provides **3,026 Good-quality (blue) armor and weapon records** with required level 1–79. Their IDs, names, levels, armor/weapon types, positions, suit IDs, base effects, deterministic fixed attributes, and decoded native stats are retained as source references. Of those, 727 have attribute IDs absent from this snapshot's `addpowerobject.db`, and 98 have effects outside the mapped core stat model; they remain visible but are excluded from curve candidates. Source file SHA-256 values are embedded for reproducibility. The generated output is design evidence, not a claim that all 3,026 items are normally obtainable at their required level.
|
||||
|
||||
The parser uses the local `ObjectStruct.h` `GameObjectWeaponENUM`, `GameObjectArmorENUM`, and `WearEqTypeENUM`, and fixed record layouts from `armorobject.db`, `weaponobject.db`, and `addpowerobject.db`. Numeric quality 2 is the client's Good/blue tier, consistent with the [wiki quality legend](https://runesofmagic.fandom.com/wiki/Category:Items). An effect with type 7 adds its value to all five primary attributes. Only 100% fixed item attributes are summed; random attribute rolls are listed by ID without being assumed.
|
||||
|
||||
## Design rules
|
||||
|
||||
| Primary class | Armor reference | Main weapon reference | Other weapon reference |
|
||||
| --- | --- | --- | --- |
|
||||
| Knight/Scout | Plate | One-hand mace | Shield |
|
||||
| Priest/Knight | Cloth | Two-hand staff | None in this curve |
|
||||
| Scout/Rogue | Leather | Dagger | Bow |
|
||||
| Rogue/Warrior | Leather | Dagger | Off-hand dagger |
|
||||
| Mage/Warlock | Cloth | Two-hand staff | None |
|
||||
|
||||
These source-reference choices follow the [official class equipment guide](https://archive.runesofmagic.gameforge.com/en/classes.html). Scout is **leather**, not chain. Priest can also legally use a wand or mace with a shield, but this client has only one complete blue wand record at level 57. The native Priest reference curve uses the well-populated staff series; the active WoW-stat hybrid equips a mace and shield to support the Priest/Knight pairing. A shield must not be combined with a two-hand staff. The Mage/Warlock remains dwarf; race has no effect on the equipment reference table itself.
|
||||
|
||||
The generator chooses a median item at each observed required level using role-oriented native-stat scores, then substitutes matching 50/55/60/65 package-suit armor pieces at those milestone levels. For levels between source points it interpolates each stat; below the first point or above the last it scales from the nearest source point. It rounds positive values to at least one and retains the prior level's value when the next candidate would lower a stat. `referenceItemIds` are the points behind that level, while `packageId` identifies a matched package-suit milestone. A curve row can therefore combine retained prior stats with current source values and **is not an exact copy of its listed item**. These scoring coefficients are selection heuristics, not certified combat stat weights.
|
||||
|
||||
All five profiles cover head, hands, feet, chest, legs, back, waist, shoulder, neck, finger, trinket, and main hand. Off hand exists for Knight and Rogue; Scout has ranged. Source earrings are represented by the game's `trinket` reference slot. The source catalog has no matching wrist slot for this export. The game must resolve its final slot design before equipping the sets.
|
||||
|
||||
## Numeric cross-checks
|
||||
|
||||
| Source item | Required level | Confirmed decoded effects |
|
||||
| --- | ---: | --- |
|
||||
| Magic Robe of the Lake, ID 229164 | 65 | 1,029 DEF, 1,655 MDEF, 273 HP, 290 MATK, 100 Stamina, 67 Intelligence |
|
||||
| Gale Staff, ID 213416 | 60 | 903 weapon damage, 1,094 magical weapon damage, 172 MATK, 20 Stamina, 56 Intelligence, 56 Wisdom |
|
||||
| Rescuer's Blessing, ID 212999 | 50 | 440 Heal Point, 660 MATK, 1,717 magical weapon damage, 50 Wisdom; a useful healing reference but an unusually strong source item, so it is not a median staff anchor |
|
||||
|
||||
The [Gale Staff wiki entry](https://runesofmagic.fandom.com/wiki/Gale_Staff) provides an independent level-60 cross-check. More source IDs, including each 65 package chest, are queryable through `romSourceReferenceItem` and each curve's `referenceItemIds`.
|
||||
|
||||
## Use in the hybrid runtime
|
||||
|
||||
The active RoM runtime uses WoW-shaped heirloom stats and WoW combat formulas by design; see `docs/ROM_STATS_AND_GEARING.md`. These native source curves remain references for level progression and class identity. Feeding native values directly into the WoW path would produce meaningless balance. The five active hybrid loadouts use legal weapon layouts, a WoW-stat level budget, and no comparison against WoW boss drops. Tune Knight survival, Priest healing/sustain, and Scout/Rogue/Mage damage across levels 1–79. Keep dungeon bosses on the existing cards-only leveling policy; heirlooms and package gear should come from progression, not dungeon equipment drops. Elite skills remain out of scope.
|
||||
@@ -0,0 +1,44 @@
|
||||
# RoM leveling gear package catalog
|
||||
|
||||
Status: **quest choices and several armor-set families verified; exact client package expansion unverified**. This is source evidence for a RoM leveling route, not a runtime gear grant. The generated inventory is `src/game/generated/romLevelingGearPackages.json`, owned by `scripts/runewaker-pipeline/export-leveling-gear-packages.mjs`. Refresh it with `node scripts/runewaker-pipeline/export-leveling-gear-packages.mjs` against the sibling RuneWaker client snapshot, or set `RUNEWAKER_ROOT` to another snapshot. Native item-stat references and 1–79 heirloom curves are in `docs/ROM_HEIRLOOM_REFERENCE.md`.
|
||||
|
||||
## Confirmed quest milestones
|
||||
|
||||
The client `Resource/data/questdetailobject.db` links each quest to the armor-category reward packages in the order below. `itemobject.db` confirms that the later parent packages and their similarly named weapons and armor subpackages exist. The client strings describe each later parent choice as giving one weapons package and one armor package; their exact item links have not been decoded. The generated catalog records matching seven-piece source suits from `suitobject.db` and `armorobject.db`; wiki pages independently identify several as package contents, but do not establish every weapon, accessory, quantity, or version in this client snapshot.
|
||||
|
||||
| Quest level and name | Quest ID | Plate package | Chain package | Leather package | Cloth package |
|
||||
| --- | ---: | ---: | ---: | ---: | ---: |
|
||||
| 5 Equip Yourself | 423783 | — | 209025 | 209024 | 209015 |
|
||||
| 30 Key to Victory | 423771 | 208802* | 208803 | 208804 | 208805 |
|
||||
| 50 Appropriate Support | 424596 | 240384 | 240387 | 240390 | 240393 |
|
||||
| 55 Rewards for Students | 424597 | 240353 | 240356 | 240359 | 240362 |
|
||||
| 60 Lance's Support | 424598 | 240341 | 240344 | 240347 | 240350 |
|
||||
| 65 Return to Dalanis | 424213 | 240304 | 240307 | 240310 | 240313 |
|
||||
|
||||
*The level-30 source name is **World Battlefield Bonus Armor Package**, without the word “Plate.” Its plate classification is only a likely reading of its position beside chain, leather, and cloth; the generated catalog uses `armor` until verified. This is a battlefield reward, so it is an optional source candidate rather than a guaranteed leveling grant. The level-5 quest has only three choices, and its chain choice is named **Chainmail Armor Box**. Neither early tier has confirmed individual package contents. The level-65 rewards are the Redhill Mountains Pioneer packages. The source-linked subpackage IDs, source names, matching suit IDs, seven armor member IDs, and their actual required levels for levels 50–65 are in the JSON catalog. Some level-50 suit members have required levels below 50; the quest reward level is the milestone, not an assertion that every item requires that level.
|
||||
|
||||
## Wiki cross-checks
|
||||
|
||||
The [Item Sets index](https://runesofmagic.fandom.com/wiki/Category:Item_Sets) and [Leveling Packages index](https://runesofmagic.fandom.com/wiki/Category:Leveling_Packages) give additional reference families beyond the client quests. Their set pages support these specific connections:
|
||||
|
||||
| Milestone | Package or set evidence | Confidence for armor pieces |
|
||||
| --- | --- | --- |
|
||||
| 50 | [Issac's sets](https://runesofmagic.fandom.com/wiki/Issac%27s_%28Item_Set%29) identify four seven-piece sets in the level-50 armor packages. | Wiki-confirmed family; source suit member IDs in catalog. |
|
||||
| 55 | [Savage Lands sets](https://runesofmagic.fandom.com/wiki/Savage_Lands_%28Item_Set%29) identify Haz, Eduth, Luzan, and Rufa with Bodo Treasure rewards. | Wiki-confirmed family; source suit member IDs in catalog. |
|
||||
| 60 | [Lance's Support](https://runesofmagic.fandom.com/wiki/Lance%27s_Support) identifies Rh'anka, Sley, Tempest, and Croso package choices; the [Tempest set](https://runesofmagic.fandom.com/wiki/Tempest_Set_%28Item_Set%29) supplies a piece-level cross-check. | Wiki-confirmed family; source suit member IDs in catalog. |
|
||||
| 65 | [Cloth Armor Package (Multi)](https://runesofmagic.fandom.com/wiki/Cloth_Armor_Package_%28Multi%29) names the Lake Energy cloth set at 65; the other armor categories currently have matching source suits but need their package pages checked. | Cloth wiki-confirmed; other categories matched by source name/type/level. |
|
||||
|
||||
The wiki is a reference for the RoM item families, while the local RuneWaker records are the authority for item IDs and numeric stats in this snapshot. Page descriptions alone do not prove that this snapshot's package-opening script grants the same items.
|
||||
|
||||
## Applying these to the five classes
|
||||
|
||||
The current game marks Knight as plate, Priest and Mage as cloth, and Rogue and Scout as leather in `src/app/characterCatalog.ts`. The [official class guide](https://archive.runesofmagic.gameforge.com/en/classes.html) confirms that Scout wears cloth or leather, while Knight can wear plate and Priest/Mage cloth. This suggests one plate, two leather, and two cloth choices for the five-class cohort; chain remains in the source catalog but is not a default choice for these primary classes. A secondary class shares the primary's level and does not require a second package progression.
|
||||
|
||||
During leveling, `src/game/lootPolicy.ts` suppresses all boss item drops. Boss cards are awarded separately. Package gear should come from a level or quest milestone, never from a dungeon boss loot table. A fallback starter kit can cover levels for which no verified source package exists.
|
||||
|
||||
## Gaps before playable package gear
|
||||
|
||||
1. Locate and verify package expansion records or scripts for every parent and subpackage, including weapon selections, accessories, exact armor items, and quantities. Matching a suit by name, category, and required level is not enough to grant its pieces as historical package contents.
|
||||
2. The source-backed 1–79 reference now decodes native equipment slots, weapon/armor type, base stats, and deterministic fixed attributes. The active RoM path uses WoW-stat hybrid heirlooms; historical package pieces would need a defined translation into that stat budget and legal slot/weapon checks before they become equippable rewards. A full native item-instance and combat adapter is optional future fidelity work.
|
||||
3. Find a complete, generally available source-backed route from 1–49 and above level 65. Level 5 has only cloth, leather, and chain choices; level 30 has four battlefield choices. Logar/Hope package names exist in the item table, but their acquisition route and contents are not confirmed. Avoid inventing missing plate or weapon contents.
|
||||
4. Make the milestone grant idempotent and persistent, then verify class eligibility, package selection, leveling through each milestone, and native-stat combat outcomes for all five builds.
|
||||
@@ -0,0 +1,61 @@
|
||||
# RoM stat weights and gearing audit
|
||||
|
||||
Status: **WoW-stat hybrid chosen for gameplay; native source model retained as reference**. This audit covers the Priest/Knight healer, Knight/Scout tank, Scout/Rogue DPS, Rogue/Warrior DPS, and Mage/Warlock DPS. The game keeps RoM abilities and class pairings. The five accepted builds now use WoW-equivalent base curves, hybrid heirlooms, and WoW-derived combat stats. See `docs/ROM_HEIRLOOM_REFERENCE.md` for the client-derived blue-item atlas and native-stat curves; those numbers are reference points and are not applied directly to these five builds.
|
||||
|
||||
## Current runtime
|
||||
|
||||
- `src/game/wotlkStats.ts` gives the five accepted primaries their corresponding Wrath base attribute, HP, and Mana curves. At level 10 the secondary contributes 10% of its WoW-equivalent curve **at the primary's level**. Human curves cover Priest, Knight, Rogue, and Mage; Scout uses Night Elf. Durnik remains a RoM dwarf, but Human Mage is the base-curve fallback because Wrath 3.3.5 has no Dwarf Mage row. Other RoM classes still use `src/game/romStats.ts` native growth. The shared secondary level is the game's progression rule, even though the historical source allowed separate class levels.
|
||||
- `src/game/wotlkStats.ts` maps native Wisdom into WoW Spirit only on the remaining native-growth path, then derives HP, Mana, attack power, spell power, avoidance, and ratings with WoW rules. This is the selected compatibility adapter, not a historical RoM conversion. In particular, the global Mana multiplier and Stamina-to-HP rule are not source RoM formulas.
|
||||
- `src/game/specializationCatalog.ts` defines distinct hybrid gear profiles for the five builds. `src/game/heirloomEquipment.ts` gives these five WoW-stat heirlooms at levels 1–79 from a level budget without matching WoW boss drops. The equipment model has independent main-hand, off-hand, and ranged slots. Knight/Scout gains a bow at level 10 alongside mace/shield; Scout/Rogue gains an off-hand dagger at 16 alongside dagger/bow; Rogue/Warrior gains a projectile at 12 and an off-hand dagger at 16. Priest/Knight uses mace/shield and Mage/Warlock uses a staff; their ranged slots remain empty. The five hybrid loadouts omit the WoW wrist slot; `src/game/equipment.ts` permits Priest shields. These are active gameplay gear, while the native source curves remain references. Other RoM classes retain their existing fallback gear path until separately validated.
|
||||
- `src/game/runewakerLootCatalog.ts` keeps imported RoM drops as non-equippable `misc` items with zero stats. The loot importer preserves names and probabilities but does not decode native equipment slots, base stats, or enhancements. `src/game/lootPolicy.ts` suppresses **all** boss item drops during leveling; boss cards are awarded through a separate path. At level 80, imported RoM boss items still lack usable equipment data.
|
||||
|
||||
## Source stat model
|
||||
|
||||
The historical RuneWaker source is under `../rom-pvt-server-backups/Runewaker/`. `SourceCodeVS2019/Libraries/Union/RoleData/RoleAttr.cpp` builds each conversion argument from race + vocation + sex. `Resource/data/voctable.db`, `racetable.db`, and `sextable.db` hold those terms; `ObjectStruct.h` defines the argument layout. The player vocation record is the second record of each class pair, as already used by `scripts/runewaker-pipeline/vocation-growth.mjs`.
|
||||
|
||||
After base growth and equipment attribute bonuses, source `RoleAttr.cpp` computes:
|
||||
|
||||
| Native result | Source dependency before rate modifiers |
|
||||
| --- | --- |
|
||||
| Max HP | base HP + Strength × `HpStrArg` + Stamina × `HpStaArg` + flat equipment HP |
|
||||
| Max MP | base MP + Intelligence × `MPIntArg` + Wisdom × `MpMndArg` + flat equipment MP |
|
||||
| Physical ATK | Strength × `AtkStrArg` + Intelligence × `AtkIntArg` + Agility × `AtkAgiArg` + flat equipment ATK |
|
||||
| DEF / MDEF | Stamina × `DefArg` + armor/flat DEF; Wisdom × `MDefArg` + flat MDEF |
|
||||
| Magical ATK | Intelligence × `MAtkArg` + flat equipment MATK |
|
||||
| HP / MP regeneration | base rate + Stamina × `ReHpArg`; base rate + Wisdom × `ReMpArg` |
|
||||
| Physical hit / dodge | Agility plus corresponding equipment values and fixed arguments |
|
||||
| Magical hit / dodge | Wisdom, with Agility also contributing to magical dodge |
|
||||
|
||||
The table below shows **vocation terms only** from the player records of `voctable.db`. Race and sex terms are additive, so these are not final character coefficients and must not be pasted into a gear-score formula as complete weights.
|
||||
|
||||
| Primary role | Relevant source vocation terms | Gear consequence to validate |
|
||||
| --- | --- | --- |
|
||||
| Knight tank | Stamina → HP 5, DEF 1.5; Wisdom → MP 5, MDEF 3.2 | Value shield/armor DEF, Stamina, MDEF, and survival against actual encounter damage. A WoW defense-rating budget is not a substitute. |
|
||||
| Priest healer | Intelligence → MATK 2, MP 1; Wisdom → MP 5, MDEF 3, MP regeneration 0.3 | Keep healing effect power, Mana sustain, and Wisdom distinct from WoW Spirit and generic spell power. |
|
||||
| Scout DPS | Strength → physical ATK 2; Agility contributes to hit/dodge through fixed arguments | Bow damage and physical ATK need source conversion before a Strength/Agility score can be trusted. |
|
||||
| Rogue DPS | Strength → physical ATK 1; Agility → physical ATK 1 and hit/dodge | Evaluate both attributes and equipped dagger damage, including the off hand. |
|
||||
| Mage DPS | Intelligence → MATK 2; Wisdom → MP 5 and MDEF 2.3 | MATK, magical weapon damage, and sustain need separate scoring. |
|
||||
|
||||
Priest healing cannot be scored by MATK alone: the source healing path also uses magic-power rate and heal-point inputs. Critical chance, weapon damage, resistance, and encounter mix further change the value of a stat. These conversion coefficients are mechanics, not final DPS/HPS weights.
|
||||
|
||||
## Hybrid gameplay boundary
|
||||
|
||||
The five-class equipment path deliberately translates RoM roles into WoW combat inputs: Strength and Agility feed physical attack, Intelligence and Wisdom (as Spirit) feed spell and resource stats, defense/avoidance use armor and WoW ratings, and healing uses spell power and Mana sustain. The gear profile weights and heirloom budgets are design choices, not a claim that RoM native stats have the same numerical scale as WoW stats. The source item atlas can guide level progression and class identity without copying DEF, MDEF, MATK, Heal Point, or weapon damage into WoW fields one-for-one.
|
||||
|
||||
This reduces equipment implementation work but still requires targeted balance validation. The earlier three-seed Shadoj diagnostic with the paired cohort and active Priest defeated the boss at levels 20, 60, and 79; Mage damage remained well below Scout/Rogue, and Priest died in each level-79 run. After switching the base curves, a one-seed Shadoj screen at each of levels 20, 60, and 79 defeated the boss with zero deaths. At level 60, Mage dealt about 185 DPS versus Scout 971 and Rogue 1,026; Mage remains a large outlier. The current reports are under `artifacts/balance-lab/rom-hybrid-wowbase-l{20,60,79}-shadoj`. These are screening runs against one encounter, not balance certification. RoM remains Needs Testing; `docs/ROM_FIVE_VALIDATION.md` records the elite-skill checks and unresolved source gaps.
|
||||
|
||||
## Optional native-fidelity path
|
||||
|
||||
RoM equipment has base item properties plus additional attribute sources. [Gameforge's class guide](https://archive.runesofmagic.gameforge.com/en/classes.html) lists native class weapon/armor permissions. Its [rune guide](https://archive.runesofmagic.gameforge.com/us/runes.html) describes socketed runes and up to four rune slots; its [Arcane Transmutor guide](https://archive.runesofmagic.gameforge.com/en/arcanetransmutor.html) describes transferring attributes through Fusion and Mana Stones. These changes must be represented as item-instance bonuses rather than a WoW item-level budget. Source `RoleAttr.cpp` already distinguishes flat equipment contributions, attribute contributions, and multiplicative rates.
|
||||
|
||||
1. Use `scripts/runewaker-pipeline/export-rom-heirloom-reference.mjs` and `src/game/generated/romHeirloomReference.json` as decoded references for native slot, armor/weapon type, base effects, deterministic fixed attributes, and required level. Preserve item IDs and investigate the 727 records with unresolved attribute IDs and any special effects before importing historical source items. The `src/game/romHeirloomReference.ts` lookup is design data and is not read by combat equipment.
|
||||
2. Add a RoM item-instance bonus layer for native flat stats, transferred attributes, rune sockets, and later enhancement/tier data. Sum those bonuses into a native stat block. Keep WoW `ItemStats` and heirloom scoring unchanged for WoW characters.
|
||||
3. Extend the generated class data with race and sex arguments and the vocation conversion coefficients. Keep secondary growth tied to the primary's level, including after the level-10 secondary unlock. Convert the complete RoM stat block to combat inputs through a RoM-specific adapter, including ATK, MATK, DEF, MDEF, and separate healing inputs.
|
||||
4. If granting historical quest or item-shop packages later, resolve exact package expansion and translate their properties into the chosen WoW-stat budget or add a separate native item path. The client snapshot confirms armor-choice quest families at levels 5, 30, 50, 55, 60, and 65; see `docs/ROM_LEVELING_GEAR_PACKAGES.md`. Dungeon bosses award cards during leveling, not equipment or miscellaneous items. Do not copy native item numbers directly into WoW ratings.
|
||||
5. Derive role-specific *upgrade scores* from the marginal combat effect of each native stat at the current level and gear state. Measure Knight survival/threat, Priest effective HPS and sustain, and each DPS build's damage on the same targeted encounter set. Source coefficients seed that model; targeted combat measurements establish practical weights and catch caps or nonlinear effects.
|
||||
|
||||
## Online and validation boundary
|
||||
|
||||
`server/database.mjs` rejects a RoM/WoW source mixture when a grouped player selects a character and checks again when an activity starts. Until RoM AI companions are separately ready, online RoM groups launch players-only; the WoW-only randomized Bite-Sized Stockades activity is unavailable to them. This does not change local manual RoM party diagnostics. The five RoM profiles remain **Needs Testing** while live NPC-cast coverage, equipment compatibility, and the Mage damage outlier are investigated. The identified level-60/70 source replacement skills now execute through their parent abilities, and Priest/Knight Devotion Halo is a separate playable skill.
|
||||
|
||||
Validate the hybrid with legal slot/equip tests for all five builds, stat growth across 1–79, `npm run test:rom-five`, and targeted level/encounter diagnostics. Check Knight threat and survival, Priest effective healing and sustain, and the three DPS roles separately before promotion. If package rewards are added, compare their translated upgrades and combat outcomes. A common encounter definition can use source-specific tuning coefficients; no duplicate WoW/RoM dungeon catalogs are required by this audit.
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "healer-man",
|
||||
"version": "0.1.51",
|
||||
"version": "0.1.58",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "healer-man",
|
||||
"version": "0.1.51",
|
||||
"version": "0.1.58",
|
||||
"dependencies": {
|
||||
"@capacitor/android": "8.4.1",
|
||||
"@capacitor/core": "8.4.1",
|
||||
|
||||
+9
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "healer-man",
|
||||
"private": true,
|
||||
"version": "0.1.51",
|
||||
"version": "0.1.58",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0",
|
||||
@@ -25,6 +25,7 @@
|
||||
"coa:icons": "node scripts/export-spell-icons.mjs",
|
||||
"ascension:talents": "node scripts/generate-wow335-talents.mjs ../wow335a/Interface/AddOns/PlayerBotManager/PBM/PBM_TalentData.lua ../LadiksMPQEditor/client-current/area-52/patch-D/DBFilesClient/Spell.dbc src/game/wow335Talents.generated.ts ../LadiksMPQEditor/client-current/area-52/patch-D/DBFilesClient/Talent.dbc",
|
||||
"catalogs:shard": "vite-node scripts/generate-catalog-shards.ts",
|
||||
"talents:generate": "vite-node scripts/generate-party-runtime-talents.ts",
|
||||
"ascension:sync": "npm run coa:sync && npm run wow335:abilities && npm run ascension:talents && npm run coa:icons && npm run catalogs:shard",
|
||||
"manastorm:import": "node scripts/manastorm-import/cli.mjs",
|
||||
"manastorm:forensics": "node scripts/manastorm-import/forensics-cli.mjs",
|
||||
@@ -117,7 +118,14 @@
|
||||
"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:brackets": "python scripts/balance_lab_brackets.py --ready-only",
|
||||
"balance:lab:npc-audit": "python scripts/balance_lab_brackets.py --ready-only --seat-sweep",
|
||||
"balance:gui": "vite --mode balance-lab-gui --host 127.0.0.1 --open",
|
||||
"balance:lab:regression": "python scripts/balance_lab_brackets.py --ready-only --check",
|
||||
"balance:lab:playtest": "node scripts/playtest-balance-lab.cjs",
|
||||
"readiness:playtest": "node scripts/playtest-companion-readiness.cjs",
|
||||
"test:ready-five": "vitest run src/game/companionSpecReadiness.test.ts src/game/readyFiveCertification.test.ts && python -m unittest scripts.test_balance_lab scripts.test_balance_lab_brackets",
|
||||
"test:rom-five": "vitest run src/game/romFiveValidation.test.ts src/game/romPriestKnightPairValidation.test.ts src/game/romScoutRoguePairValidation.test.ts src/game/romMageWarlockPairValidation.test.ts src/game/romCombatRuntime.test.ts src/game/romCombatMechanics.test.ts src/game/romAbilityCatalogSemantics.test.ts src/game/romPlayerCatalog.test.ts src/game/specializationCatalog.test.ts src/game/romHeirloomReference.test.ts src/game/romCreatureFamilies.test.ts",
|
||||
"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",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"versionName": "0.1.51",
|
||||
"versionCode": 1051
|
||||
"versionName": "0.1.58",
|
||||
"versionCode": 1058
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
@echo off
|
||||
cd /d "%~dp0\.."
|
||||
call npm run balance:gui
|
||||
@@ -9,18 +9,27 @@ import {
|
||||
} from "../../src/app/characterCatalog";
|
||||
import type { ContentCategoryId } from "../../src/app/contentCategories";
|
||||
import {
|
||||
abilityById,
|
||||
abilitiesForCharacter,
|
||||
learnedAbilityForBuild,
|
||||
preloadAbilityCatalogsForClasses,
|
||||
type ResourceType,
|
||||
} 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 { companionResourceMaximum } from "../../src/game/combatActorAuthority";
|
||||
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 {
|
||||
resolvedAverageItemLevel,
|
||||
resolvedEquipmentItems,
|
||||
resolvedEquipmentStats,
|
||||
} from "../../src/game/heirloomEquipment";
|
||||
import {
|
||||
BOSS_TESTING_CATALOG,
|
||||
createBossTestingRoom,
|
||||
@@ -44,10 +53,18 @@ import {
|
||||
} 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 { COMBAT_ROTATION_VERSION, rotationProfileFor } from "../../src/game/combatRotation";
|
||||
import { subscribeRotationDecisions } from "../../src/game/rotationTelemetry";
|
||||
import {
|
||||
DEFAULT_COMPANION_COOLDOWN_POLICY,
|
||||
installCompanionCooldownPolicy,
|
||||
type CompanionCooldownPolicy,
|
||||
} from "../../src/game/companionCooldownPolicy";
|
||||
import {
|
||||
CANONICAL_BENCHMARK_GEAR_PRESET_ID,
|
||||
loadSelectableBenchmarkBuilds,
|
||||
selectableBenchmarkBuildCatalog,
|
||||
type SelectableBenchmarkBuildCatalogEntry,
|
||||
} from "../../src/game/benchmark/benchmarkBuilds";
|
||||
import type {
|
||||
BenchmarkBuildDefinition,
|
||||
@@ -67,6 +84,7 @@ interface BalanceLabConfig {
|
||||
readonly activeSlot: number;
|
||||
readonly bossCategory: BossCategory;
|
||||
readonly bossKeys?: readonly string[];
|
||||
readonly cooldownPolicy?: CompanionCooldownPolicy;
|
||||
}
|
||||
|
||||
interface MutableAbilityMetrics {
|
||||
@@ -99,6 +117,16 @@ interface MutableMemberMetrics {
|
||||
casts: number;
|
||||
minimumHealthPercent: number;
|
||||
readonly abilities: Map<string, MutableAbilityMetrics>;
|
||||
readonly rotationDecisions: Map<string, {
|
||||
profileId: string;
|
||||
ruleId: string;
|
||||
reason: string;
|
||||
abilityId: string;
|
||||
decisions: number;
|
||||
}>;
|
||||
gcdObservedMs: number;
|
||||
gcdBusyMs: number;
|
||||
readonly resourceSamples: Map<string, { samples: number; starved: number; capped: number }>;
|
||||
}
|
||||
|
||||
const scenario = (durationMs: number): BenchmarkScenarioDefinition => ({
|
||||
@@ -147,6 +175,28 @@ function bossCatalogRows() {
|
||||
}));
|
||||
}
|
||||
|
||||
function resolvedGearSummary(build: SelectableBenchmarkBuildCatalogEntry) {
|
||||
const specialization = specializationsForClass(build.classId)
|
||||
.find((definition) => definition.displayName === build.orientation);
|
||||
const context = {
|
||||
ownerId: `balance-lab-catalog:${build.id}`,
|
||||
ownerKind: "player" as const,
|
||||
classId: build.classId,
|
||||
specializationId: specialization?.id ?? null,
|
||||
role: build.role,
|
||||
level: build.level,
|
||||
};
|
||||
const items = resolvedEquipmentItems({}, [], context);
|
||||
return {
|
||||
presetId: CANONICAL_BENCHMARK_GEAR_PRESET_ID,
|
||||
model: "synthetic-specialization-heirloom-v1",
|
||||
itemCount: items.length,
|
||||
averageItemLevel: resolvedAverageItemLevel({}, [], context),
|
||||
itemLevels: [...new Set(items.map((item) => item.itemLevel))].sort((left, right) => left - right),
|
||||
stats: resolvedEquipmentStats({}, [], context),
|
||||
};
|
||||
}
|
||||
|
||||
async function writeJson(path: string, value: unknown): Promise<void> {
|
||||
const absolute = resolve(path);
|
||||
await mkdir(dirname(absolute), { recursive: true });
|
||||
@@ -166,10 +216,25 @@ function catalogPayload(source: PartyCatalogSource, level: number) {
|
||||
id: build.id,
|
||||
source: build.source,
|
||||
classId: build.classId,
|
||||
raceId: build.raceId ?? null,
|
||||
secondaryClassId: build.secondaryClassId ?? null,
|
||||
className: classById(build.classId).name,
|
||||
role: build.role,
|
||||
specialization: build.orientation,
|
||||
label: `${classById(build.classId).name} — ${build.orientation} (${build.role})`,
|
||||
specializationId: build.specializationId ?? null,
|
||||
readinessStatus: build.readinessStatus ?? null,
|
||||
characterName: build.characterName ?? null,
|
||||
talentBuildId: build.talentBuildId ?? null,
|
||||
talentBuildName: build.talentBuildName ?? null,
|
||||
talentBuildSource: build.talentBuildSource ?? null,
|
||||
talentBuildSourcePointCount: build.talentBuildSourcePointCount ?? null,
|
||||
talentBuildSourcePointsRetained: build.talentBuildSourcePointsRetained ?? null,
|
||||
talentBuildFallbackPointCount: build.talentBuildFallbackPointCount ?? null,
|
||||
defaultTalentBuild: build.defaultTalentBuild ?? true,
|
||||
label: build.secondaryClassId
|
||||
? `${classById(build.classId).name}/${classById(build.secondaryClassId).name} — ${build.characterName ?? build.orientation} (${build.role})`
|
||||
: `${classById(build.classId).name} — ${build.orientation} (${build.role})${build.talentBuildName ? ` — ${build.talentBuildName}` : ""}`,
|
||||
gear: resolvedGearSummary(build),
|
||||
})),
|
||||
bosses: bossCatalogRows(),
|
||||
};
|
||||
@@ -205,8 +270,9 @@ function configuredCompanion(
|
||||
const specialization = specializationsForClass(build.classId)
|
||||
.find((definition) => definition.displayName === build.orientation);
|
||||
return createConfiguredPartyMember({
|
||||
name: `${classById(build.classId).name} ${build.orientation}`,
|
||||
name: build.characterName ?? `${classById(build.classId).name} ${build.orientation}`,
|
||||
classId: build.classId,
|
||||
raceId: build.raceId,
|
||||
secondaryClassId: build.secondaryClassId,
|
||||
specialization: build.orientation,
|
||||
specializationId: specialization?.id ?? null,
|
||||
@@ -238,6 +304,13 @@ function abilityMetrics(member: MutableMemberMetrics, abilityId: string | undefi
|
||||
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];
|
||||
@@ -246,18 +319,30 @@ function currentHealth(actorId: string): readonly [number, number] {
|
||||
}
|
||||
|
||||
function availableAbilities(build: BenchmarkBuildDefinition) {
|
||||
const specialization = specializationsForClass(build.classId)
|
||||
.find((definition) => definition.displayName === build.orientation);
|
||||
const profile = rotationProfileFor(build.classId, specialization?.id, build.orientation);
|
||||
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;
|
||||
const hints = aiHintsForAbility(ranked);
|
||||
const rotationPriority = profile?.abilityPriority.indexOf(ranked.name) ?? -1;
|
||||
const conditional = hints.categories.some((category) => [
|
||||
"area-damage", "control", "dispel", "emergency-heal", "execute", "interrupt",
|
||||
"mitigation", "resurrection", "taunt",
|
||||
].includes(category));
|
||||
return {
|
||||
abilityId: ranked.id,
|
||||
abilityName: ranked.name,
|
||||
icon: ranked.icon,
|
||||
categories: aiHintsForAbility(ranked).categories,
|
||||
categories: hints.categories,
|
||||
cooldownSeconds: ranked.cooldownMs / 1_000,
|
||||
rotationProfileId: profile?.id ?? null,
|
||||
rotationPriority: rotationPriority >= 0 ? rotationPriority + 1 : null,
|
||||
expectedUsage: conditional ? "conditional" : rotationPriority >= 0 ? "profile" : "available",
|
||||
};
|
||||
})
|
||||
.filter((ability) => ability !== null)
|
||||
@@ -274,7 +359,16 @@ function serializedMember(
|
||||
activePlayer: metrics.actorId === PLAYER_AGGRO_ID,
|
||||
actorId: metrics.actorId,
|
||||
buildId: metrics.build.id,
|
||||
characterName: metrics.build.characterName ?? null,
|
||||
talentBuildId: metrics.build.talentBuildId ?? null,
|
||||
talentBuildName: metrics.build.talentBuildName ?? null,
|
||||
talentBuildSource: metrics.build.talentBuildSource ?? null,
|
||||
talentBuildSourcePointCount: metrics.build.talentBuildSourcePointCount ?? null,
|
||||
talentBuildSourcePointsRetained: metrics.build.talentBuildSourcePointsRetained ?? null,
|
||||
talentBuildFallbackPointCount: metrics.build.talentBuildFallbackPointCount ?? null,
|
||||
classId: metrics.build.classId,
|
||||
raceId: metrics.build.raceId ?? null,
|
||||
secondaryClassId: metrics.build.secondaryClassId ?? null,
|
||||
className: classById(metrics.build.classId).name,
|
||||
specialization: metrics.build.orientation,
|
||||
role: metrics.build.role,
|
||||
@@ -300,6 +394,18 @@ function serializedMember(
|
||||
maxHealth,
|
||||
minimumHealthPercent: metrics.minimumHealthPercent,
|
||||
casts: metrics.casts,
|
||||
gcdUptimePercent: metrics.gcdObservedMs > 0 ? metrics.gcdBusyMs / metrics.gcdObservedMs * 100 : null,
|
||||
resources: [...metrics.resourceSamples.entries()].map(([resource, samples]) => ({
|
||||
resource,
|
||||
samples: samples.samples,
|
||||
starvedPercent: samples.starved / Math.max(1, samples.samples) * 100,
|
||||
cappedPercent: samples.capped / Math.max(1, samples.samples) * 100,
|
||||
})).sort((left, right) => left.resource.localeCompare(right.resource)),
|
||||
rotationDecisions: [...metrics.rotationDecisions.values()]
|
||||
.sort((left, right) => right.decisions - left.decisions
|
||||
|| left.profileId.localeCompare(right.profileId)
|
||||
|| left.ruleId.localeCompare(right.ruleId)
|
||||
|| left.abilityId.localeCompare(right.abilityId)),
|
||||
availableAbilities: availableAbilities(metrics.build),
|
||||
abilities: [...metrics.abilities.entries()]
|
||||
.map(([abilityId, ability]) => ({ abilityId, ...ability }))
|
||||
@@ -368,13 +474,47 @@ async function runBoss(
|
||||
casts: 0,
|
||||
minimumHealthPercent: 1,
|
||||
abilities: new Map(),
|
||||
rotationDecisions: new Map(),
|
||||
gcdObservedMs: 0,
|
||||
gcdBusyMs: 0,
|
||||
resourceSamples: new Map(),
|
||||
}));
|
||||
const byActorId = new Map(memberMetrics.map((metrics) => [metrics.actorId, metrics]));
|
||||
const actorAlive = new Map<string, boolean>();
|
||||
const bossAbilities = new Map<string, MutableAbilityMetrics>();
|
||||
const pressureWindows = new Set<number>();
|
||||
const healerThroughputWindows = new Set<number>();
|
||||
const combatWindow = (occurredAt: number) => Math.max(0, Math.floor((occurredAt - 1_000_000) / 5_000));
|
||||
const stopRotationDecisions = subscribeRotationDecisions((event) => {
|
||||
const member = byActorId.get(event.actorId);
|
||||
if (!member) return;
|
||||
const key = `${event.profileId}\u0000${event.ruleId}\u0000${event.abilityId}`;
|
||||
const current = member.rotationDecisions.get(key);
|
||||
if (current) current.decisions += 1;
|
||||
else member.rotationDecisions.set(key, {
|
||||
profileId: event.profileId,
|
||||
ruleId: event.ruleId,
|
||||
reason: event.reason,
|
||||
abilityId: event.abilityId,
|
||||
decisions: 1,
|
||||
});
|
||||
});
|
||||
// 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") {
|
||||
@@ -398,6 +538,7 @@ async function runBoss(
|
||||
ability.absorbs += event.absorbed;
|
||||
ability.hits += 1;
|
||||
bossAbilities.set(key, ability);
|
||||
if (event.effectiveAmount > 0) pressureWindows.add(combatWindow(event.occurredAt));
|
||||
}
|
||||
} else if (event.kind === "healing") {
|
||||
if (source) {
|
||||
@@ -409,13 +550,17 @@ async function runBoss(
|
||||
ability.effectiveHealing += event.effectiveAmount;
|
||||
ability.overheal += event.overheal;
|
||||
ability.hits += 1;
|
||||
if (source.build.role === "healer" && event.effectiveAmount > 0) {
|
||||
healerThroughputWindows.add(combatWindow(event.occurredAt));
|
||||
}
|
||||
}
|
||||
if (target) target.healingReceived += event.effectiveAmount;
|
||||
} else if (event.kind === "absorb" && source) {
|
||||
source.absorbs += event.effectiveAmount;
|
||||
abilityMetrics(source, event.abilityId).absorbs += event.effectiveAmount;
|
||||
} else if (event.kind === "death" && target) {
|
||||
target.deathEvents += 1;
|
||||
if (source.build.role === "healer" && event.effectiveAmount > 0) {
|
||||
healerThroughputWindows.add(combatWindow(event.occurredAt));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -425,6 +570,7 @@ async function runBoss(
|
||||
const combat = useCombatStore.getState();
|
||||
combat.initializeCharacter({
|
||||
classId: activeBuild.classId,
|
||||
raceId: activeBuild.raceId,
|
||||
secondaryClassId: activeBuild.secondaryClassId,
|
||||
specializationId: specialization?.id,
|
||||
role: activeBuild.role,
|
||||
@@ -449,6 +595,9 @@ async function runBoss(
|
||||
memberTargetIds: {},
|
||||
});
|
||||
combat.synchronizeActors();
|
||||
for (const metrics of memberMetrics) {
|
||||
actorAlive.set(metrics.actorId, currentHealth(metrics.actorId)[0] > 0);
|
||||
}
|
||||
combat.registerMob(bossActorId, {
|
||||
name: entity.name,
|
||||
boss: true,
|
||||
@@ -477,8 +626,34 @@ async function runBoss(
|
||||
combat.advanceMobCombat(now);
|
||||
advanceDungeonPartyCombat(now);
|
||||
controller.act(now, activeBuild.role, runScenario, false);
|
||||
for (const member of usePartyStore.getState().members) {
|
||||
const metrics = byActorId.get(member.id);
|
||||
if (!metrics) continue;
|
||||
// Dead companions cannot act or gain/spend resources. Sampling them until
|
||||
// the fight ends makes an early death look like an idle/resource deadlock.
|
||||
if (currentHealth(member.id)[0] <= 0) continue;
|
||||
metrics.gcdObservedMs += 100;
|
||||
if (member.globalCooldownEndsAt > now || member.activeCast) metrics.gcdBusyMs += 100;
|
||||
const resources = new Set<ResourceType>([
|
||||
...(Object.keys(member.resourcePools) as ResourceType[]),
|
||||
member.resourceType,
|
||||
]);
|
||||
for (const resource of resources) {
|
||||
const maximum = companionResourceMaximum(member, resource);
|
||||
if (maximum <= 0) continue;
|
||||
const value = member.resourcePools[resource] ?? (resource === member.resourceType ? member.resource : 0);
|
||||
const sample = metrics.resourceSamples.get(resource) ?? { samples: 0, starved: 0, capped: 0 };
|
||||
sample.samples += 1;
|
||||
if (value / maximum <= 0.1) sample.starved += 1;
|
||||
if (value / maximum >= 0.95) sample.capped += 1;
|
||||
metrics.resourceSamples.set(resource, sample);
|
||||
}
|
||||
}
|
||||
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,
|
||||
@@ -501,6 +676,11 @@ async function runBoss(
|
||||
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);
|
||||
const healerResponseWindows = [...pressureWindows].filter((window) => (
|
||||
healerThroughputWindows.has(window - 1)
|
||||
|| healerThroughputWindows.has(window)
|
||||
|| healerThroughputWindows.has(window + 1)
|
||||
)).length;
|
||||
return {
|
||||
bossKey: boss.key,
|
||||
bossName: boss.name,
|
||||
@@ -521,6 +701,10 @@ async function runBoss(
|
||||
incomingDamage,
|
||||
rawIncomingDamage,
|
||||
incomingDtps: incomingDamage / Math.max(0.1, activeSeconds),
|
||||
pressureWindows: pressureWindows.size,
|
||||
healerThroughputWindows: healerThroughputWindows.size,
|
||||
healerResponseWindows,
|
||||
healerResponseRate: healerResponseWindows / Math.max(1, pressureWindows.size),
|
||||
deaths: members.filter((member) => !member.alive).length,
|
||||
deathEvents: members.reduce((total, member) => total + member.deathEvents, 0),
|
||||
bossAttacks: mob?.attackRevision ?? 0,
|
||||
@@ -531,6 +715,8 @@ async function runBoss(
|
||||
|| left.abilityId.localeCompare(right.abilityId)),
|
||||
};
|
||||
} finally {
|
||||
stopRotationDecisions();
|
||||
stopPresentation();
|
||||
stopEvents();
|
||||
restoreClock();
|
||||
clearMobRuntimeRegistry();
|
||||
@@ -547,6 +733,7 @@ async function simulateConfig(config: BalanceLabConfig, output: string): Promise
|
||||
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.");
|
||||
installCompanionCooldownPolicy(config.cooldownPolicy ?? DEFAULT_COMPANION_COOLDOWN_POLICY);
|
||||
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) => {
|
||||
@@ -589,7 +776,16 @@ async function simulateConfig(config: BalanceLabConfig, output: string): Promise
|
||||
slot,
|
||||
activePlayer: slot === config.activeSlot,
|
||||
buildId: build.id,
|
||||
characterName: build.characterName ?? null,
|
||||
talentBuildId: build.talentBuildId ?? null,
|
||||
talentBuildName: build.talentBuildName ?? null,
|
||||
talentBuildSource: build.talentBuildSource ?? null,
|
||||
talentBuildSourcePointCount: build.talentBuildSourcePointCount ?? null,
|
||||
talentBuildSourcePointsRetained: build.talentBuildSourcePointsRetained ?? null,
|
||||
talentBuildFallbackPointCount: build.talentBuildFallbackPointCount ?? null,
|
||||
classId: build.classId,
|
||||
raceId: build.raceId ?? null,
|
||||
secondaryClassId: build.secondaryClassId ?? null,
|
||||
className: classById(build.classId).name,
|
||||
specialization: build.orientation,
|
||||
role: build.role,
|
||||
|
||||
+241
-30
@@ -41,17 +41,23 @@ def vite_node_command() -> Path:
|
||||
return executable
|
||||
|
||||
|
||||
def runtime_session_command(source: str, level: int) -> list[str]:
|
||||
return [
|
||||
str(vite_node_command()),
|
||||
"--mode",
|
||||
"balance-lab",
|
||||
str(RUNTIME),
|
||||
"session",
|
||||
"--source",
|
||||
source,
|
||||
"--level",
|
||||
str(level),
|
||||
]
|
||||
|
||||
|
||||
class RuntimeSession:
|
||||
def __init__(self, source: str, level: int, workspace: Path) -> None:
|
||||
command = [
|
||||
str(vite_node_command()),
|
||||
str(RUNTIME),
|
||||
"session",
|
||||
"--source",
|
||||
source,
|
||||
"--level",
|
||||
str(level),
|
||||
]
|
||||
command = runtime_session_command(source, level)
|
||||
self.process = subprocess.Popen(
|
||||
command,
|
||||
cwd=ROOT,
|
||||
@@ -112,7 +118,7 @@ def build_aliases(build: dict[str, Any]) -> set[str]:
|
||||
class_name = build["className"]
|
||||
role = build["role"]
|
||||
specialization = build["specialization"]
|
||||
return {
|
||||
aliases = {
|
||||
normalized(build["id"]),
|
||||
normalized(f"{class_id}:{specialization}"),
|
||||
normalized(f"{class_name}:{specialization}"),
|
||||
@@ -120,6 +126,11 @@ def build_aliases(build: dict[str, Any]) -> set[str]:
|
||||
normalized(f"{class_name}:{role}:{specialization}"),
|
||||
normalized(build["label"]),
|
||||
}
|
||||
for value in (build.get("characterName"), build.get("talentBuildId"), build.get("talentBuildName")):
|
||||
if value:
|
||||
aliases.add(normalized(str(value)))
|
||||
aliases.add(normalized(f"{class_id}:{specialization}:{value}"))
|
||||
return aliases
|
||||
|
||||
|
||||
def resolve_build(selector: str, builds: Sequence[dict[str, Any]]) -> dict[str, Any]:
|
||||
@@ -129,6 +140,9 @@ def resolve_build(selector: str, builds: Sequence[dict[str, Any]]) -> dict[str,
|
||||
return matches[0]
|
||||
if not matches:
|
||||
raise SystemExit(f"No party member matches '{selector}'. Use --list-members to see valid choices.")
|
||||
defaults = [match for match in matches if match.get("defaultTalentBuild")]
|
||||
if len(defaults) == 1:
|
||||
return defaults[0]
|
||||
labels = ", ".join(match["label"] for match in matches)
|
||||
raise SystemExit(f"Party member selector '{selector}' is ambiguous: {labels}")
|
||||
|
||||
@@ -150,6 +164,9 @@ def resolve_boss(selector: str, bosses: Sequence[dict[str, Any]]) -> dict[str, A
|
||||
|
||||
def random_party(builds: Sequence[dict[str, Any]], seed: int) -> list[dict[str, Any]]:
|
||||
generator = random.Random(seed)
|
||||
# Named alternatives remain directly selectable and are covered by "all",
|
||||
# but ordinary random/filler seats should use the declared default profile.
|
||||
builds = random_party_builds(builds)
|
||||
by_role: dict[str, list[dict[str, Any]]] = defaultdict(list)
|
||||
for build in builds:
|
||||
by_role[build["role"]].append(build)
|
||||
@@ -159,6 +176,39 @@ def random_party(builds: Sequence[dict[str, Any]], seed: int) -> list[dict[str,
|
||||
return [generator.choice(by_role["tank"]), generator.choice(by_role["healer"]), *damage]
|
||||
|
||||
|
||||
def random_party_builds(builds: Sequence[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
defaults = [build for build in builds if build.get("defaultTalentBuild")]
|
||||
return defaults or list(builds)
|
||||
|
||||
|
||||
def random_party_capacity(builds: Sequence[dict[str, Any]]) -> int:
|
||||
"""Return the number of unique 1/1/3 compositions in the random pool."""
|
||||
by_role: dict[str, int] = defaultdict(int)
|
||||
for build in random_party_builds(builds):
|
||||
by_role[build["role"]] += 1
|
||||
if not by_role["tank"] or not by_role["healer"] or by_role["damage"] < 3:
|
||||
return 0
|
||||
return by_role["tank"] * by_role["healer"] * math.comb(by_role["damage"], 3)
|
||||
|
||||
|
||||
def readiness_eligible_builds(
|
||||
builds: Sequence[dict[str, Any]],
|
||||
policy: dict[str, Any] | None,
|
||||
include_beta_specs: bool,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Return the Ready pool across every source; Beta requires explicit opt-in."""
|
||||
published = policy or {}
|
||||
config = published.get("config", published)
|
||||
statuses = config.get("statuses", {}) if isinstance(config, dict) else {}
|
||||
eligible: list[dict[str, Any]] = []
|
||||
for build in builds:
|
||||
specialization_id = build.get("specializationId")
|
||||
status = statuses.get(specialization_id, build.get("readinessStatus", "needs-testing"))
|
||||
if status == "ready" or (include_beta_specs and status == "beta"):
|
||||
eligible.append(build)
|
||||
return eligible
|
||||
|
||||
|
||||
def automatic_parties(
|
||||
builds: Sequence[dict[str, Any]],
|
||||
sources: Sequence[str],
|
||||
@@ -169,14 +219,28 @@ def automatic_parties(
|
||||
configurations: list[dict[str, Any]] = []
|
||||
for source in sources:
|
||||
source_builds = [build for build in builds if build["source"] == source]
|
||||
capacity = random_party_capacity(source_builds)
|
||||
if capacity < 1:
|
||||
raise SystemExit(
|
||||
f"The {source} eligible pool does not expose enough tank, healer, and damage builds "
|
||||
"for a 1/1/3 party."
|
||||
)
|
||||
target_configurations = min(configurations_per_source, capacity)
|
||||
if target_configurations < configurations_per_source:
|
||||
print(
|
||||
f"[balance-lab] {source} eligible pool has {capacity} unique party "
|
||||
f"configuration{'s' if capacity != 1 else ''}; running each once instead of "
|
||||
f"duplicating {configurations_per_source - capacity} identical parties.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
seen: set[tuple[str, str, tuple[str, ...]]] = set()
|
||||
attempts = 0
|
||||
while len(seen) < configurations_per_source:
|
||||
while len(seen) < target_configurations:
|
||||
attempts += 1
|
||||
if attempts > max(1_000, configurations_per_source * 100):
|
||||
if attempts > max(1_000, target_configurations * 100):
|
||||
raise SystemExit(
|
||||
f"Could not generate {configurations_per_source} unique {source} parties. "
|
||||
"Request fewer party configurations."
|
||||
f"Could not generate {target_configurations} unique {source} parties from a "
|
||||
f"calculated capacity of {capacity}."
|
||||
)
|
||||
party = random_party(source_builds, seed + offsets[source] + attempts - 1)
|
||||
signature = (party[0]["id"], party[1]["id"], tuple(sorted(member["id"] for member in party[2:])))
|
||||
@@ -200,6 +264,7 @@ def coverage_parties(
|
||||
sources: Sequence[str],
|
||||
selected_build_ids: Sequence[str] | None,
|
||||
seed: int,
|
||||
filler_builds: Sequence[dict[str, Any]] | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Build the minimum deterministic party set that covers every requested build."""
|
||||
requested = set(selected_build_ids) if selected_build_ids is not None else None
|
||||
@@ -214,7 +279,14 @@ def coverage_parties(
|
||||
continue
|
||||
available_by_role: dict[str, list[dict[str, Any]]] = defaultdict(list)
|
||||
targets_by_role: dict[str, list[dict[str, Any]]] = defaultdict(list)
|
||||
for build in source_builds:
|
||||
filler_catalog = list(filler_builds or builds)
|
||||
# Prefer same-source companions, then fall back to the globally Ready
|
||||
# pool so an intentional Needs Testing target can still be diagnosed
|
||||
# without admitting other uncertified seats.
|
||||
source_fillers = [build for build in filler_catalog if build["source"] == source]
|
||||
source_fillers.extend(build for build in filler_catalog if build["source"] != source)
|
||||
default_builds = [build for build in source_fillers if build.get("defaultTalentBuild")]
|
||||
for build in default_builds or source_fillers:
|
||||
available_by_role[build["role"]].append(build)
|
||||
for build in targets:
|
||||
targets_by_role[build["role"]].append(build)
|
||||
@@ -788,7 +860,8 @@ def create_reports(raw_path: Path, output_dir: Path) -> dict[str, Any]:
|
||||
"policyId", "rotationVersion", "bossKey", "bossName", "bossCategory", "dungeonId", "dungeonTitle", "seed", "outcome",
|
||||
"elapsedSeconds", "timeToDefeatSeconds", "bossLevel", "bossMaxHealth", "bossRemainingHealth",
|
||||
"partyDamage", "bossDamage", "partyDps", "bossDpsTaken", "incomingDamage", "rawIncomingDamage",
|
||||
"incomingDtps", "deaths", "deathEvents", "bossAttacks",
|
||||
"incomingDtps", "pressureWindows", "healerThroughputWindows", "healerResponseWindows",
|
||||
"healerResponseRate", "deaths", "deathEvents", "bossAttacks",
|
||||
))
|
||||
member_abilities = flattened_abilities(raw, "members")
|
||||
write_csv(output_dir / "member_abilities.csv", member_abilities, (
|
||||
@@ -816,6 +889,13 @@ def aggregate_class_specs(
|
||||
"className": class_name,
|
||||
"specialization": specialization,
|
||||
"role": role,
|
||||
"characterName": observations[0].get("characterName"),
|
||||
"talentBuildId": observations[0].get("talentBuildId"),
|
||||
"talentBuildName": observations[0].get("talentBuildName"),
|
||||
"talentBuildSource": observations[0].get("talentBuildSource"),
|
||||
"talentBuildSourcePointCount": observations[0].get("talentBuildSourcePointCount"),
|
||||
"talentBuildSourcePointsRetained": observations[0].get("talentBuildSourcePointsRetained"),
|
||||
"talentBuildFallbackPointCount": observations[0].get("talentBuildFallbackPointCount"),
|
||||
"partyConfigurations": len({row["partyConfigurationId"] for row in observations}),
|
||||
"runs": len(observations),
|
||||
"dps": weighted_rate(observations, "damage"),
|
||||
@@ -823,6 +903,10 @@ def aggregate_class_specs(
|
||||
"hps": weighted_rate(observations, "healing"),
|
||||
"hpsReceived": weighted_rate(observations, "healingReceived"),
|
||||
"dtps": weighted_rate(observations, "damageTaken"),
|
||||
"gcdUptimePercent": mean(
|
||||
float(row["gcdUptimePercent"]) for row in observations
|
||||
if row.get("gcdUptimePercent") is not None
|
||||
),
|
||||
"totalDamage": sum(row["damage"] for row in observations),
|
||||
"totalDamageTaken": sum(row["damageTaken"] for row in observations),
|
||||
"totalHealing": sum(row["healing"] for row in observations),
|
||||
@@ -872,9 +956,19 @@ def aggregate_class_abilities(
|
||||
available_runs = sum(bool(row["available"]) for row in observations)
|
||||
class_damage = sum(float(row["damage"]) for row in class_observations)
|
||||
metadata = next((row for row in observations if row["available"]), observations[0])
|
||||
rotation_decisions = sum(int(row.get("rotationDecisions", 0)) for row in observations)
|
||||
effective_healing = sum(float(row["effectiveHealing"]) for row in observations)
|
||||
absorbs = sum(float(row["absorbs"]) for row in observations)
|
||||
if active_runs == 0 and available_runs > 0:
|
||||
# A profile entry is often an alternative, execute, or lower-priority
|
||||
# fallback and is not required to appear in every short fight. Flag it
|
||||
# only when the AI repeatedly selected it without any committed cast or
|
||||
# effect; one terminal decision per run may simply be interrupted by
|
||||
# the boss dying or the duration limit.
|
||||
if (active_runs == 0 and available_runs > 0
|
||||
and rotation_decisions > available_runs
|
||||
and metadata.get("expectedUsage") == "profile"):
|
||||
status = "missing-profile-action"
|
||||
elif active_runs == 0 and available_runs > 0:
|
||||
status = "unused"
|
||||
elif effective_damage > 0:
|
||||
status = "damage"
|
||||
@@ -894,6 +988,10 @@ def aggregate_class_abilities(
|
||||
"icon": metadata.get("icon") or "",
|
||||
"categories": ", ".join(metadata.get("categories") or []),
|
||||
"cooldownSeconds": metadata.get("cooldownSeconds"),
|
||||
"rotationProfileId": metadata.get("rotationProfileId"),
|
||||
"rotationPriority": metadata.get("rotationPriority"),
|
||||
"expectedUsage": metadata.get("expectedUsage", "available"),
|
||||
"rotationDecisions": rotation_decisions,
|
||||
"partyConfigurations": len({row["partyConfigurationId"] for row in class_observations}),
|
||||
"bosses": len({row["bossKey"] for row in class_observations}),
|
||||
"runs": len(class_observations),
|
||||
@@ -914,7 +1012,8 @@ def aggregate_class_abilities(
|
||||
"overheal": sum(float(row["overheal"]) for row in observations),
|
||||
"absorbs": absorbs,
|
||||
"status": status,
|
||||
"unusedAvailable": status == "unused" and available_runs > 0,
|
||||
"unusedAvailable": status in {"unused", "missing-profile-action"} and available_runs > 0,
|
||||
"missingProfileAction": status == "missing-profile-action",
|
||||
})
|
||||
rows.sort(key=lambda row: (
|
||||
row["source"], row["className"], row["specialization"],
|
||||
@@ -1101,6 +1200,8 @@ def create_suite_reports(entries: Sequence[dict[str, Any]], output_dir: Path, le
|
||||
class_groups: dict[tuple[str, ...], list[dict[str, Any]]] = defaultdict(list)
|
||||
class_boss_groups: dict[tuple[str, ...], list[dict[str, Any]]] = defaultdict(list)
|
||||
class_ability_groups: dict[tuple[str, ...], list[dict[str, Any]]] = defaultdict(list)
|
||||
npc_class_groups: dict[tuple[str, ...], list[dict[str, Any]]] = defaultdict(list)
|
||||
npc_class_ability_groups: dict[tuple[str, ...], list[dict[str, Any]]] = defaultdict(list)
|
||||
policy_ids: set[str] = set()
|
||||
rotation_versions: set[str] = set()
|
||||
for entry in entries:
|
||||
@@ -1151,10 +1252,15 @@ def create_suite_reports(entries: Sequence[dict[str, Any]], output_dir: Path, le
|
||||
)
|
||||
class_groups[key].append(observation)
|
||||
class_boss_groups[(*key, run["bossKey"], run["bossName"], run["dungeonTitle"])].append(observation)
|
||||
if not member.get("activePlayer"):
|
||||
npc_class_groups[key].append(observation)
|
||||
available = {
|
||||
ability["abilityId"]: ability for ability in member.get("availableAbilities", [])
|
||||
}
|
||||
observed = {ability["abilityId"]: ability for ability in member.get("abilities", [])}
|
||||
decisions_by_ability: dict[str, int] = defaultdict(int)
|
||||
for decision in member.get("rotationDecisions", []):
|
||||
decisions_by_ability[decision["abilityId"]] += int(decision.get("decisions", 0))
|
||||
for ability_id in sorted((set(available) | set(observed)) - {"balance-lab-pull"}):
|
||||
expected = available.get(ability_id, {})
|
||||
metrics = observed.get(ability_id, {})
|
||||
@@ -1162,7 +1268,7 @@ def create_suite_reports(entries: Sequence[dict[str, Any]], output_dir: Path, le
|
||||
"casts", "hits", "rawDamage", "effectiveDamage", "rawHealing",
|
||||
"effectiveHealing", "overheal", "absorbs",
|
||||
))
|
||||
class_ability_groups[(*key, ability_id)].append({
|
||||
ability_observation = {
|
||||
"partyConfigurationId": entry["id"],
|
||||
"bossKey": run["bossKey"],
|
||||
"elapsedSeconds": run["elapsedSeconds"],
|
||||
@@ -1170,6 +1276,10 @@ def create_suite_reports(entries: Sequence[dict[str, Any]], output_dir: Path, le
|
||||
"icon": expected.get("icon"),
|
||||
"categories": expected.get("categories", []),
|
||||
"cooldownSeconds": expected.get("cooldownSeconds"),
|
||||
"rotationProfileId": expected.get("rotationProfileId"),
|
||||
"rotationPriority": expected.get("rotationPriority"),
|
||||
"expectedUsage": expected.get("expectedUsage", "available"),
|
||||
"rotationDecisions": decisions_by_ability[ability_id],
|
||||
"available": ability_id in available,
|
||||
"used": used,
|
||||
"casts": metrics.get("casts", 0),
|
||||
@@ -1180,10 +1290,15 @@ def create_suite_reports(entries: Sequence[dict[str, Any]], output_dir: Path, le
|
||||
"effectiveHealing": metrics.get("effectiveHealing", 0),
|
||||
"overheal": metrics.get("overheal", 0),
|
||||
"absorbs": metrics.get("absorbs", 0),
|
||||
})
|
||||
}
|
||||
class_ability_groups[(*key, ability_id)].append(ability_observation)
|
||||
if not member.get("activePlayer"):
|
||||
npc_class_ability_groups[(*key, ability_id)].append(ability_observation)
|
||||
bosses, pressure_median = aggregate_boss_runs(compact_runs)
|
||||
class_specs = aggregate_class_specs(class_groups)
|
||||
class_abilities = aggregate_class_abilities(class_ability_groups, class_groups)
|
||||
npc_class_specs = aggregate_class_specs(npc_class_groups)
|
||||
npc_class_abilities = aggregate_class_abilities(npc_class_ability_groups, npc_class_groups)
|
||||
class_spec_by_boss = []
|
||||
for key, observations in class_boss_groups.items():
|
||||
source, build_id, class_id, class_name, specialization, role, boss_key, boss_name, dungeon_title = key
|
||||
@@ -1227,10 +1342,14 @@ def create_suite_reports(entries: Sequence[dict[str, Any]], output_dir: Path, le
|
||||
"runs": len(compact_runs),
|
||||
"classSpecs": len(class_specs),
|
||||
"classAbilities": len(class_abilities),
|
||||
"npcClassSpecs": len(npc_class_specs),
|
||||
"npcClassAbilities": len(npc_class_abilities),
|
||||
"bossPressureMedianDtps": pressure_median,
|
||||
},
|
||||
"classSpecs": class_specs,
|
||||
"classAbilities": class_abilities,
|
||||
"npcClassSpecs": npc_class_specs,
|
||||
"npcClassAbilities": npc_class_abilities,
|
||||
"partyConfigurations": party_rows,
|
||||
"bosses": bosses,
|
||||
"bossByParty": boss_by_party,
|
||||
@@ -1244,16 +1363,34 @@ def create_suite_reports(entries: Sequence[dict[str, Any]], output_dir: Path, le
|
||||
"defeatRate", "wipeRate", "averageDeaths", "reportPath",
|
||||
))
|
||||
write_csv(output_dir / "class_specs_across_parties.csv", class_specs, (
|
||||
"source", "buildId", "classId", "className", "specialization", "role", "partyConfigurations",
|
||||
"runs", "dps", "bossDps", "hps", "hpsReceived", "dtps", "damageVsSourceRoleMedian", "damageFlag",
|
||||
"source", "buildId", "characterName", "talentBuildId", "talentBuildName", "talentBuildSource",
|
||||
"talentBuildSourcePointCount", "talentBuildSourcePointsRetained", "talentBuildFallbackPointCount",
|
||||
"classId", "className", "specialization", "role", "partyConfigurations",
|
||||
"runs", "dps", "bossDps", "hps", "hpsReceived", "dtps", "gcdUptimePercent", "damageVsSourceRoleMedian", "damageFlag",
|
||||
"totalDamage", "totalDamageTaken", "totalHealing", "totalHealingReceived", "totalAbsorbs", "deathEvents",
|
||||
))
|
||||
write_csv(output_dir / "class_abilities_across_parties.csv", class_abilities, (
|
||||
"source", "buildId", "classId", "className", "specialization", "role", "abilityId",
|
||||
"abilityName", "categories", "cooldownSeconds", "partyConfigurations", "bosses", "runs",
|
||||
"abilityName", "categories", "cooldownSeconds", "rotationProfileId", "rotationPriority",
|
||||
"expectedUsage", "rotationDecisions", "partyConfigurations", "bosses", "runs",
|
||||
"availableRuns", "activeRuns", "usageRate", "casts", "castsPerMinute", "hits", "hitsPerRun",
|
||||
"rawDamage", "effectiveDamage", "dps", "damageShare", "averageDamagePerHit", "rawHealing",
|
||||
"effectiveHealing", "overheal", "absorbs", "status", "unusedAvailable",
|
||||
"effectiveHealing", "overheal", "absorbs", "status", "unusedAvailable", "missingProfileAction",
|
||||
))
|
||||
write_csv(output_dir / "npc_class_specs_across_parties.csv", npc_class_specs, (
|
||||
"source", "buildId", "characterName", "talentBuildId", "talentBuildName", "talentBuildSource",
|
||||
"talentBuildSourcePointCount", "talentBuildSourcePointsRetained", "talentBuildFallbackPointCount",
|
||||
"classId", "className", "specialization", "role", "partyConfigurations",
|
||||
"runs", "dps", "bossDps", "hps", "hpsReceived", "dtps", "gcdUptimePercent", "damageVsSourceRoleMedian", "damageFlag",
|
||||
"totalDamage", "totalDamageTaken", "totalHealing", "totalHealingReceived", "totalAbsorbs", "deathEvents",
|
||||
))
|
||||
write_csv(output_dir / "npc_class_abilities_across_parties.csv", npc_class_abilities, (
|
||||
"source", "buildId", "classId", "className", "specialization", "role", "abilityId",
|
||||
"abilityName", "categories", "cooldownSeconds", "rotationProfileId", "rotationPriority",
|
||||
"expectedUsage", "rotationDecisions", "partyConfigurations", "bosses", "runs",
|
||||
"availableRuns", "activeRuns", "usageRate", "casts", "castsPerMinute", "hits", "hitsPerRun",
|
||||
"rawDamage", "effectiveDamage", "dps", "damageShare", "averageDamagePerHit", "rawHealing",
|
||||
"effectiveHealing", "overheal", "absorbs", "status", "unusedAvailable", "missingProfileAction",
|
||||
))
|
||||
write_csv(output_dir / "bosses_across_parties.csv", bosses, (
|
||||
"bossKey", "bossName", "category", "dungeonId", "dungeonTitle", "runs", "incomingDtps",
|
||||
@@ -1276,7 +1413,8 @@ def create_suite_reports(entries: Sequence[dict[str, Any]], output_dir: Path, le
|
||||
"dungeonTitle", "seed", "outcome",
|
||||
"elapsedSeconds", "timeToDefeatSeconds", "bossLevel", "bossMaxHealth", "bossRemainingHealth",
|
||||
"partyDamage", "bossDamage", "partyDps", "bossDpsTaken", "incomingDamage", "rawIncomingDamage",
|
||||
"incomingDtps", "deaths", "deathEvents", "bossAttacks",
|
||||
"incomingDtps", "pressureWindows", "healerThroughputWindows", "healerResponseWindows",
|
||||
"healerResponseRate", "deaths", "deathEvents", "bossAttacks",
|
||||
))
|
||||
return report
|
||||
|
||||
@@ -1294,12 +1432,22 @@ def parser() -> argparse.ArgumentParser:
|
||||
result.add_argument("--party-configurations", type=int, default=10, help="automatic parties per selected source")
|
||||
result.add_argument("--member", action="append", default=[], help="manual build ID or class:spec selector; repeat five times")
|
||||
result.add_argument("--active-slot", type=int, help="1-based member controlled by the benchmark policy; defaults to healer")
|
||||
result.add_argument("--seat-sweep", action="store_true",
|
||||
help="run every party five times so each member is active once and an NPC four times")
|
||||
result.add_argument("--boss-category", choices=("all", "wow", "rom"), default="all")
|
||||
result.add_argument("--boss", action="append", default=[], help="boss key/name selector; repeat as needed; default is every boss")
|
||||
result.add_argument("--simulations-per-party", type=int, default=3,
|
||||
help="deterministic runs for each party/boss pairing")
|
||||
result.add_argument("--seeds", type=parse_seeds, help="explicit comma-separated seeds; overrides --simulations-per-party")
|
||||
result.add_argument("--duration", type=int, default=60, help="maximum fight duration in seconds")
|
||||
result.add_argument("--cooldown-policy", type=Path,
|
||||
help="JSON companion cooldown policy; defaults to the built-in generic rules")
|
||||
result.add_argument("--companion-readiness-policy", type=Path,
|
||||
help="published companion readiness policy JSON; defaults to shipped catalog statuses")
|
||||
result.add_argument("--include-beta-specs", action="store_true",
|
||||
help="include Beta specs in automatic, random, and filler NPC seats")
|
||||
result.add_argument("--ready-only", action="store_true",
|
||||
help="reject explicit Needs Testing targets; used by certification and regression runs")
|
||||
result.add_argument("--output-dir", type=Path, help="report directory")
|
||||
result.add_argument("--list-members", action="store_true")
|
||||
result.add_argument("--list-bosses", action="store_true")
|
||||
@@ -1343,14 +1491,39 @@ def main(argv: Sequence[str] | None = None) -> int:
|
||||
if args.simulations_per_party < 1:
|
||||
session.close()
|
||||
raise SystemExit("--simulations-per-party must be at least 1")
|
||||
if args.seat_sweep and args.active_slot is not None:
|
||||
session.close()
|
||||
raise SystemExit("--seat-sweep and --active-slot cannot be used together")
|
||||
seeds = args.seeds or list(range(1, args.simulations_per_party + 1))
|
||||
cooldown_policy = None
|
||||
if args.cooldown_policy:
|
||||
try:
|
||||
cooldown_policy = json.loads(args.cooldown_policy.resolve().read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as error:
|
||||
session.close()
|
||||
raise SystemExit(f"Could not read --cooldown-policy: {error}") from error
|
||||
readiness_policy = None
|
||||
if args.companion_readiness_policy:
|
||||
try:
|
||||
readiness_policy = json.loads(args.companion_readiness_policy.resolve().read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as error:
|
||||
session.close()
|
||||
raise SystemExit(f"Could not read --companion-readiness-policy: {error}") from error
|
||||
eligible_builds = readiness_eligible_builds(builds, readiness_policy, args.include_beta_specs)
|
||||
if args.party_mode == "automatic":
|
||||
sources = ("wow", "coa", "rom") if args.source == "all" else (args.source,)
|
||||
requested_sources = ("wow", "coa", "rom") if args.source == "all" else (args.source,)
|
||||
sources = requested_sources if args.spec_selection == "specific" else tuple(
|
||||
source for source in requested_sources
|
||||
if any(build["source"] == source for build in eligible_builds)
|
||||
)
|
||||
if not sources:
|
||||
session.close()
|
||||
raise SystemExit("No Ready companion builds exist for the selected source. Promote specs before running automatic coverage.")
|
||||
if args.spec_selection != "specific" and args.spec:
|
||||
session.close()
|
||||
raise SystemExit("--spec values require --spec-selection specific")
|
||||
if args.spec_selection == "random":
|
||||
configurations = automatic_parties(builds, sources, args.party_configurations, args.party_seed)
|
||||
configurations = automatic_parties(eligible_builds, sources, args.party_configurations, args.party_seed)
|
||||
selected_build_ids: list[str] = []
|
||||
else:
|
||||
if args.spec_selection == "specific" and not args.spec:
|
||||
@@ -1359,11 +1532,20 @@ def main(argv: Sequence[str] | None = None) -> int:
|
||||
selected_build_ids = list(dict.fromkeys(
|
||||
resolve_build(selector, builds)["id"] for selector in args.spec
|
||||
))
|
||||
if args.ready_only:
|
||||
eligible_ids = {build["id"] for build in eligible_builds}
|
||||
rejected = [build_id for build_id in selected_build_ids if build_id not in eligible_ids]
|
||||
if rejected:
|
||||
session.close()
|
||||
raise SystemExit(
|
||||
"--ready-only rejected Needs Testing targets: " + ", ".join(rejected)
|
||||
)
|
||||
configurations = coverage_parties(
|
||||
builds,
|
||||
eligible_builds if args.spec_selection == "all" else builds,
|
||||
sources,
|
||||
None if args.spec_selection == "all" else selected_build_ids,
|
||||
args.party_seed,
|
||||
eligible_builds,
|
||||
)
|
||||
if args.active_slot is not None:
|
||||
if not 1 <= args.active_slot <= 5:
|
||||
@@ -1375,13 +1557,23 @@ def main(argv: Sequence[str] | None = None) -> int:
|
||||
session.close()
|
||||
raise SystemExit("Manual mode requires exactly five --member values.")
|
||||
party = [resolve_build(selector, builds) for selector in args.member]
|
||||
if args.ready_only:
|
||||
eligible_ids = {build["id"] for build in eligible_builds}
|
||||
rejected = [build["id"] for build in party if build["id"] not in eligible_ids]
|
||||
if rejected:
|
||||
session.close()
|
||||
raise SystemExit("--ready-only rejected Needs Testing members: " + ", ".join(rejected))
|
||||
active_slot = args.active_slot or next(
|
||||
(index + 1 for index, build in enumerate(party) if build["role"] == "healer"), 1
|
||||
)
|
||||
configurations = [{"id": f"{args.source}-party-001", "source": args.source,
|
||||
"party": party, "activeSlot": active_slot - 1}]
|
||||
else:
|
||||
party = random_party(builds, args.party_seed)
|
||||
random_pool = [
|
||||
build for build in eligible_builds
|
||||
if args.source == "all" or build["source"] == args.source
|
||||
]
|
||||
party = random_party(random_pool, args.party_seed)
|
||||
active_slot = args.active_slot or next(
|
||||
(index + 1 for index, build in enumerate(party) if build["role"] == "healer"), 1
|
||||
)
|
||||
@@ -1390,6 +1582,17 @@ def main(argv: Sequence[str] | None = None) -> int:
|
||||
if any(not 0 <= configuration["activeSlot"] < 5 for configuration in configurations):
|
||||
session.close()
|
||||
raise SystemExit("--active-slot must be from 1 through 5")
|
||||
if args.seat_sweep:
|
||||
configurations = [
|
||||
{
|
||||
**configuration,
|
||||
"id": f"{configuration['id']}-seat-{slot + 1}",
|
||||
"seatSweepGroupId": configuration["id"],
|
||||
"activeSlot": slot,
|
||||
}
|
||||
for configuration in configurations
|
||||
for slot in range(5)
|
||||
]
|
||||
bosses = [resolve_boss(selector, catalog["bosses"]) for selector in args.boss]
|
||||
boss_count = len(bosses) if bosses else sum(
|
||||
args.boss_category == "all" or boss["category"] == args.boss_category for boss in catalog["bosses"]
|
||||
@@ -1400,13 +1603,17 @@ def main(argv: Sequence[str] | None = None) -> int:
|
||||
f"x {len(seeds)} runs per party/boss)..."
|
||||
)
|
||||
entries = []
|
||||
automatic = args.party_mode == "automatic"
|
||||
automatic = args.party_mode == "automatic" or args.seat_sweep
|
||||
if automatic:
|
||||
write_json(output_dir / "suite-config.json", {
|
||||
"schemaVersion": 1,
|
||||
"policyId": catalog["policyId"],
|
||||
"rotationVersion": catalog["rotationVersion"],
|
||||
"mode": "automatic",
|
||||
"seatSweep": args.seat_sweep,
|
||||
"includeBetaSpecs": args.include_beta_specs,
|
||||
"readyOnly": args.ready_only,
|
||||
"companionReadinessPolicy": readiness_policy,
|
||||
"specSelection": args.spec_selection,
|
||||
"selectedBuildIds": selected_build_ids,
|
||||
"source": args.source,
|
||||
@@ -1422,6 +1629,7 @@ def main(argv: Sequence[str] | None = None) -> int:
|
||||
"parties": [{
|
||||
"id": configuration["id"],
|
||||
"source": configuration["source"],
|
||||
"seatSweepGroupId": configuration.get("seatSweepGroupId"),
|
||||
"activeSlot": configuration["activeSlot"],
|
||||
"partyBuildIds": [build["id"] for build in configuration["party"]],
|
||||
} for configuration in configurations],
|
||||
@@ -1439,6 +1647,9 @@ def main(argv: Sequence[str] | None = None) -> int:
|
||||
"partyBuildIds": [build["id"] for build in party],
|
||||
"activeSlot": configuration["activeSlot"],
|
||||
"bossCategory": args.boss_category,
|
||||
**({"cooldownPolicy": cooldown_policy} if cooldown_policy else {}),
|
||||
**({"seatSweepGroupId": configuration["seatSweepGroupId"]}
|
||||
if configuration.get("seatSweepGroupId") else {}),
|
||||
**({"bossKeys": [boss["key"] for boss in bosses]} if bosses else {}),
|
||||
}
|
||||
config_path = party_dir / "config.json"
|
||||
|
||||
@@ -0,0 +1,537 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run and compare repeatable local Balance Lab level brackets."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable, Sequence
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
BALANCE_LAB = ROOT / "scripts" / "balance_lab.py"
|
||||
|
||||
|
||||
def write_text(path: Path, value: str) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(value.rstrip() + "\n", encoding="utf-8", newline="\n")
|
||||
|
||||
|
||||
def write_json(path: Path, value: Any) -> None:
|
||||
write_text(path, json.dumps(value, indent=2, ensure_ascii=False))
|
||||
|
||||
|
||||
def write_csv(path: Path, rows: Sequence[dict[str, Any]], fields: Sequence[str]) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with path.open("w", encoding="utf-8", newline="") as stream:
|
||||
writer = csv.DictWriter(stream, fieldnames=fields, extrasaction="ignore", lineterminator="\n")
|
||||
writer.writeheader()
|
||||
writer.writerows(rows)
|
||||
|
||||
|
||||
def bracket_levels(step: int) -> list[int]:
|
||||
if step not in (1, 5, 10):
|
||||
raise ValueError("Bracket step must be 1, 5, or 10.")
|
||||
if step == 1:
|
||||
return list(range(1, 81))
|
||||
return [1, *range(step, 81, step)]
|
||||
|
||||
|
||||
def parse_levels(value: str) -> list[int]:
|
||||
try:
|
||||
levels = sorted({int(part.strip()) for part in value.split(",") if part.strip()})
|
||||
except ValueError as error:
|
||||
raise argparse.ArgumentTypeError("Levels must be comma-separated integers.") from error
|
||||
if not levels or levels[0] < 1 or levels[-1] > 80:
|
||||
raise argparse.ArgumentTypeError("Levels must be between 1 and 80.")
|
||||
return levels
|
||||
|
||||
|
||||
def build_balance_lab_command(args: argparse.Namespace, level: int, output_dir: Path) -> list[str]:
|
||||
command = [
|
||||
sys.executable,
|
||||
str(BALANCE_LAB),
|
||||
"--source", args.source,
|
||||
"--level", str(level),
|
||||
"--party-mode", "automatic",
|
||||
"--spec-selection", args.spec_selection,
|
||||
"--party-seed", str(args.party_seed),
|
||||
"--party-configurations", str(args.party_configurations),
|
||||
"--simulations-per-party", str(args.simulations_per_party),
|
||||
"--duration", str(args.duration),
|
||||
"--boss-category", args.boss_category,
|
||||
"--output-dir", str(output_dir),
|
||||
]
|
||||
if args.seeds:
|
||||
command.extend(("--seeds", args.seeds))
|
||||
if args.active_slot is not None:
|
||||
command.extend(("--active-slot", str(args.active_slot)))
|
||||
if getattr(args, "seat_sweep", False):
|
||||
command.append("--seat-sweep")
|
||||
if getattr(args, "cooldown_policy", None):
|
||||
command.extend(("--cooldown-policy", str(args.cooldown_policy.resolve())))
|
||||
if getattr(args, "companion_readiness_policy", None):
|
||||
command.extend(("--companion-readiness-policy", str(args.companion_readiness_policy.resolve())))
|
||||
if getattr(args, "include_beta_specs", False):
|
||||
command.append("--include-beta-specs")
|
||||
if getattr(args, "ready_only", False):
|
||||
command.append("--ready-only")
|
||||
for selector in args.spec:
|
||||
command.extend(("--spec", selector))
|
||||
for selector in args.boss:
|
||||
command.extend(("--boss", selector))
|
||||
return command
|
||||
|
||||
|
||||
def run_level(args: argparse.Namespace, level: int, output_dir: Path) -> tuple[int, subprocess.CompletedProcess[str]]:
|
||||
result = subprocess.run(
|
||||
build_balance_lab_command(args, level, output_dir),
|
||||
cwd=ROOT,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
check=False,
|
||||
)
|
||||
return level, result
|
||||
|
||||
|
||||
def weighted_mean(rows: Iterable[dict[str, Any]], field: str, weight: str = "runs") -> float:
|
||||
materialized = list(rows)
|
||||
total_weight = sum(float(row.get(weight, 0)) for row in materialized)
|
||||
if total_weight <= 0:
|
||||
return 0.0
|
||||
return sum(float(row.get(field, 0) or 0) * float(row.get(weight, 0)) for row in materialized) / total_weight
|
||||
|
||||
|
||||
def raw_run_totals(report_path: Path) -> dict[str, Any] | None:
|
||||
raw_paths = sorted(report_path.parent.glob("parties/*/raw-runs.json"))
|
||||
if not raw_paths:
|
||||
return None
|
||||
totals: dict[str, Any] = {
|
||||
"runs": 0,
|
||||
"seconds": 0.0,
|
||||
"partyDamage": 0.0,
|
||||
"bossDamage": 0.0,
|
||||
"incomingDamage": 0.0,
|
||||
"rawIncomingDamage": 0.0,
|
||||
"defeats": 0,
|
||||
"wipes": 0,
|
||||
"incomingRuns": 0,
|
||||
"healingRequiredRuns": 0,
|
||||
"minimumHealthPercent": 0.0,
|
||||
"pressureWindows": 0,
|
||||
"healerThroughputWindows": 0,
|
||||
"healerResponseWindows": 0,
|
||||
"windowMetricsAvailable": False,
|
||||
}
|
||||
minimums = []
|
||||
for raw_path in raw_paths:
|
||||
raw = json.loads(raw_path.read_text(encoding="utf-8"))
|
||||
for run in raw["runs"]:
|
||||
totals["runs"] += 1
|
||||
totals["seconds"] += float(run["elapsedSeconds"])
|
||||
totals["partyDamage"] += float(run["partyDamage"])
|
||||
totals["bossDamage"] += float(run["bossDamage"])
|
||||
totals["incomingDamage"] += float(run["incomingDamage"])
|
||||
totals["rawIncomingDamage"] += float(run["rawIncomingDamage"])
|
||||
totals["defeats"] += run["outcome"] == "defeated"
|
||||
totals["wipes"] += run["outcome"] == "wipe"
|
||||
totals["incomingRuns"] += float(run["incomingDamage"]) > 0
|
||||
healer_throughput = sum(
|
||||
float(member.get("healing", 0)) + float(member.get("absorbs", 0))
|
||||
for member in run["members"] if member["role"] == "healer"
|
||||
)
|
||||
totals["healingRequiredRuns"] += healer_throughput > 0
|
||||
if "pressureWindows" in run:
|
||||
totals["windowMetricsAvailable"] = True
|
||||
totals["pressureWindows"] += int(run["pressureWindows"])
|
||||
totals["healerThroughputWindows"] += int(run["healerThroughputWindows"])
|
||||
totals["healerResponseWindows"] += int(run["healerResponseWindows"])
|
||||
minimums.append(min(float(member["minimumHealthPercent"]) for member in run["members"]))
|
||||
totals["minimumHealthPercent"] = sum(minimums) / len(minimums) if minimums else 1.0
|
||||
return totals
|
||||
|
||||
|
||||
def csv_run_totals(report_path: Path) -> dict[str, Any] | None:
|
||||
candidates = [
|
||||
report_path.with_name("suite_runs.csv"),
|
||||
report_path.parent / f"level-{json.loads(report_path.read_text(encoding='utf-8'))['level']}-suite_runs.csv",
|
||||
]
|
||||
run_path = next((path for path in candidates if path.exists()), None)
|
||||
if run_path is None:
|
||||
return None
|
||||
with run_path.open("r", encoding="utf-8", newline="") as stream:
|
||||
rows = list(csv.DictReader(stream))
|
||||
return {
|
||||
"runs": len(rows),
|
||||
"seconds": sum(float(row["elapsedSeconds"]) for row in rows),
|
||||
"partyDamage": sum(float(row["partyDamage"]) for row in rows),
|
||||
"bossDamage": sum(float(row["bossDamage"]) for row in rows),
|
||||
"incomingDamage": sum(float(row["incomingDamage"]) for row in rows),
|
||||
"rawIncomingDamage": sum(float(row["rawIncomingDamage"]) for row in rows),
|
||||
"defeats": sum(row["outcome"] == "defeated" for row in rows),
|
||||
"wipes": sum(row["outcome"] == "wipe" for row in rows),
|
||||
"incomingRuns": None,
|
||||
"healingRequiredRuns": None,
|
||||
"minimumHealthPercent": None,
|
||||
"pressureWindows": None,
|
||||
"healerThroughputWindows": None,
|
||||
"healerResponseWindows": None,
|
||||
"windowMetricsAvailable": False,
|
||||
}
|
||||
|
||||
|
||||
def summarize_report(report_path: Path) -> dict[str, Any]:
|
||||
report = json.loads(report_path.read_text(encoding="utf-8"))
|
||||
bosses = report["bosses"]
|
||||
totals = raw_run_totals(report_path) or csv_run_totals(report_path)
|
||||
if totals is None:
|
||||
raise ValueError(f"No raw-runs.json or suite_runs.csv found for {report_path}")
|
||||
seconds = max(0.1, float(totals["seconds"]))
|
||||
runs = max(1, int(totals["runs"]))
|
||||
healers = [row for row in report["classSpecs"] if row["role"] == "healer"]
|
||||
healer_healing = sum(float(row["totalHealing"]) for row in healers)
|
||||
healer_absorbs = sum(float(row["totalAbsorbs"]) for row in healers)
|
||||
party_dps = float(totals["partyDamage"]) / seconds
|
||||
boss_dps = float(totals["bossDamage"]) / seconds
|
||||
incoming_dtps = float(totals["incomingDamage"]) / seconds
|
||||
raw_incoming_dtps = float(totals["rawIncomingDamage"]) / seconds
|
||||
boss_health = weighted_mean(bosses, "bossMaxHealth")
|
||||
defeated_runs = sum(float(row["runs"]) * float(row["defeatRate"]) for row in bosses)
|
||||
defeated_seconds = sum(
|
||||
float(row["runs"]) * float(row["defeatRate"]) * float(row["averageTimeToDefeat"] or 0)
|
||||
for row in bosses
|
||||
)
|
||||
return {
|
||||
"level": int(report["level"]),
|
||||
"reportPath": str(report_path),
|
||||
"partyConfigurations": int(report["summary"]["partyConfigurations"]),
|
||||
"bosses": int(report["summary"]["bosses"]),
|
||||
"runs": int(totals["runs"]),
|
||||
"bossHealth": boss_health,
|
||||
"partyDps": party_dps,
|
||||
"bossDps": boss_dps,
|
||||
"healthToDpsSeconds": boss_health / max(0.1, boss_dps),
|
||||
"incomingDtps": incoming_dtps,
|
||||
"rawIncomingDtps": raw_incoming_dtps,
|
||||
"mitigationRate": 1 - incoming_dtps / raw_incoming_dtps if raw_incoming_dtps > 0 else 0,
|
||||
"healerHps": healer_healing / seconds,
|
||||
"healerAbsorbsPerSecond": healer_absorbs / seconds,
|
||||
"healingCoverageRate": (healer_healing + healer_absorbs) / max(0.1, float(totals["incomingDamage"])),
|
||||
"incomingRunRate": (
|
||||
float(totals["incomingRuns"]) / runs if totals["incomingRuns"] is not None else None
|
||||
),
|
||||
"healingRequiredRunRate": (
|
||||
float(totals["healingRequiredRuns"]) / runs if totals["healingRequiredRuns"] is not None else None
|
||||
),
|
||||
"healerResponseRate": (
|
||||
float(totals["healerResponseWindows"]) / max(1, int(totals["pressureWindows"]))
|
||||
if totals["windowMetricsAvailable"] else None
|
||||
),
|
||||
"healerWindowsPerPressureWindow": (
|
||||
float(totals["healerThroughputWindows"]) / max(1, int(totals["pressureWindows"]))
|
||||
if totals["windowMetricsAvailable"] else None
|
||||
),
|
||||
"averageMinimumHealthPercent": totals["minimumHealthPercent"],
|
||||
"defeatRate": float(totals["defeats"]) / runs,
|
||||
"wipeRate": float(totals["wipes"]) / runs,
|
||||
"averageTimeToDefeat": defeated_seconds / defeated_runs if defeated_runs > 0 else None,
|
||||
}
|
||||
|
||||
|
||||
def add_growth_metrics(rows: list[dict[str, Any]]) -> None:
|
||||
rows.sort(key=lambda row: row["level"])
|
||||
previous = None
|
||||
for row in rows:
|
||||
if previous is None:
|
||||
row["previousHealthToDpsSeconds"] = None
|
||||
row["bossHealthGrowth"] = None
|
||||
row["partyDpsGrowth"] = None
|
||||
row["durabilityGrowthVsDps"] = None
|
||||
else:
|
||||
row["previousHealthToDpsSeconds"] = previous["healthToDpsSeconds"]
|
||||
health_growth = row["bossHealth"] / max(0.1, previous["bossHealth"])
|
||||
dps_growth = row["bossDps"] / max(0.1, previous["bossDps"])
|
||||
row["bossHealthGrowth"] = health_growth
|
||||
row["partyDpsGrowth"] = dps_growth
|
||||
row["durabilityGrowthVsDps"] = health_growth / max(0.01, dps_growth)
|
||||
previous = row
|
||||
|
||||
|
||||
def evaluate_rows(rows: Sequence[dict[str, Any]], args: argparse.Namespace) -> bool:
|
||||
passed = True
|
||||
for row in rows:
|
||||
flags = []
|
||||
if row["healthToDpsSeconds"] < args.min_health_to_dps_seconds:
|
||||
flags.append("boss durability below limit")
|
||||
if row["healthToDpsSeconds"] > args.max_health_to_dps_seconds:
|
||||
flags.append("boss durability above limit")
|
||||
growth = row["durabilityGrowthVsDps"]
|
||||
if (
|
||||
growth is not None
|
||||
and growth > args.max_durability_growth
|
||||
and row["previousHealthToDpsSeconds"] >= args.min_health_to_dps_seconds
|
||||
):
|
||||
flags.append("boss health outgrew party DPS")
|
||||
if row["incomingDtps"] <= 0:
|
||||
flags.append("boss dealt no effective damage")
|
||||
if row["healerHps"] <= 0 and row["incomingDtps"] > 0:
|
||||
flags.append("healer produced no effective healing")
|
||||
if row["incomingRunRate"] is not None and row["incomingRunRate"] < args.min_incoming_run_rate:
|
||||
flags.append("too many runs had no incoming damage")
|
||||
if (
|
||||
row["healingRequiredRunRate"] is not None
|
||||
and row["healingRequiredRunRate"] < args.min_healing_required_run_rate
|
||||
):
|
||||
flags.append("too many runs required no healer throughput")
|
||||
if (
|
||||
row.get("healerResponseRate") is not None
|
||||
and row["healerResponseRate"] < args.min_healer_response_rate
|
||||
):
|
||||
flags.append("healer did not answer enough pressure windows")
|
||||
row["flags"] = flags
|
||||
row["passed"] = not flags
|
||||
passed = passed and not flags
|
||||
return passed
|
||||
|
||||
|
||||
def discover_reports(directory: Path) -> list[Path]:
|
||||
candidates = [*directory.glob("level-*/suite-report.json"), *directory.glob("level-*.json")]
|
||||
reports = []
|
||||
for path in sorted(set(candidates)):
|
||||
try:
|
||||
value = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
continue
|
||||
if "level" in value and "bosses" in value and "classSpecs" in value:
|
||||
reports.append(path)
|
||||
return reports
|
||||
|
||||
|
||||
def weapon_dps(weapon: dict[str, Any] | None) -> float:
|
||||
if not weapon:
|
||||
return 0.0
|
||||
return (float(weapon["damageMin"]) + float(weapon["damageMax"])) / 2 / max(
|
||||
0.1, float(weapon["speedMs"]) / 1_000
|
||||
)
|
||||
|
||||
|
||||
def collect_heirlooms(report_paths: Sequence[Path]) -> list[dict[str, Any]]:
|
||||
rows = []
|
||||
for report_path in report_paths:
|
||||
catalog_path = report_path.parent / "catalog.json"
|
||||
if not catalog_path.exists():
|
||||
continue
|
||||
catalog = json.loads(catalog_path.read_text(encoding="utf-8"))
|
||||
for build in catalog["builds"]:
|
||||
gear = build.get("gear")
|
||||
if not gear:
|
||||
continue
|
||||
stats = gear["stats"]
|
||||
melee_weapon_dps = weapon_dps(stats.get("meleeWeapon"))
|
||||
ranged_weapon_dps = weapon_dps(stats.get("rangedWeapon"))
|
||||
rows.append({
|
||||
"level": catalog["level"],
|
||||
"source": build["source"],
|
||||
"classId": build["classId"],
|
||||
"className": build["className"],
|
||||
"specialization": build["specialization"],
|
||||
"role": build["role"],
|
||||
"presetId": gear["presetId"],
|
||||
"model": gear["model"],
|
||||
"itemCount": gear["itemCount"],
|
||||
"averageItemLevel": gear["averageItemLevel"],
|
||||
"strength": stats["strength"],
|
||||
"agility": stats["agility"],
|
||||
"stamina": stats["stamina"],
|
||||
"intellect": stats["intellect"],
|
||||
"spirit": stats["spirit"],
|
||||
"attackPower": stats["attackPower"],
|
||||
"rangedAttackPower": stats["rangedAttackPower"],
|
||||
"spellPower": stats["spellPower"],
|
||||
"criticalStrikeRating": stats["criticalStrikeRating"],
|
||||
"hitRating": stats["hitRating"],
|
||||
"hasteRating": stats["hasteRating"],
|
||||
"expertiseRating": stats["expertiseRating"],
|
||||
"armorPenetrationRating": stats["armorPenetrationRating"],
|
||||
"defenseRating": stats["defenseRating"],
|
||||
"dodgeRating": stats["dodgeRating"],
|
||||
"parryRating": stats["parryRating"],
|
||||
"blockRating": stats["blockRating"],
|
||||
"blockValue": stats["blockValue"],
|
||||
"mp5": stats["mp5"],
|
||||
"armor": stats["armor"],
|
||||
"meleeWeaponDps": melee_weapon_dps,
|
||||
"rangedWeaponDps": ranged_weapon_dps,
|
||||
"weaponDps": max(melee_weapon_dps, ranged_weapon_dps),
|
||||
})
|
||||
return rows
|
||||
|
||||
|
||||
def percent(value: Any) -> str:
|
||||
return "—" if value is None else f"{float(value) * 100:.1f}%"
|
||||
|
||||
|
||||
def ratio(value: Any) -> str:
|
||||
return "—" if value is None else f"{float(value):.2f}×"
|
||||
|
||||
|
||||
def markdown_report(rows: Sequence[dict[str, Any]]) -> str:
|
||||
lines = [
|
||||
"# Balance Lab local bracket comparison",
|
||||
"",
|
||||
"| Level | Boss HP | Boss DPS taken | HP / DPS | Incoming DTPS | Healer HPS | Heal coverage | Pressure windows answered | Defeat | Wipe | HP growth / DPS growth | Result |",
|
||||
"| ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | --- |",
|
||||
]
|
||||
for row in rows:
|
||||
result = "pass" if row["passed"] else "; ".join(row["flags"])
|
||||
lines.append(
|
||||
f"| {row['level']} | {row['bossHealth']:.0f} | {row['bossDps']:.1f} | "
|
||||
f"{row['healthToDpsSeconds']:.1f}s | {row['incomingDtps']:.1f} | {row['healerHps']:.1f} | "
|
||||
f"{percent(row['healingCoverageRate'])} | {percent(row['healerResponseRate'])} | {percent(row['defeatRate'])} | "
|
||||
f"{percent(row['wipeRate'])} | {ratio(row['durabilityGrowthVsDps'])} | {result} |"
|
||||
)
|
||||
lines.extend([
|
||||
"",
|
||||
"`HP / DPS` uses damage applied directly to the boss. `Heal coverage` is effective healer healing plus absorbs divided by effective incoming party damage. Pressure-window response counts a five-second incoming-damage window when healer throughput occurs in that window or an adjacent one. Growth compares each row with the preceding tested level.",
|
||||
])
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
SUMMARY_FIELDS = (
|
||||
"level", "partyConfigurations", "bosses", "runs", "bossHealth", "partyDps", "bossDps",
|
||||
"healthToDpsSeconds", "incomingDtps", "rawIncomingDtps", "mitigationRate", "healerHps",
|
||||
"healerAbsorbsPerSecond", "healingCoverageRate", "incomingRunRate", "healingRequiredRunRate",
|
||||
"healerResponseRate", "healerWindowsPerPressureWindow", "averageMinimumHealthPercent",
|
||||
"defeatRate", "wipeRate", "averageTimeToDefeat",
|
||||
"previousHealthToDpsSeconds", "bossHealthGrowth",
|
||||
"partyDpsGrowth", "durabilityGrowthVsDps", "passed", "flags", "reportPath",
|
||||
)
|
||||
|
||||
HEIRLOOM_FIELDS = (
|
||||
"level", "source", "classId", "className", "specialization", "role", "presetId", "model",
|
||||
"itemCount", "averageItemLevel", "strength", "agility", "stamina", "intellect", "spirit",
|
||||
"attackPower", "rangedAttackPower", "spellPower", "criticalStrikeRating", "hitRating",
|
||||
"hasteRating", "expertiseRating", "armorPenetrationRating", "defenseRating", "dodgeRating",
|
||||
"parryRating", "blockRating", "blockValue", "mp5", "armor", "meleeWeaponDps",
|
||||
"rangedWeaponDps", "weaponDps",
|
||||
)
|
||||
|
||||
|
||||
def parser() -> argparse.ArgumentParser:
|
||||
result = argparse.ArgumentParser(description="Run and compare local Balance Lab level brackets.")
|
||||
result.add_argument("--step", type=int, choices=(1, 5, 10), default=10,
|
||||
help="level bracket step; includes level 1 (default: 10)")
|
||||
result.add_argument("--levels", type=parse_levels, help="explicit comma-separated levels; overrides --step")
|
||||
result.add_argument("--source", choices=("wow", "coa", "rom", "all"), default="wow")
|
||||
result.add_argument("--spec-selection", choices=("random", "all", "specific"), default="all")
|
||||
result.add_argument("--spec", action="append", default=[])
|
||||
result.add_argument("--party-seed", type=int, default=1)
|
||||
result.add_argument("--party-configurations", type=int, default=10)
|
||||
result.add_argument("--simulations-per-party", type=int, default=3)
|
||||
result.add_argument("--seeds", help="explicit comma-separated seeds")
|
||||
result.add_argument("--duration", type=int, default=80)
|
||||
result.add_argument("--active-slot", type=int)
|
||||
result.add_argument("--seat-sweep", action="store_true",
|
||||
help="run every party with each of its five active-seat assignments")
|
||||
result.add_argument("--cooldown-policy", type=Path)
|
||||
result.add_argument("--companion-readiness-policy", type=Path)
|
||||
result.add_argument("--include-beta-specs", action="store_true")
|
||||
result.add_argument("--ready-only", action="store_true",
|
||||
help="reject Needs Testing targets and retain only Ready/Beta automatic pools")
|
||||
result.add_argument("--boss-category", choices=("all", "wow", "rom"), default="all")
|
||||
result.add_argument("--boss", action="append", default=[])
|
||||
result.add_argument("--jobs", type=int, default=min(4, os.cpu_count() or 1),
|
||||
help="level processes to run concurrently (default: up to 4)")
|
||||
result.add_argument("--output-dir", type=Path)
|
||||
result.add_argument("--analyze-existing", type=Path,
|
||||
help="skip simulations and compare reports already under this directory")
|
||||
result.add_argument("--check", action="store_true", help="exit nonzero when a regression limit is exceeded")
|
||||
result.add_argument("--min-health-to-dps-seconds", type=float, default=20)
|
||||
result.add_argument("--max-health-to-dps-seconds", type=float, default=70)
|
||||
result.add_argument("--max-durability-growth", type=float, default=1.5)
|
||||
result.add_argument("--min-incoming-run-rate", type=float, default=0.95)
|
||||
result.add_argument("--min-healing-required-run-rate", type=float, default=0.8)
|
||||
result.add_argument("--min-healer-response-rate", type=float, default=0.6)
|
||||
return result
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
args = parser().parse_args(argv)
|
||||
if args.spec_selection == "specific" and not args.spec:
|
||||
raise SystemExit("--spec-selection specific requires at least one --spec value")
|
||||
if args.jobs < 1:
|
||||
raise SystemExit("--jobs must be at least 1")
|
||||
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||
output_dir = (args.analyze_existing or args.output_dir
|
||||
or ROOT / "artifacts" / "balance-lab-brackets" / timestamp).resolve()
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
if args.analyze_existing is None:
|
||||
levels = args.levels or bracket_levels(args.step)
|
||||
print(f"Running {len(levels)} local brackets with up to {args.jobs} concurrent level processes...")
|
||||
failures = []
|
||||
with ThreadPoolExecutor(max_workers=min(args.jobs, len(levels))) as executor:
|
||||
futures = {
|
||||
executor.submit(run_level, args, level, output_dir / f"level-{level}"): level
|
||||
for level in levels
|
||||
}
|
||||
for future in as_completed(futures):
|
||||
level, result = future.result()
|
||||
if result.returncode == 0:
|
||||
print(f" level {level}: complete")
|
||||
else:
|
||||
failures.append(level)
|
||||
print(f" level {level}: failed", file=sys.stderr)
|
||||
if result.stdout.strip():
|
||||
print(result.stdout.rstrip(), file=sys.stderr)
|
||||
if result.stderr.strip():
|
||||
print(result.stderr.rstrip(), file=sys.stderr)
|
||||
if failures:
|
||||
raise SystemExit(f"Balance Lab failed at levels: {', '.join(map(str, sorted(failures)))}")
|
||||
report_paths = discover_reports(output_dir)
|
||||
if not report_paths:
|
||||
raise SystemExit(f"No suite reports found under {output_dir}")
|
||||
rows = [summarize_report(path) for path in report_paths]
|
||||
add_growth_metrics(rows)
|
||||
passed = evaluate_rows(rows, args)
|
||||
serializable_rows = [{**row, "flags": list(row["flags"])} for row in rows]
|
||||
write_json(output_dir / "bracket-summary.json", {
|
||||
"schemaVersion": 1,
|
||||
"generatedAt": datetime.now().astimezone().isoformat(),
|
||||
"passed": passed,
|
||||
"levels": [row["level"] for row in rows],
|
||||
"thresholds": {
|
||||
"minHealthToDpsSeconds": args.min_health_to_dps_seconds,
|
||||
"maxHealthToDpsSeconds": args.max_health_to_dps_seconds,
|
||||
"maxDurabilityGrowth": args.max_durability_growth,
|
||||
"minIncomingRunRate": args.min_incoming_run_rate,
|
||||
"minHealingRequiredRunRate": args.min_healing_required_run_rate,
|
||||
"minHealerResponseRate": args.min_healer_response_rate,
|
||||
},
|
||||
"brackets": serializable_rows,
|
||||
})
|
||||
write_csv(output_dir / "bracket-summary.csv", [
|
||||
{**row, "flags": "; ".join(row["flags"])} for row in rows
|
||||
], SUMMARY_FIELDS)
|
||||
write_text(output_dir / "bracket-summary.md", markdown_report(rows))
|
||||
heirlooms = collect_heirlooms(report_paths)
|
||||
if heirlooms:
|
||||
write_json(output_dir / "heirloom-summary.json", heirlooms)
|
||||
write_csv(output_dir / "heirloom-summary.csv", heirlooms, HEIRLOOM_FIELDS)
|
||||
print(f"Comparison: {output_dir / 'bracket-summary.md'}")
|
||||
if heirlooms:
|
||||
print(f"Resolved heirlooms: {output_dir / 'heirloom-summary.csv'}")
|
||||
return 0 if passed or not args.check else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -90,7 +90,7 @@ function help(): string {
|
||||
"Usage: npm run balance:benchmark -- [options]",
|
||||
"",
|
||||
"Options:",
|
||||
" --class mage,templar Limit primary classes.",
|
||||
" --class mage,templar Explicit targeted class diagnostic.",
|
||||
" --role damage,healer Limit roles.",
|
||||
" --scenario id,id Limit scenario IDs.",
|
||||
" --level 60 Comparison level (default 60).",
|
||||
@@ -99,6 +99,9 @@ function help(): string {
|
||||
" --output path Output directory under the current path or absolute.",
|
||||
" --help Show this help.",
|
||||
"",
|
||||
"Default scope: the five Ready WoW specializations only.",
|
||||
"CoA, RoM, and other WoW specializations require an explicit --class filter.",
|
||||
"",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
@@ -151,7 +154,7 @@ async function main(): Promise<void> {
|
||||
appVersion: packageJson.version,
|
||||
cohort: {
|
||||
level: options.level,
|
||||
gearPresetId: builds[0]?.gearPresetId ?? "coefficient-empty",
|
||||
gearPresetId: builds[0]?.gearPresetId ?? "virtual-heirloom",
|
||||
seeds: options.seeds,
|
||||
durationScale: options.durationScale,
|
||||
policyId: BENCHMARK_POLICY_ID,
|
||||
|
||||
@@ -17,7 +17,7 @@ test("ships one lossless object for identical assets and verifies original bytes
|
||||
const original = Buffer.from("original geometry, textures, and animation\n".repeat(4096));
|
||||
await writeFile(sourcePath, original);
|
||||
const common = { sourcePath, bytes: original.length, sha256: createHash("sha256").update(original).digest("hex"), extension: ".glb" };
|
||||
const files = ["first", "second"].map((id) => ({ ...common, logicalPath: `assets/creatures/${id}/model.glb`, packId: `dungeon:${id}` }));
|
||||
const files = ["first", "second"].map((id) => ({ ...common, logicalPath: `assets/creatures/${id}/model.glb`, packId: `creatures:${id}` }));
|
||||
const result = await prepareContentObjects(files, { directory: path.join(directory, "objects"), baseUrl: "/content/bundled" });
|
||||
assert.equal(result.uniqueObjects, 1);
|
||||
assert.equal(result.uniqueBytes, original.length);
|
||||
|
||||
@@ -25,6 +25,12 @@ export const CONTENT_SCHEMA_VERSION = 1;
|
||||
export const DEFAULT_MINIMUM_APP_VERSION = "0.1.0";
|
||||
export const WOW_CAMPAIGN_DUNGEON_COUNT = 54;
|
||||
export const BUNDLED_ANDROID_PACKS = Object.freeze([...contentPolicy.bootstrapPackIds]);
|
||||
export const RANDOMIZED_BITE_SIZED_PACK_DEFINITION = Object.freeze({
|
||||
id: "activity:randomized-bite-sized",
|
||||
label: "Randomized Bite-Sized Base",
|
||||
bundledOnAndroid: false,
|
||||
dependencies: ["dungeon:stormwind-stockade"],
|
||||
});
|
||||
|
||||
const RUNTIME_EXTENSIONS = new Set([
|
||||
".avif", ".glb", ".js", ".json", ".ktx2", ".mp3", ".ogg", ".png",
|
||||
@@ -72,6 +78,7 @@ function packLabel(packId) {
|
||||
const [category, id = category] = packId.split(":", 2);
|
||||
const name = id.split("-").map((part) => `${part.slice(0, 1).toUpperCase()}${part.slice(1)}`).join(" ");
|
||||
if (category === "dungeon") return name;
|
||||
if (category === "creatures") return `${name} Creatures`;
|
||||
if (packId === "characters:wow335a-v1") return "Extra Wrath Character Models & Skins";
|
||||
if (packId === "characters:ascension-v1") return "Ascension Character Models & Skins";
|
||||
if (packId === "characters:runewaker-v1") return "RuneWaker Character Models & Skins";
|
||||
@@ -101,12 +108,13 @@ export function contentPackForPath(logicalPath) {
|
||||
return basicWrathHuman ? "core:character-basic" : `characters:${segments[2]}`;
|
||||
}
|
||||
if (segments[1] === "creatures" && segments[2]) {
|
||||
return segments[2] === "shared" ? "shared:creatures" : `dungeon:${segments[2]}`;
|
||||
return segments[2] === "shared" ? "shared:creatures" : `creatures:${segments[2]}`;
|
||||
}
|
||||
if (segments[1] === "game" && segments[2] === "creatures" && segments[3]) {
|
||||
return segments[3] === "shared" ? "shared:creatures" : `dungeon:${segments[3]}`;
|
||||
return segments[3] === "shared" ? "shared:creatures" : `creatures:${segments[3]}`;
|
||||
}
|
||||
if (segments[1] === "game" && segments[2] === "dungeons" && segments[3]) {
|
||||
if (segments[4] === "creatures") return `creatures:${segments[3]}`;
|
||||
return `dungeon:${segments[3]}`;
|
||||
}
|
||||
if (segments[1] === "game" && segments[2] === "arenas") return "core:arenas";
|
||||
@@ -197,44 +205,58 @@ function assetPackId(url) {
|
||||
|
||||
/**
|
||||
* Builds the logical install topology for the 54-dungeon WoW campaign.
|
||||
* Environment files intentionally remain in their independently downloadable
|
||||
* Manastorm packs; a dungeon pack owns its creatures and pulls in exactly the
|
||||
* environment/shared-creature packs referenced by the generated definition.
|
||||
* Environment and creature files remain independently downloadable. A dungeon
|
||||
* pack pulls in exactly the environment and creature packs referenced by its
|
||||
* generated definition, while randomized modes can install creatures alone.
|
||||
*/
|
||||
export function campaignDungeonPackDefinitions(definitions) {
|
||||
const packs = new Map();
|
||||
for (const definition of definitions) {
|
||||
const dungeonPackId = `dungeon:${definition.id}`;
|
||||
if (definition.id === "wailing-caverns") {
|
||||
const referencedCreaturePacks = new Set([
|
||||
"creatures:wailing-caverns",
|
||||
...Object.values(definition.entities ?? {})
|
||||
.map((entity) => entity.visual?.model?.url)
|
||||
.filter(Boolean)
|
||||
.map(assetPackId),
|
||||
]);
|
||||
const unexpectedCreaturePacks = [...referencedCreaturePacks]
|
||||
.filter((id) => id !== "creatures:wailing-caverns" && id !== "shared:creatures");
|
||||
if (unexpectedCreaturePacks.length) {
|
||||
throw new Error(`${definition.id} references unexpected creature packs: ${unexpectedCreaturePacks.join(", ")}`);
|
||||
}
|
||||
packs.set(dungeonPackId, {
|
||||
id: dungeonPackId,
|
||||
label: definition.title ?? packLabel(dungeonPackId),
|
||||
bundledOnAndroid: true,
|
||||
dependencies: [],
|
||||
dependencies: [...referencedCreaturePacks],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const environmentUrls = [
|
||||
...(definition.assets?.visual ?? []).map((asset) => asset.url),
|
||||
...(definition.assets?.collision ?? []).map((asset) => asset.url),
|
||||
definition.assets?.navigation?.url,
|
||||
].filter(Boolean);
|
||||
const resolvedEnvironmentPacks = environmentUrls.map(assetPackId);
|
||||
if (!resolvedEnvironmentPacks.length || resolvedEnvironmentPacks.some((id) => !id?.startsWith("manastorm:"))) {
|
||||
throw new Error(`${definition.id} has an environment asset outside a Manastorm content pack.`);
|
||||
if (!resolvedEnvironmentPacks.length
|
||||
|| resolvedEnvironmentPacks.some((id) => !id?.startsWith("manastorm:"))) {
|
||||
throw new Error(`${definition.id} has an environment asset outside its expected content pack.`);
|
||||
}
|
||||
const environmentPackIds = new Set(resolvedEnvironmentPacks);
|
||||
if (environmentPackIds.size !== 1) {
|
||||
throw new Error(`${definition.id} must resolve to exactly one Manastorm environment pack.`);
|
||||
}
|
||||
const [environmentPackId] = environmentPackIds;
|
||||
packs.set(environmentPackId, {
|
||||
id: environmentPackId,
|
||||
label: packLabel(environmentPackId),
|
||||
bundledOnAndroid: false,
|
||||
dependencies: [],
|
||||
});
|
||||
if (environmentPackId !== dungeonPackId) {
|
||||
packs.set(environmentPackId, {
|
||||
id: environmentPackId,
|
||||
label: packLabel(environmentPackId),
|
||||
bundledOnAndroid: false,
|
||||
dependencies: [],
|
||||
});
|
||||
}
|
||||
|
||||
const creatureUrls = Object.values(definition.entities ?? {})
|
||||
.map((entity) => entity.visual?.model?.url)
|
||||
@@ -243,16 +265,21 @@ export function campaignDungeonPackDefinitions(definitions) {
|
||||
if (resolvedCreaturePacks.some((id) => !id)) {
|
||||
throw new Error(`${definition.id} has a creature asset outside a content pack.`);
|
||||
}
|
||||
const referencedCreaturePacks = new Set(resolvedCreaturePacks.filter((id) => id !== dungeonPackId));
|
||||
const unexpectedCreaturePacks = [...referencedCreaturePacks].filter((id) => id !== "shared:creatures");
|
||||
const referencedCreaturePacks = new Set(resolvedCreaturePacks);
|
||||
const expectedCreaturePackId = `creatures:${definition.id}`;
|
||||
const unexpectedCreaturePacks = [...referencedCreaturePacks]
|
||||
.filter((id) => id !== expectedCreaturePackId && id !== "shared:creatures");
|
||||
if (unexpectedCreaturePacks.length) {
|
||||
throw new Error(`${definition.id} references unexpected creature packs: ${unexpectedCreaturePacks.join(", ")}`);
|
||||
}
|
||||
packs.set(dungeonPackId, {
|
||||
id: dungeonPackId,
|
||||
label: definition.title ?? packLabel(dungeonPackId),
|
||||
bundledOnAndroid: false,
|
||||
dependencies: [environmentPackId, ...referencedCreaturePacks],
|
||||
bundledOnAndroid: definition.id === "wailing-caverns",
|
||||
dependencies: [
|
||||
...(environmentPackId === dungeonPackId ? [] : [environmentPackId]),
|
||||
...referencedCreaturePacks,
|
||||
],
|
||||
});
|
||||
}
|
||||
return [...packs.values()].sort((left, right) => left.id.localeCompare(right.id));
|
||||
@@ -344,6 +371,15 @@ export function createContentManifest(files, options = {}) {
|
||||
});
|
||||
}
|
||||
|
||||
// RuneWaker dungeon definitions are generated TypeScript rather than part of
|
||||
// the WoW JSON catalog. Their matching creature dependency is recoverable
|
||||
// from the stable pack namespace without importing runtime code here.
|
||||
for (const creaturePack of [...packs.values()].filter((pack) => pack.id.startsWith("creatures:"))) {
|
||||
const dungeonPack = packs.get(`dungeon:${creaturePack.id.slice("creatures:".length)}`);
|
||||
if (!dungeonPack || dungeonPack.dependencies.includes(creaturePack.id)) continue;
|
||||
dungeonPack.dependencies = [...dungeonPack.dependencies, creaturePack.id];
|
||||
}
|
||||
|
||||
const sortedPacks = [...packs.values()].sort((left, right) => left.id.localeCompare(right.id));
|
||||
for (const pack of sortedPacks) {
|
||||
const objects = new Map(files.filter((file) => file.packId === pack.id)
|
||||
@@ -435,6 +471,7 @@ export async function publishContent(options = {}) {
|
||||
: [];
|
||||
const packDefinitions = [
|
||||
...campaignPackDefinitions,
|
||||
...(campaignDefinitions ? [RANDOMIZED_BITE_SIZED_PACK_DEFINITION] : []),
|
||||
...(options.packDefinitions ?? []),
|
||||
];
|
||||
if (campaignDefinitions) assertCampaignDungeonManifest(createContentManifest(files, { ...options, packDefinitions }), campaignDefinitions);
|
||||
|
||||
@@ -6,6 +6,7 @@ import test from "node:test";
|
||||
import {
|
||||
BUNDLED_ANDROID_PACKS,
|
||||
PROJECT_ROOT,
|
||||
RANDOMIZED_BITE_SIZED_PACK_DEFINITION,
|
||||
assertCampaignDungeonManifest,
|
||||
contentPackForPath,
|
||||
createContentManifest,
|
||||
@@ -14,9 +15,20 @@ import {
|
||||
scanContentSources,
|
||||
} from "./publish-content.mjs";
|
||||
|
||||
test("gives randomized bite-sized its base location dependency closure", () => {
|
||||
assert.deepEqual(RANDOMIZED_BITE_SIZED_PACK_DEFINITION, {
|
||||
id: "activity:randomized-bite-sized",
|
||||
label: "Randomized Bite-Sized Base",
|
||||
bundledOnAndroid: false,
|
||||
dependencies: ["dungeon:stormwind-stockade"],
|
||||
});
|
||||
});
|
||||
|
||||
test("assigns stable player-facing packs from logical asset paths", () => {
|
||||
assert.equal(contentPackForPath("assets/game/dungeons/wailing-caverns/visual.glb"), "dungeon:wailing-caverns");
|
||||
assert.equal(contentPackForPath("assets/creatures/wailing-caverns/mutanus.glb"), "dungeon:wailing-caverns");
|
||||
assert.equal(contentPackForPath("assets/creatures/wailing-caverns/mutanus.glb"), "creatures:wailing-caverns");
|
||||
assert.equal(contentPackForPath("assets/game/creatures/deadmines/mine-rat.glb"), "creatures:deadmines");
|
||||
assert.equal(contentPackForPath("assets/game/dungeons/deadmines/creatures/mr-smite.glb"), "creatures:deadmines");
|
||||
assert.equal(contentPackForPath("assets/characters/wow335a-v1/manifest.json"), "core:character-basic");
|
||||
assert.equal(contentPackForPath("assets/characters/wow335a-v1/human/male/rig.glb"), "core:character-basic");
|
||||
assert.equal(contentPackForPath("assets/characters/wow335a-v1/textures/character/human/male/humanmaleskin00_00.webp"), "core:character-basic");
|
||||
@@ -34,6 +46,27 @@ test("assigns stable player-facing packs from logical asset paths", () => {
|
||||
assert.equal(contentPackForPath("basis/basis_transcoder.wasm"), "core:runtime");
|
||||
});
|
||||
|
||||
test("keeps RuneWaker environments and creatures in independently installable packs", () => {
|
||||
const files = [
|
||||
"assets/game/dungeons/forsaken-abbey/visual.glb",
|
||||
"assets/creatures/forsaken-abbey/ancalon.glb",
|
||||
].map((logicalPath, index) => ({
|
||||
logicalPath,
|
||||
sourcePath: logicalPath,
|
||||
sha256: String(index + 1).repeat(64),
|
||||
bytes: 100,
|
||||
extension: ".glb",
|
||||
packId: contentPackForPath(logicalPath),
|
||||
}));
|
||||
const manifest = createContentManifest(files, { version: "runewaker-creature-split" });
|
||||
const packs = new Map(manifest.packs.map((pack) => [pack.id, pack]));
|
||||
|
||||
assert.deepEqual(packs.get("dungeon:forsaken-abbey")?.dependencies, ["creatures:forsaken-abbey"]);
|
||||
assert.equal(packs.get("dungeon:forsaken-abbey")?.fileCount, 1);
|
||||
assert.equal(packs.get("creatures:forsaken-abbey")?.fileCount, 1);
|
||||
assert.equal(manifest.files["/assets/creatures/forsaken-abbey/ancalon.glb"].packId, "creatures:forsaken-abbey");
|
||||
});
|
||||
|
||||
test("gives every WoW campaign dungeon an exact install dependency closure", async () => {
|
||||
const campaign = JSON.parse(await readFile(path.join(
|
||||
PROJECT_ROOT,
|
||||
@@ -103,6 +136,7 @@ test("gives every WoW campaign dungeon an exact install dependency closure", asy
|
||||
packId: contentPackForPath(logicalPath),
|
||||
});
|
||||
addFile("assets/game/dungeons/wailing-caverns/visual.glb");
|
||||
for (const creature of wailingCreatureManifest.creatures) addFile(creature.url.slice(1));
|
||||
for (const dungeon of campaign.definitions) {
|
||||
for (const asset of [
|
||||
...dungeon.assets.visual,
|
||||
@@ -124,20 +158,27 @@ test("gives every WoW campaign dungeon an exact install dependency closure", asy
|
||||
const pack = packs.get(`dungeon:${dungeon.id}`);
|
||||
assert.ok(pack, dungeon.id);
|
||||
if (dungeon.id === "wailing-caverns") {
|
||||
const creaturePacks = [...new Set([
|
||||
"creatures:wailing-caverns",
|
||||
...Object.values(dungeon.entities).flatMap((entity) => (
|
||||
entity.visual?.model?.url ? [contentPackForPath(entity.visual.model.url.slice(1))] : []
|
||||
)),
|
||||
])];
|
||||
assert.equal(pack.bundledOnAndroid, true);
|
||||
assert.deepEqual(pack.dependencies, []);
|
||||
assert.deepEqual(pack.dependencies, creaturePacks);
|
||||
continue;
|
||||
}
|
||||
const environmentPack = contentPackForPath(dungeon.assets.visual[0].url.slice(1));
|
||||
const needsSharedCreatures = Object.values(dungeon.entities).some((entity) => (
|
||||
entity.visual?.model?.url?.startsWith("/assets/game/creatures/shared/")
|
||||
));
|
||||
const creaturePacks = [...new Set(Object.values(dungeon.entities).flatMap((entity) => (
|
||||
entity.visual?.model?.url ? [contentPackForPath(entity.visual.model.url.slice(1))] : []
|
||||
)))];
|
||||
assert.deepEqual(
|
||||
pack.dependencies,
|
||||
[environmentPack, ...(needsSharedCreatures ? ["shared:creatures"] : [])],
|
||||
[...(environmentPack === pack.id ? [] : [environmentPack]), ...creaturePacks],
|
||||
dungeon.id,
|
||||
);
|
||||
assert.deepEqual(packs.get(environmentPack)?.dependencies, [], dungeon.id);
|
||||
assert.equal(pack.bundledOnAndroid, dungeon.id === "wailing-caverns");
|
||||
if (environmentPack !== pack.id) assert.deepEqual(packs.get(environmentPack)?.dependencies, [], dungeon.id);
|
||||
}
|
||||
|
||||
for (const [dungeonId, environmentPack] of [
|
||||
|
||||
@@ -46,8 +46,9 @@ export async function verifyReleaseBundle(root) {
|
||||
fail(`bootstrap packs were ${actualPacks.join(", ")}; expected ${expectedPacks.join(", ")}`);
|
||||
}
|
||||
const hiddenPack = actualPacks.find((packId) => (
|
||||
contentPolicy.hiddenContentPackIds.includes(packId)
|
||||
|| contentPolicy.hiddenContentPackPrefixes.some((prefix) => packId.startsWith(prefix))
|
||||
!contentPolicy.bootstrapPackIds.includes(packId)
|
||||
&& (contentPolicy.hiddenContentPackIds.includes(packId)
|
||||
|| contentPolicy.hiddenContentPackPrefixes.some((prefix) => packId.startsWith(prefix)))
|
||||
));
|
||||
if (hiddenPack) fail(`bootstrap contains hidden pack ${hiddenPack}`);
|
||||
|
||||
|
||||
@@ -1,22 +1,278 @@
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import type { BaseClassId } from "../src/app/characterCatalog";
|
||||
import {
|
||||
PARTY_TALENT_SPECIALIZATIONS,
|
||||
partyTalentAllocationPlan,
|
||||
} from "../src/game/partyTalents";
|
||||
import {
|
||||
allocateTalentPoint,
|
||||
talentNodesForTree,
|
||||
talentTreesForClass,
|
||||
type TalentNodeDefinition,
|
||||
type TalentRanks,
|
||||
} from "../src/game/talentCatalog";
|
||||
|
||||
const outputPath = resolve("src/game/generated/partyTalentRuntimePlans.json");
|
||||
const plans = Object.fromEntries(PARTY_TALENT_SPECIALIZATIONS.map(({ classId, specialization }) => [
|
||||
`${classId}:${specialization}`,
|
||||
partyTalentAllocationPlan(classId, specialization).map((selection) => selection.nodeId),
|
||||
const playerbotsConfigPath = resolve(
|
||||
"../wow-pvt-server-backups-2/azerothcore-wotlk/modules/mod-playerbots/conf/playerbots.conf.dist",
|
||||
);
|
||||
const PLAYERBOTS_COMMIT = "531282e";
|
||||
|
||||
interface PlayerbotsSpecReference {
|
||||
readonly classId: BaseClassId;
|
||||
readonly classNumber: number;
|
||||
readonly specNumber: number;
|
||||
readonly specialization: string;
|
||||
}
|
||||
|
||||
interface PlayerbotsMilestone {
|
||||
readonly level: number;
|
||||
readonly link: string;
|
||||
}
|
||||
|
||||
interface GeneratedLevelPlan {
|
||||
readonly minimumLevel: number;
|
||||
readonly sourceLevels: readonly number[];
|
||||
readonly sourcePointCount: number;
|
||||
readonly sourcePointFlags: readonly boolean[];
|
||||
readonly plan: readonly string[];
|
||||
}
|
||||
|
||||
const PLAYERBOTS_PVE_SPECS: readonly PlayerbotsSpecReference[] = [
|
||||
["warrior", 1, 0, "Arms"], ["warrior", 1, 1, "Fury"], ["warrior", 1, 2, "Protection"],
|
||||
["paladin", 2, 0, "Holy"], ["paladin", 2, 1, "Protection"], ["paladin", 2, 2, "Retribution"],
|
||||
["hunter", 3, 0, "Beast Mastery"], ["hunter", 3, 1, "Marksmanship"], ["hunter", 3, 2, "Survival"],
|
||||
["rogue", 4, 0, "Assassination"], ["rogue", 4, 1, "Combat"], ["rogue", 4, 2, "Subtlety"],
|
||||
["priest", 5, 0, "Discipline"], ["priest", 5, 1, "Holy"], ["priest", 5, 2, "Shadow"],
|
||||
["death-knight", 6, 0, "Blood"], ["death-knight", 6, 1, "Frost"], ["death-knight", 6, 2, "Unholy"],
|
||||
["shaman", 7, 0, "Elemental"], ["shaman", 7, 1, "Enhancement"], ["shaman", 7, 2, "Restoration"],
|
||||
["mage", 8, 0, "Arcane"], ["mage", 8, 1, "Fire"], ["mage", 8, 2, "Frost"],
|
||||
["warlock", 9, 0, "Affliction"], ["warlock", 9, 1, "Demonology"], ["warlock", 9, 2, "Destruction"],
|
||||
["druid", 11, 0, "Balance"], ["druid", 11, 1, "Feral (Bear)"], ["druid", 11, 2, "Restoration"], ["druid", 11, 3, "Feral (Cat)"],
|
||||
].map(([classId, classNumber, specNumber, specialization]) => ({
|
||||
classId: classId as BaseClassId,
|
||||
classNumber: classNumber as number,
|
||||
specNumber: specNumber as number,
|
||||
specialization: specialization as string,
|
||||
}));
|
||||
|
||||
// Stable identities make a build recognizable in logs and leave room for
|
||||
// multiple builds of the same class/spec without silently replacing one.
|
||||
const CHARACTER_NAMES = [
|
||||
"Aldren", "Briala", "Caelen", "Darian", "Elowen", "Faelar", "Gavran", "Helia",
|
||||
"Ilyra", "Jorren", "Kaelis", "Liora", "Marek", "Nerys", "Orin", "Phaela",
|
||||
"Quorin", "Rielle", "Soren", "Talia", "Ulric", "Vaela", "Wrenna", "Xavian",
|
||||
"Ysara", "Zoren", "Arwyn", "Brenna", "Corvin", "Delia", "Edrin", "Fiora",
|
||||
"Galen", "Hestia", "Isolde", "Jarek", "Kiera", "Lucan", "Mira", "Nolan",
|
||||
"Oriana", "Perrin", "Quilla", "Ronan", "Selene", "Theron", "Una", "Varen",
|
||||
"Willow", "Xara", "Yorin", "Zella", "Ansel", "Brynn", "Cyrus", "Dessa",
|
||||
"Evren", "Freya", "Garrick", "Halen", "Ilara", "Jessa",
|
||||
] as const;
|
||||
|
||||
function specKey(classId: BaseClassId, specialization: string): string {
|
||||
return `${classId}:${specialization}`;
|
||||
}
|
||||
|
||||
function slug(value: string): string {
|
||||
return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
||||
}
|
||||
|
||||
function parsePlayerbotsConfig(source: string): {
|
||||
names: ReadonlyMap<string, string>;
|
||||
links: ReadonlyMap<string, readonly PlayerbotsMilestone[]>;
|
||||
} {
|
||||
const names = new Map<string, string>();
|
||||
const links = new Map<string, PlayerbotsMilestone[]>();
|
||||
for (const rawLine of source.split(/\r?\n/)) {
|
||||
const line = rawLine.trim();
|
||||
const name = /^AiPlayerbot\.PremadeSpecName\.(\d+)\.(\d+)\s*=\s*(.+)$/.exec(line);
|
||||
if (name) names.set(`${name[1]}:${name[2]}`, name[3].trim());
|
||||
const link = /^AiPlayerbot\.PremadeSpecLink\.(\d+)\.(\d+)\.(\d+)\s*=\s*([0-9-]+)$/.exec(line);
|
||||
if (!link) continue;
|
||||
const key = `${link[1]}:${link[2]}`;
|
||||
const existing = links.get(key) ?? [];
|
||||
existing.push({ level: Number(link[3]), link: link[4] });
|
||||
links.set(key, existing);
|
||||
}
|
||||
for (const milestones of links.values()) milestones.sort((left, right) => left.level - right.level);
|
||||
return { names, links };
|
||||
}
|
||||
|
||||
function orderedTreeNodes(treeId: string): readonly TalentNodeDefinition[] {
|
||||
return [...talentNodesForTree(treeId)].sort((left, right) => (
|
||||
left.row - right.row || left.column - right.column || left.index - right.index
|
||||
));
|
||||
}
|
||||
|
||||
function ranksFromPlayerbotsLink(classId: BaseClassId, link: string): TalentRanks {
|
||||
const ranks: Record<string, number> = {};
|
||||
const treeLinks = link.split("-");
|
||||
for (const [treeIndex, tree] of talentTreesForClass(classId).entries()) {
|
||||
const digits = treeLinks[treeIndex] ?? "";
|
||||
for (const [nodeIndex, node] of orderedTreeNodes(tree.id).entries()) {
|
||||
const digit = Number(digits[nodeIndex] ?? "0");
|
||||
if (Number.isInteger(digit) && digit > 0) ranks[node.id] = Math.min(node.maxRank, digit);
|
||||
}
|
||||
}
|
||||
return ranks;
|
||||
}
|
||||
|
||||
function desiredNodeOrder(classId: BaseClassId, target: TalentRanks): readonly string[] {
|
||||
const trees = talentTreesForClass(classId);
|
||||
const treeScore = new Map(trees.map((tree) => [
|
||||
tree.id,
|
||||
orderedTreeNodes(tree.id).filter((node) => (target[node.id] ?? 0) > 0).length,
|
||||
]));
|
||||
const treeIndex = new Map(trees.map((tree, index) => [tree.id, index]));
|
||||
return trees.flatMap((tree) => orderedTreeNodes(tree.id))
|
||||
.filter((node) => (target[node.id] ?? 0) > 0)
|
||||
.sort((left, right) => (
|
||||
(treeScore.get(right.treeId) ?? 0) - (treeScore.get(left.treeId) ?? 0)
|
||||
|| (treeIndex.get(left.treeId) ?? 0) - (treeIndex.get(right.treeId) ?? 0)
|
||||
|| left.row - right.row
|
||||
|| left.column - right.column
|
||||
|| left.index - right.index
|
||||
))
|
||||
.map((node) => node.id);
|
||||
}
|
||||
|
||||
function playerbotsAllocationPlan(
|
||||
reference: PlayerbotsSpecReference,
|
||||
milestones: readonly PlayerbotsMilestone[],
|
||||
): {
|
||||
readonly plan: readonly string[];
|
||||
readonly sourcePointCount: number;
|
||||
readonly sourcePointsRetained: number;
|
||||
readonly fallbackPointCount: number;
|
||||
readonly levelPlans: readonly GeneratedLevelPlan[];
|
||||
} {
|
||||
if (milestones.length === 0) throw new Error(`No Playerbots links for ${specKey(reference.classId, reference.specialization)}.`);
|
||||
const targets = milestones.map((milestone) => ({
|
||||
...milestone,
|
||||
ranks: ranksFromPlayerbotsLink(reference.classId, milestone.link),
|
||||
}));
|
||||
const fallback = partyTalentAllocationPlan(reference.classId, reference.specialization).map((entry) => entry.nodeId);
|
||||
const levelPlans = targets.map((_, startIndex): GeneratedLevelPlan => {
|
||||
// PlayerbotFactory walks backward to the closest configured link, then
|
||||
// applies that link and each later link until the bot runs out of points.
|
||||
// Level 80 starts at the 80 link directly. A plan variant per configured
|
||||
// starting link preserves that behavior without storing 80 duplicate plans.
|
||||
const appliedTargets = targets.slice(startIndex);
|
||||
const sourceCapacity: Record<string, number> = {};
|
||||
for (const target of appliedTargets) {
|
||||
for (const [nodeId, rank] of Object.entries(target.ranks)) {
|
||||
sourceCapacity[nodeId] = Math.max(sourceCapacity[nodeId] ?? 0, rank);
|
||||
}
|
||||
}
|
||||
const plan: string[] = [];
|
||||
const sourcePointFlags: boolean[] = [];
|
||||
let ranks: TalentRanks = {};
|
||||
for (let point = 1; point <= 71; point += 1) {
|
||||
const level = point + 9;
|
||||
let allocated = false;
|
||||
for (const target of appliedTargets) {
|
||||
for (const nodeId of desiredNodeOrder(reference.classId, target.ranks)) {
|
||||
if ((ranks[nodeId] ?? 0) >= (target.ranks[nodeId] ?? 0)) continue;
|
||||
const result = allocateTalentPoint(ranks, reference.classId, nodeId, level);
|
||||
if (!result.ok) continue;
|
||||
ranks = result.ranks;
|
||||
plan.push(nodeId);
|
||||
sourcePointFlags.push(true);
|
||||
allocated = true;
|
||||
break;
|
||||
}
|
||||
if (allocated) break;
|
||||
}
|
||||
if (!allocated) {
|
||||
for (const nodeId of fallback) {
|
||||
const result = allocateTalentPoint(ranks, reference.classId, nodeId, level);
|
||||
if (!result.ok) continue;
|
||||
ranks = result.ranks;
|
||||
plan.push(nodeId);
|
||||
sourcePointFlags.push(false);
|
||||
allocated = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!allocated) throw new Error(`Could not allocate Playerbots point ${point} for ${specKey(reference.classId, reference.specialization)}.`);
|
||||
}
|
||||
return {
|
||||
minimumLevel: startIndex === 0 ? 1 : targets[startIndex].level,
|
||||
sourceLevels: appliedTargets.map((target) => target.level),
|
||||
sourcePointCount: Object.values(sourceCapacity).reduce((total, rank) => total + rank, 0),
|
||||
sourcePointFlags,
|
||||
plan,
|
||||
};
|
||||
});
|
||||
const final = levelPlans[levelPlans.length - 1];
|
||||
const sourcePointsRetained = final.sourcePointFlags.filter(Boolean).length;
|
||||
return {
|
||||
plan: final.plan,
|
||||
sourcePointCount: final.sourcePointCount,
|
||||
sourcePointsRetained,
|
||||
fallbackPointCount: Math.max(0, 71 - sourcePointsRetained),
|
||||
levelPlans,
|
||||
};
|
||||
}
|
||||
|
||||
const config = parsePlayerbotsConfig(readFileSync(playerbotsConfigPath, "utf8"));
|
||||
const playerbotsBySpec = new Map(PLAYERBOTS_PVE_SPECS.map((reference) => [
|
||||
specKey(reference.classId, reference.specialization),
|
||||
reference,
|
||||
]));
|
||||
const profiles = PARTY_TALENT_SPECIALIZATIONS.flatMap(({ classId, specialization }, index) => {
|
||||
const key = specKey(classId, specialization);
|
||||
const reference = playerbotsBySpec.get(key);
|
||||
if (!reference) throw new Error(`No Playerbots PvE mapping for ${key}.`);
|
||||
const configKey = `${reference.classNumber}:${reference.specNumber}`;
|
||||
const milestones = config.links.get(configKey) ?? [];
|
||||
const specSlug = `${classId}-${slug(specialization)}`;
|
||||
const playerbotsPlan = playerbotsAllocationPlan(reference, milestones);
|
||||
return [
|
||||
{
|
||||
id: `playerbots-pve-${PLAYERBOTS_COMMIT}:${specSlug}`,
|
||||
classId,
|
||||
specialization,
|
||||
name: `${specialization} Playerbots PvE`,
|
||||
characterName: CHARACTER_NAMES[index],
|
||||
source: "azerothcore-playerbots",
|
||||
sourceVersion: PLAYERBOTS_COMMIT,
|
||||
defaultForSpecialization: true,
|
||||
playerbotsSpecName: config.names.get(configKey) ?? null,
|
||||
milestones,
|
||||
...playerbotsPlan,
|
||||
},
|
||||
{
|
||||
id: `healer-man-executable-v1:${specSlug}`,
|
||||
classId,
|
||||
specialization,
|
||||
name: `${specialization} Executable`,
|
||||
characterName: CHARACTER_NAMES[index + PARTY_TALENT_SPECIALIZATIONS.length],
|
||||
source: "healer-man-generated",
|
||||
sourceVersion: "v1",
|
||||
defaultForSpecialization: false,
|
||||
playerbotsSpecName: null,
|
||||
milestones: [],
|
||||
sourcePointCount: null,
|
||||
sourcePointsRetained: null,
|
||||
fallbackPointCount: 0,
|
||||
plan: partyTalentAllocationPlan(classId, specialization).map((selection) => selection.nodeId),
|
||||
levelPlans: [],
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
const output = {
|
||||
schemaVersion: 1,
|
||||
generatedFrom: "src/game/partyTalents.ts",
|
||||
plans,
|
||||
schemaVersion: 2,
|
||||
generatedFrom: [
|
||||
"src/game/partyTalents.ts",
|
||||
"../wow-pvt-server-backups-2/azerothcore-wotlk/modules/mod-playerbots/conf/playerbots.conf.dist",
|
||||
],
|
||||
playerbotsCommit: PLAYERBOTS_COMMIT,
|
||||
profiles,
|
||||
};
|
||||
|
||||
mkdirSync(dirname(outputPath), { recursive: true });
|
||||
writeFileSync(outputPath, `${JSON.stringify(output, null, 2)}\n`, "utf8");
|
||||
console.log(`Wrote ${Object.keys(plans).length} party talent plans to ${outputPath}`);
|
||||
|
||||
console.log(`Wrote ${profiles.length} named party talent profiles to ${outputPath}`);
|
||||
@@ -54,14 +54,28 @@ const runtimeData = fs.mkdtempSync(path.join(os.tmpdir(), 'healer-man-balance-ui
|
||||
await page.locator('.balance-lab-screen').waitFor();
|
||||
await page.getByText('Server runtime ready').waitFor();
|
||||
assert.match(await page.locator('.balance-lab-runtime').innerText(), /Python 3\./);
|
||||
assert.match(await page.locator('.balance-lab-runtime').innerText(), /0\/4 workers active/);
|
||||
await page.getByLabel('Party source').selectOption('wow');
|
||||
await page.getByLabel('Level').fill('20');
|
||||
await page.getByLabel('Level coverage').selectOption('brackets');
|
||||
assert.equal(await page.getByLabel('Bracket spacing').inputValue(), '10');
|
||||
assert.equal(await page.locator('.balance-lab-estimate span').filter({ hasText: 'Level jobs' }).locator('b').innerText(), '9');
|
||||
await page.getByLabel('Bracket spacing').selectOption('5');
|
||||
assert.equal(await page.locator('.balance-lab-estimate span').filter({ hasText: 'Level jobs' }).locator('b').innerText(), '17');
|
||||
await page.getByLabel('Bracket spacing').selectOption('1');
|
||||
assert.equal(await page.locator('.balance-lab-estimate span').filter({ hasText: 'Level jobs' }).locator('b').innerText(), '80');
|
||||
await page.getByLabel('Bracket spacing').selectOption('10');
|
||||
await capture('02-level-batch-config');
|
||||
await page.getByLabel('Level coverage').selectOption('single');
|
||||
await page.getByLabel('Level', { exact: true }).fill('20');
|
||||
await page.getByLabel('Class/spec selection').selectOption('all');
|
||||
assert(Number(await page.getByLabel('Coverage parties').inputValue()) > 1, 'all-spec coverage should derive multiple parties');
|
||||
await page.getByLabel('Class/spec selection').selectOption('specific');
|
||||
await page.getByLabel('Search Balance Lab class specs').fill('Paladin Holy');
|
||||
await page.getByRole('checkbox', { name: /Paladin · Holy/ }).check();
|
||||
await page.getByRole('checkbox', { name: /Paladin · Holy.*Holy Playerbots PvE/ }).check();
|
||||
assert.equal(await page.getByLabel('Coverage parties').inputValue(), '1');
|
||||
await page.getByLabel('Class/spec selection').selectOption('random');
|
||||
await page.getByLabel('Parties per source').fill('10');
|
||||
await page.getByText(/eligible pools provide 1 unique party configuration/i).waitFor();
|
||||
await page.getByLabel('Runs per party/boss').fill('1');
|
||||
await page.getByLabel('Fight limit (seconds)').fill('5');
|
||||
await page.getByLabel('Bosses').selectOption('specific');
|
||||
@@ -70,40 +84,54 @@ const runtimeData = fs.mkdtempSync(path.join(os.tmpdir(), 'healer-man-balance-ui
|
||||
const totalSimulations = page.locator('.balance-lab-estimate span').filter({ hasText: 'Total simulations' }).locator('b');
|
||||
await totalSimulations.waitFor();
|
||||
assert.equal(await totalSimulations.innerText(), '1');
|
||||
await capture('02-server-job-config');
|
||||
await capture('03-server-job-config');
|
||||
|
||||
await page.getByRole('button', { name: 'Run on server' }).click();
|
||||
await page.locator('.balance-lab-job-list em.is-complete').waitFor({ timeout: 240_000 });
|
||||
await page.getByRole('button', { name: 'Open full report' }).waitFor();
|
||||
assert.equal(await page.locator('.balance-lab-artifacts > button').count(), 12);
|
||||
await capture('03-server-job-complete');
|
||||
assert((await page.locator('.balance-lab-artifacts > button').count()) >= 12);
|
||||
await capture('04-server-job-complete');
|
||||
|
||||
await page.getByRole('button', { name: 'View graphs and filterable data' }).click();
|
||||
await page.locator('.balance-results').waitFor();
|
||||
assert.equal(await page.locator('.balance-results-chart').count(), 7);
|
||||
assert.match(await page.locator('.balance-results-chart').filter({ hasText: 'Boss damage to party' }).innerText(), /Eck the Ferocious/);
|
||||
await capture('04-run-analytics');
|
||||
await capture('05-run-analytics');
|
||||
|
||||
await page.getByRole('button', { name: 'bosses', exact: true }).click();
|
||||
assert.equal(await page.locator('.balance-results-table-panel tbody tr').count(), 1);
|
||||
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');
|
||||
assert.equal(allClassRows, 4, 'the four NPC teammates should be present in the NPC-only class/spec table');
|
||||
await page.getByLabel('Sort class specs').selectOption('totalDamageTaken');
|
||||
assert.equal(await page.getByLabel('Sort class specs').inputValue(), 'totalDamageTaken');
|
||||
await page.getByLabel('Class role').selectOption('damage');
|
||||
const damageRows = await page.locator('.balance-results-table-panel tbody tr').count();
|
||||
assert(damageRows > 0 && damageRows < allClassRows, 'role filtering should narrow the complete class/spec table');
|
||||
await page.getByLabel('Class role').selectOption('all');
|
||||
await page.getByRole('button', { name: 'abilities', exact: true }).click();
|
||||
const 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, 'ability breakdown should retain observed and available abilities');
|
||||
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 capture('05b-ability-breakdown');
|
||||
await page.getByRole('button', { name: 'parties', exact: true }).click();
|
||||
assert.equal(await page.locator('.balance-results-table-panel tbody tr').count(), 1);
|
||||
await page.getByLabel('Search Balance Lab run history').fill('does-not-match');
|
||||
@@ -118,11 +146,22 @@ const runtimeData = fs.mkdtempSync(path.join(os.tmpdir(), 'healer-man-balance-ui
|
||||
await page.getByRole('button', { name: 'overview', exact: true }).click();
|
||||
await page.getByRole('heading', { name: 'Boss damage to party' }).waitFor();
|
||||
await page.getByRole('button', { name: 'Main menu' }).waitFor();
|
||||
await capture('05-mobile-analytics');
|
||||
await capture('06-mobile-analytics');
|
||||
await page.getByRole('button', { name: 'abilities', exact: true }).click();
|
||||
await page.getByText('Every class/spec ability').scrollIntoViewIfNeeded();
|
||||
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth), true);
|
||||
await capture('06-mobile-abilities');
|
||||
await capture('07-mobile-abilities');
|
||||
|
||||
await page.setViewportSize({ width: 1440, height: 1000 });
|
||||
await page.getByLabel('Level coverage').selectOption('brackets');
|
||||
await page.getByLabel('Bracket spacing').selectOption('10');
|
||||
assert.equal(await page.locator('.balance-lab-estimate span').filter({ hasText: 'Level jobs' }).locator('b').innerText(), '9');
|
||||
await page.getByRole('button', { name: 'Queue 9 level jobs' }).click();
|
||||
await page.waitForFunction(() => (
|
||||
document.querySelectorAll('.balance-lab-job-list em.is-complete').length >= 10
|
||||
), null, { timeout: 240_000 });
|
||||
assert.equal(await page.locator('.balance-lab-job-list em.is-failed').count(), 0);
|
||||
await capture('08-nine-bracket-jobs-complete');
|
||||
assert.deepEqual(pageErrors, []);
|
||||
console.log('Balance Lab GM playtest passed');
|
||||
} finally {
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
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.COMPANION_READINESS_PLAYTEST_DIR || 'playtest-artifacts/companion-readiness');
|
||||
const port = Number(process.env.COMPANION_READINESS_PLAYTEST_PORT || 4192);
|
||||
const base = `http://127.0.0.1:${port}`;
|
||||
fs.mkdirSync(directory, { recursive: true });
|
||||
const runtimeData = fs.mkdtempSync(path.join(os.tmpdir(), 'healer-man-readiness-ui-'));
|
||||
|
||||
(async () => {
|
||||
const { createGameServer } = await import('../server/server.mjs');
|
||||
const runtime = createGameServer({ dataDir: runtimeData, enableShelvedModes: false });
|
||||
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(120_000);
|
||||
const pageErrors = [];
|
||||
page.on('pageerror', (error) => pageErrors.push(error.message));
|
||||
const capture = async (name, fullPage = true) => {
|
||||
await page.screenshot({ path: path.join(directory, `${name}.png`), fullPage });
|
||||
console.log(`Captured ${name}`);
|
||||
};
|
||||
|
||||
try {
|
||||
await page.goto(base, { waitUntil: 'domcontentloaded' });
|
||||
await page.getByLabel('Username').fill('Phenom');
|
||||
await page.getByLabel('Password').fill('readiness-playtest');
|
||||
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('ReadinessQA');
|
||||
await page.getByRole('button', { name: /Create character/ }).click();
|
||||
await page.getByRole('button', { name: /Continue/ }).first().click();
|
||||
await page.locator('.main-menu-screen').waitFor();
|
||||
|
||||
await page.getByRole('button', { name: /Companion Readiness/ }).click();
|
||||
await page.locator('.companion-readiness').waitFor();
|
||||
const totals = page.getByLabel('Readiness totals');
|
||||
assert.match(await totals.innerText(), /5\s+Ready/);
|
||||
assert.match(await totals.innerText(), /0\s+Beta/);
|
||||
assert.match(await totals.innerText(), /26\s+Needs Testing/);
|
||||
assert.equal(await page.locator('.companion-readiness__spec').count(), 31);
|
||||
assert.match(await page.locator('.companion-readiness__spec').filter({ hasText: 'Aldren' }).innerText(), /Protection/);
|
||||
assert.match(await page.locator('.companion-readiness__spec').filter({ hasText: 'Quorin' }).innerText(), /Discipline/);
|
||||
await capture('01-readiness-desktop');
|
||||
|
||||
await page.setViewportSize({ width: 480, height: 900 });
|
||||
await page.waitForTimeout(250);
|
||||
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth), true);
|
||||
await page.getByLabel('Status').selectOption('ready');
|
||||
assert.equal(await page.locator('.companion-readiness__spec').count(), 5);
|
||||
await capture('02-readiness-narrow');
|
||||
|
||||
await page.setViewportSize({ width: 1440, height: 1000 });
|
||||
await page.getByLabel('Status').selectOption('all');
|
||||
await page.getByLabel('Arms readiness').selectOption('beta');
|
||||
await page.getByRole('button', { name: 'Publish policy' }).click();
|
||||
await page.getByText('Published policy is active').waitFor();
|
||||
assert.match(await totals.innerText(), /1\s+Beta/);
|
||||
assert.match(await totals.innerText(), /25\s+Needs Testing/);
|
||||
await page.getByRole('button', { name: 'Back' }).click();
|
||||
await page.locator('.main-menu-screen').waitFor();
|
||||
|
||||
await page.getByRole('button', { name: /Dungeons/ }).click();
|
||||
await page.locator('.dungeon-select-screen').waitFor();
|
||||
const beta = page.getByRole('checkbox', { name: /Include Beta Specs/ });
|
||||
assert.equal(await beta.isDisabled(), false);
|
||||
assert.equal(await beta.isChecked(), false);
|
||||
assert.match(await page.locator('.companion-pool-controls').innerText(), /1 beta spec available/);
|
||||
await page.locator('.dungeon-select-row').filter({ hasText: 'Wailing Caverns' }).click();
|
||||
await page.locator('.dungeon-select-copy h2').getByText('Wailing Caverns', { exact: true }).waitFor();
|
||||
await capture('03-dungeon-ready-pool');
|
||||
|
||||
await page.getByRole('button', { name: /Choose role/ }).click();
|
||||
const roleButton = page.locator('.dungeon-role-option').first();
|
||||
await roleButton.click();
|
||||
await page.locator('.party-frames').waitFor({ timeout: 180_000 });
|
||||
await page.locator('.party-frame').nth(4).waitFor();
|
||||
await page.locator('.loading-screen').waitFor({ state: 'detached', timeout: 180_000 });
|
||||
assert.equal(await page.locator('.party-frame').count(), 5);
|
||||
const npcFrames = await page.locator('.party-frame:not(.party-frame--player):not(.party-frame--online)').allInnerTexts();
|
||||
assert.equal(npcFrames.length, 4);
|
||||
for (const frame of npcFrames) {
|
||||
assert.match(frame, /Protection|Discipline|Marksmanship|Fire|Assassination/i);
|
||||
assert.doesNotMatch(frame, /Arms/i);
|
||||
}
|
||||
await capture('04-live-dungeon-ready-party', false);
|
||||
assert.deepEqual(pageErrors, []);
|
||||
console.log(`Companion Readiness playtest passed with NPCs: ${npcFrames.map((frame) => frame.split('\n')[0]).join(', ')}`);
|
||||
} 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;
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
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', 'party-heal-popups');
|
||||
const runtimeData = fs.mkdtempSync(path.join(os.tmpdir(), 'healer-man-party-heals-'));
|
||||
fs.mkdirSync(directory, { recursive: true });
|
||||
|
||||
(async () => {
|
||||
const { createGameServer } = await import('../server/server.mjs');
|
||||
const runtime = createGameServer({ dataDir: runtimeData, enableShelvedModes: false });
|
||||
await new Promise((resolve) => runtime.server.listen(4194, '127.0.0.1', resolve));
|
||||
const browser = await chromium.launch({ channel: 'msedge', headless: true });
|
||||
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } });
|
||||
page.setDefaultTimeout(180_000);
|
||||
const pageErrors = [];
|
||||
page.on('pageerror', (error) => pageErrors.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 stores = async () => {
|
||||
const values = (await Promise.all(urls.map((url) => import(url)))).flatMap(Object.values);
|
||||
const candidates = values.filter((value) => value && typeof value.getState === 'function');
|
||||
const party = candidates.find((store) => Array.isArray(store.getState()?.members)
|
||||
&& typeof store.getState()?.initializeParty === 'function');
|
||||
const combat = candidates.find((store) => Array.isArray(store.getState()?.combatEvents)
|
||||
&& typeof store.getState()?.healActor === 'function');
|
||||
const shell = candidates.find((store) => Array.isArray(store.getState()?.characters)
|
||||
&& typeof store.getState()?.openMainMenu === 'function');
|
||||
if (!party || !combat || !shell) throw new Error('Party/combat/shell stores were not found.');
|
||||
return { party, combat, shell };
|
||||
};
|
||||
window.openMainMenuForPartyHealPlaytest = async () => {
|
||||
const { shell } = await stores();
|
||||
shell.getState().openMainMenu();
|
||||
};
|
||||
window.enterDungeonForPartyHealPlaytest = async () => {
|
||||
const { shell } = await stores();
|
||||
const ok = shell.getState().enterDungeon('wailing-caverns');
|
||||
return { ok, notice: shell.getState().notice, phase: shell.getState().phase };
|
||||
};
|
||||
window.openDungeonsForPartyHealPlaytest = async () => {
|
||||
const { shell } = await stores();
|
||||
shell.getState().openDungeons();
|
||||
};
|
||||
window.triggerPartyHealPopup = async () => {
|
||||
const { party, combat } = await stores();
|
||||
const member = party.getState().members[0];
|
||||
if (!member) throw new Error('No NPC party member is available.');
|
||||
const injuredHealth = Math.max(1, member.maxHealth - 300);
|
||||
party.setState({
|
||||
members: party.getState().members.map((candidate) => candidate.id === member.id
|
||||
? { ...candidate, health: injuredHealth }
|
||||
: candidate),
|
||||
});
|
||||
const actor = combat.getState().actors[member.id];
|
||||
if (!actor) throw new Error('The NPC party actor is unavailable.');
|
||||
combat.setState({ actors: {
|
||||
...combat.getState().actors,
|
||||
[member.id]: { ...actor, health: injuredHealth, alive: true },
|
||||
} });
|
||||
const mob = Object.values(combat.getState().mobs).find((candidate) => !candidate.dead && !candidate.despawned);
|
||||
if (!mob) throw new Error('No living dungeon enemy is available.');
|
||||
combat.setState({ mobs: { ...combat.getState().mobs, [mob.id]: { ...mob, engaged: true } } });
|
||||
const result = combat.getState().healActor(member.id, 137, {
|
||||
sourceActorId: '__player__',
|
||||
abilityId: 'party-frame-playtest-heal',
|
||||
now: Date.now(),
|
||||
amountAlreadyModified: true,
|
||||
});
|
||||
return { memberId: member.id, amount: result.effectiveAmount };
|
||||
};
|
||||
}, moduleUrls);
|
||||
|
||||
try {
|
||||
await page.goto('http://127.0.0.1:4194', { 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('HealpopupQA');
|
||||
await page.getByRole('button', { name: /Create character/ }).click();
|
||||
await page.evaluate(() => window.openMainMenuForPartyHealPlaytest());
|
||||
await page.locator('.main-menu-screen').waitFor();
|
||||
await page.evaluate(() => window.openDungeonsForPartyHealPlaytest());
|
||||
await page.locator('.dungeon-select-screen').waitFor();
|
||||
const entry = await page.evaluate(() => window.enterDungeonForPartyHealPlaytest());
|
||||
assert.equal(entry.ok, true, JSON.stringify(entry));
|
||||
await page.locator('.party-frames').waitFor();
|
||||
await page.locator('.party-frame').nth(4).waitFor();
|
||||
await page.locator('.loading-screen').waitFor({ state: 'detached' });
|
||||
|
||||
const result = await page.evaluate(() => window.triggerPartyHealPopup());
|
||||
assert.equal(result.amount, 137);
|
||||
const popup = page.locator('.party-frame__heal-popup', { hasText: '+137' });
|
||||
await popup.waitFor({ state: 'visible' });
|
||||
const targetFrame = page.locator('.party-frame').filter({ has: popup });
|
||||
assert.equal(await targetFrame.count(), 1, 'heal number belongs to exactly one receiving frame');
|
||||
assert.equal(await page.locator('.desktop-action-feedback').count(), 0, 'center action feedback is absent');
|
||||
assert.equal((await page.locator('.action-bar__feedback').allInnerTexts()).join('').trim(), '', 'controller bar has no combat log text');
|
||||
await page.screenshot({ path: path.join(directory, 'party-heal-popup.png') });
|
||||
assert.deepEqual(pageErrors, []);
|
||||
console.log(`Party-frame heal popup playtest passed for ${result.memberId}.`);
|
||||
} 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;
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
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.ROM_FIVE_PLAYTEST_DIR || 'artifacts/playtest/rom-five');
|
||||
const port = Number(process.env.ROM_FIVE_PLAYTEST_PORT || 4193);
|
||||
const base = `http://127.0.0.1:${port}`;
|
||||
fs.mkdirSync(directory, { recursive: true });
|
||||
const runtimeData = fs.mkdtempSync(path.join(os.tmpdir(), 'healer-man-rom-five-ui-'));
|
||||
|
||||
(async () => {
|
||||
const { createGameServer } = await import('../server/server.mjs');
|
||||
const runtime = createGameServer({ dataDir: runtimeData, enableShelvedModes: false });
|
||||
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: 900 } });
|
||||
const page = await context.newPage();
|
||||
page.setDefaultTimeout(120_000);
|
||||
const pageErrors = [];
|
||||
page.on('pageerror', (error) => pageErrors.push(error.message));
|
||||
|
||||
try {
|
||||
await page.goto(base, { waitUntil: 'domcontentloaded' });
|
||||
await page.getByLabel('Username').fill('RomFiveQA');
|
||||
await page.getByLabel('Password').fill('rom-five-playtest');
|
||||
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.locator('.creator-category-tabs button').filter({ hasText: 'RoM' }).click();
|
||||
await page.locator('.class-option').filter({ hasText: 'Rogue' }).click();
|
||||
await page.getByText('Skills unlock by level').waitFor();
|
||||
await page.getByRole('button', { name: /Customize & name/ }).click();
|
||||
await page.getByLabel('Character name').fill('RomRogueQA');
|
||||
await page.getByRole('button', { name: /Create character/ }).click();
|
||||
await page.locator('.character-select-screen').waitFor();
|
||||
await page.locator('.character-preview canvas').waitFor({ timeout: 180_000 });
|
||||
await page.waitForTimeout(3_000);
|
||||
await page.screenshot({ path: path.join(directory, 'roster-preview.png') });
|
||||
|
||||
await page.getByRole('button', { name: /Create character/ }).click();
|
||||
await page.locator('.creator-category-tabs button').filter({ hasText: 'RoM' }).click();
|
||||
await page.locator('.race-option').filter({ hasText: 'Dwarf' }).click();
|
||||
await page.getByRole('button', { name: 'Mage Damage' }).click();
|
||||
await page.locator('.creator-preview-stage canvas').waitFor({ timeout: 180_000 });
|
||||
await page.waitForTimeout(3_000);
|
||||
await page.screenshot({ path: path.join(directory, 'dwarf-mage-creator.png') });
|
||||
await page.getByRole('button', { name: /Customize & name/ }).click();
|
||||
await page.getByLabel('Character name').fill('RomDwarfMageQA');
|
||||
await page.getByRole('button', { name: /Create character/ }).click();
|
||||
await page.locator('.character-select-screen').waitFor();
|
||||
await page.locator('.character-preview canvas').waitFor({ timeout: 180_000 });
|
||||
await page.waitForTimeout(3_000);
|
||||
await page.screenshot({ path: path.join(directory, 'dwarf-mage-roster.png') });
|
||||
await page.locator('.roster-character').filter({ hasText: 'RomRogueQA' }).click();
|
||||
|
||||
// The dual-display toggle overlays the narrow roster footer at this viewport.
|
||||
await page.locator('.roster-enter').evaluate((button) => button.click());
|
||||
await page.locator('.main-menu-screen').waitFor();
|
||||
await page.getByRole('button', { name: /Dungeons/ }).click();
|
||||
await page.locator('.dungeon-select-screen').waitFor();
|
||||
await page.locator('.dungeon-category-tab').filter({ hasText: 'WoW' }).click();
|
||||
await page.locator('.dungeon-select-row').filter({ hasText: 'Wailing Caverns' }).click();
|
||||
await page.getByRole('button', { name: /Choose role/ }).click();
|
||||
await page.locator('.dungeon-role-option--damage').click();
|
||||
await page.locator('.party-frames').waitFor({ timeout: 180_000 });
|
||||
await page.locator('.loading-screen').waitFor({ state: 'detached', timeout: 180_000 });
|
||||
assert.equal(await page.locator('.party-frame').count(), 5);
|
||||
await page.screenshot({ path: path.join(directory, 'dungeon-rogue.png') });
|
||||
await page.getByRole('button', { name: /Tactical/ }).click();
|
||||
await page.getByRole('button', { name: 'Skills', exact: true }).click();
|
||||
await page.locator('.rom-skills-panel').waitFor();
|
||||
assert.doesNotMatch(await page.locator('.rom-skills-panel').innerText(), /\bRank\s+\d+\b/);
|
||||
await page.screenshot({ path: path.join(directory, 'skills-no-ranks.png') });
|
||||
await page.locator('.rom-skills-panel').getByRole('button', { name: 'Close' }).click();
|
||||
await page.getByRole('button', { name: /Tactical/ }).click();
|
||||
await page.getByRole('button', { name: 'Spells', exact: true }).click();
|
||||
await page.locator('.spellbook-panel').waitFor();
|
||||
assert.doesNotMatch(await page.locator('.spellbook-panel').innerText(), /\bRank\s+\d+\b/);
|
||||
await page.screenshot({ path: path.join(directory, 'spellbook-no-ranks.png') });
|
||||
assert.deepEqual(pageErrors, []);
|
||||
console.log(`RoM Rogue roster and dungeon screenshots: ${directory}`);
|
||||
} 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;
|
||||
});
|
||||
+458
-84
@@ -1,4 +1,4 @@
|
||||
"""Visual, fail-closed release manager for Healer Man.
|
||||
"""Visual Git, TrueNAS, and optional APK release manager with 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,19 @@ 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
|
||||
DEFAULT_GIT_TRUENAS_ONLY = True
|
||||
DEFAULT_RUN_TESTS = True
|
||||
|
||||
|
||||
class ReleaseError(RuntimeError):
|
||||
@@ -69,25 +83,84 @@ 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
|
||||
version_code: int
|
||||
|
||||
|
||||
def full_release_checks(npm: str) -> tuple[tuple[list[str], str], ...]:
|
||||
"""Commands that must succeed before a release can be staged."""
|
||||
return (
|
||||
def full_release_checks(
|
||||
npm: str,
|
||||
*,
|
||||
include_android: bool = True,
|
||||
include_tests: bool = True,
|
||||
) -> tuple[tuple[list[str], str], ...]:
|
||||
"""Commands that must succeed before an update can be staged."""
|
||||
test_checks = (
|
||||
([npm, "run", "content:test"], "Content tests"),
|
||||
([npm, "run", "server:test"], "Server tests"),
|
||||
([npm, "test"], "Application tests"),
|
||||
([npm, "run", "test:shelved"], "Shelved-mode application tests"),
|
||||
)
|
||||
required_checks = (
|
||||
([npm, "run", "content:publish"], "Publish verified 54-dungeon content manifest"),
|
||||
([npm, "run", "build"], "Production build"),
|
||||
)
|
||||
checks = (test_checks if include_tests else ()) + required_checks
|
||||
if not include_android:
|
||||
return checks
|
||||
return checks + (
|
||||
([npm, "run", "android:sync"], "Android web build and Capacitor sync"),
|
||||
)
|
||||
|
||||
|
||||
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:
|
||||
@@ -586,7 +659,7 @@ def launch_gui(*, smoke_test: bool = False) -> int:
|
||||
)
|
||||
ttk.Label(
|
||||
title,
|
||||
text="Validate → inspect → commit → push → publish signed APK",
|
||||
text="Validate → inspect → commit → push → deploy, with optional signed APK",
|
||||
style="Sub.TLabel",
|
||||
).grid(row=1, column=0, sticky="w", pady=(2, 0))
|
||||
self.refresh_button = ttk.Button(title, text="Refresh", command=self.refresh)
|
||||
@@ -606,6 +679,12 @@ def launch_gui(*, smoke_test: bool = False) -> int:
|
||||
self.next_version_var = tk.StringVar()
|
||||
self.next_code_var = tk.StringVar()
|
||||
self.commit_message_var = tk.StringVar()
|
||||
self.git_truenas_only_var = tk.BooleanVar(
|
||||
value=DEFAULT_GIT_TRUENAS_ONLY
|
||||
)
|
||||
self.run_tests_var = tk.BooleanVar(value=DEFAULT_RUN_TESTS)
|
||||
self.release_mode_hint_var = tk.StringVar()
|
||||
self.test_mode_hint_var = tk.StringVar()
|
||||
|
||||
ttk.Label(release_panel, text="Current", style="Muted.Panel.TLabel").grid(
|
||||
row=1, column=0, sticky="w"
|
||||
@@ -623,25 +702,31 @@ def launch_gui(*, smoke_test: bool = False) -> int:
|
||||
ttk.Label(release_panel, text="Bump", style="Muted.Panel.TLabel").grid(
|
||||
row=2, column=0, sticky="w", pady=(12, 0)
|
||||
)
|
||||
bump_box = ttk.Combobox(
|
||||
self.bump_box = ttk.Combobox(
|
||||
release_panel,
|
||||
textvariable=self.bump_var,
|
||||
values=("Patch", "Minor", "Major"),
|
||||
state="readonly",
|
||||
width=10,
|
||||
)
|
||||
bump_box.grid(row=2, column=1, sticky="w", padx=(8, 20), pady=(12, 0))
|
||||
bump_box.bind("<<ComboboxSelected>>", lambda _event: self._suggest_versions())
|
||||
self.bump_box.grid(row=2, column=1, sticky="w", padx=(8, 20), pady=(12, 0))
|
||||
self.bump_box.bind("<<ComboboxSelected>>", lambda _event: self._suggest_versions())
|
||||
ttk.Label(release_panel, text="Next version", style="Muted.Panel.TLabel").grid(
|
||||
row=3, column=0, sticky="w", pady=(9, 0)
|
||||
)
|
||||
ttk.Entry(release_panel, textvariable=self.next_version_var, width=15).grid(
|
||||
self.next_version_entry = ttk.Entry(
|
||||
release_panel, textvariable=self.next_version_var, width=15
|
||||
)
|
||||
self.next_version_entry.grid(
|
||||
row=3, column=1, sticky="ew", padx=(8, 20), pady=(9, 0)
|
||||
)
|
||||
ttk.Label(release_panel, text="Next code", style="Muted.Panel.TLabel").grid(
|
||||
row=3, column=2, sticky="w", pady=(9, 0)
|
||||
)
|
||||
ttk.Entry(release_panel, textvariable=self.next_code_var, width=12).grid(
|
||||
self.next_code_entry = ttk.Entry(
|
||||
release_panel, textvariable=self.next_code_var, width=12
|
||||
)
|
||||
self.next_code_entry.grid(
|
||||
row=3, column=3, sticky="ew", padx=(8, 0), pady=(9, 0)
|
||||
)
|
||||
ttk.Label(release_panel, text="Commit message", style="Muted.Panel.TLabel").grid(
|
||||
@@ -650,7 +735,7 @@ def launch_gui(*, smoke_test: bool = False) -> int:
|
||||
ttk.Entry(release_panel, textvariable=self.commit_message_var).grid(
|
||||
row=4, column=1, columnspan=3, sticky="ew", padx=(8, 0), pady=(9, 0)
|
||||
)
|
||||
ttk.Label(release_panel, text="Release notes", style="Muted.Panel.TLabel").grid(
|
||||
ttk.Label(release_panel, text="Release notes (APK only)", style="Muted.Panel.TLabel").grid(
|
||||
row=5, column=0, sticky="nw", pady=(9, 0)
|
||||
)
|
||||
self.release_notes = tk.Text(
|
||||
@@ -668,12 +753,42 @@ def launch_gui(*, smoke_test: bool = False) -> int:
|
||||
self.release_notes.grid(
|
||||
row=5, column=1, columnspan=3, sticky="nsew", padx=(8, 0), pady=(9, 0)
|
||||
)
|
||||
self.release_mode_toggle = ttk.Checkbutton(
|
||||
release_panel,
|
||||
text="Git + TrueNAS only (skip Android version bump and APK publication)",
|
||||
variable=self.git_truenas_only_var,
|
||||
command=self._update_release_mode,
|
||||
)
|
||||
self.release_mode_toggle.grid(
|
||||
row=6, column=0, columnspan=4, sticky="w", pady=(12, 0)
|
||||
)
|
||||
ttk.Label(
|
||||
release_panel,
|
||||
textvariable=self.release_mode_hint_var,
|
||||
style="Muted.Panel.TLabel",
|
||||
wraplength=610,
|
||||
).grid(row=7, column=0, columnspan=4, sticky="w", pady=(4, 0))
|
||||
self.run_tests_toggle = ttk.Checkbutton(
|
||||
release_panel,
|
||||
text="Run test suites (recommended)",
|
||||
variable=self.run_tests_var,
|
||||
command=self._update_test_mode,
|
||||
)
|
||||
self.run_tests_toggle.grid(
|
||||
row=8, column=0, columnspan=4, sticky="w", pady=(10, 0)
|
||||
)
|
||||
ttk.Label(
|
||||
release_panel,
|
||||
textvariable=self.test_mode_hint_var,
|
||||
style="Muted.Panel.TLabel",
|
||||
wraplength=610,
|
||||
).grid(row=9, column=0, columnspan=4, sticky="w", pady=(4, 0))
|
||||
|
||||
secrets_panel = ttk.Frame(shell, style="Panel.TFrame", padding=15)
|
||||
secrets_panel.grid(row=1, column=1, sticky="nsew", padx=(8, 0))
|
||||
secrets_panel.columnconfigure(1, weight=1)
|
||||
ttk.Label(
|
||||
secrets_panel, text="Signing and Gitea", style="Panel.TLabel",
|
||||
secrets_panel, text="Signing and Gitea (APK only)", style="Panel.TLabel",
|
||||
font=("Segoe UI Semibold", 12),
|
||||
).grid(row=0, column=0, columnspan=3, sticky="w")
|
||||
ttk.Label(
|
||||
@@ -810,6 +925,54 @@ def launch_gui(*, smoke_test: bool = False) -> int:
|
||||
self.retry_button.grid(row=0, column=1, padx=7)
|
||||
self.cancel_button = ttk.Button(actions, text="Cancel", command=self.cancel, state="disabled")
|
||||
self.cancel_button.grid(row=0, column=2, padx=(7, 0))
|
||||
self._update_release_mode()
|
||||
self._update_test_mode()
|
||||
|
||||
def _update_release_mode(self) -> None:
|
||||
git_truenas_only = self.git_truenas_only_var.get()
|
||||
self.bump_box.configure(state="disabled" if git_truenas_only else "readonly")
|
||||
entry_state = "disabled" if git_truenas_only else "normal"
|
||||
self.next_version_entry.configure(state=entry_state)
|
||||
self.next_code_entry.configure(state=entry_state)
|
||||
if git_truenas_only:
|
||||
self.release_button.configure(text="Validate and Push Git + TrueNAS Update")
|
||||
self.release_mode_hint_var.set(
|
||||
"Default mode: keeps the current Android version, skips Android sync and APK "
|
||||
"publication, and prepares the pinned TrueNAS deployment commands after push."
|
||||
)
|
||||
else:
|
||||
self.release_button.configure(text="Validate, Push, and Publish APK")
|
||||
self.release_mode_hint_var.set(
|
||||
"APK mode: bumps the app and Android versions, runs Android sync, and publishes "
|
||||
"the signed APK after the Git push."
|
||||
)
|
||||
self._suggest_versions()
|
||||
current_notes = self.release_notes.get("1.0", "end").strip()
|
||||
server_default = "Healer Man Git and TrueNAS server update."
|
||||
apk_default = (
|
||||
f"Healer Man {self.next_version_var.get()} app and Android update."
|
||||
)
|
||||
generated_apk_notes = re.fullmatch(
|
||||
r"Healer Man \d+\.\d+\.\d+ app and Android update\.",
|
||||
current_notes,
|
||||
)
|
||||
if git_truenas_only and (not current_notes or generated_apk_notes):
|
||||
self._set_text(self.release_notes, server_default)
|
||||
elif not git_truenas_only and (
|
||||
not current_notes or current_notes == server_default
|
||||
):
|
||||
self._set_text(self.release_notes, apk_default)
|
||||
|
||||
def _update_test_mode(self) -> None:
|
||||
if self.run_tests_var.get():
|
||||
self.test_mode_hint_var.set(
|
||||
"Runs content, server, normal-application, and shelved-application tests."
|
||||
)
|
||||
else:
|
||||
self.test_mode_hint_var.set(
|
||||
"Skips those four test suites. Candidate safety checks, content publication, "
|
||||
"the production build, and staged-file review still run."
|
||||
)
|
||||
|
||||
def _command_text_widget(self, notebook: ttk.Notebook) -> tk.Text:
|
||||
frame = ttk.Frame(notebook)
|
||||
@@ -879,7 +1042,7 @@ def launch_gui(*, smoke_test: bool = False) -> int:
|
||||
self.last_commit = current_commit()
|
||||
self.current_version_var.set(package_version)
|
||||
self.current_code_var.set(str(release_version.version_code))
|
||||
self._suggest_versions()
|
||||
self._update_release_mode()
|
||||
change_count = len(
|
||||
[line for line in status.splitlines() if not line.startswith("##")]
|
||||
)
|
||||
@@ -897,10 +1060,6 @@ def launch_gui(*, smoke_test: bool = False) -> int:
|
||||
generate_truenas_verify_commands(),
|
||||
readonly=True,
|
||||
)
|
||||
if not self.release_notes.get("1.0", "end").strip():
|
||||
self.release_notes.insert(
|
||||
"1.0", f"Healer Man {self.next_version_var.get()} app and Android update."
|
||||
)
|
||||
self.status_var.set("Ready")
|
||||
except Exception as exc:
|
||||
self.status_var.set("Configuration error")
|
||||
@@ -914,7 +1073,10 @@ def launch_gui(*, smoke_test: bool = False) -> int:
|
||||
next_code = suggest_version_code(next_version, release.version_code)
|
||||
self.next_version_var.set(next_version)
|
||||
self.next_code_var.set(str(next_code))
|
||||
self.commit_message_var.set(f"Release Healer Man {next_version}")
|
||||
if self.git_truenas_only_var.get():
|
||||
self.commit_message_var.set("Update Healer Man server")
|
||||
else:
|
||||
self.commit_message_var.set(f"Release Healer Man {next_version}")
|
||||
except Exception as exc:
|
||||
self.status_var.set(str(exc))
|
||||
|
||||
@@ -978,32 +1140,84 @@ def launch_gui(*, smoke_test: bool = False) -> int:
|
||||
def start_full_release(self) -> None:
|
||||
if self.busy:
|
||||
return
|
||||
publish_apk = not self.git_truenas_only_var.get()
|
||||
run_tests = self.run_tests_var.get()
|
||||
test_summary = (
|
||||
"The four test suites will run."
|
||||
if run_tests
|
||||
else "The four test suites will be skipped; safety checks and the production build still run."
|
||||
)
|
||||
try:
|
||||
version = self._requested_version()
|
||||
credentials = self._credentials()
|
||||
toolchain_ready, toolchain_detail = android_toolchain_status()
|
||||
if not toolchain_ready:
|
||||
raise ReleaseError(toolchain_detail)
|
||||
commit_message = self.commit_message_var.get().strip()
|
||||
notes = self.release_notes.get("1.0", "end").strip()
|
||||
if not commit_message:
|
||||
raise ReleaseError("Commit message is required.")
|
||||
if not notes:
|
||||
raise ReleaseError("Release notes are required.")
|
||||
version: ReleaseVersion | None = None
|
||||
credentials: dict[str, str] | None = None
|
||||
notes = ""
|
||||
if publish_apk:
|
||||
version = self._requested_version()
|
||||
credentials = self._credentials()
|
||||
toolchain_ready, toolchain_detail = android_toolchain_status()
|
||||
if not toolchain_ready:
|
||||
raise ReleaseError(toolchain_detail)
|
||||
notes = self.release_notes.get("1.0", "end").strip()
|
||||
if not notes:
|
||||
raise ReleaseError("Release notes are required.")
|
||||
except Exception as exc:
|
||||
messagebox.showerror("Cannot start release", str(exc), parent=self.root)
|
||||
return
|
||||
if not publish_apk:
|
||||
if not messagebox.askyesno(
|
||||
"Start Git + TrueNAS update?",
|
||||
"Run the server validation and production build, review and commit the exact "
|
||||
"changed files, then push main?\n\nThe Android version will not change and no "
|
||||
"APK will be built or published. The Deploy tab will be pinned to the new commit.\n\n"
|
||||
+ test_summary,
|
||||
parent=self.root,
|
||||
):
|
||||
return
|
||||
self._begin_worker(
|
||||
"Validating Git + TrueNAS update",
|
||||
lambda: self._full_release_worker(
|
||||
None,
|
||||
commit_message,
|
||||
"",
|
||||
None,
|
||||
publish_apk=False,
|
||||
run_tests=run_tests,
|
||||
),
|
||||
(
|
||||
"Git main is published without changing the Android version or publishing "
|
||||
"an APK.\n\nUse the TrueNAS Deploy tab after stopping the app, then the "
|
||||
"Verify tab after restarting it."
|
||||
),
|
||||
)
|
||||
return
|
||||
assert version is not None
|
||||
assert credentials is not None
|
||||
if not messagebox.askyesno(
|
||||
"Start full release?",
|
||||
f"Prepare Healer Man {version.version_name} (Android code {version.version_code})?\n\n"
|
||||
"The app will bump versions, run all release checks, then show the exact staged files "
|
||||
"before it commits, pushes main, and publishes the signed APK.",
|
||||
"before it commits, pushes main, and publishes the signed APK.\n\n"
|
||||
+ test_summary,
|
||||
parent=self.root,
|
||||
):
|
||||
return
|
||||
self._begin_worker(
|
||||
"Validating release",
|
||||
lambda: self._full_release_worker(version, commit_message, notes, credentials),
|
||||
lambda: self._full_release_worker(
|
||||
version,
|
||||
commit_message,
|
||||
notes,
|
||||
credentials,
|
||||
publish_apk=True,
|
||||
run_tests=run_tests,
|
||||
),
|
||||
(
|
||||
"Git main and the signed APK release are published.\n\nUse the TrueNAS Deploy "
|
||||
"tab after stopping the app, then the Verify tab after restarting it."
|
||||
),
|
||||
)
|
||||
|
||||
def start_apk_only(self) -> None:
|
||||
@@ -1036,9 +1250,18 @@ def launch_gui(*, smoke_test: bool = False) -> int:
|
||||
self._begin_worker(
|
||||
"Publishing current APK",
|
||||
lambda: self._apk_only_worker(release_version, notes, credentials),
|
||||
(
|
||||
"The signed APK release is published for the current Git commit.\n\nUse the "
|
||||
"TrueNAS Deploy tab only if that commit is not already deployed."
|
||||
),
|
||||
)
|
||||
|
||||
def _begin_worker(self, label: str, target: Callable[[], None]) -> None:
|
||||
def _begin_worker(
|
||||
self,
|
||||
label: str,
|
||||
target: Callable[[], None],
|
||||
success_message: str,
|
||||
) -> None:
|
||||
self.busy = True
|
||||
self.cancel_event.clear()
|
||||
self.status_var.set(label)
|
||||
@@ -1046,11 +1269,21 @@ def launch_gui(*, smoke_test: bool = False) -> int:
|
||||
self.release_button.configure(state="disabled")
|
||||
self.retry_button.configure(state="disabled")
|
||||
self.refresh_button.configure(state="disabled")
|
||||
self.release_mode_toggle.configure(state="disabled")
|
||||
self.run_tests_toggle.configure(state="disabled")
|
||||
self.cancel_button.configure(state="normal")
|
||||
self._append_log(f"\n=== {label} ===")
|
||||
threading.Thread(target=self._worker_wrapper, args=(target,), daemon=True).start()
|
||||
threading.Thread(
|
||||
target=self._worker_wrapper,
|
||||
args=(target, success_message),
|
||||
daemon=True,
|
||||
).start()
|
||||
|
||||
def _worker_wrapper(self, target: Callable[[], None]) -> None:
|
||||
def _worker_wrapper(
|
||||
self,
|
||||
target: Callable[[], None],
|
||||
success_message: str,
|
||||
) -> None:
|
||||
try:
|
||||
target()
|
||||
except ReleaseCancelled as exc:
|
||||
@@ -1058,7 +1291,7 @@ def launch_gui(*, smoke_test: bool = False) -> int:
|
||||
except Exception as exc:
|
||||
self.events.put(("done", "error", str(exc)))
|
||||
else:
|
||||
self.events.put(("done", "success", "Release workflow completed."))
|
||||
self.events.put(("done", "success", success_message))
|
||||
|
||||
def _preflight_git(self, *, require_clean: bool) -> None:
|
||||
if git_output("branch", "--show-current") != "main":
|
||||
@@ -1078,10 +1311,13 @@ def launch_gui(*, smoke_test: bool = False) -> int:
|
||||
|
||||
def _full_release_worker(
|
||||
self,
|
||||
version: ReleaseVersion,
|
||||
version: ReleaseVersion | None,
|
||||
commit_message: str,
|
||||
notes: str,
|
||||
credentials: dict[str, str],
|
||||
credentials: dict[str, str] | None,
|
||||
*,
|
||||
publish_apk: bool,
|
||||
run_tests: bool,
|
||||
) -> None:
|
||||
self._preflight_git(require_clean=False)
|
||||
self._log_event(
|
||||
@@ -1094,11 +1330,21 @@ def launch_gui(*, smoke_test: bool = False) -> int:
|
||||
f"({len(candidate_paths)} changed path(s), including untracked files)"
|
||||
)
|
||||
npm = shutil.which("npm")
|
||||
powershell = shutil.which("powershell.exe") or shutil.which("powershell")
|
||||
if not npm or not powershell:
|
||||
raise ReleaseError("npm and Windows PowerShell must be available on PATH.")
|
||||
if not npm:
|
||||
raise ReleaseError("npm must be available on PATH.")
|
||||
powershell = None
|
||||
if publish_apk:
|
||||
if version is None or credentials is None:
|
||||
raise ReleaseError("APK publication requires version and signing details.")
|
||||
powershell = shutil.which("powershell.exe") or shutil.which("powershell")
|
||||
if not powershell:
|
||||
raise ReleaseError("Windows PowerShell must be available on PATH.")
|
||||
|
||||
managed_files = (PACKAGE_FILE, PACKAGE_LOCK_FILE, RELEASE_VERSION_FILE)
|
||||
managed_files = (
|
||||
(PACKAGE_FILE, PACKAGE_LOCK_FILE, RELEASE_VERSION_FILE)
|
||||
if publish_apk
|
||||
else ()
|
||||
)
|
||||
backups = {
|
||||
path: path.read_bytes() if path.exists() else None for path in managed_files
|
||||
}
|
||||
@@ -1106,17 +1352,51 @@ def launch_gui(*, smoke_test: bool = False) -> int:
|
||||
committed = False
|
||||
base_commit = current_commit()
|
||||
try:
|
||||
self._run_command(
|
||||
[npm, "version", version.version_name, "--no-git-tag-version"],
|
||||
"Bump package version",
|
||||
)
|
||||
write_release_version(version)
|
||||
self._log_event(
|
||||
f"Recorded Android version {version.version_name} / {version.version_code}."
|
||||
)
|
||||
if publish_apk:
|
||||
assert version is not None
|
||||
self._run_command(
|
||||
[npm, "version", version.version_name, "--no-git-tag-version"],
|
||||
"Bump package version",
|
||||
)
|
||||
write_release_version(version)
|
||||
self._log_event(
|
||||
f"Recorded Android version {version.version_name} / {version.version_code}."
|
||||
)
|
||||
else:
|
||||
self._log_event(
|
||||
"Git + TrueNAS mode: keeping the current Android version and "
|
||||
"skipping APK publication."
|
||||
)
|
||||
if not run_tests:
|
||||
self._log_event(
|
||||
"⚠ Test suites disabled: skipping content, server, normal-application, "
|
||||
"and shelved-application tests."
|
||||
)
|
||||
|
||||
for command, label in full_release_checks(npm):
|
||||
self._run_command(command, label)
|
||||
for command, label in full_release_checks(
|
||||
npm,
|
||||
include_android=publish_apk,
|
||||
include_tests=run_tests,
|
||||
):
|
||||
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
|
||||
|
||||
@@ -1144,15 +1424,49 @@ def launch_gui(*, smoke_test: bool = False) -> int:
|
||||
if not staged_paths:
|
||||
raise ReleaseError("There are no staged changes to release.")
|
||||
stat = git_output("diff", "--cached", "--stat")
|
||||
if publish_apk:
|
||||
assert version is not None
|
||||
review_heading = (
|
||||
f"Version: {version.version_name}\n"
|
||||
f"Android code: {version.version_code}\n"
|
||||
)
|
||||
review_question = "Commit, push main, and publish the APK?"
|
||||
approval_title = "Review staged release"
|
||||
approval_heading = "Inspect every staged path before publishing"
|
||||
approve_label = "Commit, Push, and Publish"
|
||||
cancel_label = "Cancel release"
|
||||
else:
|
||||
current_android = read_release_version()
|
||||
review_heading = (
|
||||
"Mode: Git + TrueNAS only\n"
|
||||
f"Android version remains: {current_android.version_name} "
|
||||
f"(code {current_android.version_code})\n"
|
||||
)
|
||||
review_question = (
|
||||
"Commit and push main without publishing an APK? The TrueNAS Deploy tab "
|
||||
"will be pinned to the new commit."
|
||||
)
|
||||
approval_title = "Review staged Git + TrueNAS update"
|
||||
approval_heading = "Inspect every staged path before pushing"
|
||||
approve_label = "Commit and Push"
|
||||
cancel_label = "Cancel update"
|
||||
review_heading += (
|
||||
f"Test suites: {'run' if run_tests else 'SKIPPED'}\n"
|
||||
)
|
||||
review = (
|
||||
f"Version: {version.version_name}\n"
|
||||
f"Android code: {version.version_code}\n"
|
||||
f"Commit: {commit_message}\n\n"
|
||||
review_heading
|
||||
+ f"Commit: {commit_message}\n\n"
|
||||
f"Staged paths ({len(staged_paths)}):\n"
|
||||
+ "\n".join(staged_paths)
|
||||
+ f"\n\nDiff summary:\n{stat}\n\nCommit, push main, and publish the APK?"
|
||||
+ f"\n\nDiff summary:\n{stat}\n\n{review_question}"
|
||||
)
|
||||
approved = self._request_approval(
|
||||
review,
|
||||
title=approval_title,
|
||||
heading=approval_heading,
|
||||
approve_label=approve_label,
|
||||
cancel_label=cancel_label,
|
||||
)
|
||||
approved = self._request_approval(review)
|
||||
if not approved:
|
||||
raise ReleaseCancelled("Release stopped at the staged-file review.")
|
||||
|
||||
@@ -1167,7 +1481,11 @@ def launch_gui(*, smoke_test: bool = False) -> int:
|
||||
f"Remote main is {remote_commit or 'unknown'}, expected {release_commit}."
|
||||
)
|
||||
self.events.put(("commit", release_commit))
|
||||
self._publish_apk(powershell, version, notes, credentials)
|
||||
if publish_apk:
|
||||
assert powershell is not None
|
||||
assert version is not None
|
||||
assert credentials is not None
|
||||
self._publish_apk(powershell, version, notes, credentials)
|
||||
except Exception:
|
||||
if current_commit() != base_commit:
|
||||
committed = True
|
||||
@@ -1180,18 +1498,31 @@ def launch_gui(*, smoke_test: bool = False) -> int:
|
||||
stderr=subprocess.DEVNULL,
|
||||
check=False,
|
||||
)
|
||||
for path, content in backups.items():
|
||||
if content is None:
|
||||
if path.exists():
|
||||
path.unlink()
|
||||
else:
|
||||
path.write_bytes(content)
|
||||
self._log_event("Restored version files because no release commit was created.")
|
||||
if publish_apk:
|
||||
for path, content in backups.items():
|
||||
if content is None:
|
||||
if path.exists():
|
||||
path.unlink()
|
||||
else:
|
||||
path.write_bytes(content)
|
||||
self._log_event(
|
||||
"Restored version files because no release commit was created."
|
||||
)
|
||||
else:
|
||||
self._log_event(
|
||||
"No update commit was created; reviewed files were left in the working tree."
|
||||
)
|
||||
else:
|
||||
self._log_event(
|
||||
"The release commit exists. Version files were kept; use "
|
||||
"'Publish Current APK Only' after correcting the publication problem."
|
||||
)
|
||||
if publish_apk:
|
||||
self._log_event(
|
||||
"The release commit exists. Version files were kept; use "
|
||||
"'Publish Current APK Only' after correcting the publication problem."
|
||||
)
|
||||
else:
|
||||
self._log_event(
|
||||
"The update commit exists locally; resolve the push problem without "
|
||||
"rewriting the commit."
|
||||
)
|
||||
raise
|
||||
|
||||
def _apk_only_worker(
|
||||
@@ -1267,31 +1598,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 +1667,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 +1692,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 +1741,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)
|
||||
@@ -1400,27 +1773,28 @@ def launch_gui(*, smoke_test: bool = False) -> int:
|
||||
self.release_button.configure(state="normal")
|
||||
self.retry_button.configure(state="normal")
|
||||
self.refresh_button.configure(state="normal")
|
||||
self.release_mode_toggle.configure(state="normal")
|
||||
self.run_tests_toggle.configure(state="normal")
|
||||
self.cancel_button.configure(state="disabled")
|
||||
self.keystore_password_var.set("")
|
||||
self.key_password_var.set("")
|
||||
if self.remember_token_var.get() and SAVED_GITEA_TOKEN_FILE.is_file():
|
||||
self.token_var.set(read_saved_gitea_token())
|
||||
if result == "success":
|
||||
self.status_var.set("Release completed")
|
||||
self._append_log("\n✓ Release workflow completed successfully.")
|
||||
self.status_var.set("Workflow completed")
|
||||
self._append_log("\n✓ Workflow completed successfully.")
|
||||
messagebox.showinfo(
|
||||
"Release complete",
|
||||
"Git main and the signed APK release are published.\n\n"
|
||||
"Use the TrueNAS Deploy tab after stopping the app, then the Verify tab after restarting it.",
|
||||
"Workflow complete",
|
||||
detail,
|
||||
parent=self.root,
|
||||
)
|
||||
elif result == "cancelled":
|
||||
self.status_var.set("Release cancelled")
|
||||
self.status_var.set("Workflow cancelled")
|
||||
self._append_log(f"\nCancelled: {detail}")
|
||||
else:
|
||||
self.status_var.set("Release failed")
|
||||
self.status_var.set("Workflow failed")
|
||||
self._append_log(f"\nERROR: {detail}")
|
||||
messagebox.showerror("Release failed", detail, parent=self.root)
|
||||
messagebox.showerror("Workflow failed", detail, parent=self.root)
|
||||
self.refresh()
|
||||
|
||||
def _close(self) -> None:
|
||||
|
||||
@@ -22,6 +22,10 @@ class VersionTests(unittest.TestCase):
|
||||
|
||||
|
||||
class SafetyTests(unittest.TestCase):
|
||||
def test_safe_update_options_are_enabled_by_default(self):
|
||||
self.assertTrue(release_manager.DEFAULT_GIT_TRUENAS_ONLY)
|
||||
self.assertTrue(release_manager.DEFAULT_RUN_TESTS)
|
||||
|
||||
def test_detects_outputs_and_secrets(self):
|
||||
paths = [
|
||||
"src/game/example.ts",
|
||||
@@ -54,6 +58,61 @@ 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_git_and_truenas_checks_skip_only_the_android_stage(self):
|
||||
checks = release_manager.full_release_checks("npm", include_android=False)
|
||||
commands = [command for command, _label in checks]
|
||||
self.assertIn(["npm", "run", "content:publish"], commands)
|
||||
self.assertIn(["npm", "run", "build"], commands)
|
||||
self.assertNotIn(["npm", "run", "android:sync"], commands)
|
||||
|
||||
def test_disabled_test_suites_keep_safety_and_build_checks(self):
|
||||
checks = release_manager.full_release_checks(
|
||||
"npm",
|
||||
include_android=False,
|
||||
include_tests=False,
|
||||
)
|
||||
commands = [command for command, _label in checks]
|
||||
self.assertEqual(
|
||||
commands,
|
||||
[
|
||||
["npm", "run", "content:publish"],
|
||||
["npm", "run", "build"],
|
||||
],
|
||||
)
|
||||
|
||||
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,154 @@
|
||||
#!/usr/bin/env node
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { projectRoot, sourceRootFor } from "./lib/recipe.mjs";
|
||||
|
||||
const sourceRoot = sourceRootFor({
|
||||
source: { rootEnvironmentVariable: "RUNEWAKER_ROOT", relativeDefault: "../rom-pvt-server-backups/Runewaker" },
|
||||
});
|
||||
const dataRoot = path.join(sourceRoot, "Resource", "data");
|
||||
const outputPath = path.join(projectRoot, "src", "game", "generated", "romLevelingGearPackages.json");
|
||||
const files = ["questdetailobject.db", "itemobject.db", "armorobject.db", "suitobject.db", "string_enus.db"];
|
||||
const buffers = Object.fromEntries(await Promise.all(files.map(async (name) => (
|
||||
[name, await readFile(path.join(dataRoot, name))]
|
||||
))));
|
||||
|
||||
const DATA_START = 140;
|
||||
const COUNT_OFFSET = 132;
|
||||
const layouts = {
|
||||
"questdetailobject.db": { size: 1656, type: 7 },
|
||||
"itemobject.db": { size: 1776, type: 2 },
|
||||
"armorobject.db": { size: 1776, type: 2 },
|
||||
"suitobject.db": { size: 1284, type: 17 },
|
||||
};
|
||||
const indexes = Object.fromEntries(Object.entries(layouts).map(([name, layout]) => {
|
||||
const buffer = buffers[name];
|
||||
const count = buffer.readInt32LE(COUNT_OFFSET);
|
||||
if (count < 1 || DATA_START + count * layout.size > buffer.length) {
|
||||
throw new Error(`${name} record count or layout drifted.`);
|
||||
}
|
||||
const index = new Map();
|
||||
for (let row = 0; row < count; row += 1) {
|
||||
const offset = DATA_START + row * layout.size;
|
||||
if (buffer.readInt32LE(offset + 4) !== layout.type) {
|
||||
throw new Error(`${name} object type drifted at row ${row}.`);
|
||||
}
|
||||
index.set(buffer.readInt32LE(offset), offset);
|
||||
}
|
||||
return [name, index];
|
||||
}));
|
||||
|
||||
function record(name, id) {
|
||||
const offset = indexes[name].get(id);
|
||||
if (offset === undefined) throw new Error(`${name} is missing source ID ${id}.`);
|
||||
return buffers[name].subarray(offset, offset + layouts[name].size);
|
||||
}
|
||||
|
||||
const names = new Map();
|
||||
function sourceName(id) {
|
||||
if (names.has(id)) return names.get(id);
|
||||
const buffer = buffers["string_enus.db"];
|
||||
const key = Buffer.from(`Sys${id}_name\0`, "ascii");
|
||||
const position = buffer.indexOf(key);
|
||||
if (position < 0) throw new Error(`Missing English name for source ID ${id}.`);
|
||||
for (let offset = position + key.length; offset < Math.min(buffer.length - 4, position + key.length + 600); offset += 1) {
|
||||
const length = buffer.readInt32LE(offset);
|
||||
if (length < 2 || length > 300 || offset + 4 + length > buffer.length) continue;
|
||||
const value = buffer.subarray(offset + 4, offset + 3 + length);
|
||||
if (buffer[offset + 3 + length] !== 0 || value.includes(0)) continue;
|
||||
try {
|
||||
const name = new TextDecoder("utf-8", { fatal: true }).decode(value);
|
||||
names.set(id, name);
|
||||
return name;
|
||||
} catch { /* continue past metadata */ }
|
||||
}
|
||||
throw new Error(`Could not decode English name for source ID ${id}.`);
|
||||
}
|
||||
|
||||
// The quest rewards are source-linked. Suit matches are curated from the
|
||||
// source's armor category, required levels, names, and suit membership. Package
|
||||
// expansion itself is not present in these DB records and remains unverified.
|
||||
const milestones = [
|
||||
{ level: 5, questId: 423783, packageStructure: "single-package-contents-unverified", choices: [
|
||||
["cloth", 209015],
|
||||
["leather", 209024],
|
||||
["chain", 209025],
|
||||
] },
|
||||
{ level: 30, questId: 423771, packageStructure: "battlefield-package-contents-unverified", choices: [
|
||||
["armor", 208802],
|
||||
["chain", 208803],
|
||||
["leather", 208804],
|
||||
["cloth", 208805],
|
||||
] },
|
||||
{ level: 50, questId: 424596, packageStructure: "named-subpackages-links-unverified", choices: [
|
||||
["plate", 240384, 240385, 240386, 610363],
|
||||
["chain", 240387, 240388, 240389, 610364],
|
||||
["leather", 240390, 240391, 240392, 610365],
|
||||
["cloth", 240393, 240394, 240395, 610366],
|
||||
] },
|
||||
{ level: 55, questId: 424597, packageStructure: "named-subpackages-links-unverified", choices: [
|
||||
["plate", 240353, 240354, 240355, 610395],
|
||||
["chain", 240356, 240357, 240358, 610396],
|
||||
["leather", 240359, 240360, 240361, 610397],
|
||||
["cloth", 240362, 240363, 240364, 610398],
|
||||
] },
|
||||
{ level: 60, questId: 424598, packageStructure: "named-subpackages-links-unverified", choices: [
|
||||
["plate", 240341, 240342, 240343, 610391],
|
||||
["chain", 240344, 240345, 240346, 610392],
|
||||
["leather", 240347, 240348, 240349, 610393],
|
||||
["cloth", 240350, 240351, 240352, 610394],
|
||||
] },
|
||||
{ level: 65, questId: 424213, packageStructure: "named-subpackages-links-unverified", choices: [
|
||||
["plate", 240304, 240302, 240303, 610387],
|
||||
["chain", 240307, 240305, 240306, 610388],
|
||||
["leather", 240310, 240308, 240309, 610389],
|
||||
["cloth", 240313, 240311, 240312, 610390],
|
||||
] },
|
||||
];
|
||||
|
||||
const extracted = milestones.map(({ level, questId, packageStructure, choices }) => {
|
||||
const quest = record("questdetailobject.db", questId);
|
||||
if (quest.readInt32LE(0xc0) !== level) throw new Error(`Quest ${questId} level drifted.`);
|
||||
const rewardIds = Array.from({ length: 4 }, (_, index) => quest.readInt32LE(0x34c + index * 4));
|
||||
if (rewardIds.slice(choices.length).some((id) => id !== 0)) {
|
||||
throw new Error(`Quest ${questId} has an unrecorded reward choice.`);
|
||||
}
|
||||
const packages = choices.map(([armorCategory, packageId, weaponPackageId, armorPackageId, matchingSuitId], index) => {
|
||||
if (rewardIds[index] !== packageId) throw new Error(`Quest ${questId} reward order drifted.`);
|
||||
for (const id of [packageId, weaponPackageId, armorPackageId].filter(Boolean)) record("itemobject.db", id);
|
||||
const suit = matchingSuitId ? record("suitobject.db", matchingSuitId) : null;
|
||||
const matchingArmorItems = suit ? Array.from({ length: 10 }, (_, member) => suit.readInt32LE(1224 + member * 4))
|
||||
.filter((id) => id > 0)
|
||||
.map((itemId) => {
|
||||
const item = record("armorobject.db", itemId);
|
||||
if (item.readInt32LE(0x1a8) !== matchingSuitId) {
|
||||
throw new Error(`Armor ${itemId} lost suit ${matchingSuitId}.`);
|
||||
}
|
||||
const requiredLevel = item.readInt32LE(0x78);
|
||||
if (requiredLevel < 1 || requiredLevel > level) {
|
||||
throw new Error(`Armor ${itemId} has unexpected required level ${requiredLevel}.`);
|
||||
}
|
||||
return { itemId, name: sourceName(itemId), requiredLevel };
|
||||
}) : [];
|
||||
if (suit && matchingArmorItems.length !== 7) throw new Error(`Suit ${matchingSuitId} should have seven armor pieces.`);
|
||||
return {
|
||||
armorCategory,
|
||||
packageId,
|
||||
packageName: sourceName(packageId),
|
||||
...(weaponPackageId ? { weaponPackageId, weaponPackageName: sourceName(weaponPackageId) } : {}),
|
||||
...(armorPackageId ? { armorPackageId, armorPackageName: sourceName(armorPackageId) } : {}),
|
||||
...(suit ? { matchingSuitId, matchingSuitName: sourceName(matchingSuitId), matchingArmorItems } : {}),
|
||||
};
|
||||
});
|
||||
return { level, questId, questName: sourceName(questId), packageStructure, packages };
|
||||
});
|
||||
|
||||
const output = {
|
||||
schemaVersion: 2,
|
||||
contentStatus: "quest-rewards-confirmed-package-expansion-unverified",
|
||||
sourceFiles: Object.fromEntries(files.map((name) => [name, createHash("sha256").update(buffers[name]).digest("hex")])),
|
||||
milestones: extracted,
|
||||
};
|
||||
await writeFile(outputPath, `${JSON.stringify(output, null, 2)}\n`);
|
||||
console.log(`Exported ${extracted.length} RoM leveling milestones and ${extracted.flatMap((entry) => entry.packages).length} package choices.`);
|
||||
@@ -0,0 +1,43 @@
|
||||
#!/usr/bin/env node
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { projectRoot, readJson, sourceRootFor } from "./lib/recipe.mjs";
|
||||
|
||||
const recipe = await readJson(path.join(projectRoot, "scripts/runewaker-pipeline/recipes/raksha-temple-population.json"));
|
||||
const source = path.join(sourceRootFor(recipe), recipe.source.resourceRoot ?? "Resource", "data/npcobject.db");
|
||||
const bytes = await readFile(source);
|
||||
const dataStart = 140;
|
||||
const count = bytes.readInt32LE(132);
|
||||
const recordSize = 2_224;
|
||||
const raceOffset = 176;
|
||||
if (count <= 0 || dataStart + count * recordSize > bytes.length) throw new Error("Unexpected NPCObjectDB layout");
|
||||
const knownIds = [];
|
||||
const undeadIds = [];
|
||||
const demonIds = [];
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
const row = dataStart + index * recordSize;
|
||||
if (bytes.readInt32LE(row + 4) !== 1) throw new Error(`Unexpected NPC object type at row ${index}`);
|
||||
const id = bytes.readInt32LE(row);
|
||||
if (id <= 0) continue;
|
||||
knownIds.push(id);
|
||||
const race = bytes.readInt32LE(row + raceOffset);
|
||||
// ObjectStruct.h Race_ENUM names value 5 Undead. The source Demon Soldier
|
||||
// and Demon Bodo NPC records use value 12.
|
||||
if (race === 5) undeadIds.push(id);
|
||||
if (race === 12) demonIds.push(id);
|
||||
}
|
||||
const sorted = (ids) => [...new Set(ids)].sort((left, right) => left - right);
|
||||
const output = {
|
||||
schemaVersion: 1,
|
||||
sourceSha256: createHash("sha256").update(bytes).digest("hex"),
|
||||
raceOffset,
|
||||
knownIds: sorted(knownIds),
|
||||
undeadIds: sorted(undeadIds),
|
||||
demonIds: sorted(demonIds),
|
||||
};
|
||||
await writeFile(
|
||||
path.join(projectRoot, "src/game/generated/romCreatureFamilies.json"),
|
||||
`${JSON.stringify(output)}\n`,
|
||||
);
|
||||
console.log(`Exported ${output.knownIds.length} NPC families (${output.undeadIds.length} undead, ${output.demonIds.length} demon).`);
|
||||
@@ -0,0 +1,268 @@
|
||||
#!/usr/bin/env node
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { projectRoot, sourceRootFor } from "./lib/recipe.mjs";
|
||||
|
||||
const sourceRoot = sourceRootFor({
|
||||
source: { rootEnvironmentVariable: "RUNEWAKER_ROOT", relativeDefault: "../rom-pvt-server-backups/Runewaker" },
|
||||
});
|
||||
const dataRoot = path.join(sourceRoot, "Resource", "data");
|
||||
const outputPath = path.join(projectRoot, "src", "game", "generated", "romHeirloomReference.json");
|
||||
const packagePath = path.join(projectRoot, "src", "game", "generated", "romLevelingGearPackages.json");
|
||||
const files = ["armorobject.db", "weaponobject.db", "addpowerobject.db", "string_enus.db"];
|
||||
const buffers = Object.fromEntries(await Promise.all(files.map(async (name) => (
|
||||
[name, await readFile(path.join(dataRoot, name))]
|
||||
))));
|
||||
const packageCatalog = JSON.parse(await readFile(packagePath, "utf8"));
|
||||
|
||||
const RECORD_START = 140;
|
||||
const RECORD_SIZE = 1776;
|
||||
const ATTRIBUTE_RECORD_SIZE = 372;
|
||||
const QUALITY_GOOD_BLUE = 2;
|
||||
const STAT_TYPES = Object.freeze({
|
||||
2: "strength", 3: "stamina", 4: "intellect", 5: "wisdom", 6: "agility",
|
||||
8: "health", 9: "mana", 10: "healthRecovery", 11: "manaRecovery",
|
||||
12: "physicalAttack", 13: "physicalDefense", 14: "magicalDefense", 15: "magicalAttack",
|
||||
16: "physicalHit", 17: "dodge", 18: "physicalCritical", 20: "magicalCritical",
|
||||
22: "parry", 25: "weaponDamage", 148: "magicalDamage", 150: "healingPower",
|
||||
191: "magicalWeaponDamage", 195: "magicalHit",
|
||||
});
|
||||
const STAT_KEYS = [...new Set(Object.values(STAT_TYPES))];
|
||||
const ARMOR_SLOTS = { head: 0, hands: 1, feet: 2, chest: 3, legs: 4, back: 5, waist: 6, shoulder: 7 };
|
||||
const COMMON_SLOTS = Object.keys(ARMOR_SLOTS);
|
||||
const PROFILES = {
|
||||
"rom-knight": { armorType: 0, weaponType: 5, weaponPos: 2, offHand: "shield", weights: { stamina: 5, health: 0.2, physicalDefense: 0.12, magicalDefense: 0.06, parry: 2, physicalAttack: 0.15 } },
|
||||
"rom-priest": { armorType: 3, weaponType: 7, weaponPos: 3, weights: { wisdom: 5, intellect: 2, healingPower: 1.5, mana: 0.1, magicalAttack: 0.3, magicalDefense: 0.02 } },
|
||||
"rom-scout": { armorType: 2, weaponType: 2, weaponPos: 2, rangedType: 11, weights: { strength: 3, agility: 2, physicalAttack: 1, weaponDamage: 1, physicalHit: 1, physicalCritical: 1 } },
|
||||
"rom-rogue": { armorType: 2, weaponType: 2, weaponPos: 2, offHand: "dagger", weights: { agility: 3, strength: 2, physicalAttack: 1, weaponDamage: 1, physicalHit: 1, physicalCritical: 1 } },
|
||||
"rom-mage": { armorType: 3, weaponType: 7, weaponPos: 3, weights: { intellect: 5, wisdom: 1, magicalAttack: 1, magicalDamage: 1, magicalWeaponDamage: 1, magicalCritical: 1 } },
|
||||
};
|
||||
|
||||
function indexNames(buffer) {
|
||||
const text = buffer.toString("latin1");
|
||||
const positions = new Map();
|
||||
for (const match of text.matchAll(/Sys(\d+)_name\0/g)) positions.set(Number(match[1]), match.index + match[0].length);
|
||||
return positions;
|
||||
}
|
||||
|
||||
const namePositions = indexNames(buffers["string_enus.db"]);
|
||||
function sourceName(id) {
|
||||
const buffer = buffers["string_enus.db"];
|
||||
const position = namePositions.get(id);
|
||||
if (position === undefined) throw new Error(`Missing English item name for ${id}.`);
|
||||
for (let offset = position; offset < Math.min(buffer.length - 4, position + 600); offset += 1) {
|
||||
const length = buffer.readInt32LE(offset);
|
||||
if (length < 2 || length > 300 || offset + 4 + length > buffer.length) continue;
|
||||
const value = buffer.subarray(offset + 4, offset + 3 + length);
|
||||
if (buffer[offset + 3 + length] !== 0 || value.includes(0)) continue;
|
||||
try { return new TextDecoder("utf-8", { fatal: true }).decode(value); } catch { /* metadata before the text */ }
|
||||
}
|
||||
throw new Error(`Could not decode English item name for ${id}.`);
|
||||
}
|
||||
|
||||
function effectStats(buffer, offset, unmappedEffects = []) {
|
||||
const stats = {};
|
||||
for (let effect = 0; effect < 10; effect += 1) {
|
||||
const type = buffer.readInt32LE(offset + 0xd8 + effect * 4);
|
||||
const value = buffer.readInt32LE(offset + 0x100 + effect * 4);
|
||||
if (type === 7 && value > 0) {
|
||||
for (const key of ["strength", "stamina", "intellect", "wisdom", "agility"]) {
|
||||
stats[key] = (stats[key] ?? 0) + value;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const key = STAT_TYPES[type];
|
||||
if (key && value > 0) stats[key] = (stats[key] ?? 0) + value;
|
||||
else if (type && value > 0) unmappedEffects.push({ type, value });
|
||||
}
|
||||
return stats;
|
||||
}
|
||||
|
||||
const attributeBuffer = buffers["addpowerobject.db"];
|
||||
const attributeCount = attributeBuffer.readInt32LE(132);
|
||||
if (attributeCount < 1 || RECORD_START + attributeCount * ATTRIBUTE_RECORD_SIZE > attributeBuffer.length) {
|
||||
throw new Error("addpowerobject.db fixed-record layout changed.");
|
||||
}
|
||||
const attributes = new Map();
|
||||
for (let index = 0; index < attributeCount; index += 1) {
|
||||
const offset = RECORD_START + index * ATTRIBUTE_RECORD_SIZE;
|
||||
if (attributeBuffer.readInt32LE(offset + 4) !== 5) throw new Error(`Attribute object type changed at row ${index}.`);
|
||||
const unmappedEffects = [];
|
||||
const stats = effectStats(attributeBuffer, offset, unmappedEffects);
|
||||
attributes.set(attributeBuffer.readInt32LE(offset), { stats, unmappedEffects });
|
||||
}
|
||||
|
||||
function sourceItems(name) {
|
||||
const buffer = buffers[name];
|
||||
const count = buffer.readInt32LE(132);
|
||||
if (count < 1 || RECORD_START + count * RECORD_SIZE > buffer.length) {
|
||||
throw new Error(`${name} fixed-record layout changed.`);
|
||||
}
|
||||
const items = [];
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
const offset = RECORD_START + index * RECORD_SIZE;
|
||||
if (buffer.readInt32LE(offset + 4) !== 2) throw new Error(`${name} object type changed at row ${index}.`);
|
||||
const level = buffer.readInt32LE(offset + 0x78);
|
||||
const quality = buffer.readInt32LE(offset + 0x60);
|
||||
if (level < 1 || level > 79 || quality !== QUALITY_GOOD_BLUE) continue;
|
||||
const itemId = buffer.readInt32LE(offset);
|
||||
const unmappedEffects = [];
|
||||
const baseStats = effectStats(buffer, offset, unmappedEffects);
|
||||
const fixedStats = {};
|
||||
const fixedAttributeIds = [];
|
||||
const randomAttributeIds = [];
|
||||
const unresolvedAttributeIds = [];
|
||||
for (let effect = 0; effect < 6; effect += 1) {
|
||||
const attributeId = buffer.readInt32LE(offset + 0x134 + effect * 8);
|
||||
const rate = buffer.readInt32LE(offset + 0x138 + effect * 8);
|
||||
if (!attributeId) continue;
|
||||
if (!attributes.has(attributeId)) {
|
||||
unresolvedAttributeIds.push(attributeId);
|
||||
continue;
|
||||
}
|
||||
if (rate !== 100_000) {
|
||||
randomAttributeIds.push(attributeId);
|
||||
continue;
|
||||
}
|
||||
fixedAttributeIds.push(attributeId);
|
||||
const attribute = attributes.get(attributeId);
|
||||
for (const [key, value] of Object.entries(attribute.stats)) {
|
||||
fixedStats[key] = (fixedStats[key] ?? 0) + value;
|
||||
}
|
||||
unmappedEffects.push(...attribute.unmappedEffects.map((entry) => ({ ...entry, attributeId })));
|
||||
}
|
||||
const stats = { ...baseStats };
|
||||
for (const [key, value] of Object.entries(fixedStats)) stats[key] = (stats[key] ?? 0) + value;
|
||||
items.push({
|
||||
itemId,
|
||||
name: sourceName(itemId),
|
||||
level,
|
||||
quality,
|
||||
sourceCatalog: name,
|
||||
type: buffer.readInt32LE(offset + 0xd0),
|
||||
position: buffer.readInt32LE(offset + 0xd4),
|
||||
suitId: buffer.readInt32LE(offset + 0x1a8),
|
||||
...(name === "weaponobject.db" ? { speedTenths: buffer.readInt32LE(offset + 0x1b0) } : {}),
|
||||
baseStats,
|
||||
fixedStats,
|
||||
fixedAttributeIds,
|
||||
...(unmappedEffects.length ? { unmappedEffects } : {}),
|
||||
...(randomAttributeIds.length ? { randomAttributeIds } : {}),
|
||||
...(unresolvedAttributeIds.length ? { unresolvedAttributeIds } : {}),
|
||||
stats,
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
const items = [...sourceItems("armorobject.db"), ...sourceItems("weaponobject.db")]
|
||||
.sort((left, right) => left.level - right.level || left.itemId - right.itemId);
|
||||
const byId = new Map(items.map((item) => [item.itemId, item]));
|
||||
const packageAnchors = new Map();
|
||||
for (const milestone of packageCatalog.milestones) {
|
||||
if (milestone.level < 50 || milestone.level > 65) continue;
|
||||
for (const choice of milestone.packages) {
|
||||
for (const member of choice.matchingArmorItems ?? []) {
|
||||
const item = byId.get(member.itemId);
|
||||
if (!item || item.quality !== 2) throw new Error(`Package suit member ${member.itemId} is not Good quality source gear.`);
|
||||
if (item.unresolvedAttributeIds?.length || item.unmappedEffects?.length) continue;
|
||||
packageAnchors.set(`${milestone.level}:${item.type}:${item.position}`, {
|
||||
...item,
|
||||
packageId: choice.packageId,
|
||||
packageLevel: milestone.level,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function medianItem(candidates, weights) {
|
||||
const scored = candidates.map((item) => ({
|
||||
item,
|
||||
score: Object.entries(weights).reduce((sum, [key, weight]) => sum + (item.stats[key] ?? 0) * weight, 0),
|
||||
})).sort((left, right) => left.score - right.score || left.item.itemId - right.item.itemId);
|
||||
return scored[Math.floor((scored.length - 1) / 2)].item;
|
||||
}
|
||||
|
||||
function referenceCurve(candidates, weights, packageType = null, packagePosition = null) {
|
||||
if (!candidates.length) throw new Error("A RoM heirloom slot has no Good quality source references.");
|
||||
const byLevel = new Map();
|
||||
for (const item of candidates) {
|
||||
const group = byLevel.get(item.level) ?? [];
|
||||
group.push(item);
|
||||
byLevel.set(item.level, group);
|
||||
}
|
||||
const anchors = new Map([...byLevel].map(([level, group]) => [level, medianItem(group, weights)]));
|
||||
if (packageType !== null) for (const level of [50, 55, 60, 65]) {
|
||||
const item = packageAnchors.get(`${level}:${packageType}:${packagePosition}`);
|
||||
if (item) anchors.set(level, item);
|
||||
}
|
||||
const anchorLevels = [...anchors.keys()].sort((left, right) => left - right);
|
||||
const result = [];
|
||||
const relevant = new Set([
|
||||
...Object.keys(weights), "stamina", "health", "physicalDefense", "magicalDefense",
|
||||
"weaponDamage", "magicalWeaponDamage",
|
||||
]);
|
||||
const relevantKeys = STAT_KEYS.filter((key) => relevant.has(key));
|
||||
const previous = Object.fromEntries(relevantKeys.map((key) => [key, 0]));
|
||||
for (let level = 1; level <= 79; level += 1) {
|
||||
const lowerLevel = anchorLevels.filter((entry) => entry <= level).at(-1);
|
||||
const upperLevel = anchorLevels.find((entry) => entry >= level);
|
||||
const lower = anchors.get(lowerLevel ?? upperLevel);
|
||||
const upper = anchors.get(upperLevel ?? lowerLevel);
|
||||
const blend = lowerLevel === undefined ? level / upperLevel
|
||||
: upperLevel === undefined ? level / lowerLevel
|
||||
: lowerLevel === upperLevel ? 0 : (level - lowerLevel) / (upperLevel - lowerLevel);
|
||||
const stats = {};
|
||||
for (const key of relevantKeys) {
|
||||
const value = lowerLevel === undefined ? (upper.stats[key] ?? 0) * blend
|
||||
: upperLevel === undefined ? (lower.stats[key] ?? 0) * blend
|
||||
: (lower.stats[key] ?? 0) * (1 - blend) + (upper.stats[key] ?? 0) * blend;
|
||||
previous[key] = Math.max(previous[key], value > 0 ? Math.max(1, Math.round(value)) : 0);
|
||||
if (previous[key] > 0) stats[key] = previous[key];
|
||||
}
|
||||
result.push({
|
||||
level,
|
||||
stats,
|
||||
referenceItemIds: [...new Set([lower.itemId, upper.itemId])],
|
||||
...(anchors.get(level)?.packageId ? { packageId: anchors.get(level).packageId } : {}),
|
||||
...(lower.speedTenths || upper.speedTenths ? { speedTenths: lower.speedTenths || upper.speedTenths } : {}),
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function armorCandidates(type, position) {
|
||||
return items.filter((item) => item.sourceCatalog === "armorobject.db" && item.type === type && item.position === position && !item.unresolvedAttributeIds?.length && !item.unmappedEffects?.length);
|
||||
}
|
||||
function weaponCandidates(type, position) {
|
||||
return items.filter((item) => item.sourceCatalog === "weaponobject.db" && item.type === type && item.position === position && !item.unresolvedAttributeIds?.length && !item.unmappedEffects?.length);
|
||||
}
|
||||
|
||||
const profiles = Object.fromEntries(Object.entries(PROFILES).map(([classId, profile]) => {
|
||||
const curves = {};
|
||||
for (const slot of COMMON_SLOTS) {
|
||||
const armorType = slot === "back" ? 3 : profile.armorType;
|
||||
curves[slot] = referenceCurve(armorCandidates(armorType, ARMOR_SLOTS[slot]), profile.weights, armorType, ARMOR_SLOTS[slot]);
|
||||
}
|
||||
for (const [slot, position] of [["neck", 8], ["finger", 9], ["trinket", 10]]) {
|
||||
curves[slot] = referenceCurve(armorCandidates(7, position), profile.weights);
|
||||
}
|
||||
curves["main-hand"] = referenceCurve(weaponCandidates(profile.weaponType, profile.weaponPos), profile.weights);
|
||||
if (profile.offHand === "shield") curves["off-hand"] = referenceCurve(armorCandidates(5, 11), profile.weights);
|
||||
if (profile.offHand === "dagger") curves["off-hand"] = referenceCurve(weaponCandidates(2, 2), profile.weights);
|
||||
if (profile.rangedType) curves.ranged = referenceCurve(weaponCandidates(profile.rangedType, 5), profile.weights);
|
||||
return [classId, curves];
|
||||
}));
|
||||
|
||||
const output = {
|
||||
schemaVersion: 1,
|
||||
status: "source-base-and-fixed-effects-with-synthetic-interpolation",
|
||||
sourceFiles: Object.fromEntries(files.map((name) => [name, createHash("sha256").update(buffers[name]).digest("hex")])),
|
||||
quality: { sourceValue: QUALITY_GOOD_BLUE, name: "Good", color: "blue" },
|
||||
statTypeMap: STAT_TYPES,
|
||||
sourceItems: items,
|
||||
profiles,
|
||||
};
|
||||
await writeFile(outputPath, `${JSON.stringify(output, null, 2)}\n`);
|
||||
console.log(`Exported ${items.length} Good source items (${items.filter((item) => item.unresolvedAttributeIds?.length).length} with unresolved attributes) and ${Object.keys(profiles).length} RoM heirloom curves at levels 1–79.`);
|
||||
@@ -6,12 +6,30 @@ from scripts.balance_lab import (
|
||||
automatic_parties,
|
||||
coverage_parties,
|
||||
random_party,
|
||||
random_party_capacity,
|
||||
readiness_eligible_builds,
|
||||
resolve_build,
|
||||
runtime_session_command,
|
||||
search_bosses,
|
||||
tuning_label,
|
||||
)
|
||||
|
||||
|
||||
class BalanceLabControllerTests(unittest.TestCase):
|
||||
def test_class_spec_selector_prefers_the_named_default_build(self) -> None:
|
||||
builds = [
|
||||
{"id": "fire-default", "classId": "mage", "className": "Mage", "role": "damage",
|
||||
"specialization": "Fire", "label": "Mage Fire Playerbots", "defaultTalentBuild": True},
|
||||
{"id": "fire-alt", "classId": "mage", "className": "Mage", "role": "damage",
|
||||
"specialization": "Fire", "label": "Mage Fire Executable", "defaultTalentBuild": False},
|
||||
]
|
||||
self.assertEqual(resolve_build("mage:fire", builds)["id"], "fire-default")
|
||||
|
||||
def test_headless_runtime_uses_the_no_watch_vite_mode(self) -> None:
|
||||
command = runtime_session_command("wow", 40)
|
||||
self.assertEqual(command[1:3], ["--mode", "balance-lab"])
|
||||
self.assertEqual(command[-4:], ["--source", "wow", "--level", "40"])
|
||||
|
||||
def test_random_party_is_repeatable_and_complete(self) -> None:
|
||||
builds = [
|
||||
{"id": "tank-a", "role": "tank"},
|
||||
@@ -28,6 +46,20 @@ class BalanceLabControllerTests(unittest.TestCase):
|
||||
])
|
||||
self.assertEqual(len({member["id"] for member in first[2:]}), 3)
|
||||
|
||||
def test_random_party_uses_named_default_profiles(self) -> None:
|
||||
builds = [
|
||||
{"id": "tank-default", "role": "tank", "defaultTalentBuild": True},
|
||||
{"id": "tank-alt", "role": "tank", "defaultTalentBuild": False},
|
||||
{"id": "healer-default", "role": "healer", "defaultTalentBuild": True},
|
||||
{"id": "healer-alt", "role": "healer", "defaultTalentBuild": False},
|
||||
*({"id": f"damage-default-{index}", "role": "damage", "defaultTalentBuild": True}
|
||||
for index in range(3)),
|
||||
*({"id": f"damage-alt-{index}", "role": "damage", "defaultTalentBuild": False}
|
||||
for index in range(3)),
|
||||
]
|
||||
party = random_party(builds, 17)
|
||||
self.assertTrue(all(member["defaultTalentBuild"] for member in party))
|
||||
|
||||
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")
|
||||
@@ -58,6 +90,19 @@ class BalanceLabControllerTests(unittest.TestCase):
|
||||
"tank", "healer", "damage", "damage", "damage",
|
||||
])
|
||||
|
||||
def test_automatic_parties_cap_to_the_unique_ready_pool(self) -> None:
|
||||
builds = [
|
||||
{"id": "ready-tank", "source": "wow", "role": "tank", "defaultTalentBuild": True},
|
||||
{"id": "ready-healer", "source": "wow", "role": "healer", "defaultTalentBuild": True},
|
||||
*({"id": f"ready-damage-{index}", "source": "wow", "role": "damage", "defaultTalentBuild": True}
|
||||
for index in range(3)),
|
||||
{"id": "testing-tank-alt", "source": "wow", "role": "tank", "defaultTalentBuild": False},
|
||||
]
|
||||
self.assertEqual(random_party_capacity(builds), 1)
|
||||
configurations = automatic_parties(builds, ("wow",), 10, 29)
|
||||
self.assertEqual(len(configurations), 1)
|
||||
self.assertEqual(len({member["id"] for member in configurations[0]["party"]}), 5)
|
||||
|
||||
def test_coverage_parties_include_every_build_with_minimum_unique_parties(self) -> None:
|
||||
builds = [
|
||||
{"id": "tank-a", "source": "wow", "role": "tank"},
|
||||
@@ -96,6 +141,58 @@ class BalanceLabControllerTests(unittest.TestCase):
|
||||
self.assertTrue(set(selected).issubset(covered))
|
||||
self.assertTrue(all(observed.count(build_id) == 1 for build_id in selected))
|
||||
|
||||
def test_readiness_filters_every_source_and_beta_requires_opt_in(self) -> None:
|
||||
builds = [
|
||||
{"id": "ready-tank", "source": "wow", "specializationId": "warrior:protection"},
|
||||
{"id": "beta-damage", "source": "wow", "specializationId": "paladin:retribution"},
|
||||
{"id": "testing-damage", "source": "wow", "specializationId": "priest:shadow"},
|
||||
{"id": "rom-damage", "source": "rom", "specializationId": "mage:primary"},
|
||||
]
|
||||
policy = {"config": {"statuses": {
|
||||
"warrior:protection": "ready",
|
||||
"paladin:retribution": "beta",
|
||||
"priest:shadow": "needs-testing",
|
||||
}}}
|
||||
self.assertEqual(
|
||||
[build["id"] for build in readiness_eligible_builds(builds, policy, False)],
|
||||
["ready-tank"],
|
||||
)
|
||||
self.assertEqual(
|
||||
[build["id"] for build in readiness_eligible_builds(builds, policy, True)],
|
||||
["ready-tank", "beta-damage"],
|
||||
)
|
||||
|
||||
def test_targeted_needs_testing_build_uses_ready_fillers(self) -> None:
|
||||
builds = [
|
||||
{"id": "ready-tank", "source": "wow", "role": "tank", "specializationId": "warrior:protection", "readinessStatus": "ready", "defaultTalentBuild": True},
|
||||
{"id": "ready-healer", "source": "wow", "role": "healer", "specializationId": "priest:discipline", "readinessStatus": "ready", "defaultTalentBuild": True},
|
||||
{"id": "ready-hunter", "source": "wow", "role": "damage", "specializationId": "hunter:marksmanship", "readinessStatus": "ready", "defaultTalentBuild": True},
|
||||
{"id": "ready-mage", "source": "wow", "role": "damage", "specializationId": "mage:fire", "readinessStatus": "ready", "defaultTalentBuild": True},
|
||||
{"id": "ready-rogue", "source": "wow", "role": "damage", "specializationId": "rogue:assassination", "readinessStatus": "ready", "defaultTalentBuild": True},
|
||||
{"id": "testing-shadow", "source": "wow", "role": "damage", "specializationId": "priest:shadow", "readinessStatus": "needs-testing", "defaultTalentBuild": True},
|
||||
]
|
||||
eligible = readiness_eligible_builds(builds, None, False)
|
||||
parties = coverage_parties(builds, ("wow",), ["testing-shadow"], 13, eligible)
|
||||
self.assertEqual(len(parties), 1)
|
||||
ids = [member["id"] for member in parties[0]["party"]]
|
||||
self.assertIn("testing-shadow", ids)
|
||||
self.assertTrue(all(build_id == "testing-shadow" or build_id.startswith("ready-") for build_id in ids))
|
||||
|
||||
def test_targeted_non_wow_build_uses_ready_wow_fillers(self) -> None:
|
||||
ready = [
|
||||
{"id": "ready-tank", "source": "wow", "role": "tank", "readinessStatus": "ready", "defaultTalentBuild": True},
|
||||
{"id": "ready-healer", "source": "wow", "role": "healer", "readinessStatus": "ready", "defaultTalentBuild": True},
|
||||
*(
|
||||
{"id": f"ready-damage-{index}", "source": "wow", "role": "damage", "readinessStatus": "ready", "defaultTalentBuild": True}
|
||||
for index in range(3)
|
||||
),
|
||||
]
|
||||
target = {"id": "coa-target", "source": "coa", "role": "damage", "readinessStatus": "needs-testing", "defaultTalentBuild": True}
|
||||
parties = coverage_parties([*ready, target], ("coa",), ["coa-target"], 21, ready)
|
||||
ids = [member["id"] for member in parties[0]["party"]]
|
||||
self.assertIn("coa-target", ids)
|
||||
self.assertEqual(sum(build_id.startswith("ready-") for build_id in ids), 4)
|
||||
|
||||
def test_boss_search_matches_name_dungeon_key_and_source(self) -> None:
|
||||
bosses = [
|
||||
{"key": "gundrak/eck", "name": "Eck the Ferocious", "dungeonTitle": "Gundrak", "category": "wow"},
|
||||
@@ -158,6 +255,22 @@ class BalanceLabControllerTests(unittest.TestCase):
|
||||
self.assertEqual(by_id["mage-fireball"]["damageShare"], 0.5)
|
||||
self.assertTrue(by_id["mage-unused-blast"]["unusedAvailable"])
|
||||
|
||||
def test_missing_profile_action_requires_repeated_uncommitted_decisions(self) -> None:
|
||||
class_key = ("wow", "mage-fire", "mage", "Mage", "Fire", "damage")
|
||||
class_runs = [{"partyConfigurationId": "party-1", "bossKey": "boss-1", "elapsedSeconds": 10, "damage": 0}]
|
||||
base = {
|
||||
"abilityName": "Fireball", "icon": "", "categories": ["damage"],
|
||||
"cooldownSeconds": 0, "rotationProfileId": "mage:fire", "rotationPriority": 5,
|
||||
"expectedUsage": "profile", "available": True, "used": False,
|
||||
"casts": 0, "hits": 0, "rawDamage": 0, "effectiveDamage": 0,
|
||||
"rawHealing": 0, "effectiveHealing": 0, "overheal": 0, "absorbs": 0,
|
||||
"partyConfigurationId": "party-1", "bossKey": "boss-1", "elapsedSeconds": 10,
|
||||
}
|
||||
once = aggregate_class_abilities({(*class_key, "fireball"): [{**base, "rotationDecisions": 1}]}, {class_key: class_runs})[0]
|
||||
repeated = aggregate_class_abilities({(*class_key, "fireball"): [{**base, "rotationDecisions": 3}]}, {class_key: class_runs})[0]
|
||||
self.assertEqual(once["status"], "unused")
|
||||
self.assertEqual(repeated["status"], "missing-profile-action")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,105 @@
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from scripts.balance_lab_brackets import (
|
||||
add_growth_metrics,
|
||||
bracket_levels,
|
||||
build_balance_lab_command,
|
||||
evaluate_rows,
|
||||
summarize_report,
|
||||
)
|
||||
|
||||
|
||||
class BalanceLabBracketTests(unittest.TestCase):
|
||||
def test_bracket_levels_match_the_gm_schedules(self) -> None:
|
||||
self.assertEqual(bracket_levels(10), [1, 10, 20, 30, 40, 50, 60, 70, 80])
|
||||
self.assertEqual(bracket_levels(5)[:4], [1, 5, 10, 15])
|
||||
self.assertEqual(bracket_levels(1), list(range(1, 81)))
|
||||
|
||||
def test_command_uses_the_existing_live_balance_lab_controller(self) -> None:
|
||||
args = argparse.Namespace(
|
||||
source="wow", spec_selection="all", party_seed=7, party_configurations=10,
|
||||
simulations_per_party=3, duration=80, boss_category="wow", seeds="1,2,3",
|
||||
active_slot=None, spec=[], boss=["stormwind-stockade/entry-1717"],
|
||||
seat_sweep=True, cooldown_policy=None,
|
||||
companion_readiness_policy=Path("policy.json"), include_beta_specs=True,
|
||||
ready_only=True,
|
||||
)
|
||||
command = build_balance_lab_command(args, 40, Path("output/level-40"))
|
||||
self.assertEqual(command[0], sys.executable)
|
||||
self.assertTrue(command[1].endswith("scripts\\balance_lab.py") or command[1].endswith("scripts/balance_lab.py"))
|
||||
self.assertIn("automatic", command)
|
||||
self.assertEqual(command[command.index("--level") + 1], "40")
|
||||
self.assertEqual(command[command.index("--seeds") + 1], "1,2,3")
|
||||
self.assertIn("--seat-sweep", command)
|
||||
self.assertIn("--include-beta-specs", command)
|
||||
self.assertIn("--ready-only", command)
|
||||
self.assertEqual(
|
||||
command[command.index("--companion-readiness-policy") + 1],
|
||||
str(Path("policy.json").resolve()),
|
||||
)
|
||||
|
||||
def test_summary_measures_healer_requirement_from_raw_runs(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
report_path = root / "suite-report.json"
|
||||
report = {
|
||||
"level": 20,
|
||||
"summary": {"partyConfigurations": 1, "bosses": 1},
|
||||
"bosses": [{
|
||||
"runs": 1, "bossMaxHealth": 1000, "defeatRate": 1,
|
||||
"averageTimeToDefeat": 10,
|
||||
}],
|
||||
"classSpecs": [{
|
||||
"role": "healer", "totalHealing": 150, "totalAbsorbs": 25,
|
||||
}],
|
||||
}
|
||||
report_path.write_text(json.dumps(report), encoding="utf-8")
|
||||
raw_path = root / "parties" / "wow-party-001" / "raw-runs.json"
|
||||
raw_path.parent.mkdir(parents=True)
|
||||
raw_path.write_text(json.dumps({"runs": [{
|
||||
"elapsedSeconds": 10, "partyDamage": 1200, "bossDamage": 1000,
|
||||
"incomingDamage": 200, "rawIncomingDamage": 250, "outcome": "defeated",
|
||||
"pressureWindows": 2, "healerThroughputWindows": 2, "healerResponseWindows": 2,
|
||||
"members": [
|
||||
{"role": "healer", "healing": 150, "absorbs": 25, "minimumHealthPercent": 0.6},
|
||||
{"role": "tank", "healing": 0, "absorbs": 0, "minimumHealthPercent": 0.4},
|
||||
],
|
||||
}]}), encoding="utf-8")
|
||||
summary = summarize_report(report_path)
|
||||
self.assertEqual(summary["healthToDpsSeconds"], 10)
|
||||
self.assertEqual(summary["incomingDtps"], 20)
|
||||
self.assertEqual(summary["healerHps"], 15)
|
||||
self.assertEqual(summary["healingCoverageRate"], 0.875)
|
||||
self.assertEqual(summary["incomingRunRate"], 1)
|
||||
self.assertEqual(summary["healingRequiredRunRate"], 1)
|
||||
self.assertEqual(summary["healerResponseRate"], 1)
|
||||
self.assertEqual(summary["averageMinimumHealthPercent"], 0.4)
|
||||
|
||||
def test_growth_check_flags_health_that_outpaces_damage(self) -> None:
|
||||
rows = [
|
||||
{"level": 10, "bossHealth": 1000, "bossDps": 100},
|
||||
{"level": 20, "bossHealth": 2500, "bossDps": 150},
|
||||
]
|
||||
for row in rows:
|
||||
row.update({
|
||||
"healthToDpsSeconds": row["bossHealth"] / row["bossDps"],
|
||||
"incomingDtps": 10, "healerHps": 8, "incomingRunRate": 1,
|
||||
"healingRequiredRunRate": 1,
|
||||
})
|
||||
add_growth_metrics(rows)
|
||||
args = argparse.Namespace(
|
||||
min_health_to_dps_seconds=5, max_health_to_dps_seconds=70, max_durability_growth=1.4,
|
||||
min_incoming_run_rate=0.95, min_healing_required_run_rate=0.8,
|
||||
min_healer_response_rate=0.6,
|
||||
)
|
||||
self.assertFalse(evaluate_rows(rows, args))
|
||||
self.assertIn("boss health outgrew party DPS", rows[1]["flags"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+142
-57
@@ -17,6 +17,7 @@ const SOURCES = new Set(["wow", "coa", "rom", "all"]);
|
||||
const BOSS_CATEGORIES = new Set(["all", "wow", "rom"]);
|
||||
const SPEC_SELECTIONS = new Set(["random", "all", "specific"]);
|
||||
const JOB_STATUSES = new Set(["queued", "running", "complete", "failed"]);
|
||||
const MAX_BATCH_JOBS = 80;
|
||||
const JOB_METADATA_NAME = "job.json";
|
||||
const JOB_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
const ARTIFACTS = new Map([
|
||||
@@ -26,6 +27,8 @@ const ARTIFACTS = new Map([
|
||||
["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"],
|
||||
["npc_class_specs_across_parties.csv", "text/csv; charset=utf-8"],
|
||||
["npc_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"],
|
||||
@@ -54,6 +57,9 @@ export function normalizeBalanceLabJobSpec(input) {
|
||||
if (!SPEC_SELECTIONS.has(specSelection)) {
|
||||
throw new GameDatabaseError("Class/spec selection must be random, all, or specific.", 400, "invalid_balance_lab_job");
|
||||
}
|
||||
if (input?.includeBetaSpecs !== undefined && typeof input.includeBetaSpecs !== "boolean") {
|
||||
throw new GameDatabaseError("Include Beta Specs must be boolean.", 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");
|
||||
@@ -84,6 +90,8 @@ export function normalizeBalanceLabJobSpec(input) {
|
||||
partySeed: integer(input?.partySeed ?? 1, "Party seed", -2_147_483_648, 2_147_483_647),
|
||||
bossCategory,
|
||||
bossKeys: Object.freeze(bossKeys),
|
||||
seatSweep: input?.seatSweep === true,
|
||||
includeBetaSpecs: input?.includeBetaSpecs === true,
|
||||
});
|
||||
const sourceCount = spec.source === "all" ? 3 : 1;
|
||||
const bossEstimate = Math.max(1, spec.bossKeys.length || (spec.bossCategory === "all" ? 500 : 300));
|
||||
@@ -92,7 +100,7 @@ export function normalizeBalanceLabJobSpec(input) {
|
||||
: 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;
|
||||
const estimatedRuns = partyEstimate * (spec.seatSweep ? 5 : 1) * 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.",
|
||||
@@ -103,6 +111,18 @@ export function normalizeBalanceLabJobSpec(input) {
|
||||
return spec;
|
||||
}
|
||||
|
||||
export function normalizeBalanceLabJobBatch(input) {
|
||||
const rawSpecs = Array.isArray(input?.specs) ? input.specs : [];
|
||||
if (rawSpecs.length < 1 || rawSpecs.length > MAX_BATCH_JOBS) {
|
||||
throw new GameDatabaseError(
|
||||
`A Balance Lab batch must contain 1 through ${MAX_BATCH_JOBS} level jobs.`,
|
||||
400,
|
||||
"invalid_balance_lab_batch",
|
||||
);
|
||||
}
|
||||
return Object.freeze(rawSpecs.map((spec) => normalizeBalanceLabJobSpec(spec)));
|
||||
}
|
||||
|
||||
export function balanceLabCommand(spec, outputDir, options = {}) {
|
||||
const projectRoot = path.resolve(options.projectRoot ?? fileURLToPath(new URL("..", import.meta.url)));
|
||||
const executable = String(options.pythonExecutable
|
||||
@@ -123,6 +143,10 @@ export function balanceLabCommand(spec, outputDir, options = {}) {
|
||||
];
|
||||
for (const buildId of spec.selectedBuildIds) args.push("--spec", buildId);
|
||||
for (const bossKey of spec.bossKeys) args.push("--boss", bossKey);
|
||||
if (spec.seatSweep) args.push("--seat-sweep");
|
||||
if (spec.includeBetaSpecs) args.push("--include-beta-specs");
|
||||
if (options.cooldownPolicyPath) args.push("--cooldown-policy", options.cooldownPolicyPath);
|
||||
if (options.readinessPolicyPath) args.push("--companion-readiness-policy", options.readinessPolicyPath);
|
||||
return { executable, args, cwd: projectRoot };
|
||||
}
|
||||
|
||||
@@ -185,6 +209,8 @@ function reportSummary(outputDir) {
|
||||
runs: Number(summary.runs) || 0,
|
||||
classSpecs: Number(summary.classSpecs) || 0,
|
||||
classAbilities: Number(summary.classAbilities) || 0,
|
||||
npcClassSpecs: Number(summary.npcClassSpecs) || 0,
|
||||
npcClassAbilities: Number(summary.npcClassAbilities) || 0,
|
||||
bossPressureMedianDtps: Number(summary.bossPressureMedianDtps) || 0,
|
||||
};
|
||||
} catch {
|
||||
@@ -193,9 +219,10 @@ function reportSummary(outputDir) {
|
||||
}
|
||||
|
||||
function expectedPartyConfigurations(spec) {
|
||||
return spec.specSelection === "random"
|
||||
const parties = spec.specSelection === "random"
|
||||
? spec.partyConfigurations * (spec.source === "all" ? 3 : 1)
|
||||
: spec.partyConfigurations;
|
||||
return parties * (spec.seatSweep ? 5 : 1);
|
||||
}
|
||||
|
||||
function lineCollector(onLine) {
|
||||
@@ -214,19 +241,30 @@ function lineCollector(onLine) {
|
||||
};
|
||||
}
|
||||
|
||||
function processFailureMessage(job, code) {
|
||||
if (job.logTail.some((line) => line.includes("ENOSPC: System limit for number of file watchers reached"))) {
|
||||
return "Balance Lab could not start because the server file-watcher limit was exhausted.";
|
||||
}
|
||||
return code === 0
|
||||
? "Balance Lab ended without producing its suite report."
|
||||
: `Balance Lab process exited with code ${code ?? "unknown"}.`;
|
||||
}
|
||||
|
||||
export function createBalanceLabJobService(options = {}) {
|
||||
const projectRoot = path.resolve(options.projectRoot ?? fileURLToPath(new URL("..", import.meta.url)));
|
||||
const dataDir = path.resolve(options.dataDir ?? process.env.DATA_DIR ?? "data/runtime");
|
||||
const jobsDir = path.join(dataDir, "balance-lab", "jobs");
|
||||
const configuredConcurrency = Number(options.maxConcurrent ?? process.env.BALANCE_LAB_MAX_CONCURRENT ?? 1);
|
||||
const configuredConcurrency = Number(options.maxConcurrent ?? process.env.BALANCE_LAB_MAX_CONCURRENT ?? 4);
|
||||
const maxConcurrent = Number.isFinite(configuredConcurrency) && configuredConcurrency >= 1
|
||||
? Math.min(4, Math.floor(configuredConcurrency))
|
||||
: 1;
|
||||
const configuredQueueLimit = Number(options.maxQueued ?? process.env.BALANCE_LAB_MAX_QUEUED ?? 8);
|
||||
? Math.min(16, Math.floor(configuredConcurrency))
|
||||
: 4;
|
||||
const configuredQueueLimit = Number(options.maxQueued ?? process.env.BALANCE_LAB_MAX_QUEUED ?? 100);
|
||||
const maxQueued = Number.isFinite(configuredQueueLimit) && configuredQueueLimit >= 1
|
||||
? Math.min(50, Math.floor(configuredQueueLimit))
|
||||
: 8;
|
||||
? Math.min(200, Math.floor(configuredQueueLimit))
|
||||
: 100;
|
||||
const launch = options.spawnProcess ?? spawn;
|
||||
const cooldownPolicyProvider = options.cooldownPolicyProvider;
|
||||
const readinessPolicyProvider = options.readinessPolicyProvider;
|
||||
const pythonExecutable = String(options.pythonExecutable
|
||||
?? process.env.BALANCE_LAB_PYTHON
|
||||
?? (process.platform === "win32" ? "python" : "python3"));
|
||||
@@ -239,8 +277,14 @@ export function createBalanceLabJobService(options = {}) {
|
||||
const children = new Map();
|
||||
let running = 0;
|
||||
let closed = false;
|
||||
let inspectedRuntime = null;
|
||||
mkdirSync(jobsDir, { recursive: true });
|
||||
|
||||
function currentRuntime() {
|
||||
inspectedRuntime ??= runtimeProbe();
|
||||
return inspectedRuntime;
|
||||
}
|
||||
|
||||
function persistJob(job) {
|
||||
const record = publicJob(job);
|
||||
writeFileSync(path.join(job.outputDir, JOB_METADATA_NAME), `${JSON.stringify(record, null, 2)}\n`, "utf8");
|
||||
@@ -334,6 +378,8 @@ export function createBalanceLabJobService(options = {}) {
|
||||
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 planned = /^Running\s+[\d,]+\s+simulations\s+\((\d+)\s+parties\s+x/i.exec(line);
|
||||
if (planned) job.progress.partiesTotal = Number(planned[1]);
|
||||
const party = /^Party\s+(\d+)\/(\d+):/i.exec(line);
|
||||
if (party) {
|
||||
job.progress.party = Number(party[1]);
|
||||
@@ -362,6 +408,8 @@ export function createBalanceLabJobService(options = {}) {
|
||||
const command = balanceLabCommand(job.spec, job.outputDir, {
|
||||
projectRoot,
|
||||
pythonExecutable,
|
||||
cooldownPolicyPath: job.cooldownPolicyPath,
|
||||
readinessPolicyPath: job.readinessPolicyPath,
|
||||
});
|
||||
const child = launch(command.executable, command.args, {
|
||||
cwd: command.cwd,
|
||||
@@ -392,9 +440,7 @@ export function createBalanceLabJobService(options = {}) {
|
||||
} 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"}.`);
|
||||
?? processFailureMessage(job, code);
|
||||
}
|
||||
persistJob(job);
|
||||
pump();
|
||||
@@ -414,57 +460,90 @@ export function createBalanceLabJobService(options = {}) {
|
||||
return job;
|
||||
}
|
||||
|
||||
function requireAvailableRuntime() {
|
||||
if (closed) throw new GameDatabaseError("Balance Lab runner is shutting down.", 503, "balance_lab_unavailable");
|
||||
const runtime = currentRuntime();
|
||||
if (!runtime.available) {
|
||||
throw new GameDatabaseError(
|
||||
`${runtime.error} Install Python 3 in the server container and set BALANCE_LAB_PYTHON=python3, then restart the app.`,
|
||||
503,
|
||||
"balance_lab_runtime_unavailable",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function requireQueueCapacity(count) {
|
||||
const pending = [...jobs.values()].filter((job) => job.status === "queued" || job.status === "running").length;
|
||||
if (pending + count > maxQueued) {
|
||||
throw new GameDatabaseError(
|
||||
`The Balance Lab server queue is full for this request; it has room for ${Math.max(0, maxQueued - pending)} more jobs. Wait for running jobs to finish or submit a smaller level batch.`,
|
||||
429,
|
||||
"balance_lab_queue_full",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function queueJob(spec, account) {
|
||||
const id = randomUUID();
|
||||
const outputDir = path.join(jobsDir, id);
|
||||
mkdirSync(outputDir, { recursive: true });
|
||||
const cooldownPolicyPath = path.join(outputDir, "companion-cooldown-policy.json");
|
||||
if (cooldownPolicyProvider) {
|
||||
const published = cooldownPolicyProvider();
|
||||
writeFileSync(cooldownPolicyPath, `${JSON.stringify(published.config ?? published, null, 2)}\n`, "utf8");
|
||||
}
|
||||
const readinessPolicyPath = path.join(outputDir, "companion-spec-readiness.json");
|
||||
if (readinessPolicyProvider) {
|
||||
const published = readinessPolicyProvider();
|
||||
writeFileSync(readinessPolicyPath, `${JSON.stringify(published, null, 2)}\n`, "utf8");
|
||||
}
|
||||
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,
|
||||
cooldownPolicyPath: cooldownPolicyProvider ? cooldownPolicyPath : null,
|
||||
readinessPolicyPath: readinessPolicyProvider ? readinessPolicyPath : null,
|
||||
};
|
||||
jobs.set(id, job);
|
||||
persistJob(job);
|
||||
queue.push(job);
|
||||
return job;
|
||||
}
|
||||
|
||||
return {
|
||||
start(input, account) {
|
||||
if (closed) throw new GameDatabaseError("Balance Lab runner is shutting down.", 503, "balance_lab_unavailable");
|
||||
const runtime = runtimeProbe();
|
||||
if (!runtime.available) {
|
||||
throw new GameDatabaseError(
|
||||
`${runtime.error} Install Python 3 in the server container and set BALANCE_LAB_PYTHON=python3, then restart the app.`,
|
||||
503,
|
||||
"balance_lab_runtime_unavailable",
|
||||
);
|
||||
}
|
||||
const pending = [...jobs.values()].filter((job) => job.status === "queued" || job.status === "running").length;
|
||||
if (pending >= maxQueued) {
|
||||
throw new GameDatabaseError(
|
||||
"The Balance Lab server queue is full. Wait for a running job to finish.",
|
||||
429,
|
||||
"balance_lab_queue_full",
|
||||
);
|
||||
}
|
||||
const spec = normalizeBalanceLabJobSpec(input);
|
||||
const id = randomUUID();
|
||||
const outputDir = path.join(jobsDir, id);
|
||||
mkdirSync(outputDir, { recursive: true });
|
||||
const totalParties = expectedPartyConfigurations(spec);
|
||||
const job = {
|
||||
id,
|
||||
status: "queued",
|
||||
createdAt: new Date().toISOString(),
|
||||
startedAt: null,
|
||||
completedAt: null,
|
||||
requestedBy: account.username,
|
||||
spec,
|
||||
progress: {
|
||||
party: 0,
|
||||
partiesCompleted: 0,
|
||||
partiesTotal: totalParties,
|
||||
simulationsCompleted: 0,
|
||||
simulationsTotal: 0,
|
||||
},
|
||||
artifacts: [],
|
||||
error: null,
|
||||
logTail: [],
|
||||
summary: null,
|
||||
outputDir,
|
||||
};
|
||||
jobs.set(id, job);
|
||||
persistJob(job);
|
||||
queue.push(job);
|
||||
requireAvailableRuntime();
|
||||
requireQueueCapacity(1);
|
||||
const job = queueJob(spec, account);
|
||||
pump();
|
||||
return publicJob(job);
|
||||
},
|
||||
startMany(input, account) {
|
||||
const specs = normalizeBalanceLabJobBatch(input);
|
||||
requireAvailableRuntime();
|
||||
requireQueueCapacity(specs.length);
|
||||
const batch = specs.map((spec) => queueJob(spec, account));
|
||||
pump();
|
||||
return batch.map(publicJob);
|
||||
},
|
||||
list() {
|
||||
return [...jobs.values()]
|
||||
.sort((left, right) => Date.parse(right.createdAt) - Date.parse(left.createdAt))
|
||||
@@ -472,7 +551,13 @@ export function createBalanceLabJobService(options = {}) {
|
||||
.map(publicJob);
|
||||
},
|
||||
runtime() {
|
||||
return { ...runtimeProbe() };
|
||||
return {
|
||||
...currentRuntime(),
|
||||
maxConcurrent,
|
||||
running,
|
||||
queued: queue.length,
|
||||
maxQueued,
|
||||
};
|
||||
},
|
||||
get(id) {
|
||||
return publicJob(requireJob(id));
|
||||
|
||||
@@ -1,17 +1,31 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, 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 { resolveConfig } from "vite";
|
||||
import {
|
||||
balanceLabCommand,
|
||||
createBalanceLabJobService,
|
||||
inspectBalanceLabRuntime,
|
||||
normalizeBalanceLabJobBatch,
|
||||
normalizeBalanceLabJobSpec,
|
||||
} from "./balance-lab-jobs.mjs";
|
||||
|
||||
test("disables public assets and filesystem watching in Balance Lab Vite mode", async () => {
|
||||
const config = await resolveConfig({ server: { watch: null } }, "serve", "balance-lab");
|
||||
assert.equal(config.publicDir, "");
|
||||
assert.deepEqual(config.server.watch?.ignored, ["**/*"]);
|
||||
});
|
||||
|
||||
test("enables the local Balance Lab GUI without watching its generated reports", async () => {
|
||||
const config = await resolveConfig({}, "serve", "balance-lab-gui");
|
||||
assert.equal(config.define["import.meta.env.VITE_LOCAL_BALANCE_LAB_GUI"], '"true"');
|
||||
assert.ok(config.server.watch?.ignored?.includes("**/artifacts/balance-lab-gui-runtime/**"));
|
||||
});
|
||||
|
||||
test("normalizes and bounds server Balance Lab requests", () => {
|
||||
const spec = normalizeBalanceLabJobSpec({
|
||||
source: "ALL",
|
||||
@@ -22,6 +36,8 @@ test("normalizes and bounds server Balance Lab requests", () => {
|
||||
simulationsPerParty: 3,
|
||||
durationSeconds: 45,
|
||||
partySeed: 17,
|
||||
seatSweep: true,
|
||||
includeBetaSpecs: true,
|
||||
bossCategory: "all",
|
||||
bossKeys: ["gundrak/entry-29932", "gundrak/entry-29932"],
|
||||
});
|
||||
@@ -29,6 +45,8 @@ test("normalizes and bounds server Balance Lab requests", () => {
|
||||
assert.equal(spec.specSelection, "specific");
|
||||
assert.deepEqual(spec.selectedBuildIds, ["balance-wow-priest-healer-holy-level-40"]);
|
||||
assert.deepEqual(spec.bossKeys, ["gundrak/entry-29932"]);
|
||||
assert.equal(spec.seatSweep, true);
|
||||
assert.equal(spec.includeBetaSpecs, true);
|
||||
assert.throws(
|
||||
() => normalizeBalanceLabJobSpec({ source: "wow", level: 81 }),
|
||||
/Level must be a whole number/,
|
||||
@@ -43,10 +61,26 @@ test("normalizes and bounds server Balance Lab requests", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("normalizes complete level batches before any jobs are queued", () => {
|
||||
const batch = normalizeBalanceLabJobBatch({
|
||||
specs: [10, 20, 30].map((level) => ({
|
||||
source: "wow", level, partyConfigurations: 1, simulationsPerParty: 1,
|
||||
durationSeconds: 5, partySeed: 1, bossCategory: "all", bossKeys: ["gundrak/entry-29932"],
|
||||
})),
|
||||
});
|
||||
assert.deepEqual(batch.map((spec) => spec.level), [10, 20, 30]);
|
||||
assert.throws(() => normalizeBalanceLabJobBatch({ specs: [] }), /must contain 1 through 80/);
|
||||
assert.throws(() => normalizeBalanceLabJobBatch({
|
||||
specs: [{ source: "wow", level: 10 }, { source: "wow", level: 81 }],
|
||||
}), /Level must be a whole number/);
|
||||
});
|
||||
|
||||
test("builds an argument-array command without a shell", () => {
|
||||
const spec = normalizeBalanceLabJobSpec({
|
||||
source: "rom", level: 60, partyConfigurations: 4, simulationsPerParty: 2,
|
||||
durationSeconds: 30, partySeed: 9, bossCategory: "rom", bossKeys: ["abbey/boss-1"],
|
||||
seatSweep: true,
|
||||
includeBetaSpecs: true,
|
||||
specSelection: "specific", selectedBuildIds: ["balance-rom-mage-damage-primary-level-60"],
|
||||
});
|
||||
const command = balanceLabCommand(spec, "C:/reports/job", {
|
||||
@@ -59,6 +93,8 @@ test("builds an argument-array command without a shell", () => {
|
||||
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.args.includes("--seat-sweep"), true);
|
||||
assert.equal(command.args.includes("--include-beta-specs"), true);
|
||||
assert.equal(command.cwd, path.resolve("C:/app"));
|
||||
});
|
||||
|
||||
@@ -108,6 +144,7 @@ test("queues a job, reports progress, and exposes only allow-listed artifacts",
|
||||
setImmediate(() => {
|
||||
mkdirSync(outputDir, { recursive: true });
|
||||
writeFileSync(path.join(outputDir, "suite-report.json"), "{}\n");
|
||||
child.stdout.write("Running 1 simulations (1 parties x 1 bosses x 1 runs per party/boss)...\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();
|
||||
@@ -140,6 +177,125 @@ test("queues a job, reports progress, and exposes only allow-listed artifacts",
|
||||
}
|
||||
});
|
||||
|
||||
test("corrects queued party progress when the eligible pool caps unique compositions", async () => {
|
||||
const directory = mkdtempSync(path.join(os.tmpdir(), "healer-man-balance-capacity-"));
|
||||
try {
|
||||
const spawnProcess = (_executable, args) => {
|
||||
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"), "{}\n");
|
||||
child.stdout.write("Running 1 simulations (1 parties x 1 bosses x 1 runs per party/boss)...\n");
|
||||
child.stdout.write("Party 1/1: wow-party-001\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: 10, simulationsPerParty: 1,
|
||||
durationSeconds: 5, partySeed: 1, bossCategory: "all", bossKeys: ["gundrak/entry-29932"],
|
||||
}, { username: "Phenom" });
|
||||
assert.equal(started.progress.partiesTotal, 10);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
const completed = service.get(started.id);
|
||||
assert.equal(completed.status, "complete");
|
||||
assert.equal(completed.progress.partiesTotal, 1);
|
||||
assert.equal(completed.progress.partiesCompleted, 1);
|
||||
service.close();
|
||||
} finally {
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("snapshots the published companion readiness policy for each server job", async () => {
|
||||
const directory = mkdtempSync(path.join(os.tmpdir(), "healer-man-balance-readiness-"));
|
||||
try {
|
||||
let launchedArgs = [];
|
||||
const spawnProcess = (_executable, args) => {
|
||||
launchedArgs = args;
|
||||
const child = new EventEmitter();
|
||||
child.stdout = new PassThrough();
|
||||
child.stderr = new PassThrough();
|
||||
child.kill = () => child.emit("close", null);
|
||||
return child;
|
||||
};
|
||||
const policy = {
|
||||
revision: 7,
|
||||
updatedAt: 123,
|
||||
updatedBy: "Phenom",
|
||||
config: { schemaVersion: 1, statuses: { "warrior:protection": "ready" } },
|
||||
};
|
||||
const service = createBalanceLabJobService({
|
||||
dataDir: directory,
|
||||
projectRoot: directory,
|
||||
spawnProcess,
|
||||
pythonExecutable: "python-test",
|
||||
readinessPolicyProvider: () => policy,
|
||||
});
|
||||
const job = service.start({
|
||||
source: "wow", level: 40, partyConfigurations: 1, simulationsPerParty: 1,
|
||||
durationSeconds: 5, partySeed: 1, bossCategory: "all", bossKeys: ["gundrak/entry-29932"],
|
||||
includeBetaSpecs: true,
|
||||
}, { username: "Phenom" });
|
||||
const policyPath = path.join(directory, "balance-lab", "jobs", job.id, "companion-spec-readiness.json");
|
||||
assert.equal(JSON.parse(readFileSync(policyPath, "utf8")).revision, 7);
|
||||
assert.equal(launchedArgs.includes("--companion-readiness-policy"), true);
|
||||
assert.equal(launchedArgs.includes(policyPath), true);
|
||||
assert.equal(launchedArgs.includes("--include-beta-specs"), true);
|
||||
service.close();
|
||||
} finally {
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("reports exhausted file watchers as a specific startup failure", async () => {
|
||||
const directory = mkdtempSync(path.join(os.tmpdir(), "healer-man-balance-watchers-"));
|
||||
try {
|
||||
const spawnProcess = () => {
|
||||
const child = new EventEmitter();
|
||||
child.stdout = new PassThrough();
|
||||
child.stderr = new PassThrough();
|
||||
child.kill = () => child.emit("close", null);
|
||||
setImmediate(() => {
|
||||
child.stderr.write("Error: ENOSPC: System limit for number of file watchers reached, watch '/app/public/assets/example'\n");
|
||||
child.stdout.end();
|
||||
child.stderr.end();
|
||||
child.emit("close", 1);
|
||||
});
|
||||
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" });
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
const failed = service.get(started.id);
|
||||
assert.equal(failed.status, "failed");
|
||||
assert.match(failed.error, /file-watcher limit was exhausted/i);
|
||||
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 {
|
||||
@@ -293,3 +449,69 @@ test("bounds the number of pending server jobs", () => {
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("runs separate level jobs concurrently and reports worker utilization", () => {
|
||||
const directory = mkdtempSync(path.join(os.tmpdir(), "healer-man-balance-parallel-"));
|
||||
try {
|
||||
let launches = 0;
|
||||
const spawnProcess = () => {
|
||||
launches += 1;
|
||||
const child = new EventEmitter();
|
||||
child.stdout = new PassThrough();
|
||||
child.stderr = new PassThrough();
|
||||
child.kill = () => child.emit("close", null);
|
||||
return child;
|
||||
};
|
||||
const service = createBalanceLabJobService({
|
||||
dataDir: directory,
|
||||
projectRoot: directory,
|
||||
spawnProcess,
|
||||
pythonExecutable: "python-test",
|
||||
maxConcurrent: 3,
|
||||
maxQueued: 10,
|
||||
});
|
||||
const requestForLevel = (level) => ({
|
||||
source: "wow", level, partyConfigurations: 1, simulationsPerParty: 1,
|
||||
durationSeconds: 5, partySeed: 1, bossCategory: "all", bossKeys: ["gundrak/entry-29932"],
|
||||
});
|
||||
const jobs = service.startMany({ specs: [10, 20, 30].map(requestForLevel) }, { username: "Phenom" });
|
||||
assert.equal(launches, 3);
|
||||
assert.deepEqual(jobs.map((job) => job.status), ["running", "running", "running"]);
|
||||
assert.deepEqual(jobs.map((job) => job.spec.level), [10, 20, 30]);
|
||||
assert.deepEqual(service.runtime(), {
|
||||
available: true,
|
||||
executable: "python-test",
|
||||
version: "test process",
|
||||
error: null,
|
||||
maxConcurrent: 3,
|
||||
running: 3,
|
||||
queued: 0,
|
||||
maxQueued: 10,
|
||||
});
|
||||
service.close();
|
||||
} finally {
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects an oversized batch without queueing a partial sweep", () => {
|
||||
const directory = mkdtempSync(path.join(os.tmpdir(), "healer-man-balance-batch-capacity-"));
|
||||
try {
|
||||
const service = createBalanceLabJobService({
|
||||
dataDir: directory,
|
||||
projectRoot: directory,
|
||||
spawnProcess: () => { throw new Error("an over-capacity batch must not launch"); },
|
||||
pythonExecutable: "python-test",
|
||||
maxQueued: 2,
|
||||
});
|
||||
const specs = [10, 20, 30].map((level) => ({
|
||||
source: "wow", level, partyConfigurations: 1, simulationsPerParty: 1,
|
||||
durationSeconds: 5, partySeed: 1, bossCategory: "all", bossKeys: ["gundrak/entry-29932"],
|
||||
}));
|
||||
assert.throws(() => service.startMany({ specs }, { username: "Phenom" }), /server queue is full/);
|
||||
assert.equal(service.list().length, 0);
|
||||
service.close();
|
||||
} finally {
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
export const DEFAULT_COMPANION_COOLDOWN_POLICY = Object.freeze({
|
||||
schemaVersion: 1,
|
||||
generic: Object.freeze({
|
||||
defensiveHealthPercent: 55,
|
||||
emergencyHealthPercent: 35,
|
||||
healerMitigationHealthPercent: 80,
|
||||
offensiveMajorCooldownSeconds: 60,
|
||||
conserveOffensiveCooldownsForDurableTargets: true,
|
||||
}),
|
||||
mechanics: Object.freeze([]),
|
||||
});
|
||||
|
||||
function invalid(message) {
|
||||
const error = new Error(message);
|
||||
error.code = "invalid_companion_cooldown_policy";
|
||||
return error;
|
||||
}
|
||||
|
||||
function number(value, label, minimum, maximum) {
|
||||
if (typeof value !== "number" || !Number.isFinite(value) || value < minimum || value > maximum) {
|
||||
throw invalid(`${label} must be a number from ${minimum} through ${maximum}.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function string(value, label, allowEmpty = false) {
|
||||
if (typeof value !== "string" || (!allowEmpty && !value.trim()) || value.length > 256) {
|
||||
throw invalid(`${label} must be ${allowEmpty ? "a" : "a non-empty"} string no longer than 256 characters.`);
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
export function normalizeCompanionCooldownPolicy(value) {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value) || value.schemaVersion !== 1) {
|
||||
throw invalid("Companion cooldown policy schemaVersion must be 1.");
|
||||
}
|
||||
const generic = value.generic;
|
||||
if (!generic || typeof generic !== "object" || Array.isArray(generic)) throw invalid("generic rules are required.");
|
||||
if (typeof generic.conserveOffensiveCooldownsForDurableTargets !== "boolean") {
|
||||
throw invalid("generic.conserveOffensiveCooldownsForDurableTargets must be boolean.");
|
||||
}
|
||||
if (!Array.isArray(value.mechanics) || value.mechanics.length > 1_000) throw invalid("mechanics must be an array of at most 1,000 rules.");
|
||||
const seen = new Set();
|
||||
const mechanics = value.mechanics.map((rule, index) => {
|
||||
if (!rule || typeof rule !== "object" || Array.isArray(rule)) throw invalid(`mechanics[${index}] must be an object.`);
|
||||
const id = string(rule.id, `mechanics[${index}].id`);
|
||||
if (seen.has(id)) throw invalid(`mechanic rule id ${id} is duplicated.`);
|
||||
seen.add(id);
|
||||
if (typeof rule.enabled !== "boolean") throw invalid(`mechanics[${index}].enabled must be boolean.`);
|
||||
if (!new Set(["tank", "party"]).has(rule.scope)) throw invalid(`mechanics[${index}].scope is invalid.`);
|
||||
const serverEntry = rule.bossServerEntry;
|
||||
if (serverEntry !== null && (!Number.isInteger(serverEntry) || serverEntry <= 0)) {
|
||||
throw invalid(`mechanics[${index}].bossServerEntry must be null or a positive integer.`);
|
||||
}
|
||||
const bossName = string(rule.bossName, `mechanics[${index}].bossName`, true);
|
||||
if (serverEntry === null && !bossName) {
|
||||
throw invalid(`mechanics[${index}].bossName is required when bossServerEntry is null.`);
|
||||
}
|
||||
return {
|
||||
id,
|
||||
enabled: rule.enabled,
|
||||
bossKey: string(rule.bossKey, `mechanics[${index}].bossKey`, true),
|
||||
bossName,
|
||||
bossServerEntry: serverEntry,
|
||||
attackId: string(rule.attackId, `mechanics[${index}].attackId`),
|
||||
attackName: string(rule.attackName, `mechanics[${index}].attackName`),
|
||||
scope: rule.scope,
|
||||
leadTimeMs: number(rule.leadTimeMs, `mechanics[${index}].leadTimeMs`, 0, 15_000),
|
||||
minimumCooldownSeconds: number(rule.minimumCooldownSeconds, `mechanics[${index}].minimumCooldownSeconds`, 0, 600),
|
||||
};
|
||||
});
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
generic: {
|
||||
defensiveHealthPercent: number(generic.defensiveHealthPercent, "generic.defensiveHealthPercent", 1, 100),
|
||||
emergencyHealthPercent: number(generic.emergencyHealthPercent, "generic.emergencyHealthPercent", 1, 100),
|
||||
healerMitigationHealthPercent: number(generic.healerMitigationHealthPercent, "generic.healerMitigationHealthPercent", 1, 100),
|
||||
offensiveMajorCooldownSeconds: number(generic.offensiveMajorCooldownSeconds, "generic.offensiveMajorCooldownSeconds", 0, 600),
|
||||
conserveOffensiveCooldownsForDurableTargets: generic.conserveOffensiveCooldownsForDurableTargets,
|
||||
},
|
||||
mechanics,
|
||||
};
|
||||
}
|
||||
|
||||
export function cloneDefaultCompanionCooldownPolicy() {
|
||||
return JSON.parse(JSON.stringify(DEFAULT_COMPANION_COOLDOWN_POLICY));
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import {
|
||||
DEFAULT_COMPANION_COOLDOWN_POLICY,
|
||||
normalizeCompanionCooldownPolicy,
|
||||
} from "./companion-cooldown-policy.mjs";
|
||||
|
||||
test("normalizes valid generic and boss-mechanic cooldown rules", () => {
|
||||
const policy = normalizeCompanionCooldownPolicy({
|
||||
...DEFAULT_COMPANION_COOLDOWN_POLICY,
|
||||
generic: { ...DEFAULT_COMPANION_COOLDOWN_POLICY.generic, defensiveHealthPercent: 62 },
|
||||
mechanics: [{
|
||||
id: "tank-buster", enabled: true, bossKey: "test/boss", bossName: "Test Boss",
|
||||
bossServerEntry: 123, attackId: "heavy-strike", attackName: "Heavy Strike",
|
||||
scope: "tank", leadTimeMs: 2_500, minimumCooldownSeconds: 20,
|
||||
}],
|
||||
});
|
||||
assert.equal(policy.generic.defensiveHealthPercent, 62);
|
||||
assert.equal(policy.mechanics[0].attackId, "heavy-strike");
|
||||
});
|
||||
|
||||
test("rejects duplicate mechanic ids and out-of-range thresholds", () => {
|
||||
const rule = {
|
||||
id: "duplicate", enabled: true, bossKey: "", bossName: "Boss", bossServerEntry: null,
|
||||
attackId: "attack", attackName: "Attack", scope: "party", leadTimeMs: 1_000,
|
||||
minimumCooldownSeconds: 20,
|
||||
};
|
||||
assert.throws(() => normalizeCompanionCooldownPolicy({
|
||||
...DEFAULT_COMPANION_COOLDOWN_POLICY,
|
||||
mechanics: [rule, rule],
|
||||
}), /duplicated/);
|
||||
assert.throws(() => normalizeCompanionCooldownPolicy({
|
||||
...DEFAULT_COMPANION_COOLDOWN_POLICY,
|
||||
generic: { ...DEFAULT_COMPANION_COOLDOWN_POLICY.generic, defensiveHealthPercent: 101 },
|
||||
}), /1 through 100/);
|
||||
assert.throws(() => normalizeCompanionCooldownPolicy({
|
||||
...DEFAULT_COMPANION_COOLDOWN_POLICY,
|
||||
generic: { ...DEFAULT_COMPANION_COOLDOWN_POLICY.generic, conserveOffensiveCooldownsForDurableTargets: "yes" },
|
||||
}), /must be boolean/);
|
||||
assert.throws(() => normalizeCompanionCooldownPolicy({
|
||||
...DEFAULT_COMPANION_COOLDOWN_POLICY,
|
||||
mechanics: [{ ...rule, id: "missing-boss", bossName: "" }],
|
||||
}), /bossName is required/);
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
const WOW_SPECIALIZATIONS = Object.freeze([
|
||||
["warrior:arms", "damage"], ["warrior:fury", "damage"], ["warrior:protection", "tank"],
|
||||
["paladin:holy", "healer"], ["paladin:protection", "tank"], ["paladin:retribution", "damage"],
|
||||
["hunter:beast-mastery", "damage"], ["hunter:marksmanship", "damage"], ["hunter:survival", "damage"],
|
||||
["rogue:assassination", "damage"], ["rogue:combat", "damage"], ["rogue:subtlety", "damage"],
|
||||
["priest:discipline", "healer"], ["priest:holy", "healer"], ["priest:shadow", "damage"],
|
||||
["death-knight:blood", "tank"], ["death-knight:frost", "damage"], ["death-knight:unholy", "damage"],
|
||||
["shaman:elemental", "damage"], ["shaman:enhancement", "damage"], ["shaman:restoration", "healer"],
|
||||
["mage:arcane", "damage"], ["mage:fire", "damage"], ["mage:frost", "damage"],
|
||||
["warlock:affliction", "damage"], ["warlock:demonology", "damage"], ["warlock:destruction", "damage"],
|
||||
["druid:balance", "damage"], ["druid:feral-bear", "tank"], ["druid:feral-cat", "damage"], ["druid:restoration", "healer"],
|
||||
]);
|
||||
|
||||
const READY_IDS = new Set([
|
||||
"warrior:protection",
|
||||
"priest:discipline",
|
||||
"hunter:marksmanship",
|
||||
"mage:fire",
|
||||
"rogue:assassination",
|
||||
]);
|
||||
const SPECIALIZATION_ROLE = new Map(WOW_SPECIALIZATIONS);
|
||||
const VALID_STATUSES = new Set(["ready", "beta", "needs-testing"]);
|
||||
|
||||
export const DEFAULT_COMPANION_SPEC_READINESS_POLICY = Object.freeze({
|
||||
schemaVersion: 1,
|
||||
statuses: Object.freeze(Object.fromEntries(WOW_SPECIALIZATIONS.map(([id]) => [
|
||||
id,
|
||||
READY_IDS.has(id) ? "ready" : "needs-testing",
|
||||
]))),
|
||||
});
|
||||
|
||||
function invalid(message) {
|
||||
const error = new Error(message);
|
||||
error.code = "invalid_companion_spec_readiness";
|
||||
return error;
|
||||
}
|
||||
|
||||
export function normalizeCompanionSpecReadinessPolicy(value) {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value) || value.schemaVersion !== 1) {
|
||||
throw invalid("Companion readiness policy schemaVersion must be 1.");
|
||||
}
|
||||
if (!value.statuses || typeof value.statuses !== "object" || Array.isArray(value.statuses)) {
|
||||
throw invalid("Companion readiness statuses are required.");
|
||||
}
|
||||
for (const [id, status] of Object.entries(value.statuses)) {
|
||||
if (!SPECIALIZATION_ROLE.has(id)) throw invalid(`Unknown WoW specialization: ${id}.`);
|
||||
if (!VALID_STATUSES.has(status)) throw invalid(`Invalid readiness status for ${id}.`);
|
||||
}
|
||||
const statuses = Object.fromEntries(WOW_SPECIALIZATIONS.map(([id]) => [id, value.statuses[id] ?? "needs-testing"]));
|
||||
const ready = WOW_SPECIALIZATIONS.filter(([id]) => statuses[id] === "ready");
|
||||
if (!ready.some(([, role]) => role === "tank")) throw invalid("At least one tank specialization must be Ready.");
|
||||
if (!ready.some(([, role]) => role === "healer")) throw invalid("At least one healer specialization must be Ready.");
|
||||
if (ready.filter(([, role]) => role === "damage").length < 3) {
|
||||
throw invalid("At least three distinct damage specializations must be Ready.");
|
||||
}
|
||||
return { schemaVersion: 1, statuses };
|
||||
}
|
||||
|
||||
export function cloneDefaultCompanionSpecReadinessPolicy() {
|
||||
return JSON.parse(JSON.stringify(DEFAULT_COMPANION_SPEC_READINESS_POLICY));
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import {
|
||||
cloneDefaultCompanionSpecReadinessPolicy,
|
||||
normalizeCompanionSpecReadinessPolicy,
|
||||
} from "./companion-spec-readiness.mjs";
|
||||
|
||||
test("default companion readiness policy certifies the five focused specs", () => {
|
||||
const policy = normalizeCompanionSpecReadinessPolicy(cloneDefaultCompanionSpecReadinessPolicy());
|
||||
assert.deepEqual(Object.entries(policy.statuses).filter(([, status]) => status === "ready").map(([id]) => id), [
|
||||
"warrior:protection",
|
||||
"hunter:marksmanship",
|
||||
"rogue:assassination",
|
||||
"priest:discipline",
|
||||
"mage:fire",
|
||||
]);
|
||||
});
|
||||
|
||||
test("companion readiness validation rejects invalid coverage and unknown specs", () => {
|
||||
assert.throws(() => normalizeCompanionSpecReadinessPolicy({ schemaVersion: 1, statuses: {} }), /tank specialization/);
|
||||
assert.throws(() => normalizeCompanionSpecReadinessPolicy({
|
||||
schemaVersion: 1,
|
||||
statuses: { ...cloneDefaultCompanionSpecReadinessPolicy().statuses, "future:spec": "ready" },
|
||||
}), /Unknown WoW specialization/);
|
||||
});
|
||||
+163
-4
@@ -8,6 +8,14 @@ import {
|
||||
normalizeManastormAdminConfig,
|
||||
stampManastormReviewMetadata,
|
||||
} from "./manastorm-config.mjs";
|
||||
import {
|
||||
cloneDefaultCompanionCooldownPolicy,
|
||||
normalizeCompanionCooldownPolicy,
|
||||
} from "./companion-cooldown-policy.mjs";
|
||||
import {
|
||||
cloneDefaultCompanionSpecReadinessPolicy,
|
||||
normalizeCompanionSpecReadinessPolicy,
|
||||
} from "./companion-spec-readiness.mjs";
|
||||
import { arenaRatingBand, calculateArenaRating, clampArenaRating } from "./arena-rules.mjs";
|
||||
import { arenaRoleForSpecialization } from "./arena-specializations.mjs";
|
||||
import { createDungeonReportRepository } from "./dungeon-reports.mjs";
|
||||
@@ -44,7 +52,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"] : [];
|
||||
@@ -93,6 +101,24 @@ export function openGameDatabase(options = {}) {
|
||||
updated_by_username TEXT,
|
||||
updated_at INTEGER
|
||||
) STRICT;
|
||||
CREATE TABLE IF NOT EXISTS companion_cooldown_policy (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
schema_version INTEGER NOT NULL,
|
||||
revision INTEGER NOT NULL,
|
||||
config_json TEXT NOT NULL,
|
||||
updated_by_account_id TEXT REFERENCES accounts(id) ON DELETE SET NULL,
|
||||
updated_by_username TEXT,
|
||||
updated_at INTEGER
|
||||
) STRICT;
|
||||
CREATE TABLE IF NOT EXISTS companion_spec_readiness (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
schema_version INTEGER NOT NULL,
|
||||
revision INTEGER NOT NULL,
|
||||
config_json TEXT NOT NULL,
|
||||
updated_by_account_id TEXT REFERENCES accounts(id) ON DELETE SET NULL,
|
||||
updated_by_username TEXT,
|
||||
updated_at INTEGER
|
||||
) STRICT;
|
||||
CREATE TABLE IF NOT EXISTS online_groups (
|
||||
id TEXT PRIMARY KEY,
|
||||
leader_account_id TEXT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
|
||||
@@ -184,6 +210,16 @@ export function openGameDatabase(options = {}) {
|
||||
id, schema_version, revision, config_json, updated_by_account_id, updated_by_username, updated_at
|
||||
) VALUES (1, 1, 0, ?, NULL, NULL, NULL)
|
||||
`).run(JSON.stringify(cloneDefaultManastormAdminConfig()));
|
||||
database.prepare(`
|
||||
INSERT OR IGNORE INTO companion_cooldown_policy (
|
||||
id, schema_version, revision, config_json, updated_by_account_id, updated_by_username, updated_at
|
||||
) VALUES (1, 1, 0, ?, NULL, NULL, NULL)
|
||||
`).run(JSON.stringify(cloneDefaultCompanionCooldownPolicy()));
|
||||
database.prepare(`
|
||||
INSERT OR IGNORE INTO companion_spec_readiness (
|
||||
id, schema_version, revision, config_json, updated_by_account_id, updated_by_username, updated_at
|
||||
) VALUES (1, 1, 0, ?, NULL, NULL, NULL)
|
||||
`).run(JSON.stringify(cloneDefaultCompanionSpecReadinessPolicy()));
|
||||
|
||||
const statements = {
|
||||
accountByUsername: database.prepare("SELECT * FROM accounts WHERE username_key = ?"),
|
||||
@@ -215,6 +251,22 @@ export function openGameDatabase(options = {}) {
|
||||
WHERE id = 1 AND revision = ?
|
||||
RETURNING revision, updated_at
|
||||
`),
|
||||
companionCooldownPolicy: database.prepare("SELECT * FROM companion_cooldown_policy WHERE id = 1"),
|
||||
updateCompanionCooldownPolicy: database.prepare(`
|
||||
UPDATE companion_cooldown_policy
|
||||
SET schema_version = 1, revision = revision + 1, config_json = ?,
|
||||
updated_by_account_id = ?, updated_by_username = ?, updated_at = ?
|
||||
WHERE id = 1 AND revision = ?
|
||||
RETURNING revision, updated_at
|
||||
`),
|
||||
companionSpecReadiness: database.prepare("SELECT * FROM companion_spec_readiness WHERE id = 1"),
|
||||
updateCompanionSpecReadiness: database.prepare(`
|
||||
UPDATE companion_spec_readiness
|
||||
SET schema_version = 1, revision = revision + 1, config_json = ?,
|
||||
updated_by_account_id = ?, updated_by_username = ?, updated_at = ?
|
||||
WHERE id = 1 AND revision = ?
|
||||
RETURNING revision, updated_at
|
||||
`),
|
||||
arenaLoadouts: database.prepare(`
|
||||
SELECT * FROM arena_loadouts WHERE account_id = ? AND character_id = ? ORDER BY slot
|
||||
`),
|
||||
@@ -364,6 +416,74 @@ export function openGameDatabase(options = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
function getCompanionCooldownPolicy() {
|
||||
const row = statements.companionCooldownPolicy.get();
|
||||
try {
|
||||
return {
|
||||
revision: Number(row?.revision ?? 0),
|
||||
updatedAt: row?.updated_at == null ? null : Number(row.updated_at),
|
||||
updatedBy: row?.updated_by_username ?? null,
|
||||
config: normalizeCompanionCooldownPolicy(JSON.parse(row?.config_json ?? "null")),
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Healer Man companion cooldown policy is corrupt; using safe defaults", error);
|
||||
return { revision: 0, updatedAt: null, updatedBy: null, config: cloneDefaultCompanionCooldownPolicy() };
|
||||
}
|
||||
}
|
||||
|
||||
function putCompanionCooldownPolicy(account, expectedRevision, config) {
|
||||
if (!isGameMaster(account)) throw new GameDatabaseError("Game Master access is required.", 403, "gm_required");
|
||||
if (!Number.isInteger(expectedRevision) || expectedRevision < 0) {
|
||||
throw new GameDatabaseError("A valid expected revision is required.", 400, "invalid_revision");
|
||||
}
|
||||
let normalized;
|
||||
try {
|
||||
normalized = normalizeCompanionCooldownPolicy(config);
|
||||
} catch (error) {
|
||||
throw new GameDatabaseError(error instanceof Error ? error.message : "Invalid companion cooldown policy.", 400, "invalid_companion_cooldown_policy");
|
||||
}
|
||||
const now = Date.now();
|
||||
const row = statements.updateCompanionCooldownPolicy.get(
|
||||
JSON.stringify(normalized), account.id, account.username, now, expectedRevision,
|
||||
);
|
||||
if (!row) throw new GameDatabaseError("The companion cooldown policy changed. Reload and try again.", 409, "revision_conflict");
|
||||
return { revision: Number(row.revision), updatedAt: Number(row.updated_at), updatedBy: account.username, config: normalized };
|
||||
}
|
||||
|
||||
function getCompanionSpecReadiness() {
|
||||
const row = statements.companionSpecReadiness.get();
|
||||
try {
|
||||
return {
|
||||
revision: Number(row?.revision ?? 0),
|
||||
updatedAt: row?.updated_at == null ? null : Number(row.updated_at),
|
||||
updatedBy: row?.updated_by_username ?? null,
|
||||
config: normalizeCompanionSpecReadinessPolicy(JSON.parse(row?.config_json ?? "null")),
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Healer Man companion readiness policy is corrupt; using shipped defaults", error);
|
||||
return { revision: 0, updatedAt: null, updatedBy: null, config: cloneDefaultCompanionSpecReadinessPolicy() };
|
||||
}
|
||||
}
|
||||
|
||||
function putCompanionSpecReadiness(account, expectedRevision, config) {
|
||||
if (!isGameMaster(account)) throw new GameDatabaseError("Game Master access is required.", 403, "gm_required");
|
||||
if (!Number.isInteger(expectedRevision) || expectedRevision < 0) {
|
||||
throw new GameDatabaseError("A valid expected revision is required.", 400, "invalid_revision");
|
||||
}
|
||||
let normalized;
|
||||
try {
|
||||
normalized = normalizeCompanionSpecReadinessPolicy(config);
|
||||
} catch (error) {
|
||||
throw new GameDatabaseError(error instanceof Error ? error.message : "Invalid companion readiness policy.", 400, "invalid_companion_spec_readiness");
|
||||
}
|
||||
const now = Date.now();
|
||||
const row = statements.updateCompanionSpecReadiness.get(
|
||||
JSON.stringify(normalized), account.id, account.username, now, expectedRevision,
|
||||
);
|
||||
if (!row) throw new GameDatabaseError("The companion readiness policy changed. Reload and try again.", 409, "revision_conflict");
|
||||
return { revision: Number(row.revision), updatedAt: Number(row.updated_at), updatedBy: account.username, config: normalized };
|
||||
}
|
||||
|
||||
function pruneExpiredGroupInvites() {
|
||||
database.prepare(`
|
||||
UPDATE online_group_invites SET status = 'expired'
|
||||
@@ -579,16 +699,29 @@ export function openGameDatabase(options = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
function onlineGroupCharacterSource(character) {
|
||||
return character.classId.startsWith("rom-") || character.categoryId === "rom" ? "rom" : "wow";
|
||||
}
|
||||
|
||||
function updateOnlineGroupMember(accountId, character, role) {
|
||||
const group = groupMembership(accountId);
|
||||
if (!group) throw new GameDatabaseError("Join or create a group first.", 409, "group_required");
|
||||
if (!["tank", "healer", "damage"].includes(role)) {
|
||||
throw new GameDatabaseError("Choose a valid party role.", 400, "invalid_party_role");
|
||||
}
|
||||
const selectedCharacter = normalizeGroupCharacter(character);
|
||||
const otherMembers = database.prepare(`
|
||||
SELECT character_json FROM online_group_members
|
||||
WHERE group_id = ? AND account_id != ? AND character_json IS NOT NULL
|
||||
`).all(group.id, accountId);
|
||||
if (otherMembers.some((member) => onlineGroupCharacterSource(JSON.parse(member.character_json))
|
||||
!== onlineGroupCharacterSource(selectedCharacter))) {
|
||||
throw new GameDatabaseError("RoM characters cannot join a WoW group, or vice versa.", 409, "mixed_group_sources");
|
||||
}
|
||||
database.prepare(`
|
||||
UPDATE online_group_members SET character_json = ?, role = ?
|
||||
WHERE group_id = ? AND account_id = ?
|
||||
`).run(JSON.stringify(normalizeGroupCharacter(character)), role, group.id, accountId);
|
||||
`).run(JSON.stringify(selectedCharacter), role, group.id, accountId);
|
||||
database.prepare("UPDATE online_groups SET updated_at = ? WHERE id = ?").run(Date.now(), group.id);
|
||||
return onlineGroupState(accountId);
|
||||
}
|
||||
@@ -619,15 +752,37 @@ export function openGameDatabase(options = {}) {
|
||||
if (members.some((member) => !member.character_json || !["tank", "healer", "damage"].includes(member.role))) {
|
||||
throw new GameDatabaseError("Every player must select a character and party role before the group can start.", 409, "group_not_ready");
|
||||
}
|
||||
const fillWithAi = input?.fillWithAi !== false;
|
||||
const requestedSize = Math.max(1, Math.min(5, Math.trunc(Number(input?.partySize) || 5)));
|
||||
if (new Set(members.map((member) => onlineGroupCharacterSource(JSON.parse(member.character_json)))).size > 1) {
|
||||
throw new GameDatabaseError("RoM characters cannot join a WoW group, or vice versa.", 409, "mixed_group_sources");
|
||||
}
|
||||
const romGroup = onlineGroupCharacterSource(JSON.parse(members[0].character_json)) === "rom";
|
||||
const randomizedBiteSized = type === "dungeon"
|
||||
&& contentId === "randomized-bite-sized-stormwind-stockade";
|
||||
if (romGroup && randomizedBiteSized) {
|
||||
throw new GameDatabaseError("This WoW-only activity is unavailable to RoM groups.", 409, "group_source_incompatible");
|
||||
}
|
||||
const fillWithAi = !romGroup && (randomizedBiteSized || input?.fillWithAi !== false);
|
||||
if (input?.includeBetaSpecs !== undefined && typeof input.includeBetaSpecs !== "boolean") {
|
||||
throw new GameDatabaseError("includeBetaSpecs must be boolean.", 400, "invalid_beta_preference");
|
||||
}
|
||||
const includeBetaSpecs = romGroup || randomizedBiteSized ? false : input?.includeBetaSpecs === true;
|
||||
const requestedSize = randomizedBiteSized
|
||||
? 5
|
||||
: Math.max(1, Math.min(5, Math.trunc(Number(input?.partySize) || 5)));
|
||||
const partySize = fillWithAi ? Math.max(members.length, requestedSize) : members.length;
|
||||
const readiness = getCompanionSpecReadiness();
|
||||
const companionSpecializationIds = romGroup ? [] : Object.entries(readiness.config.statuses)
|
||||
.filter(([, status]) => status === "ready" || (includeBetaSpecs && status === "beta"))
|
||||
.map(([specializationId]) => specializationId);
|
||||
const activity = {
|
||||
id: `activity-${randomUUID()}`,
|
||||
type,
|
||||
contentId,
|
||||
...(type === "dungeon" ? { difficultyId: input?.difficultyId, bossDifficulty: input?.bossDifficulty ?? 1 } : {}),
|
||||
fillWithAi,
|
||||
includeBetaSpecs,
|
||||
companionReadinessRevision: readiness.revision,
|
||||
companionSpecializationIds,
|
||||
partySize,
|
||||
startingLevel: type === "manastorm"
|
||||
? Math.max(1, Math.trunc(Number(input?.startingLevel) || 1))
|
||||
@@ -921,6 +1076,10 @@ export function openGameDatabase(options = {}) {
|
||||
isGameMaster,
|
||||
getManastormConfig,
|
||||
putManastormConfig,
|
||||
getCompanionCooldownPolicy,
|
||||
putCompanionCooldownPolicy,
|
||||
getCompanionSpecReadiness,
|
||||
putCompanionSpecReadiness,
|
||||
onlineGroupState,
|
||||
inviteOnlineGroupMember,
|
||||
respondToOnlineGroupInvite,
|
||||
|
||||
+114
-2
@@ -56,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);
|
||||
}));
|
||||
@@ -139,6 +142,65 @@ test("persists revisioned Manastorm GM configuration and rejects stale or unauth
|
||||
}), (error) => error instanceof GameDatabaseError && error.status === 400);
|
||||
}));
|
||||
|
||||
test("persists revisioned companion cooldown policy and rejects stale or unauthorized writes", () => withDatabase(async (database) => {
|
||||
const gm = await database.register("Phenom", "cooldown-admin");
|
||||
const player = await database.register("Ordinary", "cooldown-player");
|
||||
const initial = database.getCompanionCooldownPolicy();
|
||||
const configured = {
|
||||
...initial.config,
|
||||
generic: { ...initial.config.generic, defensiveHealthPercent: 61 },
|
||||
mechanics: [{
|
||||
id: "tank-buster", enabled: true, bossKey: "test/boss", bossName: "Test Boss",
|
||||
bossServerEntry: 123, attackId: "heavy-strike", attackName: "Heavy Strike",
|
||||
scope: "tank", leadTimeMs: 2_500, minimumCooldownSeconds: 20,
|
||||
}],
|
||||
};
|
||||
const saved = database.putCompanionCooldownPolicy(database.authenticate(gm.token), 0, configured);
|
||||
assert.equal(saved.revision, 1);
|
||||
assert.equal(database.getCompanionCooldownPolicy().config.generic.defensiveHealthPercent, 61);
|
||||
assert.throws(() => database.putCompanionCooldownPolicy(database.authenticate(gm.token), 0, configured), (error) => (
|
||||
error instanceof GameDatabaseError && error.status === 409
|
||||
));
|
||||
assert.throws(() => database.putCompanionCooldownPolicy(database.authenticate(player.token), 1, configured), (error) => (
|
||||
error instanceof GameDatabaseError && error.status === 403
|
||||
));
|
||||
}));
|
||||
|
||||
test("persists validated companion readiness with authorization, revisions, and corrupt-data fallback", () => withDatabase(async (database) => {
|
||||
const gm = await database.register("Phenom", "readiness-admin");
|
||||
const player = await database.register("ReadinessPlayer", "readiness-player");
|
||||
const initial = database.getCompanionSpecReadiness();
|
||||
assert.equal(initial.revision, 0);
|
||||
assert.equal(initial.config.statuses["warrior:protection"], "ready");
|
||||
assert.equal(Object.values(initial.config.statuses).filter((status) => status === "ready").length, 5);
|
||||
|
||||
const configured = {
|
||||
...initial.config,
|
||||
statuses: { ...initial.config.statuses, "paladin:retribution": "beta" },
|
||||
};
|
||||
const account = database.authenticate(gm.token);
|
||||
const saved = database.putCompanionSpecReadiness(account, 0, configured);
|
||||
assert.equal(saved.revision, 1);
|
||||
assert.equal(database.getCompanionSpecReadiness().config.statuses["paladin:retribution"], "beta");
|
||||
assert.throws(() => database.putCompanionSpecReadiness(account, 0, configured), (error) => (
|
||||
error instanceof GameDatabaseError && error.status === 409
|
||||
));
|
||||
assert.throws(() => database.putCompanionSpecReadiness(database.authenticate(player.token), 1, configured), (error) => (
|
||||
error instanceof GameDatabaseError && error.status === 403
|
||||
));
|
||||
assert.throws(() => database.putCompanionSpecReadiness(account, 1, {
|
||||
schemaVersion: 1,
|
||||
statuses: { ...configured.statuses, "warrior:protection": "needs-testing" },
|
||||
}), (error) => error instanceof GameDatabaseError && error.status === 400);
|
||||
|
||||
const raw = new DatabaseSync(database.databasePath);
|
||||
raw.prepare("UPDATE companion_spec_readiness SET config_json = ? WHERE id = 1").run("{not-json");
|
||||
raw.close();
|
||||
const fallback = database.getCompanionSpecReadiness();
|
||||
assert.equal(fallback.revision, 0);
|
||||
assert.equal(fallback.config.statuses["warrior:protection"], "ready");
|
||||
}));
|
||||
|
||||
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");
|
||||
@@ -258,6 +320,37 @@ test("creates online groups through invitations and transfers leadership when th
|
||||
assert.deepEqual(transferred.group.members.map((member) => member.username), ["GroupHealer"]);
|
||||
}));
|
||||
|
||||
test("keeps RoM and WoW characters in separate online groups", () => withDatabase(async (database) => {
|
||||
const leaderAuth = await database.register("SourceLeader", "group-pass");
|
||||
const memberAuth = await database.register("SourceMember", "member-pass");
|
||||
const leader = database.authenticate(leaderAuth.token);
|
||||
const member = database.authenticate(memberAuth.token);
|
||||
database.inviteOnlineGroupMember(leader, member.username);
|
||||
const invite = database.onlineGroupState(member.id).invitations[0];
|
||||
database.respondToOnlineGroupInvite(member.id, invite.id, true);
|
||||
|
||||
const wowCharacter = { id: "wow-priest", name: "Wow Priest", classId: "priest", categoryId: "wow", level: 20 };
|
||||
const romCharacter = { id: "rom-priest", name: "RoM Priest", classId: "rom-priest", categoryId: "rom", level: 20 };
|
||||
database.updateOnlineGroupMember(leader.id, wowCharacter, "healer");
|
||||
assert.throws(() => database.updateOnlineGroupMember(member.id, romCharacter, "healer"),
|
||||
(error) => error instanceof GameDatabaseError && error.code === "mixed_group_sources");
|
||||
assert.equal(database.onlineGroupState(member.id).group.members[1].character, null);
|
||||
|
||||
database.updateOnlineGroupMember(leader.id, romCharacter, "healer");
|
||||
assert.throws(() => database.updateOnlineGroupMember(member.id, wowCharacter, "healer"),
|
||||
(error) => error instanceof GameDatabaseError && error.code === "mixed_group_sources");
|
||||
database.updateOnlineGroupMember(member.id, { ...romCharacter, id: "rom-mage", name: "RoM Mage", classId: "rom-mage" }, "damage");
|
||||
assert.throws(() => database.startOnlineGroupActivity(leader.id, {
|
||||
type: "dungeon", contentId: "randomized-bite-sized-stormwind-stockade", fillWithAi: true, partySize: 5,
|
||||
}), (error) => error instanceof GameDatabaseError && error.code === "group_source_incompatible");
|
||||
const started = database.startOnlineGroupActivity(leader.id, {
|
||||
type: "dungeon", contentId: "wailing-caverns", fillWithAi: true, partySize: 5,
|
||||
});
|
||||
assert.equal(started.group.activity.partySize, 2);
|
||||
assert.equal(started.group.activity.fillWithAi, false);
|
||||
assert.deepEqual(started.group.activity.companionSpecializationIds, []);
|
||||
}));
|
||||
|
||||
test("launches players-only and AI-filled group activities only when every member is ready", () => withDatabase(async (database) => {
|
||||
const leaderAuth = await database.register("ReadyLeader", "group-pass");
|
||||
const memberAuth = await database.register("ReadyMember", "member-pass");
|
||||
@@ -285,10 +378,29 @@ test("launches players-only and AI-filled group activities only when every membe
|
||||
assert.equal(playersOnly.group.activity.fillWithAi, false);
|
||||
|
||||
const filled = database.startOnlineGroupActivity(leader.id, {
|
||||
type: "manastorm", contentId: "manastorm", fillWithAi: true, partySize: 5, startingLevel: 10,
|
||||
type: "manastorm", contentId: "manastorm", fillWithAi: true, includeBetaSpecs: true, partySize: 5, startingLevel: 10,
|
||||
});
|
||||
assert.equal(filled.group.activity.partySize, 5);
|
||||
assert.equal(filled.group.activity.startingLevel, 10);
|
||||
assert.equal(filled.group.activity.includeBetaSpecs, true);
|
||||
const randomized = database.startOnlineGroupActivity(leader.id, {
|
||||
type: "dungeon",
|
||||
contentId: "randomized-bite-sized-stormwind-stockade",
|
||||
fillWithAi: false,
|
||||
includeBetaSpecs: true,
|
||||
partySize: 2,
|
||||
});
|
||||
assert.equal(randomized.group.activity.partySize, 5);
|
||||
assert.equal(randomized.group.activity.fillWithAi, true);
|
||||
assert.equal(randomized.group.activity.includeBetaSpecs, false);
|
||||
assert.equal(randomized.group.activity.companionReadinessRevision, 0);
|
||||
assert.deepEqual(randomized.group.activity.companionSpecializationIds.sort(), [
|
||||
"hunter:marksmanship",
|
||||
"mage:fire",
|
||||
"priest:discipline",
|
||||
"rogue:assassination",
|
||||
"warrior:protection",
|
||||
]);
|
||||
assert.throws(() => database.startOnlineGroupActivity(member.id, {
|
||||
type: "dungeon", contentId: "wailing-caverns",
|
||||
}), (error) => error instanceof GameDatabaseError && error.status === 403);
|
||||
|
||||
@@ -72,7 +72,8 @@ export function createDungeonLeaderboardRepository(database, { ErrorType }) {
|
||||
if (!Array.isArray(input.killedBossIds) || input.killedBossIds.length > 100
|
||||
|| input.killedBossIds.some((id) => !difficulty.bossIds.includes(id))) fail("Provide bosses from this dungeon and difficulty.");
|
||||
const killedBossIds = [...new Set(input.killedBossIds)].sort();
|
||||
if (!killedBossIds.includes(difficulty.finalBossId)) fail("The final boss must be defeated before recording a run.");
|
||||
const finalBossIds = difficulty.finalBossIds ?? [difficulty.finalBossId];
|
||||
if (!finalBossIds.some((id) => killedBossIds.includes(id))) fail("The final boss must be defeated before recording a run.");
|
||||
if (!Array.isArray(input.party) || input.party.length < 1 || input.party.length > 5) fail("Provide the run's party.");
|
||||
const party = input.party.map((member) => {
|
||||
if (!member || typeof member !== "object" || typeof member.name !== "string" || !member.name.trim()
|
||||
@@ -84,7 +85,9 @@ export function createDungeonLeaderboardRepository(database, { ErrorType }) {
|
||||
role: member.role, npc: member.npc,
|
||||
...(member.secondaryClassId ? { secondaryClassId: member.secondaryClassId } : {}) };
|
||||
});
|
||||
const fullClear = difficulty.bossIds.every((id) => killedBossIds.includes(id));
|
||||
const fullClear = difficulty.finalBossIds
|
||||
? difficulty.finalBossIds.some((id) => killedBossIds.includes(id))
|
||||
: difficulty.bossIds.every((id) => killedBossIds.includes(id));
|
||||
const run = { id: input.id, dungeonId: dungeon.id, difficultyId, durationMs: input.durationMs,
|
||||
...(bossDifficulty > 0 ? { bossDifficulty } : {}),
|
||||
finishedAt: input.finishedAt, killedBossIds, party, fullClear };
|
||||
|
||||
@@ -58,6 +58,19 @@ test("validates final boss, dungeon/difficulty, duration and party without trust
|
||||
assert.throws(() => database.listDungeonLeaderboard({ dungeonId: "deadmines", clear: "bad" }), { status: 400 });
|
||||
assert.throws(() => database.listDungeonLeaderboard({ dungeonId: "deadmines", offset: -1 }), { status: 400 });
|
||||
}));
|
||||
test("accepts any rolled Stockades boss as a full clear for both bite-sized modes", () => withDatabase((database) => {
|
||||
for (const dungeonId of ["bite-sized-stormwind-stockade", "randomized-bite-sized-stormwind-stockade"]) {
|
||||
const dungeon = catalog.dungeons.find((entry) => entry.id === dungeonId);
|
||||
const difficulty = dungeon.difficulties[0];
|
||||
assert.equal(difficulty.finalBossIds.length, 5);
|
||||
for (const [index, bossId] of difficulty.finalBossIds.entries()) {
|
||||
const payload = run(`${dungeonId}-${index}-run`, 1000 + index, false, dungeon.id);
|
||||
payload.killedBossIds = [bossId];
|
||||
assert.equal(database.submitDungeonRun(null, payload).run.fullClear, true);
|
||||
}
|
||||
assert.equal(database.listDungeonLeaderboard({ dungeonId: dungeon.id, clear: "full" }).total, 5);
|
||||
}
|
||||
}));
|
||||
test("public HTTP leaderboards accept completion records and return results with rank while rejecting unfinished runs", async () => {
|
||||
const directory = mkdtempSync(path.join(os.tmpdir(), "healer-man-leaderboard-http-"));
|
||||
const { server } = createGameServer({ dataDir: directory, enableShelvedModes: false });
|
||||
|
||||
@@ -2,7 +2,12 @@ import { randomUUID } from "node:crypto";
|
||||
import availability from "../src/game/generated/dungeonAvailability.json" with { type: "json" };
|
||||
import policy from "../shared/dungeon-report-policy.json" with { type: "json" };
|
||||
|
||||
export const REPORT_DUNGEON_IDS = new Set(["forsaken-abbey", ...Object.keys(availability.enabled)]);
|
||||
export const REPORT_DUNGEON_IDS = new Set([
|
||||
"bite-sized-stormwind-stockade",
|
||||
"randomized-bite-sized-stormwind-stockade",
|
||||
"forsaken-abbey",
|
||||
...Object.keys(availability.enabled),
|
||||
]);
|
||||
|
||||
export function createDungeonReportRepository(database, { isGameMaster, ErrorType }) {
|
||||
database.exec(`
|
||||
@@ -27,6 +32,15 @@ export function createDungeonReportRepository(database, { isGameMaster, ErrorTyp
|
||||
reviewed_by TEXT NOT NULL,
|
||||
reviewed_at INTEGER NOT NULL
|
||||
) STRICT;
|
||||
CREATE TABLE IF NOT EXISTS randomized_creature_readiness (
|
||||
creature_key TEXT PRIMARY KEY,
|
||||
dungeon_id TEXT NOT NULL,
|
||||
creature_name TEXT NOT NULL,
|
||||
creature_kind TEXT NOT NULL CHECK (creature_kind IN ('mob', 'boss')),
|
||||
status TEXT NOT NULL CHECK (status IN ('ready', 'needs-testing', 'not-ready')),
|
||||
reviewed_by TEXT NOT NULL,
|
||||
reviewed_at INTEGER NOT NULL
|
||||
) STRICT;
|
||||
`);
|
||||
const columns = new Set(database.prepare("PRAGMA table_info(dungeon_reports)").all().map((column) => column.name));
|
||||
if (!columns.has("boss_key")) database.exec("ALTER TABLE dungeon_reports ADD COLUMN boss_key TEXT");
|
||||
@@ -80,6 +94,39 @@ export function createDungeonReportRepository(database, { isGameMaster, ErrorTyp
|
||||
}
|
||||
|
||||
return {
|
||||
listRandomizedCreatureReadiness() {
|
||||
return { reviews: database.prepare("SELECT * FROM randomized_creature_readiness ORDER BY creature_kind, creature_name").all().map((row) => ({
|
||||
creatureKey: row.creature_key, dungeonId: row.dungeon_id, creatureName: row.creature_name,
|
||||
creatureKind: row.creature_kind, status: row.status, reviewedBy: row.reviewed_by,
|
||||
reviewedAt: Number(row.reviewed_at),
|
||||
})) };
|
||||
},
|
||||
saveRandomizedCreatureReadiness(account, input) {
|
||||
if (!isGameMaster(account)) throw new ErrorType("Game Master access is required.", 403, "gm_required");
|
||||
if (input?.dungeonId !== "randomized-bite-sized-stormwind-stockade"
|
||||
|| typeof input?.creatureKey !== "string" || !/^(wow|rom):(mob|boss):[1-9][0-9]*$/.test(input.creatureKey)
|
||||
|| input.creatureKey.length > 256 || typeof input?.creatureName !== "string"
|
||||
|| !input.creatureName.trim() || input.creatureName.length > 128
|
||||
|| !["mob", "boss"].includes(input.creatureKind)) {
|
||||
throw new ErrorType("Choose a valid randomized creature.", 400, "invalid_creature");
|
||||
}
|
||||
if (!["ready", "needs-testing", "not-ready"].includes(input.status)) {
|
||||
throw new ErrorType("Choose a valid creature readiness status.", 400, "invalid_creature_status");
|
||||
}
|
||||
const review = {
|
||||
creatureKey: input.creatureKey, dungeonId: input.dungeonId,
|
||||
creatureName: input.creatureName.trim(), creatureKind: input.creatureKind,
|
||||
status: input.status, reviewedBy: account.username, reviewedAt: Date.now(),
|
||||
};
|
||||
database.prepare(`INSERT INTO randomized_creature_readiness VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(creature_key) DO UPDATE SET dungeon_id = excluded.dungeon_id,
|
||||
creature_name = excluded.creature_name, creature_kind = excluded.creature_kind,
|
||||
status = excluded.status, reviewed_by = excluded.reviewed_by,
|
||||
reviewed_at = excluded.reviewed_at`).run(
|
||||
review.creatureKey, review.dungeonId, review.creatureName, review.creatureKind,
|
||||
review.status, review.reviewedBy, review.reviewedAt);
|
||||
return review;
|
||||
},
|
||||
listBossTestingReviews() {
|
||||
return { reviews: database.prepare("SELECT * FROM boss_testing_reviews ORDER BY boss_key").all().map((row) => ({
|
||||
bossKey: row.boss_key, dungeonId: row.dungeon_id, bossName: row.boss_name,
|
||||
|
||||
@@ -26,7 +26,14 @@ test("validates comments and dungeon IDs and stamps new reports as Submitted", (
|
||||
assert.equal(report.status, "Submitted");
|
||||
assert.equal(report.submittedBy, "Reporter");
|
||||
assert.equal(database.submitDungeonReport(null, { dungeonId: "forsaken-abbey", comment: " Door is stuck\r\nAfter a wipe " }).comment, "Door is stuck\nAfter a wipe");
|
||||
assert.deepEqual(database.listDungeonReports().openCounts, { "forsaken-abbey": 1, "stormwind-stockade": 1 });
|
||||
assert.equal(database.submitDungeonReport(null, { dungeonId: "bite-sized-stormwind-stockade", comment: "Wall issue" }).status, "Submitted");
|
||||
assert.equal(database.submitDungeonReport(null, { dungeonId: "randomized-bite-sized-stormwind-stockade", comment: "Random mob issue" }).status, "Submitted");
|
||||
assert.deepEqual(database.listDungeonReports().openCounts, {
|
||||
"bite-sized-stormwind-stockade": 1,
|
||||
"forsaken-abbey": 1,
|
||||
"randomized-bite-sized-stormwind-stockade": 1,
|
||||
"stormwind-stockade": 1,
|
||||
});
|
||||
}));
|
||||
|
||||
test("enforces GM updates, counts all open statuses, supports reopening, and prevents stale changes", () => withDatabase(async (database) => {
|
||||
@@ -93,6 +100,29 @@ test("persists boss readiness and structured boss reports without changing ordin
|
||||
} finally { database?.close(); rmSync(directory, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test("persists randomized mob and boss readiness and requires a server-authenticated GM", () => withDatabase(async (database) => {
|
||||
const gm = (await database.register("Phenom", "readiness-admin")).account;
|
||||
const player = (await database.register("ReadinessPlayer", "readiness-player")).account;
|
||||
const creature = {
|
||||
creatureKey: "wow:mob:3868",
|
||||
dungeonId: "randomized-bite-sized-stormwind-stockade",
|
||||
creatureName: "Defias Convict",
|
||||
creatureKind: "mob",
|
||||
};
|
||||
assert.throws(() => database.saveRandomizedCreatureReadiness(player, { ...creature, status: "ready" }), { status: 403 });
|
||||
for (const status of ["ready", "needs-testing", "not-ready"]) {
|
||||
assert.equal(database.saveRandomizedCreatureReadiness(gm, { ...creature, status }).status, status);
|
||||
}
|
||||
assert.throws(() => database.saveRandomizedCreatureReadiness(gm, { ...creature, status: "Closed" }), { code: "invalid_creature_status" });
|
||||
assert.throws(() => database.saveRandomizedCreatureReadiness(gm, { ...creature, creatureKind: "pet", status: "ready" }), { code: "invalid_creature" });
|
||||
assert.deepEqual(database.listRandomizedCreatureReadiness().reviews[0], {
|
||||
...creature,
|
||||
status: "not-ready",
|
||||
reviewedBy: "Phenom",
|
||||
reviewedAt: database.listRandomizedCreatureReadiness().reviews[0].reviewedAt,
|
||||
});
|
||||
}));
|
||||
|
||||
test("serves public reports and GM-only status API with shelved modes disabled", async () => {
|
||||
const directory = mkdtempSync(path.join(os.tmpdir(), "healer-man-report-api-"));
|
||||
const runtime = createGameServer({ dataDir: directory, enableShelvedModes: false });
|
||||
@@ -110,6 +140,16 @@ test("serves public reports and GM-only status API with shelved modes disabled",
|
||||
const labels = await (await fetch(`${origin}/api/boss-testing/reviews`)).json();
|
||||
assert.equal(labels.reviews[0].status, "ready");
|
||||
const gm = await runtime.database.register("Phenom", "report-admin");
|
||||
const creature = { creatureKey: "wow:mob:3868", dungeonId: "randomized-bite-sized-stormwind-stockade",
|
||||
creatureName: "Defias Convict", creatureKind: "mob", status: "ready" };
|
||||
const playerDenied = await fetch(`${origin}/api/gm/randomized-creature-readiness`, { method: "PUT",
|
||||
headers: { "Content-Type": "application/json", Authorization: `Bearer ${player.token}` }, body: JSON.stringify(creature) });
|
||||
assert.equal(playerDenied.status, 403);
|
||||
const readinessSaved = await fetch(`${origin}/api/gm/randomized-creature-readiness`, { method: "PUT",
|
||||
headers: { "Content-Type": "application/json", Authorization: `Bearer ${gm.token}` }, body: JSON.stringify(creature) });
|
||||
assert.equal(readinessSaved.status, 200);
|
||||
const readiness = await (await fetch(`${origin}/api/randomized-creature-readiness`)).json();
|
||||
assert.equal(readiness.reviews[0].status, "ready");
|
||||
const created = await request("", "POST", { dungeonId: "stormwind-stockade", comment: "Issue via API" }, player.token);
|
||||
assert.equal(created.status, 201);
|
||||
const { report } = await created.json();
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
import { createReadStream, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { createBalanceLabJobService } from "./balance-lab-jobs.mjs";
|
||||
import { cloneDefaultCompanionCooldownPolicy } from "./companion-cooldown-policy.mjs";
|
||||
import {
|
||||
cloneDefaultCompanionSpecReadinessPolicy,
|
||||
normalizeCompanionSpecReadinessPolicy,
|
||||
} from "./companion-spec-readiness.mjs";
|
||||
import { GameDatabaseError } from "./database.mjs";
|
||||
|
||||
const API_ROOT = "/api/local/balance-lab";
|
||||
|
||||
function json(response, status, body) {
|
||||
const content = Buffer.from(JSON.stringify(body));
|
||||
response.writeHead(status, {
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
"Content-Length": content.length,
|
||||
"Cache-Control": "no-store",
|
||||
});
|
||||
response.end(content);
|
||||
}
|
||||
|
||||
async function readJson(request, limit) {
|
||||
const chunks = [];
|
||||
let size = 0;
|
||||
for await (const chunk of request) {
|
||||
size += chunk.length;
|
||||
if (size > limit) throw new GameDatabaseError("Request body is too large.", 413, "body_too_large");
|
||||
chunks.push(chunk);
|
||||
}
|
||||
if (size === 0) return {};
|
||||
try {
|
||||
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
||||
} catch {
|
||||
throw new GameDatabaseError("Request body must be valid JSON.", 400, "invalid_json");
|
||||
}
|
||||
}
|
||||
|
||||
function isLoopback(address) {
|
||||
return address === "127.0.0.1" || address === "::1" || address === "::ffff:127.0.0.1";
|
||||
}
|
||||
|
||||
export function createLocalBalanceLabApi(options = {}) {
|
||||
const projectRoot = path.resolve(options.projectRoot ?? fileURLToPath(new URL("..", import.meta.url)));
|
||||
const dataDir = path.resolve(options.dataDir ?? path.join(projectRoot, "artifacts", "balance-lab-gui-runtime"));
|
||||
const readinessPath = path.join(dataDir, "companion-spec-readiness.json");
|
||||
mkdirSync(dataDir, { recursive: true });
|
||||
let readiness = { revision: 0, updatedAt: null, updatedBy: null, config: cloneDefaultCompanionSpecReadinessPolicy() };
|
||||
try {
|
||||
const stored = JSON.parse(readFileSync(readinessPath, "utf8"));
|
||||
readiness = {
|
||||
revision: Number.isInteger(stored.revision) ? stored.revision : 0,
|
||||
updatedAt: Number.isFinite(stored.updatedAt) ? stored.updatedAt : null,
|
||||
updatedBy: typeof stored.updatedBy === "string" ? stored.updatedBy : null,
|
||||
config: normalizeCompanionSpecReadinessPolicy(stored.config),
|
||||
};
|
||||
} catch { /* missing or corrupt local policy falls back to shipped defaults */ }
|
||||
const persistReadiness = () => {
|
||||
const temporaryPath = `${readinessPath}.tmp`;
|
||||
writeFileSync(temporaryPath, `${JSON.stringify(readiness, null, 2)}\n`, "utf8");
|
||||
renameSync(temporaryPath, readinessPath);
|
||||
};
|
||||
const service = createBalanceLabJobService({
|
||||
dataDir,
|
||||
projectRoot,
|
||||
pythonExecutable: options.pythonExecutable,
|
||||
spawnProcess: options.spawnProcess,
|
||||
runtimeProbe: options.runtimeProbe,
|
||||
maxConcurrent: options.maxConcurrent,
|
||||
maxQueued: options.maxQueued,
|
||||
cooldownPolicyProvider: () => ({ config: cloneDefaultCompanionCooldownPolicy() }),
|
||||
readinessPolicyProvider: () => readiness,
|
||||
});
|
||||
const account = Object.freeze({ username: "Local GUI" });
|
||||
|
||||
return {
|
||||
service,
|
||||
async handle(request, response) {
|
||||
const url = new URL(request.url ?? "/", "http://127.0.0.1");
|
||||
if (url.pathname !== API_ROOT && !url.pathname.startsWith(`${API_ROOT}/`)) return false;
|
||||
if (!isLoopback(request.socket?.remoteAddress)) {
|
||||
json(response, 403, { error: "The local Balance Lab API accepts loopback requests only.", code: "loopback_required" });
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
if (url.pathname === `${API_ROOT}/companion-spec-readiness` && request.method === "GET") {
|
||||
json(response, 200, readiness);
|
||||
return true;
|
||||
}
|
||||
if (url.pathname === `${API_ROOT}/companion-spec-readiness` && request.method === "PUT") {
|
||||
const body = await readJson(request, 256 * 1024);
|
||||
if (!Number.isInteger(body.expectedRevision) || body.expectedRevision !== readiness.revision) {
|
||||
throw new GameDatabaseError("The companion readiness policy changed. Reload and try again.", 409, "revision_conflict");
|
||||
}
|
||||
let config;
|
||||
try {
|
||||
config = normalizeCompanionSpecReadinessPolicy(body.config);
|
||||
} catch (error) {
|
||||
throw new GameDatabaseError(
|
||||
error instanceof Error ? error.message : "Invalid companion readiness policy.",
|
||||
400,
|
||||
"invalid_companion_spec_readiness",
|
||||
);
|
||||
}
|
||||
readiness = {
|
||||
revision: readiness.revision + 1,
|
||||
updatedAt: Date.now(),
|
||||
updatedBy: "Local GUI",
|
||||
config,
|
||||
};
|
||||
persistReadiness();
|
||||
json(response, 200, readiness);
|
||||
return true;
|
||||
}
|
||||
if (url.pathname === `${API_ROOT}/jobs` && request.method === "GET") {
|
||||
json(response, 200, { jobs: service.list() });
|
||||
return true;
|
||||
}
|
||||
if (url.pathname === `${API_ROOT}/runtime` && request.method === "GET") {
|
||||
json(response, 200, { runtime: service.runtime() });
|
||||
return true;
|
||||
}
|
||||
if (url.pathname === `${API_ROOT}/jobs/batch` && request.method === "POST") {
|
||||
json(response, 202, { jobs: service.startMany(await readJson(request, 4 * 1024 * 1024), account) });
|
||||
return true;
|
||||
}
|
||||
if (url.pathname === `${API_ROOT}/jobs` && request.method === "POST") {
|
||||
json(response, 202, { job: service.start(await readJson(request, 32 * 1024), account) });
|
||||
return true;
|
||||
}
|
||||
const artifactMatch = new RegExp(`^${API_ROOT}/jobs/([0-9a-f-]{36})/artifacts/([A-Za-z0-9._-]+)$`).exec(url.pathname);
|
||||
if (artifactMatch && request.method === "GET") {
|
||||
const artifact = service.artifact(artifactMatch[1], artifactMatch[2]);
|
||||
response.writeHead(200, {
|
||||
"Content-Type": artifact.contentType,
|
||||
"Content-Length": artifact.size,
|
||||
"Cache-Control": "no-store",
|
||||
"Content-Disposition": `inline; filename="${artifactMatch[2]}"`,
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
});
|
||||
createReadStream(artifact.filePath).pipe(response);
|
||||
return true;
|
||||
}
|
||||
const jobMatch = new RegExp(`^${API_ROOT}/jobs/([0-9a-f-]{36})$`).exec(url.pathname);
|
||||
if (jobMatch && request.method === "GET") {
|
||||
json(response, 200, { job: service.get(jobMatch[1]) });
|
||||
return true;
|
||||
}
|
||||
json(response, 404, { error: "Local Balance Lab route not found.", code: "not_found" });
|
||||
} catch (error) {
|
||||
const known = error instanceof GameDatabaseError;
|
||||
json(response, known ? error.status : 500, {
|
||||
error: known ? error.message : "The local Balance Lab request failed.",
|
||||
code: known ? error.code : "internal_error",
|
||||
});
|
||||
}
|
||||
return true;
|
||||
},
|
||||
close() {
|
||||
service.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { createServer } from "node:http";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { PassThrough } from "node:stream";
|
||||
import test from "node:test";
|
||||
import { createLocalBalanceLabApi } from "./local-balance-lab-api.mjs";
|
||||
|
||||
test("serves loopback-only local Balance Lab jobs without account authentication", async () => {
|
||||
const directory = mkdtempSync(path.join(os.tmpdir(), "healer-man-local-balance-gui-"));
|
||||
const spawnProcess = (_executable, args) => {
|
||||
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: 1, bosses: 1, runs: 1, classSpecs: 5, classAbilities: 5 },
|
||||
}));
|
||||
child.stdout.write("Party 1/1: wow-party-001\n");
|
||||
child.stdout.end();
|
||||
child.stderr.end();
|
||||
child.emit("close", 0);
|
||||
});
|
||||
return child;
|
||||
};
|
||||
const api = createLocalBalanceLabApi({
|
||||
dataDir: directory,
|
||||
projectRoot: directory,
|
||||
spawnProcess,
|
||||
runtimeProbe: () => ({ available: true, executable: "python-test", version: "Python 3.test", error: null }),
|
||||
});
|
||||
const server = createServer((request, response) => {
|
||||
void api.handle(request, response).then((handled) => {
|
||||
if (!handled) response.writeHead(404).end();
|
||||
});
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
const origin = `http://127.0.0.1:${server.address().port}`;
|
||||
try {
|
||||
const runtime = await (await fetch(`${origin}/api/local/balance-lab/runtime`)).json();
|
||||
assert.equal(runtime.runtime.available, true);
|
||||
const startedResponse = await fetch(`${origin}/api/local/balance-lab/jobs`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
source: "wow", level: 20, partyConfigurations: 1, simulationsPerParty: 1,
|
||||
durationSeconds: 5, partySeed: 1, bossCategory: "all",
|
||||
bossKeys: ["stormwind-stockade/entry-1717"],
|
||||
}),
|
||||
});
|
||||
assert.equal(startedResponse.status, 202);
|
||||
const started = (await startedResponse.json()).job;
|
||||
assert.equal(started.requestedBy, "Local GUI");
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
const jobs = await (await fetch(`${origin}/api/local/balance-lab/jobs`)).json();
|
||||
assert.equal(jobs.jobs[0].status, "complete");
|
||||
const artifact = await fetch(`${origin}/api/local/balance-lab/jobs/${started.id}/artifacts/suite-report.json`);
|
||||
assert.equal(artifact.status, 200);
|
||||
assert.equal((await artifact.json()).summary.runs, 1);
|
||||
assert.equal((await fetch(`${origin}/api/not-local-balance-lab`)).status, 404);
|
||||
} finally {
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
api.close();
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("persists revisioned local companion readiness and recovers from a corrupt policy file", async () => {
|
||||
const directory = mkdtempSync(path.join(os.tmpdir(), "healer-man-local-readiness-"));
|
||||
const open = async () => {
|
||||
const api = createLocalBalanceLabApi({
|
||||
dataDir: directory,
|
||||
projectRoot: directory,
|
||||
runtimeProbe: () => ({ available: true, executable: "python-test", version: "Python 3.test", error: null }),
|
||||
});
|
||||
const server = createServer((request, response) => {
|
||||
void api.handle(request, response).then((handled) => {
|
||||
if (!handled) response.writeHead(404).end();
|
||||
});
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
return {
|
||||
api,
|
||||
server,
|
||||
origin: `http://127.0.0.1:${server.address().port}`,
|
||||
close: async () => {
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
api.close();
|
||||
},
|
||||
};
|
||||
};
|
||||
try {
|
||||
const first = await open();
|
||||
const initial = await (await fetch(`${first.origin}/api/local/balance-lab/companion-spec-readiness`)).json();
|
||||
const configured = {
|
||||
...initial.config,
|
||||
statuses: { ...initial.config.statuses, "paladin:retribution": "beta" },
|
||||
};
|
||||
const savedResponse = await fetch(`${first.origin}/api/local/balance-lab/companion-spec-readiness`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ expectedRevision: 0, config: configured }),
|
||||
});
|
||||
assert.equal(savedResponse.status, 200);
|
||||
assert.equal((await savedResponse.json()).revision, 1);
|
||||
const staleResponse = await fetch(`${first.origin}/api/local/balance-lab/companion-spec-readiness`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ expectedRevision: 0, config: configured }),
|
||||
});
|
||||
assert.equal(staleResponse.status, 409);
|
||||
const invalidResponse = await fetch(`${first.origin}/api/local/balance-lab/companion-spec-readiness`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
expectedRevision: 1,
|
||||
config: { ...configured, statuses: { ...configured.statuses, "warrior:protection": "needs-testing" } },
|
||||
}),
|
||||
});
|
||||
assert.equal(invalidResponse.status, 400);
|
||||
await first.close();
|
||||
|
||||
const second = await open();
|
||||
const restored = await (await fetch(`${second.origin}/api/local/balance-lab/companion-spec-readiness`)).json();
|
||||
assert.equal(restored.revision, 1);
|
||||
assert.equal(restored.config.statuses["paladin:retribution"], "beta");
|
||||
await second.close();
|
||||
|
||||
writeFileSync(path.join(directory, "companion-spec-readiness.json"), "{not-json");
|
||||
const third = await open();
|
||||
const fallback = await (await fetch(`${third.origin}/api/local/balance-lab/companion-spec-readiness`)).json();
|
||||
assert.equal(fallback.revision, 0);
|
||||
assert.equal(fallback.config.statuses["warrior:protection"], "ready");
|
||||
await third.close();
|
||||
} finally {
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -183,6 +183,8 @@ export function createGameServer(options = {}) {
|
||||
dataDir: options.dataDir ?? path.dirname(gameDatabase.databasePath),
|
||||
projectRoot: options.projectRoot,
|
||||
pythonExecutable: options.pythonExecutable,
|
||||
cooldownPolicyProvider: () => gameDatabase.getCompanionCooldownPolicy(),
|
||||
readinessPolicyProvider: () => gameDatabase.getCompanionSpecReadiness(),
|
||||
});
|
||||
const onlineSessions = createOnlineSessionManager();
|
||||
const arenaService = createArenaService({ database: gameDatabase, autoPump: enableShelvedModes });
|
||||
@@ -334,6 +336,18 @@ export function createGameServer(options = {}) {
|
||||
json(response, 200, { review: gameDatabase.saveBossTestingReview(account, await readJson(request, 8 * 1024)) });
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/api/randomized-creature-readiness" && request.method === "GET") {
|
||||
json(response, 200, gameDatabase.listRandomizedCreatureReadiness());
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/api/gm/randomized-creature-readiness" && request.method === "PUT") {
|
||||
const account = gameDatabase.authenticate(bearerToken(request));
|
||||
json(response, 200, { review: gameDatabase.saveRandomizedCreatureReadiness(
|
||||
account,
|
||||
await readJson(request, 8 * 1024),
|
||||
) });
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/api/dungeon-reports" && request.method === "GET") {
|
||||
json(response, 200, gameDatabase.listDungeonReports({
|
||||
status: url.searchParams.get("status"),
|
||||
@@ -498,6 +512,62 @@ export function createGameServer(options = {}) {
|
||||
json(response, 200, result);
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/api/companion-cooldown-policy" && request.method === "GET") {
|
||||
const current = gameDatabase.getCompanionCooldownPolicy();
|
||||
json(response, 200, {
|
||||
revision: current.revision,
|
||||
updatedAt: current.updatedAt,
|
||||
config: current.config,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/api/gm/companion-cooldown-policy" && 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, gameDatabase.getCompanionCooldownPolicy());
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/api/gm/companion-cooldown-policy" && request.method === "PUT") {
|
||||
const account = gameDatabase.authenticate(bearerToken(request));
|
||||
if (!gameDatabase.isGameMaster(account)) {
|
||||
throw new GameDatabaseError("Game Master access is required.", 403, "gm_required");
|
||||
}
|
||||
const body = await readJson(request, bodyLimit);
|
||||
const result = gameDatabase.putCompanionCooldownPolicy(account, body.expectedRevision, body.config);
|
||||
console.info(`Companion cooldown policy revision ${result.revision} published by ${account.username}`);
|
||||
json(response, 200, result);
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/api/companion-spec-readiness" && request.method === "GET") {
|
||||
const current = gameDatabase.getCompanionSpecReadiness();
|
||||
json(response, 200, {
|
||||
revision: current.revision,
|
||||
updatedAt: current.updatedAt,
|
||||
config: current.config,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/api/gm/companion-spec-readiness" && 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, gameDatabase.getCompanionSpecReadiness());
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/api/gm/companion-spec-readiness" && request.method === "PUT") {
|
||||
const account = gameDatabase.authenticate(bearerToken(request));
|
||||
if (!gameDatabase.isGameMaster(account)) {
|
||||
throw new GameDatabaseError("Game Master access is required.", 403, "gm_required");
|
||||
}
|
||||
const body = await readJson(request, bodyLimit);
|
||||
const result = gameDatabase.putCompanionSpecReadiness(account, body.expectedRevision, body.config);
|
||||
console.info(`Companion readiness revision ${result.revision} published by ${account.username}`);
|
||||
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)) {
|
||||
@@ -514,6 +584,16 @@ export function createGameServer(options = {}) {
|
||||
json(response, 200, { runtime: balanceLabJobs.runtime() });
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/api/gm/balance-lab/jobs/batch" && request.method === "POST") {
|
||||
const account = gameDatabase.authenticate(bearerToken(request));
|
||||
if (!gameDatabase.isGameMaster(account)) {
|
||||
throw new GameDatabaseError("Game Master access is required.", 403, "gm_required");
|
||||
}
|
||||
const jobs = balanceLabJobs.startMany(await readJson(request, 4 * 1024 * 1024), account);
|
||||
console.info(`Balance Lab batch of ${jobs.length} jobs queued by ${account.username}`);
|
||||
json(response, 202, { jobs });
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/api/gm/balance-lab/jobs" && request.method === "POST") {
|
||||
const account = gameDatabase.authenticate(bearerToken(request));
|
||||
if (!gameDatabase.isGameMaster(account)) {
|
||||
|
||||
+60
-1
@@ -87,6 +87,20 @@ test("serves the game and authenticated cloud-save API", async () => {
|
||||
headers: { Authorization: `Bearer ${auth.token}` },
|
||||
});
|
||||
assert.equal(deniedRead.status, 403);
|
||||
const publicCooldownPolicy = await (await fetch(`${origin}/api/companion-cooldown-policy`)).json();
|
||||
assert.equal(publicCooldownPolicy.revision, 0);
|
||||
assert.equal(publicCooldownPolicy.config.generic.defensiveHealthPercent, 55);
|
||||
const deniedCooldownPolicy = await fetch(`${origin}/api/gm/companion-cooldown-policy`, {
|
||||
headers: { Authorization: `Bearer ${auth.token}` },
|
||||
});
|
||||
assert.equal(deniedCooldownPolicy.status, 403);
|
||||
const publicReadiness = await (await fetch(`${origin}/api/companion-spec-readiness`)).json();
|
||||
assert.equal(publicReadiness.revision, 0);
|
||||
assert.equal(publicReadiness.config.statuses["warrior:protection"], "ready");
|
||||
const deniedReadiness = await fetch(`${origin}/api/gm/companion-spec-readiness`, {
|
||||
headers: { Authorization: `Bearer ${auth.token}` },
|
||||
});
|
||||
assert.equal(deniedReadiness.status, 403);
|
||||
|
||||
const memberRegistration = await fetch(`${origin}/api/auth/register`, {
|
||||
method: "POST",
|
||||
@@ -237,6 +251,42 @@ 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 publishedCooldownPolicy = await fetch(`${origin}/api/gm/companion-cooldown-policy`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json", Authorization: `Bearer ${gmAuth.token}` },
|
||||
body: JSON.stringify({
|
||||
expectedRevision: publicCooldownPolicy.revision,
|
||||
config: {
|
||||
...publicCooldownPolicy.config,
|
||||
generic: { ...publicCooldownPolicy.config.generic, defensiveHealthPercent: 61 },
|
||||
},
|
||||
}),
|
||||
});
|
||||
assert.equal(publishedCooldownPolicy.status, 200);
|
||||
const publishedCooldownPolicyPayload = await publishedCooldownPolicy.json();
|
||||
assert.equal(publishedCooldownPolicyPayload.revision, 1);
|
||||
assert.equal(publishedCooldownPolicyPayload.config.generic.defensiveHealthPercent, 61);
|
||||
const playerCooldownPolicy = await (await fetch(`${origin}/api/companion-cooldown-policy`)).json();
|
||||
assert.equal(playerCooldownPolicy.config.generic.defensiveHealthPercent, 61);
|
||||
const publishedReadiness = await fetch(`${origin}/api/gm/companion-spec-readiness`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json", Authorization: `Bearer ${gmAuth.token}` },
|
||||
body: JSON.stringify({
|
||||
expectedRevision: publicReadiness.revision,
|
||||
config: {
|
||||
...publicReadiness.config,
|
||||
statuses: { ...publicReadiness.config.statuses, "paladin:retribution": "beta" },
|
||||
},
|
||||
}),
|
||||
});
|
||||
assert.equal(publishedReadiness.status, 200);
|
||||
assert.equal((await publishedReadiness.json()).config.statuses["paladin:retribution"], "beta");
|
||||
const staleReadiness = await fetch(`${origin}/api/gm/companion-spec-readiness`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json", Authorization: `Bearer ${gmAuth.token}` },
|
||||
body: JSON.stringify({ expectedRevision: 0, config: publicReadiness.config }),
|
||||
});
|
||||
assert.equal(staleReadiness.status, 409);
|
||||
const deniedBalanceLab = await fetch(`${origin}/api/gm/balance-lab/jobs`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Authorization: `Bearer ${auth.token}` },
|
||||
@@ -252,7 +302,9 @@ test("serves the game and authenticated cloud-save API", async () => {
|
||||
headers: { Authorization: `Bearer ${gmAuth.token}` },
|
||||
});
|
||||
assert.equal(balanceLabRuntime.status, 200);
|
||||
assert.equal((await balanceLabRuntime.json()).runtime.available, true);
|
||||
const balanceLabRuntimePayload = await balanceLabRuntime.json();
|
||||
assert.equal(balanceLabRuntimePayload.runtime.available, true);
|
||||
assert.equal(balanceLabRuntimePayload.runtime.maxConcurrent, 4);
|
||||
const invalidBalanceLab = await fetch(`${origin}/api/gm/balance-lab/jobs`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Authorization: `Bearer ${gmAuth.token}` },
|
||||
@@ -260,6 +312,13 @@ test("serves the game and authenticated cloud-save API", async () => {
|
||||
});
|
||||
assert.equal(invalidBalanceLab.status, 400);
|
||||
assert.equal((await invalidBalanceLab.json()).code, "invalid_balance_lab_job");
|
||||
const invalidBalanceLabBatch = await fetch(`${origin}/api/gm/balance-lab/jobs/batch`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Authorization: `Bearer ${gmAuth.token}` },
|
||||
body: JSON.stringify({ specs: [{ source: "wow", level: 10 }, { source: "wow", level: 81 }] }),
|
||||
});
|
||||
assert.equal(invalidBalanceLabBatch.status, 400);
|
||||
assert.equal((await invalidBalanceLabBatch.json()).code, "invalid_balance_lab_job");
|
||||
const current = await (await fetch(`${origin}/api/manastorm-config`)).json();
|
||||
const published = await fetch(`${origin}/api/gm/manastorm-config`, {
|
||||
method: "PUT",
|
||||
|
||||
@@ -5,13 +5,16 @@
|
||||
"core:equipment",
|
||||
"core:character-basic",
|
||||
"core:runtime",
|
||||
"dungeon:wailing-caverns"
|
||||
"dungeon:wailing-caverns",
|
||||
"creatures:wailing-caverns"
|
||||
],
|
||||
"hiddenContentPackIds": [
|
||||
"core:arenas"
|
||||
],
|
||||
"hiddenContentPackPrefixes": [
|
||||
"manastorm:"
|
||||
"manastorm:",
|
||||
"activity:",
|
||||
"creatures:"
|
||||
],
|
||||
"maxBootstrapStoredBytes": 83886080
|
||||
}
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
loadGameRuntime,
|
||||
preloadSharedGameplayResources,
|
||||
} from "./ui/gameplayResources";
|
||||
import { useCompanionCooldownPolicyStore } from "./game/companionCooldownPolicyStore";
|
||||
import { useCompanionSpecReadinessStore } from "./game/companionSpecReadinessStore";
|
||||
|
||||
const GameRuntime = lazy(loadGameRuntime);
|
||||
const CharacterCreateScreen = lazy(() => import("./ui/CharacterCreateScreen").then((module) => ({ default: module.CharacterCreateScreen })));
|
||||
@@ -72,6 +74,11 @@ export default function App() {
|
||||
const phase = useShellStore((state) => state.phase);
|
||||
const session = useShellStore((state) => state.session);
|
||||
const PreloadedGameRuntime = getLoadedGameRuntime();
|
||||
const refreshCooldownPolicy = useCompanionCooldownPolicyStore((state) => state.refresh);
|
||||
const refreshSpecReadiness = useCompanionSpecReadinessStore((state) => state.refresh);
|
||||
|
||||
useEffect(() => { void refreshCooldownPolicy(session); }, [refreshCooldownPolicy, session]);
|
||||
useEffect(() => { void refreshSpecReadiness(session); }, [refreshSpecReadiness, session]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!SHELVED_MODES_ENABLED && isShelvedShellPhase(phase)) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { OnlineGroupActivity } from "./onlineGroupTypes";
|
||||
import { enterOnlineGroupActivity } from "./OnlineGroupCoordinator";
|
||||
import { enterOnlineGroupActivity, onlineGroupActivityNeedsEntry } from "./OnlineGroupCoordinator";
|
||||
import { SHELVED_MODES_ENABLED } from "./releaseFeatures";
|
||||
|
||||
const normalReleaseIt = SHELVED_MODES_ENABLED ? it.skip : it;
|
||||
@@ -26,6 +26,8 @@ const MANASTORM_ACTIVITY: OnlineGroupActivity = {
|
||||
|
||||
function entryDependencies(enterDungeon: () => boolean) {
|
||||
return {
|
||||
prepareDungeonContent: vi.fn(async () => undefined),
|
||||
prepareRandomizedDungeonContent: vi.fn(async () => undefined),
|
||||
installDungeonRuntime: vi.fn(async () => undefined),
|
||||
enterDungeon: vi.fn(enterDungeon),
|
||||
installManastormRuntime: vi.fn(async () => undefined),
|
||||
@@ -34,14 +36,48 @@ function entryDependencies(enterDungeon: () => boolean) {
|
||||
}
|
||||
|
||||
describe("online group activity entry", () => {
|
||||
it("recognizes a newly published activity while the previous activity is still active", () => {
|
||||
const next = { ...DUNGEON_ACTIVITY, id: "activity-dungeon-2", startedAt: 10_000 };
|
||||
expect(onlineGroupActivityNeedsEntry(next, DUNGEON_ACTIVITY, [DUNGEON_ACTIVITY.id], 10_100)).toBe(true);
|
||||
expect(onlineGroupActivityNeedsEntry(DUNGEON_ACTIVITY, DUNGEON_ACTIVITY, [], 10_100)).toBe(false);
|
||||
});
|
||||
|
||||
it("enters with the leader's source variant and boss difficulty as a fresh shared run", async () => {
|
||||
const dependencies = entryDependencies(() => true);
|
||||
await enterOnlineGroupActivity({ ...DUNGEON_ACTIVITY, difficultyId: "heroic", bossDifficulty: 9 },
|
||||
"damage", vi.fn(), dependencies);
|
||||
expect(dependencies.prepareDungeonContent).toHaveBeenCalledWith("deadmines");
|
||||
expect(dependencies.prepareDungeonContent.mock.invocationCallOrder[0])
|
||||
.toBeLessThan(dependencies.enterDungeon.mock.invocationCallOrder[0]);
|
||||
expect(dependencies.enterDungeon).toHaveBeenCalledWith("deadmines", "damage", {
|
||||
difficultyId: "heroic", bossDifficulty: 9, freshRun: true,
|
||||
difficultyId: "heroic", bossDifficulty: 9, freshRun: true, runId: DUNGEON_ACTIVITY.id,
|
||||
});
|
||||
});
|
||||
it("downloads the seeded randomized creature packs before shared entry", async () => {
|
||||
const dependencies = entryDependencies(() => true);
|
||||
const activity = {
|
||||
...DUNGEON_ACTIVITY,
|
||||
id: "shared-randomized-seed",
|
||||
contentId: "randomized-bite-sized-stormwind-stockade",
|
||||
};
|
||||
|
||||
await enterOnlineGroupActivity(activity, "healer", vi.fn(), dependencies);
|
||||
|
||||
expect(dependencies.prepareDungeonContent).toHaveBeenCalledWith(activity.contentId);
|
||||
expect(dependencies.prepareRandomizedDungeonContent).toHaveBeenCalledWith(activity.id);
|
||||
expect(dependencies.prepareRandomizedDungeonContent.mock.invocationCallOrder[0])
|
||||
.toBeLessThan(dependencies.enterDungeon.mock.invocationCallOrder[0]);
|
||||
});
|
||||
it("does not enter a shared dungeon when its content download fails", async () => {
|
||||
const dependencies = entryDependencies(() => true);
|
||||
dependencies.prepareDungeonContent.mockRejectedValueOnce(new Error("Download failed"));
|
||||
const consumeActivity = vi.fn();
|
||||
|
||||
await expect(enterOnlineGroupActivity(DUNGEON_ACTIVITY, "healer", consumeActivity, dependencies))
|
||||
.rejects.toThrow("Download failed");
|
||||
expect(dependencies.enterDungeon).not.toHaveBeenCalled();
|
||||
expect(consumeActivity).not.toHaveBeenCalled();
|
||||
});
|
||||
it("leaves a dungeon activity unconsumed when dungeon entry fails", async () => {
|
||||
const consumeActivity = vi.fn();
|
||||
const dependencies = entryDependencies(() => false);
|
||||
@@ -54,7 +90,9 @@ describe("online group activity entry", () => {
|
||||
)).resolves.toBe(false);
|
||||
|
||||
expect(dependencies.installDungeonRuntime).toHaveBeenCalledOnce();
|
||||
expect(dependencies.enterDungeon).toHaveBeenCalledWith("deadmines", "healer");
|
||||
expect(dependencies.enterDungeon).toHaveBeenCalledWith("deadmines", "healer", {
|
||||
difficultyId: undefined, bossDifficulty: undefined, freshRun: true, runId: DUNGEON_ACTIVITY.id,
|
||||
});
|
||||
expect(consumeActivity).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
@@ -12,11 +12,15 @@ import { useContentManagerSnapshot } from "../content/useContentManager";
|
||||
import { SHELVED_MODES_ENABLED } from "./releaseFeatures";
|
||||
import type { DungeonEntrySettings } from "./dungeonShellRuntimeBridge";
|
||||
import { installShelvedOnlineGroupRuntime } from "@shelvedOnlineGroupRuntime";
|
||||
import { dungeonContentPackId, RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID } from "../game/dungeonContentSource";
|
||||
import { dungeonContentIsUsable, installContentPack } from "../content/contentManager";
|
||||
|
||||
const GROUP_POLL_MS = 2_500;
|
||||
const ACTIVITY_JOIN_WINDOW_MS = 10 * 60 * 1000;
|
||||
|
||||
interface OnlineGroupActivityEntryDependencies {
|
||||
readonly prepareDungeonContent: (dungeonId: string) => Promise<void>;
|
||||
readonly prepareRandomizedDungeonContent: (seed: string) => Promise<void>;
|
||||
readonly installDungeonRuntime: () => Promise<void>;
|
||||
readonly enterDungeon: (dungeonId: string, role?: PartyRole, settings?: DungeonEntrySettings) => boolean;
|
||||
readonly installManastormRuntime: () => Promise<void>;
|
||||
@@ -24,6 +28,15 @@ interface OnlineGroupActivityEntryDependencies {
|
||||
}
|
||||
|
||||
const DEFAULT_ACTIVITY_ENTRY_DEPENDENCIES: OnlineGroupActivityEntryDependencies = {
|
||||
prepareDungeonContent: async (dungeonId) => {
|
||||
if (dungeonContentIsUsable(dungeonId)) return;
|
||||
await installContentPack(dungeonContentPackId(dungeonId));
|
||||
if (!dungeonContentIsUsable(dungeonId)) throw new Error("The group dungeon download could not be verified.");
|
||||
},
|
||||
prepareRandomizedDungeonContent: async (seed) => {
|
||||
const { ensureRandomizedBiteSizedRunContent } = await import("./randomizedBiteSizedContent");
|
||||
await ensureRandomizedBiteSizedRunContent(seed);
|
||||
},
|
||||
installDungeonRuntime: async () => {
|
||||
const { installDungeonShellRuntime } = await import("./dungeonShellRuntime");
|
||||
installDungeonShellRuntime();
|
||||
@@ -47,12 +60,17 @@ export async function enterOnlineGroupActivity(
|
||||
): Promise<boolean> {
|
||||
let entered: boolean;
|
||||
if (activity.type === "dungeon") {
|
||||
await dependencies.prepareDungeonContent(activity.contentId);
|
||||
if (activity.contentId === RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID) {
|
||||
await dependencies.prepareRandomizedDungeonContent(activity.id);
|
||||
}
|
||||
await dependencies.installDungeonRuntime();
|
||||
entered = activity.bossDifficulty === undefined && activity.difficultyId === undefined
|
||||
? dependencies.enterDungeon(activity.contentId, role)
|
||||
: dependencies.enterDungeon(activity.contentId, role, {
|
||||
difficultyId: activity.difficultyId, bossDifficulty: activity.bossDifficulty, freshRun: true,
|
||||
});
|
||||
entered = dependencies.enterDungeon(activity.contentId, role, {
|
||||
difficultyId: activity.difficultyId,
|
||||
bossDifficulty: activity.bossDifficulty,
|
||||
freshRun: true,
|
||||
runId: activity.id,
|
||||
});
|
||||
} else {
|
||||
if (!SHELVED_MODES_ENABLED) return false;
|
||||
await dependencies.installManastormRuntime();
|
||||
@@ -65,11 +83,22 @@ export async function enterOnlineGroupActivity(
|
||||
return entered;
|
||||
}
|
||||
|
||||
export function onlineGroupActivityNeedsEntry(
|
||||
activity: OnlineGroupActivity,
|
||||
activeActivity: OnlineGroupActivity | null,
|
||||
consumedActivityIds: readonly string[],
|
||||
now = Date.now(),
|
||||
): boolean {
|
||||
return activity.id !== activeActivity?.id
|
||||
&& !consumedActivityIds.includes(activity.id)
|
||||
&& now - activity.startedAt <= ACTIVITY_JOIN_WINDOW_MS;
|
||||
}
|
||||
|
||||
export function OnlineGroupCoordinator() {
|
||||
const session = useShellStore((state) => state.session);
|
||||
const character = useShellStore(selectedCharacter);
|
||||
const phase = useShellStore((state) => state.phase);
|
||||
const snapshot = useOnlineGroupStore((state) => state.snapshot);
|
||||
const activeActivity = useOnlineGroupStore((state) => state.activeActivity);
|
||||
const consumedActivityIds = useOnlineGroupStore((state) => state.consumedActivityIds);
|
||||
const refresh = useOnlineGroupStore((state) => state.refresh);
|
||||
const updateMember = useOnlineGroupStore((state) => state.updateMember);
|
||||
@@ -112,8 +141,11 @@ export function OnlineGroupCoordinator() {
|
||||
|
||||
useEffect(() => {
|
||||
const activity = snapshot.group?.activity;
|
||||
if (!session || !activity || phase === "game" || consumedActivityIds.includes(activity.id)) return;
|
||||
if (Date.now() - activity.startedAt > ACTIVITY_JOIN_WINDOW_MS) return;
|
||||
if (!session || !activity || !onlineGroupActivityNeedsEntry(
|
||||
activity,
|
||||
activeActivity,
|
||||
consumedActivityIds,
|
||||
)) return;
|
||||
const member = snapshot.group?.members.find((candidate) => candidate.accountId === session.ownerId);
|
||||
if (!member?.character || !member.role) return;
|
||||
if (pendingActivityIds.current.has(activity.id)) return;
|
||||
@@ -125,7 +157,8 @@ export function OnlineGroupCoordinator() {
|
||||
.finally(() => {
|
||||
pendingActivityIds.current.delete(activity.id);
|
||||
});
|
||||
}, [consumeActivity, consumedActivityIds, installedRevision, phase, session?.ownerId, snapshot.group?.activity?.id]);
|
||||
}, [activeActivity?.id, consumeActivity, consumedActivityIds, installedRevision, session?.ownerId,
|
||||
snapshot.group?.activity?.id]);
|
||||
|
||||
return null;
|
||||
}
|
||||
+103
-24
@@ -4,12 +4,38 @@ 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 type BalanceLabExecutionTarget = "server" | "local";
|
||||
|
||||
function balanceLabApiPath(target: BalanceLabExecutionTarget, suffix: string): string {
|
||||
const root = target === "local" ? "/api/local/balance-lab" : "/api/gm/balance-lab";
|
||||
return `${root}${suffix}`;
|
||||
}
|
||||
|
||||
async function requestBalanceLabJson<T>(
|
||||
pathname: string,
|
||||
init: RequestInit,
|
||||
token: string,
|
||||
target: BalanceLabExecutionTarget,
|
||||
): Promise<T> {
|
||||
if (target === "server") return requestOnlineJson<T>(pathname, init, token);
|
||||
const headers = new Headers(init.headers);
|
||||
headers.set("Accept", "application/json");
|
||||
if (init.body !== undefined) headers.set("Content-Type", "application/json");
|
||||
const response = await fetch(pathname, { ...init, headers });
|
||||
const body = await response.json().catch(() => ({})) as T & { error?: string };
|
||||
if (!response.ok) throw new Error(body.error ?? `Local Balance Lab request failed (${response.status}).`);
|
||||
return body;
|
||||
}
|
||||
|
||||
export interface BalanceLabRuntimeStatus {
|
||||
readonly available: boolean;
|
||||
readonly executable: string;
|
||||
readonly version: string | null;
|
||||
readonly error: string | null;
|
||||
readonly maxConcurrent: number;
|
||||
readonly running: number;
|
||||
readonly queued: number;
|
||||
readonly maxQueued: number;
|
||||
}
|
||||
|
||||
export interface BalanceLabJobSpec {
|
||||
@@ -23,6 +49,8 @@ export interface BalanceLabJobSpec {
|
||||
readonly partySeed: number;
|
||||
readonly bossCategory: BalanceLabBossCategory;
|
||||
readonly bossKeys: readonly string[];
|
||||
readonly seatSweep?: boolean;
|
||||
readonly includeBetaSpecs?: boolean;
|
||||
}
|
||||
|
||||
export interface BalanceLabJob {
|
||||
@@ -52,6 +80,8 @@ export interface BalanceLabReportSummary {
|
||||
readonly runs: number;
|
||||
readonly classSpecs: number;
|
||||
readonly classAbilities: number;
|
||||
readonly npcClassSpecs?: number;
|
||||
readonly npcClassAbilities?: number;
|
||||
readonly bossPressureMedianDtps: number;
|
||||
}
|
||||
|
||||
@@ -78,6 +108,13 @@ export interface BalanceLabBossResult {
|
||||
export interface BalanceLabClassSpecResult {
|
||||
readonly source: string;
|
||||
readonly buildId: string;
|
||||
readonly characterName?: string | null;
|
||||
readonly talentBuildId?: string | null;
|
||||
readonly talentBuildName?: string | null;
|
||||
readonly talentBuildSource?: string | null;
|
||||
readonly talentBuildSourcePointCount?: number | null;
|
||||
readonly talentBuildSourcePointsRetained?: number | null;
|
||||
readonly talentBuildFallbackPointCount?: number | null;
|
||||
readonly classId: string;
|
||||
readonly className: string;
|
||||
readonly specialization: string;
|
||||
@@ -89,6 +126,7 @@ export interface BalanceLabClassSpecResult {
|
||||
readonly hps: number;
|
||||
readonly hpsReceived?: number;
|
||||
readonly dtps: number;
|
||||
readonly gcdUptimePercent?: number;
|
||||
readonly totalDamage: number;
|
||||
readonly totalDamageTaken: number;
|
||||
readonly totalHealing: number;
|
||||
@@ -124,6 +162,10 @@ export interface BalanceLabClassAbilityResult {
|
||||
readonly icon: string;
|
||||
readonly categories: string;
|
||||
readonly cooldownSeconds: number | null;
|
||||
readonly rotationProfileId?: string | null;
|
||||
readonly rotationPriority?: number | null;
|
||||
readonly expectedUsage?: "profile" | "conditional" | "available";
|
||||
readonly rotationDecisions?: number;
|
||||
readonly partyConfigurations: number;
|
||||
readonly bosses: number;
|
||||
readonly runs: number;
|
||||
@@ -143,7 +185,7 @@ export interface BalanceLabClassAbilityResult {
|
||||
readonly effectiveHealing: number;
|
||||
readonly overheal: number;
|
||||
readonly absorbs: number;
|
||||
readonly status: "unused" | "damage" | "support" | "used";
|
||||
readonly status: "unused" | "missing-profile-action" | "damage" | "support" | "used";
|
||||
readonly unusedAvailable: boolean;
|
||||
}
|
||||
|
||||
@@ -156,53 +198,90 @@ export interface BalanceLabSuiteReport {
|
||||
readonly bosses: readonly BalanceLabBossResult[];
|
||||
readonly classSpecs: readonly BalanceLabClassSpecResult[];
|
||||
readonly classAbilities?: readonly BalanceLabClassAbilityResult[];
|
||||
readonly npcClassSpecs?: readonly BalanceLabClassSpecResult[];
|
||||
readonly npcClassAbilities?: 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",
|
||||
export async function listBalanceLabJobs(
|
||||
token: string,
|
||||
target: BalanceLabExecutionTarget = "server",
|
||||
): Promise<readonly BalanceLabJob[]> {
|
||||
return (await requestBalanceLabJson<{ jobs: BalanceLabJob[] }>(
|
||||
balanceLabApiPath(target, "/jobs"),
|
||||
{},
|
||||
token,
|
||||
token, target,
|
||||
)).jobs;
|
||||
}
|
||||
|
||||
export async function getBalanceLabRuntime(token: string): Promise<BalanceLabRuntimeStatus> {
|
||||
return (await requestOnlineJson<{ runtime: BalanceLabRuntimeStatus }>(
|
||||
"/api/gm/balance-lab/runtime",
|
||||
export async function getBalanceLabRuntime(
|
||||
token: string,
|
||||
target: BalanceLabExecutionTarget = "server",
|
||||
): Promise<BalanceLabRuntimeStatus> {
|
||||
return (await requestBalanceLabJson<{ runtime: BalanceLabRuntimeStatus }>(
|
||||
balanceLabApiPath(target, "/runtime"),
|
||||
{},
|
||||
token,
|
||||
token, target,
|
||||
)).runtime;
|
||||
}
|
||||
|
||||
export async function startBalanceLabJob(spec: BalanceLabJobSpec, token: string): Promise<BalanceLabJob> {
|
||||
return (await requestOnlineJson<{ job: BalanceLabJob }>(
|
||||
"/api/gm/balance-lab/jobs",
|
||||
export async function startBalanceLabJob(
|
||||
spec: BalanceLabJobSpec,
|
||||
token: string,
|
||||
target: BalanceLabExecutionTarget = "server",
|
||||
): Promise<BalanceLabJob> {
|
||||
return (await requestBalanceLabJson<{ job: BalanceLabJob }>(
|
||||
balanceLabApiPath(target, "/jobs"),
|
||||
{ method: "POST", body: JSON.stringify(spec) },
|
||||
token,
|
||||
token, target,
|
||||
)).job;
|
||||
}
|
||||
|
||||
export async function getBalanceLabJob(id: string, token: string): Promise<BalanceLabJob> {
|
||||
return (await requestOnlineJson<{ job: BalanceLabJob }>(
|
||||
`/api/gm/balance-lab/jobs/${encodeURIComponent(id)}`,
|
||||
export async function startBalanceLabJobs(
|
||||
specs: readonly BalanceLabJobSpec[],
|
||||
token: string,
|
||||
target: BalanceLabExecutionTarget = "server",
|
||||
): Promise<readonly BalanceLabJob[]> {
|
||||
return (await requestBalanceLabJson<{ jobs: BalanceLabJob[] }>(
|
||||
balanceLabApiPath(target, "/jobs/batch"),
|
||||
{ method: "POST", body: JSON.stringify({ specs }) },
|
||||
token, target,
|
||||
)).jobs;
|
||||
}
|
||||
|
||||
export async function getBalanceLabJob(
|
||||
id: string,
|
||||
token: string,
|
||||
target: BalanceLabExecutionTarget = "server",
|
||||
): Promise<BalanceLabJob> {
|
||||
return (await requestBalanceLabJson<{ job: BalanceLabJob }>(
|
||||
balanceLabApiPath(target, `/jobs/${encodeURIComponent(id)}`),
|
||||
{},
|
||||
token,
|
||||
token, target,
|
||||
)).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`,
|
||||
export async function getBalanceLabReport(
|
||||
id: string,
|
||||
token: string,
|
||||
target: BalanceLabExecutionTarget = "server",
|
||||
): Promise<BalanceLabSuiteReport> {
|
||||
return requestBalanceLabJson<BalanceLabSuiteReport>(
|
||||
balanceLabApiPath(target, `/jobs/${encodeURIComponent(id)}/artifacts/suite-report.json`),
|
||||
{},
|
||||
token,
|
||||
token, target,
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchBalanceLabArtifact(id: string, name: string, token: string): Promise<Blob> {
|
||||
export async function fetchBalanceLabArtifact(
|
||||
id: string,
|
||||
name: string,
|
||||
token: string,
|
||||
target: BalanceLabExecutionTarget = "server",
|
||||
): Promise<Blob> {
|
||||
const response = await fetch(
|
||||
`${onlineApiOrigin()}/api/gm/balance-lab/jobs/${encodeURIComponent(id)}/artifacts/${encodeURIComponent(name)}`,
|
||||
{ headers: { Authorization: `Bearer ${token}` } },
|
||||
`${target === "local" ? "" : onlineApiOrigin()}${balanceLabApiPath(target, `/jobs/${encodeURIComponent(id)}/artifacts/${encodeURIComponent(name)}`)}`,
|
||||
{ headers: token ? { Authorization: `Bearer ${token}` } : undefined },
|
||||
);
|
||||
if (!response.ok) {
|
||||
const body = await response.json().catch(() => ({})) as { error?: string };
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { balanceLabBracketLevels } from "./balanceLabSchedules";
|
||||
|
||||
describe("Balance Lab level schedules", () => {
|
||||
it("includes level 1 and every ten-level bracket through 80", () => {
|
||||
expect(balanceLabBracketLevels(10)).toEqual([1, 10, 20, 30, 40, 50, 60, 70, 80]);
|
||||
});
|
||||
|
||||
it("supports every five levels without duplicating level 1", () => {
|
||||
expect(balanceLabBracketLevels(5)).toEqual([
|
||||
1, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55, 60, 65, 70, 75, 80,
|
||||
]);
|
||||
});
|
||||
|
||||
it("supports a job for every level from 1 through 80", () => {
|
||||
const levels = balanceLabBracketLevels(1);
|
||||
expect(levels).toHaveLength(80);
|
||||
expect(levels[0]).toBe(1);
|
||||
expect(levels.at(-1)).toBe(80);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
export type BalanceLabBracketStep = 1 | 5 | 10;
|
||||
|
||||
export function balanceLabBracketLevels(step: BalanceLabBracketStep): readonly number[] {
|
||||
if (step === 1) return Array.from({ length: 80 }, (_, index) => index + 1);
|
||||
return [1, ...Array.from({ length: Math.floor(80 / step) }, (_, index) => (index + 1) * step)];
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
BITE_SIZED_STOCKADES_DUNGEON_ID,
|
||||
RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID,
|
||||
} from "../game/dungeonContentSource";
|
||||
import type { DungeonRunProgress } from "../game/dungeonRunTypes";
|
||||
import { canStartAnotherBiteSizedDungeon, startAnotherBiteSizedDungeon } from "./biteSizedDungeonRuntime";
|
||||
import { useOnlineGroupStore } from "./onlineGroupStore";
|
||||
import { useOnlineSessionStore } from "./onlineSessionStore";
|
||||
import { useShellStore } from "./shellStore";
|
||||
|
||||
const progress: DungeonRunProgress = {
|
||||
id: "completed-bite-sized-run",
|
||||
dungeonId: BITE_SIZED_STOCKADES_DUNGEON_ID,
|
||||
difficultyId: "normal",
|
||||
bossDifficulty: 1,
|
||||
startedAt: 1_000,
|
||||
finishedAt: 9_000,
|
||||
durationMs: 8_000,
|
||||
timed: true,
|
||||
killedBossIds: ["creature-84027"],
|
||||
party: [{ name: "Tester", classId: "priest", level: 17, role: "healer", npc: false }],
|
||||
};
|
||||
|
||||
const originalShell = useShellStore.getState();
|
||||
const originalGroup = useOnlineGroupStore.getState();
|
||||
const originalOnline = useOnlineSessionStore.getState();
|
||||
|
||||
beforeEach(() => {
|
||||
useOnlineGroupStore.getState().reset();
|
||||
useOnlineSessionStore.getState().reset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
useShellStore.setState(originalShell, true);
|
||||
useOnlineGroupStore.setState(originalGroup, true);
|
||||
useOnlineSessionStore.setState(originalOnline, true);
|
||||
});
|
||||
|
||||
describe("bite-sized replay", () => {
|
||||
it("starts a fresh local run and treats the local player as party leader", async () => {
|
||||
const enterDungeon = vi.fn(() => true);
|
||||
const saveCheckpoint = vi.fn();
|
||||
useShellStore.setState({
|
||||
session: { kind: "offline", ownerId: "local-owner", displayName: "Tester" },
|
||||
activeDungeonRole: "healer",
|
||||
enterDungeon,
|
||||
saveActiveDungeonRunCheckpoint: saveCheckpoint,
|
||||
});
|
||||
|
||||
expect(canStartAnotherBiteSizedDungeon()).toBe(true);
|
||||
await expect(startAnotherBiteSizedDungeon(progress)).resolves.toBe(true);
|
||||
expect(saveCheckpoint).toHaveBeenCalledWith(null);
|
||||
expect(enterDungeon).toHaveBeenCalledWith(BITE_SIZED_STOCKADES_DUNGEON_ID, "healer", {
|
||||
difficultyId: "normal",
|
||||
bossDifficulty: 1,
|
||||
freshRun: true,
|
||||
runId: expect.any(String),
|
||||
});
|
||||
});
|
||||
|
||||
it("replays randomized mode with a new seed and prepares that seed's creature assets", async () => {
|
||||
const enterDungeon = vi.fn(() => true);
|
||||
const saveCheckpoint = vi.fn();
|
||||
const prepareRandomizedContent = vi.fn(async () => undefined);
|
||||
useShellStore.setState({
|
||||
session: { kind: "offline", ownerId: "local-owner", displayName: "Tester" },
|
||||
activeDungeonRole: "healer",
|
||||
enterDungeon,
|
||||
saveActiveDungeonRunCheckpoint: saveCheckpoint,
|
||||
});
|
||||
const randomizedProgress = { ...progress, dungeonId: RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID };
|
||||
|
||||
await expect(startAnotherBiteSizedDungeon(randomizedProgress, {
|
||||
random: () => 0,
|
||||
createRunId: () => "next-randomized-seed",
|
||||
contentIsUsable: () => true,
|
||||
prepareRandomizedContent,
|
||||
})).resolves.toBe(true);
|
||||
|
||||
expect(prepareRandomizedContent).toHaveBeenCalledWith("next-randomized-seed");
|
||||
expect(saveCheckpoint).toHaveBeenCalledWith(null);
|
||||
expect(enterDungeon).toHaveBeenCalledWith(RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID, "healer", {
|
||||
difficultyId: "normal",
|
||||
bossDifficulty: 1,
|
||||
freshRun: true,
|
||||
runId: "next-randomized-seed",
|
||||
});
|
||||
});
|
||||
|
||||
it("lets only the online group leader publish the next shared activity", async () => {
|
||||
const startActivity = vi.fn(async () => true);
|
||||
const saveCheckpoint = vi.fn();
|
||||
const activity = {
|
||||
id: "shared-bite-sized-run",
|
||||
type: "dungeon" as const,
|
||||
contentId: BITE_SIZED_STOCKADES_DUNGEON_ID,
|
||||
fillWithAi: true,
|
||||
partySize: 4,
|
||||
startingLevel: null,
|
||||
startedAt: 1_000,
|
||||
};
|
||||
useShellStore.setState({
|
||||
session: { kind: "account", ownerId: "leader", displayName: "Leader", accessToken: "test-token" },
|
||||
saveActiveDungeonRunCheckpoint: saveCheckpoint,
|
||||
});
|
||||
useOnlineGroupStore.setState({
|
||||
startActivity,
|
||||
snapshot: { invitations: [], group: { id: "group", leaderAccountId: "leader", members: [], activity,
|
||||
activityRevision: 1, createdAt: 1, updatedAt: 1 } },
|
||||
});
|
||||
|
||||
expect(canStartAnotherBiteSizedDungeon()).toBe(true);
|
||||
await expect(startAnotherBiteSizedDungeon(progress)).resolves.toBe(true);
|
||||
expect(startActivity).toHaveBeenCalledWith(expect.objectContaining({ ownerId: "leader" }), {
|
||||
type: "dungeon",
|
||||
contentId: BITE_SIZED_STOCKADES_DUNGEON_ID,
|
||||
difficultyId: "normal",
|
||||
bossDifficulty: 1,
|
||||
partySize: 4,
|
||||
});
|
||||
expect(saveCheckpoint).toHaveBeenCalledWith(null);
|
||||
|
||||
useShellStore.setState({ session: { kind: "account", ownerId: "follower", displayName: "Follower", accessToken: "test-token" } });
|
||||
expect(canStartAnotherBiteSizedDungeon()).toBe(false);
|
||||
});
|
||||
|
||||
it("publishes another randomized activity instead of crossing an online group into regular Bite-Sized", async () => {
|
||||
const startActivity = vi.fn(async () => true);
|
||||
useShellStore.setState({
|
||||
session: { kind: "account", ownerId: "leader", displayName: "Leader", accessToken: "test-token" },
|
||||
saveActiveDungeonRunCheckpoint: vi.fn(),
|
||||
});
|
||||
useOnlineGroupStore.setState({
|
||||
startActivity,
|
||||
snapshot: { invitations: [], group: { id: "group", leaderAccountId: "leader", members: [], activity: null,
|
||||
activityRevision: 2, createdAt: 1, updatedAt: 2 } },
|
||||
});
|
||||
|
||||
await expect(startAnotherBiteSizedDungeon(
|
||||
{ ...progress, dungeonId: RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID },
|
||||
{ random: () => 0, createRunId: () => "unused-local-seed", contentIsUsable: () => true,
|
||||
prepareRandomizedContent: vi.fn() },
|
||||
)).resolves.toBe(true);
|
||||
|
||||
expect(startActivity).toHaveBeenCalledWith(expect.objectContaining({ ownerId: "leader" }), {
|
||||
type: "dungeon",
|
||||
contentId: RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID,
|
||||
difficultyId: "normal",
|
||||
bossDifficulty: 1,
|
||||
partySize: 5,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
import { dungeonContentIsUsable } from "../content/contentManager";
|
||||
import { randomDungeonByCategory, randomRandomizedBiteSizedDungeon } from "../game/dungeonCatalog";
|
||||
import { isRandomizedBiteSizedDungeonId } from "../game/dungeonContentSource";
|
||||
import { dungeonDefinitionById } from "../game/dungeonRegistry";
|
||||
import type { DungeonRunProgress } from "../game/dungeonRunTypes";
|
||||
import { useOnlineGroupStore } from "./onlineGroupStore";
|
||||
import { useOnlineSessionStore } from "./onlineSessionStore";
|
||||
import { ensureRandomizedBiteSizedRunContent } from "./randomizedBiteSizedContent";
|
||||
import { useShellStore } from "./shellStore";
|
||||
|
||||
export function canStartAnotherBiteSizedDungeon(): boolean {
|
||||
const shell = useShellStore.getState();
|
||||
const group = useOnlineGroupStore.getState().snapshot.group;
|
||||
if (group) return group.leaderAccountId === shell.session?.ownerId;
|
||||
const online = useOnlineSessionStore.getState().snapshot;
|
||||
return !online || online.authority;
|
||||
}
|
||||
|
||||
interface BiteSizedReplayDependencies {
|
||||
readonly random: () => number;
|
||||
readonly createRunId: () => string;
|
||||
readonly contentIsUsable: (dungeonId: string) => boolean;
|
||||
readonly prepareRandomizedContent: (seed: string) => Promise<void>;
|
||||
}
|
||||
|
||||
const DEFAULT_REPLAY_DEPENDENCIES: BiteSizedReplayDependencies = {
|
||||
random: Math.random,
|
||||
createRunId: () => globalThis.crypto.randomUUID(),
|
||||
contentIsUsable: dungeonContentIsUsable,
|
||||
prepareRandomizedContent: ensureRandomizedBiteSizedRunContent,
|
||||
};
|
||||
|
||||
/** Starts a fresh random run locally, or publishes one shared activity for the whole online party. */
|
||||
export async function startAnotherBiteSizedDungeon(
|
||||
progress: DungeonRunProgress,
|
||||
dependencies: BiteSizedReplayDependencies = DEFAULT_REPLAY_DEPENDENCIES,
|
||||
): Promise<boolean> {
|
||||
const shell = useShellStore.getState();
|
||||
const groupState = useOnlineGroupStore.getState();
|
||||
const group = groupState.snapshot.group;
|
||||
const online = useOnlineSessionStore.getState().snapshot;
|
||||
if (!canStartAnotherBiteSizedDungeon()) {
|
||||
shell.setNotice("Only the party leader can start the next Bite-Sized dungeon.");
|
||||
return false;
|
||||
}
|
||||
|
||||
const randomized = isRandomizedBiteSizedDungeonId(progress.dungeonId);
|
||||
const next = randomized
|
||||
? randomRandomizedBiteSizedDungeon(progress.dungeonId, dependencies.random,
|
||||
(dungeon) => dependencies.contentIsUsable(dungeon.id))
|
||||
: randomDungeonByCategory("bite-sized", progress.dungeonId, dependencies.random,
|
||||
(dungeon) => dependencies.contentIsUsable(dungeon.id));
|
||||
if (!next) {
|
||||
shell.setNotice(randomized
|
||||
? "No Randomized Bite-Sized location is ready to enter."
|
||||
: "No installed Bite-Sized dungeon is ready to enter.");
|
||||
return false;
|
||||
}
|
||||
const definition = dungeonDefinitionById(next.id);
|
||||
const difficultyId = definition?.difficultyVariants.some((difficulty) => difficulty.id === progress.difficultyId)
|
||||
? progress.difficultyId
|
||||
: undefined;
|
||||
|
||||
if (group) {
|
||||
const session = shell.session;
|
||||
if (!session || session.kind !== "account" || !session.accessToken) {
|
||||
shell.setNotice("Reconnect before starting the next shared dungeon.");
|
||||
return false;
|
||||
}
|
||||
const started = await groupState.startActivity(session, {
|
||||
type: "dungeon",
|
||||
contentId: next.id,
|
||||
difficultyId,
|
||||
bossDifficulty: progress.bossDifficulty,
|
||||
partySize: group.activity?.partySize ?? 5,
|
||||
});
|
||||
if (!started) {
|
||||
shell.setNotice(useOnlineGroupStore.getState().error || "The next shared dungeon could not be started.");
|
||||
return false;
|
||||
}
|
||||
shell.saveActiveDungeonRunCheckpoint(null);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (online) {
|
||||
shell.setNotice("Reconnect to the party before starting the next shared dungeon.");
|
||||
return false;
|
||||
}
|
||||
const runId = dependencies.createRunId();
|
||||
if (randomized) {
|
||||
try {
|
||||
await dependencies.prepareRandomizedContent(runId);
|
||||
} catch (caught) {
|
||||
shell.setNotice(caught instanceof Error ? caught.message : "The randomized creature download failed.");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
shell.saveActiveDungeonRunCheckpoint(null);
|
||||
const entered = shell.enterDungeon(next.id, shell.activeDungeonRole, {
|
||||
difficultyId,
|
||||
bossDifficulty: progress.bossDifficulty,
|
||||
freshRun: true,
|
||||
runId,
|
||||
});
|
||||
return entered;
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { localDungeonLeaderboard, localDungeonRunPlacement, saveLocalDungeonRun, submitDungeonRun } from "./dungeonLeaderboardClient";
|
||||
import type { DungeonRunRecord } from "../game/dungeonRunTypes";
|
||||
import catalog from "../game/generated/dungeonLeaderboardCatalog.generated.json";
|
||||
import { leaderboardFinalBossIds } from "../game/dungeonLeaderboardRules";
|
||||
|
||||
const dungeon = catalog.dungeons.find((entry) => entry.id === "deadmines")!;
|
||||
const record = (id: string, durationMs: number, fullClear = true): DungeonRunRecord => ({
|
||||
@@ -41,6 +42,24 @@ describe("offline dungeon leaderboard", () => {
|
||||
expect(page.entries).toHaveLength(7);
|
||||
expect(localDungeonRunPlacement(record("offline-24", 3400))).toEqual({ finalRank: 27, finalTotal: 27, fullRank: 26, fullTotal: 26 });
|
||||
});
|
||||
it("treats any rolled bite-sized Stockades boss as a complete run", () => {
|
||||
const biteSized = catalog.dungeons.find((entry) => entry.id === "bite-sized-stormwind-stockade")!;
|
||||
const difficulty = biteSized.difficulties[0];
|
||||
const rolledBossId = leaderboardFinalBossIds(difficulty).at(-1)!;
|
||||
const run: DungeonRunRecord = {
|
||||
id: "bite-sized-local-run",
|
||||
dungeonId: biteSized.id,
|
||||
difficultyId: difficulty.id,
|
||||
durationMs: 4321,
|
||||
finishedAt: 100000,
|
||||
party: [{ name: "Tester", classId: "priest", level: 20, role: "healer", npc: false }],
|
||||
killedBossIds: [rolledBossId],
|
||||
fullClear: true,
|
||||
};
|
||||
saveLocalDungeonRun(run);
|
||||
expect(localDungeonLeaderboard(biteSized.id, difficulty.id, "full").entries)
|
||||
.toEqual([expect.objectContaining({ id: run.id, killedBossIds: [rolledBossId], fullClear: true })]);
|
||||
});
|
||||
it("retains the local time after a failed upload and uses the same run id when retried", async () => {
|
||||
const run = record("offline-retry-test", 4321);
|
||||
saveLocalDungeonRun(run);
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { PlayerSession } from "./types";
|
||||
import catalog from "../game/generated/dungeonLeaderboardCatalog.generated.json";
|
||||
import { normalizeDungeonRunProgress, type DungeonClearFilter, type DungeonLeaderboard,
|
||||
type DungeonRunPlacement, type DungeonRunRecord } from "../game/dungeonRunTypes";
|
||||
import { isLeaderboardFullClear, leaderboardFinalBossIds } from "../game/dungeonLeaderboardRules";
|
||||
|
||||
const STORAGE_KEY = "healer-man:dungeon-runs:v1";
|
||||
const listeners = new Set<() => void>();
|
||||
@@ -23,11 +24,12 @@ function localRuns(): DungeonRunRecord[] {
|
||||
const progress = normalizeDungeonRunProgress({ ...value, startedAt: value.finishedAt - value.durationMs, timed: true });
|
||||
const dungeon = catalog.dungeons.find((entry) => entry.id === progress?.dungeonId);
|
||||
const difficulty = dungeon?.difficulties.find((entry) => entry.id === progress?.difficultyId);
|
||||
const finalBossIds = difficulty ? leaderboardFinalBossIds(difficulty) : [];
|
||||
if (!progress || progress.finishedAt === null || progress.durationMs === null || !dungeon
|
||||
|| !difficulty || !progress.killedBossIds.includes(difficulty.finalBossId)
|
||||
|| !difficulty || !finalBossIds.some((id) => progress.killedBossIds.includes(id))
|
||||
|| progress.killedBossIds.some((id) => !difficulty.bossIds.includes(id))) return [];
|
||||
return [{ ...value, ...progress, durationMs: progress.durationMs, finishedAt: progress.finishedAt,
|
||||
fullClear: difficulty.bossIds.every((id) => progress.killedBossIds.includes(id)) }];
|
||||
fullClear: isLeaderboardFullClear(difficulty, progress.killedBossIds) }];
|
||||
});
|
||||
} catch { /* In-memory results remain available if storage is disabled. */ }
|
||||
return memory;
|
||||
|
||||
@@ -68,6 +68,7 @@ function enterDungeon(dungeonId: string, requestedRole?: PartyRole, settings?: D
|
||||
encounterSnapshots: resume?.encounters,
|
||||
runProgress: resume?.run,
|
||||
legacyResume: Boolean(resume && resume.run?.bossDifficulty === undefined),
|
||||
runId: settings?.runId,
|
||||
})) {
|
||||
useShellStore.setState({
|
||||
notice: dungeon.availabilityReason ?? "That dungeon failed its latest validation gate.",
|
||||
|
||||
@@ -8,6 +8,7 @@ export interface DungeonEntrySettings {
|
||||
readonly difficultyId?: string;
|
||||
readonly bossDifficulty?: number;
|
||||
readonly freshRun?: boolean;
|
||||
readonly runId?: string;
|
||||
}
|
||||
|
||||
let installedRuntime: DungeonShellRuntime | null = null;
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
currentLoginInputSnapshot,
|
||||
DEFAULT_LOGIN_KEY_ID,
|
||||
nextLoginKeyId,
|
||||
useLoginInputStore,
|
||||
} from "./loginInputStore";
|
||||
|
||||
describe("controller login input", () => {
|
||||
beforeEach(() => useLoginInputStore.getState().reset());
|
||||
|
||||
it("starts on the username field and the first alphabet key", () => {
|
||||
expect(useLoginInputStore.getState()).toMatchObject({
|
||||
activeField: "username",
|
||||
selectedKeyId: DEFAULT_LOGIN_KEY_ID,
|
||||
keyboardMode: "lower",
|
||||
});
|
||||
});
|
||||
|
||||
it("moves geometrically between character and wide action keys", () => {
|
||||
expect(nextLoginKeyId("lower", "char-3-1", "down")).toBe("control-field");
|
||||
expect(nextLoginKeyId("lower", "control-field", "down")).toBe("action-login");
|
||||
expect(nextLoginKeyId("lower", "action-login", "up")).toBe("control-field");
|
||||
});
|
||||
|
||||
it("types valid usernames, switches fields, and keeps passwords private in snapshots", () => {
|
||||
const input = useLoginInputStore.getState();
|
||||
input.pressKey("char-1-0");
|
||||
input.pressKey("char-1-1");
|
||||
input.pressKey("char-2-9");
|
||||
expect(useLoginInputStore.getState().username).toBe("qw_");
|
||||
|
||||
input.pressKey("control-done");
|
||||
expect(useLoginInputStore.getState().activeField).toBe("password");
|
||||
input.pressKey("char-1-0");
|
||||
input.pressKey("control-space");
|
||||
expect(useLoginInputStore.getState().password).toBe("q ");
|
||||
expect(currentLoginInputSnapshot()).toMatchObject({ username: "qw_", passwordLength: 2 });
|
||||
expect(currentLoginInputSnapshot()).not.toHaveProperty("password");
|
||||
});
|
||||
|
||||
it("cycles case and symbols while preserving the selected key position", () => {
|
||||
const input = useLoginInputStore.getState();
|
||||
input.pressKey("control-mode");
|
||||
expect(useLoginInputStore.getState()).toMatchObject({ keyboardMode: "upper", selectedKeyId: "control-mode" });
|
||||
input.pressKey("control-mode");
|
||||
expect(useLoginInputStore.getState().keyboardMode).toBe("symbols");
|
||||
input.pressKey("char-1-1");
|
||||
expect(useLoginInputStore.getState().username).toBe("");
|
||||
input.pressKey("control-field");
|
||||
input.pressKey("char-1-1");
|
||||
expect(useLoginInputStore.getState().password).toBe("@");
|
||||
});
|
||||
|
||||
it("turns the three final-row keys into consumable requests", () => {
|
||||
const input = useLoginInputStore.getState();
|
||||
input.pressKey("action-login");
|
||||
const request = useLoginInputStore.getState().request;
|
||||
expect(request).toMatchObject({ action: "login" });
|
||||
input.consumeRequest(request!.id);
|
||||
expect(useLoginInputStore.getState().request).toBeNull();
|
||||
input.pressKey("action-offline");
|
||||
expect(useLoginInputStore.getState().request?.action).toBe("offline");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,224 @@
|
||||
import { create } from "zustand";
|
||||
|
||||
export type LoginField = "username" | "password";
|
||||
export type LoginKeyboardMode = "lower" | "upper" | "symbols";
|
||||
export type LoginRequestAction = "login" | "create" | "offline";
|
||||
export type LoginKeyboardDirection = "up" | "down" | "left" | "right";
|
||||
|
||||
export interface LoginKeyDefinition {
|
||||
id: string;
|
||||
label: string;
|
||||
ariaLabel: string;
|
||||
kind: "character" | "mode" | "field" | "space" | "backspace" | "clear" | "done" | LoginRequestAction;
|
||||
value?: string;
|
||||
span?: number;
|
||||
}
|
||||
|
||||
export interface LoginInputSnapshot {
|
||||
username: string;
|
||||
passwordLength: number;
|
||||
activeField: LoginField;
|
||||
selectedKeyId: string;
|
||||
keyboardMode: LoginKeyboardMode;
|
||||
busy: LoginRequestAction | null;
|
||||
}
|
||||
|
||||
interface LoginRequest {
|
||||
id: number;
|
||||
action: LoginRequestAction;
|
||||
}
|
||||
|
||||
interface LoginInputState {
|
||||
username: string;
|
||||
password: string;
|
||||
activeField: LoginField;
|
||||
selectedKeyId: string;
|
||||
keyboardMode: LoginKeyboardMode;
|
||||
busy: LoginRequestAction | null;
|
||||
request: LoginRequest | null;
|
||||
requestId: number;
|
||||
setUsername: (value: string) => void;
|
||||
setPassword: (value: string) => void;
|
||||
setActiveField: (field: LoginField) => void;
|
||||
selectKey: (keyId: string) => void;
|
||||
moveSelection: (direction: LoginKeyboardDirection) => void;
|
||||
pressKey: (keyId: string) => void;
|
||||
pressSelected: () => void;
|
||||
consumeRequest: (id: number) => void;
|
||||
setBusy: (busy: LoginRequestAction | null) => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
export const DEFAULT_LOGIN_KEY_ID = "char-1-0";
|
||||
|
||||
const characterLayouts: Record<LoginKeyboardMode, readonly (readonly string[])[]> = {
|
||||
lower: [
|
||||
["1", "2", "3", "4", "5", "6", "7", "8", "9", "0"],
|
||||
["q", "w", "e", "r", "t", "y", "u", "i", "o", "p"],
|
||||
["a", "s", "d", "f", "g", "h", "j", "k", "l", "_"],
|
||||
["z", "x", "c", "v", "b", "n", "m", ".", "-"],
|
||||
],
|
||||
upper: [
|
||||
["1", "2", "3", "4", "5", "6", "7", "8", "9", "0"],
|
||||
["Q", "W", "E", "R", "T", "Y", "U", "I", "O", "P"],
|
||||
["A", "S", "D", "F", "G", "H", "J", "K", "L", "_"],
|
||||
["Z", "X", "C", "V", "B", "N", "M", ".", "-"],
|
||||
],
|
||||
symbols: [
|
||||
["1", "2", "3", "4", "5", "6", "7", "8", "9", "0"],
|
||||
["!", "@", "#", "$", "%", "^", "&", "*", "(", ")"],
|
||||
["{", "}", "[", "]", "<", ">", "=", "+", "/", "\\"],
|
||||
["~", "`", ";", ":", "'", "\"", ",", ".", "?"],
|
||||
],
|
||||
};
|
||||
|
||||
function characterKey(value: string, row: number, column: number): LoginKeyDefinition {
|
||||
return {
|
||||
id: `char-${row}-${column}`,
|
||||
label: value,
|
||||
ariaLabel: value === " " ? "Space" : value,
|
||||
kind: "character",
|
||||
value,
|
||||
};
|
||||
}
|
||||
|
||||
export function loginKeyboardRows(mode: LoginKeyboardMode): readonly (readonly LoginKeyDefinition[])[] {
|
||||
const rows = characterLayouts[mode];
|
||||
return [
|
||||
rows[0].map((value, column) => characterKey(value, 0, column)),
|
||||
rows[1].map((value, column) => characterKey(value, 1, column)),
|
||||
rows[2].map((value, column) => characterKey(value, 2, column)),
|
||||
[
|
||||
{ id: "control-mode", label: mode === "lower" ? "⇧" : mode === "upper" ? "#+=" : "abc", ariaLabel: "Change keyboard mode", kind: "mode" },
|
||||
...rows[3].map((value, column) => characterKey(value, 3, column + 1)),
|
||||
],
|
||||
[
|
||||
{ id: "control-field", label: "Field", ariaLabel: "Switch login field", kind: "field", span: 2 },
|
||||
{ id: "control-space", label: "Space", ariaLabel: "Space", kind: "space", span: 3 },
|
||||
{ id: "control-backspace", label: "⌫", ariaLabel: "Backspace", kind: "backspace", span: 2 },
|
||||
{ id: "control-clear", label: "Clear", ariaLabel: "Clear active field", kind: "clear", span: 2 },
|
||||
{ id: "control-done", label: "Done", ariaLabel: "Finish active field", kind: "done" },
|
||||
],
|
||||
[
|
||||
{ id: "action-login", label: "Enter world", ariaLabel: "Log in and enter world", kind: "login", span: 4 },
|
||||
{ id: "action-create", label: "Create", ariaLabel: "Create account", kind: "create", span: 3 },
|
||||
{ id: "action-offline", label: "Offline", ariaLabel: "Play offline", kind: "offline", span: 3 },
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
function keyPosition(rows: readonly (readonly LoginKeyDefinition[])[], keyId: string) {
|
||||
for (let rowIndex = 0; rowIndex < rows.length; rowIndex += 1) {
|
||||
let column = 0;
|
||||
for (let keyIndex = 0; keyIndex < rows[rowIndex].length; keyIndex += 1) {
|
||||
const key = rows[rowIndex][keyIndex];
|
||||
const span = key.span ?? 1;
|
||||
if (key.id === keyId) return { rowIndex, keyIndex, center: column + span / 2 };
|
||||
column += span;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function nextLoginKeyId(
|
||||
mode: LoginKeyboardMode,
|
||||
selectedKeyId: string,
|
||||
direction: LoginKeyboardDirection,
|
||||
): string {
|
||||
const rows = loginKeyboardRows(mode);
|
||||
const position = keyPosition(rows, selectedKeyId) ?? keyPosition(rows, DEFAULT_LOGIN_KEY_ID)!;
|
||||
const row = rows[position.rowIndex];
|
||||
if (direction === "left" || direction === "right") {
|
||||
const delta = direction === "left" ? -1 : 1;
|
||||
return row[(position.keyIndex + delta + row.length) % row.length].id;
|
||||
}
|
||||
const rowDelta = direction === "up" ? -1 : 1;
|
||||
const targetRowIndex = (position.rowIndex + rowDelta + rows.length) % rows.length;
|
||||
const targetRow = rows[targetRowIndex];
|
||||
let column = 0;
|
||||
return targetRow.reduce((closest, key) => {
|
||||
const center = column + (key.span ?? 1) / 2;
|
||||
column += key.span ?? 1;
|
||||
return Math.abs(center - position.center) < Math.abs(closest.center - position.center)
|
||||
? { id: key.id, center }
|
||||
: closest;
|
||||
}, { id: targetRow[0].id, center: (targetRow[0].span ?? 1) / 2 }).id;
|
||||
}
|
||||
|
||||
function findLoginKey(mode: LoginKeyboardMode, keyId: string): LoginKeyDefinition | null {
|
||||
return loginKeyboardRows(mode).flat().find((key) => key.id === keyId) ?? null;
|
||||
}
|
||||
|
||||
function nextMode(mode: LoginKeyboardMode): LoginKeyboardMode {
|
||||
if (mode === "lower") return "upper";
|
||||
if (mode === "upper") return "symbols";
|
||||
return "lower";
|
||||
}
|
||||
|
||||
const initialState = {
|
||||
username: "",
|
||||
password: "",
|
||||
activeField: "username" as LoginField,
|
||||
selectedKeyId: DEFAULT_LOGIN_KEY_ID,
|
||||
keyboardMode: "lower" as LoginKeyboardMode,
|
||||
busy: null,
|
||||
request: null,
|
||||
requestId: 0,
|
||||
};
|
||||
|
||||
export const useLoginInputStore = create<LoginInputState>((set, get) => ({
|
||||
...initialState,
|
||||
setUsername: (username) => set({ username: username.slice(0, 20), activeField: "username" }),
|
||||
setPassword: (password) => set({ password: password.slice(0, 128), activeField: "password" }),
|
||||
setActiveField: (activeField) => set({ activeField }),
|
||||
selectKey: (selectedKeyId) => {
|
||||
if (findLoginKey(get().keyboardMode, selectedKeyId)) set({ selectedKeyId });
|
||||
},
|
||||
moveSelection: (direction) => set((state) => ({
|
||||
selectedKeyId: nextLoginKeyId(state.keyboardMode, state.selectedKeyId, direction),
|
||||
})),
|
||||
pressKey: (keyId) => {
|
||||
const state = get();
|
||||
const key = findLoginKey(state.keyboardMode, keyId);
|
||||
if (!key) return;
|
||||
set({ selectedKeyId: key.id });
|
||||
if (key.kind === "character" && key.value) {
|
||||
if (state.activeField === "username") {
|
||||
if (/^[A-Za-z0-9_]$/.test(key.value) && state.username.length < 20) set({ username: state.username + key.value });
|
||||
} else if (state.password.length < 128) set({ password: state.password + key.value });
|
||||
return;
|
||||
}
|
||||
if (key.kind === "mode") set({ keyboardMode: nextMode(state.keyboardMode) });
|
||||
else if (key.kind === "field") set({ activeField: state.activeField === "username" ? "password" : "username" });
|
||||
else if (key.kind === "space" && state.activeField === "password" && state.password.length < 128) set({ password: `${state.password} ` });
|
||||
else if (key.kind === "backspace") {
|
||||
if (state.activeField === "username") set({ username: state.username.slice(0, -1) });
|
||||
else set({ password: state.password.slice(0, -1) });
|
||||
} else if (key.kind === "clear") {
|
||||
if (state.activeField === "username") set({ username: "" });
|
||||
else set({ password: "" });
|
||||
} else if (key.kind === "done") {
|
||||
if (state.activeField === "username") set({ activeField: "password" });
|
||||
else set({ selectedKeyId: "action-login" });
|
||||
} else if (["login", "create", "offline"].includes(key.kind) && !state.busy) {
|
||||
const requestId = state.requestId + 1;
|
||||
set({ requestId, request: { id: requestId, action: key.kind as LoginRequestAction } });
|
||||
}
|
||||
},
|
||||
pressSelected: () => get().pressKey(get().selectedKeyId),
|
||||
consumeRequest: (id) => set((state) => state.request?.id === id ? { request: null } : {}),
|
||||
setBusy: (busy) => set({ busy }),
|
||||
reset: () => set(initialState),
|
||||
}));
|
||||
|
||||
export function currentLoginInputSnapshot(): LoginInputSnapshot {
|
||||
const state = useLoginInputStore.getState();
|
||||
return {
|
||||
username: state.username,
|
||||
passwordLength: state.password.length,
|
||||
activeField: state.activeField,
|
||||
selectedKeyId: state.selectedKeyId,
|
||||
keyboardMode: state.keyboardMode,
|
||||
busy: state.busy,
|
||||
};
|
||||
}
|
||||
@@ -30,7 +30,9 @@ interface OnlineGroupState {
|
||||
leave: (session: PlayerSession) => Promise<boolean>;
|
||||
remove: (session: PlayerSession, accountId: string) => Promise<boolean>;
|
||||
updateMember: (session: PlayerSession, character: CharacterProfile, role: PartyRole) => Promise<boolean>;
|
||||
startActivity: (session: PlayerSession, input: Omit<StartOnlineGroupActivityInput, "fillWithAi">) => Promise<boolean>;
|
||||
startActivity: (session: PlayerSession, input: Omit<StartOnlineGroupActivityInput, "fillWithAi"> & {
|
||||
readonly fillWithAi?: boolean;
|
||||
}) => Promise<boolean>;
|
||||
clearActivity: (session: PlayerSession) => Promise<boolean>;
|
||||
setFillWithAi: (fillWithAi: boolean) => void;
|
||||
consumeActivity: (activityId: string) => void;
|
||||
@@ -127,7 +129,10 @@ export const useOnlineGroupStore = create<OnlineGroupState>((set, get) => ({
|
||||
startActivity: async (session, input) => {
|
||||
set({ busy: true, error: "" });
|
||||
try {
|
||||
const snapshot = await startOnlineGroupActivity(session, { ...input, fillWithAi: get().fillWithAi });
|
||||
const snapshot = await startOnlineGroupActivity(session, {
|
||||
...input,
|
||||
fillWithAi: input.fillWithAi ?? get().fillWithAi,
|
||||
});
|
||||
set({ snapshot, busy: false });
|
||||
return true;
|
||||
} catch (error) {
|
||||
|
||||
@@ -28,6 +28,9 @@ export interface OnlineGroupActivity {
|
||||
readonly type: "dungeon" | "manastorm";
|
||||
readonly contentId: string;
|
||||
readonly fillWithAi: boolean;
|
||||
readonly includeBetaSpecs?: boolean;
|
||||
readonly companionReadinessRevision?: number;
|
||||
readonly companionSpecializationIds?: readonly string[];
|
||||
readonly partySize: number;
|
||||
readonly startingLevel: number | null;
|
||||
readonly startedAt: number;
|
||||
@@ -63,6 +66,7 @@ export interface StartOnlineGroupActivityInput {
|
||||
readonly type: OnlineGroupActivity["type"];
|
||||
readonly contentId: string;
|
||||
readonly fillWithAi: boolean;
|
||||
readonly includeBetaSpecs?: boolean;
|
||||
readonly partySize: number;
|
||||
readonly startingLevel?: number;
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createDefaultAppearance } from "./characterCatalog";
|
||||
import type { CharacterProfile, PlayerSession } from "./types";
|
||||
import type { OnlineGroup } from "./onlineGroupTypes";
|
||||
import { dungeonById } from "../game/dungeonCatalog";
|
||||
import { dungeonContentPackId } from "../game/dungeonContentSource";
|
||||
import { queueRandomDungeon, type RandomDungeonQueueOptions } from "./randomDungeonQueue";
|
||||
|
||||
const character: CharacterProfile = {
|
||||
id: "character-1", ownerId: "player-1", name: "Remedy", raceId: "human", classId: "priest",
|
||||
specializationId: "priest:holy", categoryId: "wow", gender: "female",
|
||||
appearance: createDefaultAppearance(), level: 1, experience: 0, talentRanks: {},
|
||||
actionBindings: {}, inventory: [], equipment: {}, location: "", createdAt: 1, lastPlayedAt: 1,
|
||||
};
|
||||
const offlineSession: PlayerSession = { kind: "offline", ownerId: "player-1", displayName: "Offline" };
|
||||
const accountSession: PlayerSession = { kind: "account", ownerId: "player-1", displayName: "Remedy", accessToken: "test-token" };
|
||||
const group: OnlineGroup = {
|
||||
id: "group-1", leaderAccountId: "player-1", members: [], activity: null,
|
||||
activityRevision: 0, createdAt: 1, updatedAt: 1,
|
||||
};
|
||||
|
||||
function queueOptions(overrides: Partial<RandomDungeonQueueOptions> = {}): RandomDungeonQueueOptions {
|
||||
return {
|
||||
mode: "regular", role: "healer", character, session: offlineSession, group: null,
|
||||
includeBetaSpecs: false, fillWithAi: true, onProgress: vi.fn(),
|
||||
enterDungeon: vi.fn(() => true), updateOnlineMember: vi.fn(async () => true),
|
||||
startOnlineActivity: vi.fn(async () => true), failureNotice: () => "",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("random dungeon queue", () => {
|
||||
it("downloads a rolled full dungeon before entering a fresh local run", async () => {
|
||||
let installed = false;
|
||||
const installPack = vi.fn(async () => { installed = true; });
|
||||
const installDungeonRuntime = vi.fn(async () => undefined);
|
||||
const options = queueOptions();
|
||||
const result = await queueRandomDungeon(options, {
|
||||
random: () => 0,
|
||||
contentIsUsable: () => installed,
|
||||
installPack,
|
||||
installDungeonRuntime,
|
||||
});
|
||||
|
||||
expect(result.status).toBe("entered");
|
||||
const dungeonId = vi.mocked(options.enterDungeon).mock.calls[0][0];
|
||||
expect(["wow", "rom"]).toContain(dungeonById(dungeonId)?.category);
|
||||
expect(installPack).toHaveBeenCalledWith(dungeonContentPackId(dungeonId));
|
||||
expect(installPack.mock.invocationCallOrder[0]).toBeLessThan(installDungeonRuntime.mock.invocationCallOrder[0]);
|
||||
expect(installDungeonRuntime.mock.invocationCallOrder[0])
|
||||
.toBeLessThan(vi.mocked(options.enterDungeon).mock.invocationCallOrder[0]);
|
||||
expect(options.enterDungeon).toHaveBeenCalledWith(dungeonId, "healer", expect.objectContaining({ freshRun: true }));
|
||||
expect(options.onProgress).toHaveBeenCalledWith("downloading", expect.objectContaining({ id: dungeonId }));
|
||||
});
|
||||
|
||||
it("keeps regular Bite-Sized separate from the creature-randomized mode", async () => {
|
||||
const installPack = vi.fn(async () => undefined);
|
||||
const options = queueOptions({ mode: "bite-sized" });
|
||||
await expect(queueRandomDungeon(options, {
|
||||
random: () => 0,
|
||||
contentIsUsable: () => true,
|
||||
installPack,
|
||||
installDungeonRuntime: vi.fn(async () => undefined),
|
||||
})).resolves.toMatchObject({ status: "entered" });
|
||||
expect(options.enterDungeon).toHaveBeenCalledWith("bite-sized-stormwind-stockade", "healer", expect.any(Object));
|
||||
expect(installPack).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not enter when the downloaded pack cannot be verified", async () => {
|
||||
const options = queueOptions();
|
||||
const result = await queueRandomDungeon(options, {
|
||||
random: () => 0,
|
||||
contentIsUsable: () => false,
|
||||
installPack: vi.fn(async () => undefined),
|
||||
installDungeonRuntime: vi.fn(async () => undefined),
|
||||
});
|
||||
expect(result).toMatchObject({ status: "error", message: expect.stringMatching(/verified/i) });
|
||||
expect(options.enterDungeon).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("publishes the rolled dungeon for the online leader after installation", async () => {
|
||||
let installed = false;
|
||||
const options = queueOptions({ session: accountSession, group });
|
||||
const result = await queueRandomDungeon(options, {
|
||||
random: () => 0,
|
||||
contentIsUsable: () => installed,
|
||||
installPack: vi.fn(async () => { installed = true; }),
|
||||
installDungeonRuntime: vi.fn(async () => undefined),
|
||||
});
|
||||
expect(result.status).toBe("started");
|
||||
expect(options.updateOnlineMember).toHaveBeenCalledWith(accountSession, character, "healer");
|
||||
expect(options.startOnlineActivity).toHaveBeenCalledWith(accountSession, expect.objectContaining({
|
||||
type: "dungeon", partySize: 5, fillWithAi: true,
|
||||
}));
|
||||
expect(options.enterDungeon).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("marks a group member ready and waits for the leader's roll", async () => {
|
||||
const options = queueOptions({ session: accountSession, group: { ...group, leaderAccountId: "player-2" } });
|
||||
const installPack = vi.fn(async () => undefined);
|
||||
await expect(queueRandomDungeon(options, {
|
||||
random: () => 0,
|
||||
contentIsUsable: () => false,
|
||||
installPack,
|
||||
installDungeonRuntime: vi.fn(async () => undefined),
|
||||
})).resolves.toMatchObject({ status: "waiting" });
|
||||
expect(installPack).not.toHaveBeenCalled();
|
||||
expect(options.startOnlineActivity).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
import type { CharacterProfile, PlayerSession } from "./types";
|
||||
import type { OnlineGroup, StartOnlineGroupActivityInput } from "./onlineGroupTypes";
|
||||
import { dungeonContentIsUsable, installContentPack } from "../content/contentManager";
|
||||
import { dungeonContentPackId } from "../game/dungeonContentSource";
|
||||
import { randomDungeonByCategory, randomRegularDungeon, type DungeonCatalogEntry } from "../game/dungeonCatalog";
|
||||
import { dungeonDefinitionById } from "../game/dungeonRegistry";
|
||||
import { classCanFillPartyRole, type PartyRole } from "../game/partyRoles";
|
||||
import { specializationById } from "../game/specializationCatalog";
|
||||
import type { DungeonEntrySettings } from "./dungeonShellRuntimeBridge";
|
||||
|
||||
export type RandomDungeonQueueMode = "regular" | "bite-sized";
|
||||
export type RandomDungeonQueueStage = "rolling" | "downloading" | "entering";
|
||||
|
||||
export interface RandomDungeonQueueOptions {
|
||||
readonly mode: RandomDungeonQueueMode;
|
||||
readonly role: PartyRole;
|
||||
readonly character: CharacterProfile | null;
|
||||
readonly session: PlayerSession | null;
|
||||
readonly group: OnlineGroup | null;
|
||||
readonly includeBetaSpecs: boolean;
|
||||
readonly fillWithAi: boolean;
|
||||
readonly onProgress: (stage: RandomDungeonQueueStage, dungeon: DungeonCatalogEntry | null) => void;
|
||||
readonly enterDungeon: (dungeonId: string, role: PartyRole, settings: DungeonEntrySettings) => boolean;
|
||||
readonly updateOnlineMember: (session: PlayerSession, character: CharacterProfile, role: PartyRole) => Promise<boolean>;
|
||||
readonly startOnlineActivity: (session: PlayerSession, input: StartOnlineGroupActivityInput) => Promise<boolean>;
|
||||
readonly failureNotice: () => string;
|
||||
}
|
||||
|
||||
interface RandomDungeonQueueDependencies {
|
||||
readonly random: () => number;
|
||||
readonly contentIsUsable: (dungeonId: string) => boolean;
|
||||
readonly installPack: (packId: string) => Promise<void>;
|
||||
readonly installDungeonRuntime: () => Promise<void>;
|
||||
}
|
||||
|
||||
const DEFAULT_DEPENDENCIES: RandomDungeonQueueDependencies = {
|
||||
random: Math.random,
|
||||
contentIsUsable: dungeonContentIsUsable,
|
||||
installPack: installContentPack,
|
||||
installDungeonRuntime: async () => {
|
||||
const { installDungeonShellRuntime } = await import("./dungeonShellRuntime");
|
||||
installDungeonShellRuntime();
|
||||
},
|
||||
};
|
||||
|
||||
export type RandomDungeonQueueResult =
|
||||
| { readonly status: "entered" | "waiting" | "started"; readonly dungeonTitle?: string }
|
||||
| { readonly status: "error"; readonly message: string };
|
||||
|
||||
/** Rolls only after Queue is pressed; downloads the selected map before local or shared entry. */
|
||||
export async function queueRandomDungeon(
|
||||
options: RandomDungeonQueueOptions,
|
||||
dependencies: RandomDungeonQueueDependencies = DEFAULT_DEPENDENCIES,
|
||||
): Promise<RandomDungeonQueueResult> {
|
||||
const { character, session, group, role } = options;
|
||||
if (!character || !session) return { status: "error", message: "Select a character before queuing." };
|
||||
const specialization = specializationById(character.specializationId);
|
||||
if ((character.categoryId !== "rom" && specialization?.role !== role)
|
||||
|| !classCanFillPartyRole(character.classId, role)) {
|
||||
return { status: "error", message: "That role is unavailable for this character." };
|
||||
}
|
||||
if (group && (session.kind !== "account" || !session.accessToken)) {
|
||||
return { status: "error", message: "Reconnect before queuing with your group." };
|
||||
}
|
||||
|
||||
try {
|
||||
if (group) {
|
||||
if (!await options.updateOnlineMember(session, character, role)) {
|
||||
return { status: "error", message: options.failureNotice() || "The group role could not be updated." };
|
||||
}
|
||||
if (group.leaderAccountId !== session.ownerId) return { status: "waiting" };
|
||||
}
|
||||
|
||||
options.onProgress("rolling", null);
|
||||
const dungeon = options.mode === "regular"
|
||||
? randomRegularDungeon(undefined, dependencies.random)
|
||||
: randomDungeonByCategory("bite-sized", undefined, dependencies.random);
|
||||
if (!dungeon) return { status: "error", message: "No dungeon is currently ready for this queue." };
|
||||
if (!dependencies.contentIsUsable(dungeon.id)) {
|
||||
options.onProgress("downloading", dungeon);
|
||||
await dependencies.installPack(dungeonContentPackId(dungeon.id));
|
||||
if (!dependencies.contentIsUsable(dungeon.id)) {
|
||||
return { status: "error", message: `${dungeon.title} could not be verified after downloading.` };
|
||||
}
|
||||
}
|
||||
|
||||
const difficultyId = dungeonDefinitionById(dungeon.id)?.defaultDifficultyId;
|
||||
options.onProgress("entering", dungeon);
|
||||
if (group) {
|
||||
const started = await options.startOnlineActivity(session, {
|
||||
type: "dungeon",
|
||||
contentId: dungeon.id,
|
||||
difficultyId,
|
||||
bossDifficulty: 1,
|
||||
partySize: 5,
|
||||
fillWithAi: options.fillWithAi,
|
||||
includeBetaSpecs: options.includeBetaSpecs,
|
||||
});
|
||||
return started
|
||||
? { status: "started", dungeonTitle: dungeon.title }
|
||||
: { status: "error", message: options.failureNotice() || "The group dungeon could not be started." };
|
||||
}
|
||||
|
||||
await dependencies.installDungeonRuntime();
|
||||
const entered = options.enterDungeon(dungeon.id, role, {
|
||||
difficultyId,
|
||||
bossDifficulty: 1,
|
||||
freshRun: true,
|
||||
});
|
||||
return entered
|
||||
? { status: "entered", dungeonTitle: dungeon.title }
|
||||
: { status: "error", message: options.failureNotice() || `Could not enter ${dungeon.title}.` };
|
||||
} catch (error) {
|
||||
return { status: "error", message: error instanceof Error ? error.message : "The random dungeon queue failed." };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
ensureRandomizedBiteSizedRunContent,
|
||||
randomizedBiteSizedModelUrls,
|
||||
} from "./randomizedBiteSizedContent";
|
||||
|
||||
describe("randomized bite-sized run content", () => {
|
||||
it("derives only the distinct model URLs selected by the shared run seed", () => {
|
||||
const first = randomizedBiteSizedModelUrls("asset-seed");
|
||||
const second = randomizedBiteSizedModelUrls("asset-seed");
|
||||
expect(first).toEqual(second);
|
||||
expect(first.length).toBeGreaterThan(1);
|
||||
expect(new Set(first).size).toBe(first.length);
|
||||
expect(first.every((url) => url.startsWith("/assets/") && url.endsWith(".glb"))).toBe(true);
|
||||
});
|
||||
|
||||
it("installs the base location and only the rolled creature model packs before entry", async () => {
|
||||
const installPack = vi.fn(async () => undefined);
|
||||
const installUrls = vi.fn(async () => undefined);
|
||||
await ensureRandomizedBiteSizedRunContent("download-seed", {
|
||||
development: false,
|
||||
installPack,
|
||||
installUrls,
|
||||
packIsUsable: () => false,
|
||||
packHasUpdate: () => false,
|
||||
});
|
||||
|
||||
expect(installPack).toHaveBeenCalledWith("activity:randomized-bite-sized");
|
||||
expect(installUrls).toHaveBeenCalledWith(randomizedBiteSizedModelUrls("download-seed"));
|
||||
expect(installPack.mock.invocationCallOrder[0]).toBeLessThan(installUrls.mock.invocationCallOrder[0]);
|
||||
});
|
||||
|
||||
it("skips network installation in the unrestricted development asset server", async () => {
|
||||
const installPack = vi.fn(async () => undefined);
|
||||
const installUrls = vi.fn(async () => undefined);
|
||||
await ensureRandomizedBiteSizedRunContent("dev-seed", {
|
||||
development: true,
|
||||
installPack,
|
||||
installUrls,
|
||||
packIsUsable: () => false,
|
||||
packHasUpdate: () => false,
|
||||
});
|
||||
expect(installPack).not.toHaveBeenCalled();
|
||||
expect(installUrls).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import {
|
||||
contentPackHasUpdate,
|
||||
contentPackIsUsable,
|
||||
installContentForLogicalUrls,
|
||||
installContentPack,
|
||||
} from "../content/contentManager";
|
||||
import {
|
||||
RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID,
|
||||
dungeonContentPackId,
|
||||
} from "../game/dungeonContentSource";
|
||||
import { randomizedBiteSizedDefinitionForSeed } from "../game/dungeonRegistry";
|
||||
|
||||
export function randomizedBiteSizedModelUrls(seed: string): readonly string[] {
|
||||
const definition = randomizedBiteSizedDefinitionForSeed(seed);
|
||||
return [...new Set(Object.values(definition.entities).flatMap((entity) => (
|
||||
entity.visual.model?.url ? [entity.visual.model.url] : []
|
||||
)))].sort();
|
||||
}
|
||||
|
||||
interface RandomizedContentDependencies {
|
||||
readonly development: boolean;
|
||||
readonly installPack: (packId: string) => Promise<void>;
|
||||
readonly installUrls: (urls: readonly string[]) => Promise<void>;
|
||||
readonly packIsUsable: (packId: string) => boolean;
|
||||
readonly packHasUpdate: (packId: string) => boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_DEPENDENCIES: RandomizedContentDependencies = {
|
||||
development: import.meta.env.DEV,
|
||||
installPack: installContentPack,
|
||||
installUrls: installContentForLogicalUrls,
|
||||
packIsUsable: contentPackIsUsable,
|
||||
packHasUpdate: contentPackHasUpdate,
|
||||
};
|
||||
|
||||
/** Downloads the rolled location and creature packs before the seeded run enters gameplay. */
|
||||
export async function ensureRandomizedBiteSizedRunContent(
|
||||
seed: string,
|
||||
dependencies: RandomizedContentDependencies = DEFAULT_DEPENDENCIES,
|
||||
): Promise<void> {
|
||||
if (dependencies.development) return;
|
||||
const basePackId = dungeonContentPackId(RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID);
|
||||
if (!dependencies.packIsUsable(basePackId) || dependencies.packHasUpdate(basePackId)) {
|
||||
await dependencies.installPack(basePackId);
|
||||
}
|
||||
await dependencies.installUrls(randomizedBiteSizedModelUrls(seed));
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { create } from "zustand";
|
||||
import { requestOnlineJson } from "./onlineAccountClient";
|
||||
import type { PlayerSession } from "./types";
|
||||
import type { RandomizedBiteSizedCreature, RandomizedCreatureKind } from "../game/dungeons/randomizedBiteSizedStockades";
|
||||
|
||||
export const CREATURE_READINESS_STATUSES = ["ready", "needs-testing", "not-ready"] as const;
|
||||
export type CreatureReadinessStatus = (typeof CREATURE_READINESS_STATUSES)[number];
|
||||
|
||||
export const CREATURE_READINESS_LABELS: Readonly<Record<CreatureReadinessStatus, string>> = {
|
||||
ready: "Ready",
|
||||
"needs-testing": "Needs Tested",
|
||||
"not-ready": "Not Ready",
|
||||
};
|
||||
|
||||
export interface RandomizedCreatureReview {
|
||||
readonly creatureKey: string;
|
||||
readonly dungeonId: string;
|
||||
readonly creatureName: string;
|
||||
readonly creatureKind: RandomizedCreatureKind;
|
||||
readonly status: CreatureReadinessStatus;
|
||||
readonly reviewedBy: string;
|
||||
readonly reviewedAt: number;
|
||||
}
|
||||
|
||||
interface RandomizedCreatureReadinessState {
|
||||
readonly reviews: Readonly<Record<string, RandomizedCreatureReview>>;
|
||||
readonly loading: boolean;
|
||||
readonly error: string;
|
||||
refresh: () => Promise<void>;
|
||||
save: (
|
||||
session: PlayerSession,
|
||||
creature: RandomizedBiteSizedCreature,
|
||||
status: CreatureReadinessStatus,
|
||||
) => Promise<boolean>;
|
||||
}
|
||||
|
||||
export const useRandomizedCreatureReadiness = create<RandomizedCreatureReadinessState>((set, get) => ({
|
||||
reviews: {},
|
||||
loading: false,
|
||||
error: "",
|
||||
refresh: async () => {
|
||||
set({ loading: true, error: "" });
|
||||
try {
|
||||
const payload = await requestOnlineJson<{ reviews: RandomizedCreatureReview[] }>(
|
||||
"/api/randomized-creature-readiness",
|
||||
);
|
||||
set({ reviews: Object.fromEntries(payload.reviews.map((review) => [review.creatureKey, review])) });
|
||||
} catch (error) {
|
||||
set({ error: error instanceof Error ? error.message : "Unable to load creature readiness." });
|
||||
} finally {
|
||||
set({ loading: false });
|
||||
}
|
||||
},
|
||||
save: async (session, creature, status) => {
|
||||
if (session.kind !== "account" || !session.accessToken) return false;
|
||||
set({ loading: true, error: "" });
|
||||
try {
|
||||
const payload = await requestOnlineJson<{ review: RandomizedCreatureReview }>(
|
||||
"/api/gm/randomized-creature-readiness",
|
||||
{
|
||||
method: "PUT",
|
||||
body: JSON.stringify({
|
||||
creatureKey: creature.key,
|
||||
dungeonId: "randomized-bite-sized-stormwind-stockade",
|
||||
creatureName: creature.name,
|
||||
creatureKind: creature.kind,
|
||||
status,
|
||||
}),
|
||||
},
|
||||
session.accessToken,
|
||||
);
|
||||
set({ reviews: { ...get().reviews, [payload.review.creatureKey]: payload.review } });
|
||||
return true;
|
||||
} catch (error) {
|
||||
set({ error: error instanceof Error ? error.message : "Unable to save creature readiness." });
|
||||
return false;
|
||||
} finally {
|
||||
set({ loading: false });
|
||||
}
|
||||
},
|
||||
}));
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 312 KiB |
@@ -2,6 +2,7 @@ import { Capacitor } from "@capacitor/core";
|
||||
import {
|
||||
appVersionMeetsMinimum,
|
||||
changedFilesForPack,
|
||||
contentPackIdsForLogicalUrls,
|
||||
filesForContentPack,
|
||||
parseContentManifest,
|
||||
type ContentManifest,
|
||||
@@ -9,6 +10,7 @@ import {
|
||||
} from "./contentManifest";
|
||||
import { NativeContentStorage } from "./nativeContentStorage";
|
||||
import contentPolicy from "../../shared/content-policy.json";
|
||||
import { dungeonContentPackId } from "../game/dungeonContentSource";
|
||||
|
||||
export const CONTENT_STATE_KEY = "healer-man.content-state.v2";
|
||||
const PRODUCTION_ORIGIN = "https://iwanttoheal.phenomrom.com";
|
||||
@@ -367,7 +369,7 @@ export function contentPackIsUsable(packId: string): boolean {
|
||||
|
||||
/** Vite serves the complete source asset tree in development; shipped builds require an installed pack. */
|
||||
export function dungeonContentIsUsable(dungeonId: string): boolean {
|
||||
return import.meta.env.DEV || contentPackIsUsable(`dungeon:${dungeonId}`);
|
||||
return import.meta.env.DEV || contentPackIsUsable(dungeonContentPackId(dungeonId));
|
||||
}
|
||||
|
||||
export function contentPackHasUpdate(packId: string): boolean {
|
||||
@@ -635,6 +637,26 @@ export async function installContentPack(packId: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/** Installs only the packs owning the selected run's logical model URLs. */
|
||||
export async function installContentForLogicalUrls(logicalUrls: readonly string[]): Promise<void> {
|
||||
await validateInstalledContentStorage();
|
||||
const logicalPaths = logicalUrls.map((logicalUrl) => {
|
||||
try { return new URL(logicalUrl, "https://healer-man.invalid").pathname; } catch { return logicalUrl; }
|
||||
});
|
||||
const alreadyAvailable = logicalPaths.every((logicalPath) => (
|
||||
Boolean(snapshot.bundledManifest?.files[logicalPath])
|
||||
|| Boolean(installedState.files[logicalPath] && validatedInstalledFiles.has(logicalPath))
|
||||
));
|
||||
if (alreadyAvailable) return;
|
||||
const manifest = snapshot.manifest ?? await checkForContentUpdates();
|
||||
if (!manifest) throw new Error(snapshot.error || "Content server is unavailable.");
|
||||
for (const packId of contentPackIdsForLogicalUrls(manifest, logicalUrls)) {
|
||||
if (!contentPackIsUsable(packId) || contentPackHasUpdate(packId)) {
|
||||
await installContentPack(packId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeContentPack(packId: string): Promise<void> {
|
||||
if (contentPackIsBundled(packId)) return;
|
||||
const remainingFiles = { ...installedState.files };
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
appVersionMeetsMinimum,
|
||||
changedFilesForPack,
|
||||
contentPackIdsForLogicalUrls,
|
||||
filesForContentPack,
|
||||
parseContentManifest,
|
||||
type ContentManifest,
|
||||
@@ -46,4 +47,28 @@ describe("content manifest", () => {
|
||||
"/assets/a.glb": { sha256: "a".repeat(64) },
|
||||
}).map(([logicalPath]) => logicalPath)).toEqual(["/assets/b.glb"]);
|
||||
});
|
||||
|
||||
it("resolves a seeded run's logical models to distinct owning packs", () => {
|
||||
const withSecondPack: ContentManifest = {
|
||||
...manifest,
|
||||
packs: [...manifest.packs, {
|
||||
id: "shared:creatures", label: "Shared", bytes: 1, fileCount: 1,
|
||||
bundledOnAndroid: false, dependencies: [],
|
||||
}],
|
||||
files: {
|
||||
...manifest.files,
|
||||
"/assets/shared.glb": {
|
||||
packId: "shared:creatures", url: "/content/objects/shared.glb",
|
||||
sha256: "c".repeat(64), bytes: 1,
|
||||
},
|
||||
},
|
||||
};
|
||||
expect(contentPackIdsForLogicalUrls(withSecondPack, [
|
||||
"/assets/shared.glb",
|
||||
"/assets/a.glb",
|
||||
"/assets/shared.glb?cache=1",
|
||||
])).toEqual(["dungeon:test", "shared:creatures"]);
|
||||
expect(() => contentPackIdsForLogicalUrls(withSecondPack, ["/assets/missing.glb"]))
|
||||
.toThrow(/missing randomized asset/i);
|
||||
});
|
||||
});
|
||||
@@ -32,6 +32,26 @@ export interface ContentManifest {
|
||||
readonly files: Readonly<Record<string, ContentManifestFile>>;
|
||||
}
|
||||
|
||||
/** Resolves runtime logical asset URLs to the independently installable packs that own them. */
|
||||
export function contentPackIdsForLogicalUrls(
|
||||
manifest: ContentManifest,
|
||||
logicalUrls: readonly string[],
|
||||
): readonly string[] {
|
||||
const packIds = new Set<string>();
|
||||
for (const logicalUrl of logicalUrls) {
|
||||
let pathname: string;
|
||||
try {
|
||||
pathname = new URL(logicalUrl, "https://healer-man.invalid").pathname;
|
||||
} catch {
|
||||
throw new Error(`Invalid content asset URL: ${logicalUrl}`);
|
||||
}
|
||||
const entry = manifest.files[pathname];
|
||||
if (!entry) throw new Error(`The content catalog is missing randomized asset ${pathname}.`);
|
||||
packIds.add(entry.packId);
|
||||
}
|
||||
return [...packIds].sort();
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
@@ -316,6 +316,7 @@ export function OnlineSessionBridge() {
|
||||
ownerId: PLAYER_EQUIPMENT_OWNER_ID,
|
||||
ownerKind: "player",
|
||||
classId: combat.classId,
|
||||
secondaryClassId: combat.secondaryClassId,
|
||||
specializationId: combat.specializationId,
|
||||
role: combat.gearRole,
|
||||
level: combat.level,
|
||||
|
||||
@@ -7,6 +7,9 @@ import { useGameStore } from "./store";
|
||||
import { useManastormStore } from "./manastormStore";
|
||||
import { onlineGroupAiPartySize, useOnlineGroupStore } from "../app/onlineGroupStore";
|
||||
import { useDungeonSimulationStore } from "./dungeonSimulationStore";
|
||||
import { useCompanionSpecReadinessStore } from "./companionSpecReadinessStore";
|
||||
import { useCompanionPartyPreferences } from "./companionPartyPreferences";
|
||||
import { RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID } from "./dungeonContentSource";
|
||||
|
||||
/** Owns party lifecycle; the combat interval itself remains in CombatBridge. */
|
||||
export function PartyBridge() {
|
||||
@@ -15,10 +18,14 @@ export function PartyBridge() {
|
||||
const gameMode = useGameStore((state) => state.gameMode);
|
||||
const manastormPartySize = useManastormStore((state) => state.partySize);
|
||||
const onlineActivity = useOnlineGroupStore((state) => state.activeActivity);
|
||||
const onlineGroup = useOnlineGroupStore((state) => state.snapshot.group);
|
||||
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 readinessPolicy = useCompanionSpecReadinessStore((state) => state.config);
|
||||
const readinessPolicyRevision = useCompanionSpecReadinessStore((state) => state.revision);
|
||||
const deviceIncludeBetaSpecs = useCompanionPartyPreferences((state) => state.includeBetaSpecs);
|
||||
const mountedResetRevision = useRef(resetRevision);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -30,24 +37,43 @@ export function PartyBridge() {
|
||||
&& onlineActivity.type === (gameMode === "manastorm" ? "manastorm" : "dungeon")
|
||||
? onlineGroupAiPartySize(onlineActivity.partySize)
|
||||
: requestedPartySize;
|
||||
const sharedOnlineParty = onlineActivity && onlineGroup?.activity?.id === onlineActivity.id
|
||||
? onlineGroup.members
|
||||
: [];
|
||||
const randomizedReadyOnly = onlineActivity?.type === "dungeon"
|
||||
&& onlineActivity.contentId === RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID;
|
||||
const companionOptions = {
|
||||
readinessPolicy,
|
||||
readinessPolicyRevision: onlineActivity?.companionReadinessRevision ?? readinessPolicyRevision,
|
||||
includeBetaSpecs: randomizedReadyOnly ? false : onlineActivity?.includeBetaSpecs ?? deviceIncludeBetaSpecs,
|
||||
excludedPlayerSpecializationId: character.specializationId,
|
||||
excludedPlayerSpecializationIds: sharedOnlineParty.flatMap((member) => (
|
||||
member.character?.specializationId ? [member.character.specializationId] : []
|
||||
)),
|
||||
allowedCompanionSpecializationIds: onlineActivity?.companionSpecializationIds,
|
||||
occupiedHumanRoles: sharedOnlineParty.flatMap((member) => member.role ? [member.role] : []),
|
||||
};
|
||||
if (simulationMode === "play") {
|
||||
party.initializeParty(
|
||||
character.id,
|
||||
onlineActivity?.id ?? character.id,
|
||||
combat.level,
|
||||
combat.health,
|
||||
playerRole,
|
||||
aiPartySize,
|
||||
character.categoryId ?? "wow",
|
||||
randomizedReadyOnly ? "wow" : character.categoryId ?? "wow",
|
||||
companionOptions,
|
||||
);
|
||||
} else {
|
||||
party.initializeSpectatorParty(simulationSeed, combat.level, character.categoryId ?? "wow");
|
||||
party.initializeSpectatorParty(simulationSeed, combat.level, character.categoryId ?? "wow", companionOptions);
|
||||
}
|
||||
usePartyStore.getState().syncEquipment(combat.equipment, combat.inventory);
|
||||
combat.synchronizeActors();
|
||||
combat.applyDefaultPartyBuffs();
|
||||
combat.applyPrecombatPreparation();
|
||||
resetPartyRuntimeClock();
|
||||
}, [character?.id, gameMode, manastormPartySize, onlineActivity?.id, playerRole, simulationMode, simulationSeed]);
|
||||
}, [character?.id, deviceIncludeBetaSpecs, gameMode, manastormPartySize, onlineActivity?.id,
|
||||
onlineActivity?.includeBetaSpecs, onlineGroup?.updatedAt, playerRole, readinessPolicy,
|
||||
readinessPolicyRevision, simulationMode, simulationSeed]);
|
||||
|
||||
useEffect(() => {
|
||||
const party = usePartyStore.getState();
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import { loadCoaAbilityCatalogShard } from "./coaAbilityCatalogLoader";
|
||||
import { registerAbilityAnimationLookup } from "./abilityAnimationLookup";
|
||||
import type { AuraDefinition, DamageSchool, DispelCategory } from "./combatAuras";
|
||||
import { applyAbilityBalanceTuning, currentBalanceTuningPolicy } from "./balanceTuning";
|
||||
|
||||
export type RomSkillGroup = "primary" | "general" | "elite" | "passive";
|
||||
|
||||
@@ -28,7 +29,7 @@ export type ResourceType =
|
||||
| `custom:${string}`;
|
||||
export type AbilityTarget = "hostile" | "friendly" | "any" | "self";
|
||||
export type AbilityCastMode = "instant" | "cast" | "channel";
|
||||
export type AbilityTargetShape = "unit" | "circle" | "cone" | "chain" | "ground";
|
||||
export type AbilityTargetShape = "unit" | "circle" | "cone" | "chain" | "line" | "ground";
|
||||
|
||||
export interface AbilityTargetingRules {
|
||||
readonly shape: AbilityTargetShape;
|
||||
@@ -156,11 +157,17 @@ export interface AbilityDamageResource {
|
||||
}
|
||||
|
||||
export type AbilityEffect =
|
||||
| ({ readonly kind: "damage"; readonly coefficient: number } & AbilityAmountScaling & AbilityDamageResource)
|
||||
| { readonly kind: "finisher-damage"; readonly baseCoefficient: number; readonly perComboCoefficient: number }
|
||||
| ({ readonly kind: "damage"; readonly coefficient: number; readonly school?: DamageSchool; readonly falloffPerJump?: number; readonly bonusPerSunderStack?: number; readonly bonusFlat?: number } & AbilityAmountScaling & AbilityDamageResource)
|
||||
| {
|
||||
readonly kind: "finisher-damage";
|
||||
readonly baseCoefficient: number;
|
||||
readonly perComboCoefficient: number;
|
||||
/** Optional stack-based payload used by poison-consuming finishers. */
|
||||
readonly poison?: import("./roguePoison").RoguePoisonFinisherScaling;
|
||||
}
|
||||
| ({ readonly kind: "heal"; readonly coefficient: number; readonly percentMaxHealth?: number } & AbilityAmountScaling & AbilityHealingRecipients)
|
||||
| ({ readonly kind: "shield"; readonly coefficient: number; readonly durationMs: number } & AbilityAmountScaling & AbilityHealingRecipients)
|
||||
| ({ readonly kind: "dot"; readonly coefficient: number; readonly ticks: number; readonly intervalMs: number; readonly startsImmediately?: boolean; readonly tag?: string } & AbilityAmountScaling & AbilityDamageResource)
|
||||
| ({ readonly kind: "dot"; readonly coefficient: number; readonly school?: DamageSchool; readonly ticks: number; readonly intervalMs: number; readonly startsImmediately?: boolean; readonly tag?: string } & AbilityAmountScaling & AbilityDamageResource)
|
||||
| ({ readonly kind: "hot"; readonly coefficient: number; readonly ticks: number; readonly intervalMs: number } & AbilityAmountScaling & AbilityHealingRecipients)
|
||||
| { readonly kind: "resurrection"; readonly percentMaxHealth: number }
|
||||
| { readonly kind: "summon"; readonly creatureId: number; readonly coefficient: number; readonly durationMs: number }
|
||||
@@ -175,6 +182,8 @@ export type AbilityEffect =
|
||||
readonly recipient?: "ability-target" | "caster";
|
||||
/** Party/raid area auras are applied to the player and every active party member. */
|
||||
readonly scope?: "target" | "party";
|
||||
/** Some party effects benefit allies but exclude the caster. */
|
||||
readonly excludeCaster?: boolean;
|
||||
readonly control?: AbilityAuraControl;
|
||||
readonly sourceEffectType: number;
|
||||
readonly sourceAuraType: number;
|
||||
@@ -486,6 +495,52 @@ const HUNTER = [
|
||||
] as const;
|
||||
|
||||
const ROGUE = [
|
||||
defineAbility({
|
||||
id: "rogue-instant-poison", classId: "rogue", dbcSpellId: 57965, name: "Instant Poison",
|
||||
unlockLevel: 20,
|
||||
icon: spellIcon("Ability_Poisons"), sigil: "IP", target: "self", range: self,
|
||||
castTimeMs: 0, cooldownMs: 0, gcdMs: 0, cost: { resource: "energy", amount: 0 },
|
||||
description: "Coats the main-hand weapon with poison that can deal Nature damage on hit.",
|
||||
effects: [{
|
||||
kind: "apply-aura",
|
||||
aura: {
|
||||
id: "rogue-instant-poison-main-hand",
|
||||
name: "Instant Poison",
|
||||
disposition: "buff",
|
||||
durationMs: 3_600_000,
|
||||
exclusiveGroup: "rogue-main-hand-poison",
|
||||
utilityTags: ["weapon-enchant", "rogue-instant-poison"],
|
||||
},
|
||||
recipient: "caster",
|
||||
sourceEffectType: 6,
|
||||
sourceAuraType: 42,
|
||||
miscValue: 0,
|
||||
triggerSpellId: 0,
|
||||
}],
|
||||
}),
|
||||
defineAbility({
|
||||
id: "rogue-deadly-poison", classId: "rogue", dbcSpellId: 57970, name: "Deadly Poison",
|
||||
unlockLevel: 30,
|
||||
icon: spellIcon("Ability_Rogue_DualWeild"), sigil: "DP", target: "self", range: self,
|
||||
castTimeMs: 0, cooldownMs: 0, gcdMs: 0, cost: { resource: "energy", amount: 0 },
|
||||
description: "Coats the off-hand weapon with a stacking damage-over-time poison.",
|
||||
effects: [{
|
||||
kind: "apply-aura",
|
||||
aura: {
|
||||
id: "rogue-deadly-poison-off-hand",
|
||||
name: "Deadly Poison",
|
||||
disposition: "buff",
|
||||
durationMs: 3_600_000,
|
||||
exclusiveGroup: "rogue-off-hand-poison",
|
||||
utilityTags: ["weapon-enchant", "rogue-deadly-poison-coating"],
|
||||
},
|
||||
recipient: "caster",
|
||||
sourceEffectType: 6,
|
||||
sourceAuraType: 42,
|
||||
miscValue: 0,
|
||||
triggerSpellId: 0,
|
||||
}],
|
||||
}),
|
||||
defineAbility({
|
||||
id: "rogue-sinister-strike", classId: "rogue", dbcSpellId: 1752, name: "Sinister Strike",
|
||||
icon: spellIcon("Spell_Shadow_RitualOfSacrifice"), sigil: "SS", target: "hostile", range: melee,
|
||||
@@ -838,6 +893,9 @@ const CLASSIC_CORE_ABILITIES: Readonly<Record<BaseClassId, readonly AbilityDefin
|
||||
const abilitiesByClass: Partial<Record<ClassId, readonly AbilityDefinition[]>> = {
|
||||
...CLASSIC_CORE_ABILITIES,
|
||||
};
|
||||
const sourceAbilitiesByClass: Partial<Record<ClassId, readonly AbilityDefinition[]>> = {
|
||||
...CLASSIC_CORE_ABILITIES,
|
||||
};
|
||||
const abilityCatalog: Record<string, AbilityDefinition> = {};
|
||||
let romEliteAbilities: readonly AbilityDefinition[] = [];
|
||||
let romTriggeredAbilityResolver: ((spellId: number) => AbilityDefinition | null) | null = null;
|
||||
@@ -852,8 +910,28 @@ export const ABILITY_CATALOG = abilityCatalog as Readonly<Record<string, Ability
|
||||
|
||||
function registerClassAbilities(classId: ClassId, abilities: readonly AbilityDefinition[]): void {
|
||||
for (const previous of abilitiesByClass[classId] ?? []) delete abilityCatalog[previous.id];
|
||||
abilitiesByClass[classId] = abilities;
|
||||
for (const ability of abilities) abilityCatalog[ability.id] = ability;
|
||||
sourceAbilitiesByClass[classId] = abilities;
|
||||
const policy = currentBalanceTuningPolicy();
|
||||
const tuned = abilities.map((ability) => applyAbilityBalanceTuning(ability, policy));
|
||||
abilitiesByClass[classId] = tuned;
|
||||
for (const ability of tuned) abilityCatalog[ability.id] = ability;
|
||||
}
|
||||
|
||||
/** Rebuilds the live catalog after a local balance policy is published. */
|
||||
export function refreshAbilityBalanceTuning(): void {
|
||||
const policy = currentBalanceTuningPolicy();
|
||||
for (const [classId, source] of Object.entries(sourceAbilitiesByClass)) {
|
||||
if (!source) continue;
|
||||
for (const previous of abilitiesByClass[classId as ClassId] ?? []) delete abilityCatalog[previous.id];
|
||||
const tuned = source.map((ability) => applyAbilityBalanceTuning(ability, policy));
|
||||
abilitiesByClass[classId as ClassId] = tuned;
|
||||
for (const ability of tuned) abilityCatalog[ability.id] = ability;
|
||||
}
|
||||
}
|
||||
|
||||
/** Untuned source definitions used by the GM balance editor. */
|
||||
export function sourceAbilitiesForClass(classId: ClassId): readonly AbilityDefinition[] {
|
||||
return sourceAbilitiesByClass[classId] ?? [];
|
||||
}
|
||||
|
||||
for (const classId of BASE_CLASS_IDS) {
|
||||
|
||||
@@ -5,16 +5,16 @@ import type {
|
||||
AbilityTargetingRules,
|
||||
} from "./abilityCatalog";
|
||||
|
||||
const CHAIN_ABILITIES = new Set(["chain heal", "chain lightning", "prayer of mending"]);
|
||||
const CHAIN_ABILITIES = new Set(["chain heal", "chain lightning", "multi-shot", "prayer of mending"]);
|
||||
const GROUND_ABILITIES = new Set([
|
||||
"blizzard", "rain of fire", "flamestrike", "death and decay", "consecration",
|
||||
"volley", "hurricane", "earthquake", "meteor", "healing rain",
|
||||
]);
|
||||
const CONE_ABILITIES = new Set([
|
||||
"cone of cold", "dragon's breath", "dragons breath", "shockwave", "cleave",
|
||||
"swipe", "swipe (cat)", "fan of knives", "whirlwind",
|
||||
"swipe", "swipe (cat)", "whirlwind",
|
||||
]);
|
||||
const BEHIND_ABILITIES = new Set(["backstab", "ambush", "garrote", "ravage", "shred", "pounce"]);
|
||||
const BEHIND_ABILITIES = new Set(["backstab", "ambush", "garrote", "ravage", "shred", "pounce", "blind spot", "sneak attack"]);
|
||||
const MOVEMENT_CAST_ABILITIES = new Set(["scorch", "steady shot", "spiritwalker's grace"]);
|
||||
const EMERGENCY_HEAL_ABILITIES = new Set([
|
||||
"flash heal", "flash of light", "swiftmend", "lay on hands", "nature's swiftness",
|
||||
@@ -62,7 +62,7 @@ export function targetingRulesForAbility(ability: AbilityDefinition): AbilityTar
|
||||
requiresBehindTarget: hostile && BEHIND_ABILITIES.has(name),
|
||||
attackArcRadians: shape === "cone" ? Math.PI / 2 : Math.PI,
|
||||
...(ability.radius ? { radius: ability.radius } : {}),
|
||||
...(shape === "chain" ? { maximumTargets: 3, jumpRange: 12 } : {}),
|
||||
...(shape === "chain" ? { maximumTargets: 3, jumpRange: name === "multi-shot" ? 10 : 12 } : {}),
|
||||
...(shape === "ground" ? { groundRange: ability.range.max > 0 ? ability.range.max : name.endsWith(" trap") || name === "consecration" ? 0 : 30, radius: ability.radius ?? 8 } : {}),
|
||||
});
|
||||
}
|
||||
@@ -71,6 +71,11 @@ export function canAbilityCastWhileMoving(ability: AbilityDefinition): boolean {
|
||||
return ability.castRules?.canCastWhileMoving ?? MOVEMENT_CAST_ABILITIES.has(ability.name.toLowerCase());
|
||||
}
|
||||
|
||||
export function abilityRequiresOutOfCombat(ability: AbilityDefinition): boolean {
|
||||
return ability.source === "runewaker"
|
||||
&& /\bcannot be used (?:when already )?in combat\b/i.test(ability.description);
|
||||
}
|
||||
|
||||
export function abilityPushbackResistance(ability: AbilityDefinition): number {
|
||||
if (ability.castRules?.pushbackImmune) return 1;
|
||||
return Math.max(0, Math.min(1, ability.castRules?.pushbackResistance ?? 0));
|
||||
@@ -106,6 +111,9 @@ export function aiHintsForAbility(ability: AbilityDefinition): AbilityAiHints {
|
||||
effect.aura.absorbs?.length
|
||||
|| effect.aura.modifiers?.some((modifier) => modifier.kind === "damage"
|
||||
&& modifier.direction === "taken" && modifier.value < 0)
|
||||
|| effect.aura.modifiers?.some((modifier) => modifier.kind === "stat"
|
||||
&& ["block", "dodge", "parry"].includes(modifier.stat)
|
||||
&& modifier.value > 0)
|
||||
)) categories.add("mitigation");
|
||||
if (effect.kind === "apply-aura" && effect.aura.modifiers?.some((modifier) => (
|
||||
modifier.kind === "stat" && modifier.stat === "threat"
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import type { AbilityDefinition } from "./abilityCatalog";
|
||||
import {
|
||||
applyAbilityBalanceTuning,
|
||||
currentBalanceTuningPolicy,
|
||||
loadBalanceTuningPolicy,
|
||||
normalizeBalanceTuningPolicy,
|
||||
resetBalanceTuningCache,
|
||||
saveBalanceTuningPolicy,
|
||||
} from "./balanceTuning";
|
||||
|
||||
const ability: AbilityDefinition = {
|
||||
id: "test-fireball",
|
||||
classId: "mage",
|
||||
dbcSpellId: 1,
|
||||
name: "Test Fireball",
|
||||
unlockLevel: 1,
|
||||
icon: "",
|
||||
sigil: "TF",
|
||||
description: "Test",
|
||||
target: "hostile",
|
||||
range: { min: 0, max: 30 },
|
||||
radius: 8,
|
||||
castMode: "cast",
|
||||
castTimeMs: 2_000,
|
||||
cooldownMs: 10_000,
|
||||
gcdMs: 1_500,
|
||||
cost: { resource: "mana", amount: 20 },
|
||||
effects: [
|
||||
{ kind: "damage", coefficient: 2, basePoints: 100 },
|
||||
{ kind: "dot", coefficient: 0.5, ticks: 4, intervalMs: 1_000 },
|
||||
{ kind: "utility", action: "class-script", effectType: 1, auraType: 0, miscValue: 0, triggerSpellId: 0, durationMs: -1 },
|
||||
],
|
||||
};
|
||||
|
||||
function memoryStorage(initial: string | null = null) {
|
||||
let value = initial;
|
||||
return {
|
||||
getItem: () => value,
|
||||
setItem: (_key: string, next: string) => { value = next; },
|
||||
removeItem: () => { value = null; },
|
||||
value: () => value,
|
||||
};
|
||||
}
|
||||
|
||||
describe("local ability and talent balance tuning", () => {
|
||||
afterEach(() => resetBalanceTuningCache());
|
||||
|
||||
it("normalizes unsafe values and drops default-only overrides", () => {
|
||||
expect(normalizeBalanceTuningPolicy({
|
||||
abilityOverrides: {
|
||||
default: { cooldownPercent: 100 },
|
||||
tuned: { cooldownPercent: -50, damagePercent: 9_999 },
|
||||
},
|
||||
talentOverrides: {
|
||||
talent: { effectScalePercent: 150, modifiersPerRank: { "critical-chance-percent": 2.5 } },
|
||||
},
|
||||
})).toEqual({
|
||||
schemaVersion: 1,
|
||||
abilityOverrides: { tuned: { cooldownPercent: 0, damagePercent: 1_000 } },
|
||||
talentOverrides: {
|
||||
talent: { effectScalePercent: 150, modifiersPerRank: { "critical-chance-percent": 2.5 } },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("applies cooldown, cast, cost, damage, duration, and radius tuning", () => {
|
||||
const tuned = applyAbilityBalanceTuning(ability, normalizeBalanceTuningPolicy({
|
||||
abilityOverrides: {
|
||||
[ability.id]: {
|
||||
cooldownPercent: 50,
|
||||
castTimePercent: 0,
|
||||
resourceCostPercent: 75,
|
||||
damagePercent: 125,
|
||||
durationPercent: 150,
|
||||
radiusPercent: 200,
|
||||
},
|
||||
},
|
||||
}));
|
||||
expect(tuned).toMatchObject({
|
||||
castMode: "instant",
|
||||
castTimeMs: 0,
|
||||
cooldownMs: 5_000,
|
||||
cost: { amount: 15 },
|
||||
radius: 16,
|
||||
});
|
||||
expect(tuned.effects[0]).toMatchObject({ coefficient: 2.5, basePoints: 125 });
|
||||
expect(tuned.effects[1]).toMatchObject({ coefficient: 0.625, ticks: 6 });
|
||||
expect(tuned.effects[2]).toMatchObject({ durationMs: -1 });
|
||||
});
|
||||
|
||||
it("persists valid policy data and falls back after corrupt storage", () => {
|
||||
const storage = memoryStorage();
|
||||
saveBalanceTuningPolicy({ abilityOverrides: { x: { cooldownPercent: 80 } } }, storage);
|
||||
expect(JSON.parse(storage.value()!)).toMatchObject({ abilityOverrides: { x: { cooldownPercent: 80 } } });
|
||||
expect(currentBalanceTuningPolicy().abilityOverrides.x.cooldownPercent).toBe(80);
|
||||
expect(loadBalanceTuningPolicy(memoryStorage("not-json"))).toEqual({
|
||||
schemaVersion: 1,
|
||||
abilityOverrides: {},
|
||||
talentOverrides: {},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,322 @@
|
||||
import type {
|
||||
AbilityDefinition,
|
||||
AbilityEffect,
|
||||
AbilityRankDefinition,
|
||||
} from "./abilityCatalog";
|
||||
|
||||
export const BALANCE_TUNING_STORAGE_KEY = "healer-man:balance-tuning:v1";
|
||||
export const BALANCE_TUNING_EVENT = "healer-man:balance-tuning-changed";
|
||||
|
||||
export const TALENT_BALANCE_MODIFIER_KINDS = [
|
||||
"damage-percent",
|
||||
"critical-damage-percent",
|
||||
"healing-percent",
|
||||
"damage-taken-percent",
|
||||
"max-health-percent",
|
||||
"max-resource-percent",
|
||||
"resource-regeneration-percent",
|
||||
"resource-cost-percent",
|
||||
"critical-chance-percent",
|
||||
"haste-percent",
|
||||
"cast-time-percent",
|
||||
"cooldown-percent",
|
||||
"armor-percent",
|
||||
"attribute-percent",
|
||||
"range-percent",
|
||||
"duration-percent",
|
||||
"threat-percent",
|
||||
] as const;
|
||||
|
||||
export type TalentBalanceModifierKind = (typeof TALENT_BALANCE_MODIFIER_KINDS)[number];
|
||||
|
||||
export interface AbilityBalanceOverride {
|
||||
readonly cooldownPercent?: number;
|
||||
readonly castTimePercent?: number;
|
||||
readonly resourceCostPercent?: number;
|
||||
readonly damagePercent?: number;
|
||||
readonly healingPercent?: number;
|
||||
readonly durationPercent?: number;
|
||||
readonly radiusPercent?: number;
|
||||
}
|
||||
|
||||
export interface TalentBalanceOverride {
|
||||
/** Scales every supported passive modifier parsed from the talent tooltip. */
|
||||
readonly effectScalePercent?: number;
|
||||
/** Signed percentage-point adjustments applied per learned rank. */
|
||||
readonly modifiersPerRank?: Readonly<Partial<Record<TalentBalanceModifierKind, number>>>;
|
||||
}
|
||||
|
||||
export interface BalanceTuningPolicy {
|
||||
readonly schemaVersion: 1;
|
||||
readonly abilityOverrides: Readonly<Record<string, AbilityBalanceOverride>>;
|
||||
readonly talentOverrides: Readonly<Record<string, TalentBalanceOverride>>;
|
||||
}
|
||||
|
||||
interface StorageLike {
|
||||
getItem(key: string): string | null;
|
||||
setItem(key: string, value: string): void;
|
||||
removeItem(key: string): void;
|
||||
}
|
||||
|
||||
const DEFAULT_POLICY: BalanceTuningPolicy = Object.freeze({
|
||||
schemaVersion: 1,
|
||||
abilityOverrides: Object.freeze({}),
|
||||
talentOverrides: Object.freeze({}),
|
||||
});
|
||||
|
||||
const ABILITY_FIELDS = [
|
||||
"cooldownPercent",
|
||||
"castTimePercent",
|
||||
"resourceCostPercent",
|
||||
"damagePercent",
|
||||
"healingPercent",
|
||||
"durationPercent",
|
||||
"radiusPercent",
|
||||
] as const;
|
||||
|
||||
let cachedPolicy: BalanceTuningPolicy | null = null;
|
||||
|
||||
function browserStorage(): StorageLike | null {
|
||||
try {
|
||||
return typeof globalThis.localStorage === "undefined" ? null : globalThis.localStorage;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function bounded(value: unknown, minimum: number, maximum: number): number | undefined {
|
||||
const numeric = typeof value === "number" ? value : Number(value);
|
||||
if (!Number.isFinite(numeric)) return undefined;
|
||||
return Math.min(maximum, Math.max(minimum, Math.round(numeric * 100) / 100));
|
||||
}
|
||||
|
||||
function normalizeAbilityOverride(value: unknown): AbilityBalanceOverride | null {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
const source = value as Record<string, unknown>;
|
||||
const result: Record<string, number> = {};
|
||||
for (const field of ABILITY_FIELDS) {
|
||||
const normalized = bounded(source[field], 0, 1_000);
|
||||
if (normalized !== undefined && normalized !== 100) result[field] = normalized;
|
||||
}
|
||||
return Object.keys(result).length ? result : null;
|
||||
}
|
||||
|
||||
function normalizeTalentOverride(value: unknown): TalentBalanceOverride | null {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
const source = value as Record<string, unknown>;
|
||||
const effectScalePercent = bounded(source.effectScalePercent, 0, 1_000);
|
||||
const sourceModifiers = source.modifiersPerRank && typeof source.modifiersPerRank === "object"
|
||||
? source.modifiersPerRank as Record<string, unknown>
|
||||
: {};
|
||||
const modifiersPerRank: Partial<Record<TalentBalanceModifierKind, number>> = {};
|
||||
for (const kind of TALENT_BALANCE_MODIFIER_KINDS) {
|
||||
const normalized = bounded(sourceModifiers[kind], -1_000, 1_000);
|
||||
if (normalized !== undefined && normalized !== 0) modifiersPerRank[kind] = normalized;
|
||||
}
|
||||
const result: TalentBalanceOverride = {
|
||||
...(effectScalePercent !== undefined && effectScalePercent !== 100 ? { effectScalePercent } : {}),
|
||||
...(Object.keys(modifiersPerRank).length ? { modifiersPerRank } : {}),
|
||||
};
|
||||
return Object.keys(result).length ? result : null;
|
||||
}
|
||||
|
||||
export function normalizeBalanceTuningPolicy(value: unknown): BalanceTuningPolicy {
|
||||
if (!value || typeof value !== "object") return DEFAULT_POLICY;
|
||||
const source = value as Record<string, unknown>;
|
||||
const sourceAbilities = source.abilityOverrides && typeof source.abilityOverrides === "object"
|
||||
? source.abilityOverrides as Record<string, unknown>
|
||||
: {};
|
||||
const sourceTalents = source.talentOverrides && typeof source.talentOverrides === "object"
|
||||
? source.talentOverrides as Record<string, unknown>
|
||||
: {};
|
||||
const abilityOverrides = Object.fromEntries(Object.entries(sourceAbilities).flatMap(([id, override]) => {
|
||||
const normalized = normalizeAbilityOverride(override);
|
||||
return normalized ? [[id, normalized]] : [];
|
||||
}));
|
||||
const talentOverrides = Object.fromEntries(Object.entries(sourceTalents).flatMap(([id, override]) => {
|
||||
const normalized = normalizeTalentOverride(override);
|
||||
return normalized ? [[id, normalized]] : [];
|
||||
}));
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
abilityOverrides: Object.freeze(abilityOverrides),
|
||||
talentOverrides: Object.freeze(talentOverrides),
|
||||
});
|
||||
}
|
||||
|
||||
export function loadBalanceTuningPolicy(storage: StorageLike | null = browserStorage()): BalanceTuningPolicy {
|
||||
if (!storage) return DEFAULT_POLICY;
|
||||
try {
|
||||
const serialized = storage.getItem(BALANCE_TUNING_STORAGE_KEY);
|
||||
return serialized ? normalizeBalanceTuningPolicy(JSON.parse(serialized)) : DEFAULT_POLICY;
|
||||
} catch {
|
||||
return DEFAULT_POLICY;
|
||||
}
|
||||
}
|
||||
|
||||
export function currentBalanceTuningPolicy(): BalanceTuningPolicy {
|
||||
cachedPolicy ??= loadBalanceTuningPolicy();
|
||||
return cachedPolicy;
|
||||
}
|
||||
|
||||
export function saveBalanceTuningPolicy(
|
||||
value: unknown,
|
||||
storage: StorageLike | null = browserStorage(),
|
||||
): BalanceTuningPolicy {
|
||||
const policy = normalizeBalanceTuningPolicy(value);
|
||||
cachedPolicy = policy;
|
||||
try {
|
||||
if (storage) {
|
||||
const empty = !Object.keys(policy.abilityOverrides).length && !Object.keys(policy.talentOverrides).length;
|
||||
if (empty) storage.removeItem(BALANCE_TUNING_STORAGE_KEY);
|
||||
else storage.setItem(BALANCE_TUNING_STORAGE_KEY, JSON.stringify(policy));
|
||||
}
|
||||
} catch {
|
||||
// Runtime tuning still applies for this session when persistent storage is unavailable.
|
||||
}
|
||||
if (typeof globalThis.dispatchEvent === "function" && typeof globalThis.CustomEvent === "function") {
|
||||
globalThis.dispatchEvent(new CustomEvent(BALANCE_TUNING_EVENT, { detail: policy }));
|
||||
}
|
||||
return policy;
|
||||
}
|
||||
|
||||
export function clearBalanceTuningPolicy(storage: StorageLike | null = browserStorage()): BalanceTuningPolicy {
|
||||
return saveBalanceTuningPolicy(DEFAULT_POLICY, storage);
|
||||
}
|
||||
|
||||
/** Test helper for isolating the module-level read cache without mutating browser storage. */
|
||||
export function resetBalanceTuningCache(): void {
|
||||
cachedPolicy = null;
|
||||
}
|
||||
|
||||
function scale(value: number, percent: number): number {
|
||||
return Math.max(0, Math.round(value * percent / 100));
|
||||
}
|
||||
|
||||
function scaleOptional(value: number | undefined, percent: number): number | undefined {
|
||||
return value === undefined ? undefined : value * percent / 100;
|
||||
}
|
||||
|
||||
function scaleDuration(value: number, percent: number): number {
|
||||
// Imported catalogs use negative durations as semantic sentinels. They are
|
||||
// not elapsed time and must survive unrelated tuning unchanged.
|
||||
return value < 0 ? value : scale(value, percent);
|
||||
}
|
||||
|
||||
function tuneAmount<T extends Record<string, unknown>>(effect: T, percent: number): T {
|
||||
return {
|
||||
...effect,
|
||||
...(typeof effect.coefficient === "number" ? { coefficient: effect.coefficient * percent / 100 } : {}),
|
||||
...(typeof effect.baseCoefficient === "number" ? { baseCoefficient: effect.baseCoefficient * percent / 100 } : {}),
|
||||
...(typeof effect.perComboCoefficient === "number" ? { perComboCoefficient: effect.perComboCoefficient * percent / 100 } : {}),
|
||||
...(typeof effect.basePoints === "number" ? { basePoints: effect.basePoints * percent / 100 } : {}),
|
||||
...(typeof effect.dieSides === "number" ? { dieSides: effect.dieSides * percent / 100 } : {}),
|
||||
...(typeof effect.pointsPerLevel === "number" ? { pointsPerLevel: effect.pointsPerLevel * percent / 100 } : {}),
|
||||
...(typeof effect.coefficientPerLevel === "number" ? { coefficientPerLevel: effect.coefficientPerLevel * percent / 100 } : {}),
|
||||
...(typeof effect.bonusPowerCoefficient === "number" ? { bonusPowerCoefficient: effect.bonusPowerCoefficient * percent / 100 } : {}),
|
||||
...(typeof effect.attackPowerCoefficient === "number" ? { attackPowerCoefficient: effect.attackPowerCoefficient * percent / 100 } : {}),
|
||||
...(typeof effect.spiritCoefficient === "number" ? { spiritCoefficient: effect.spiritCoefficient * percent / 100 } : {}),
|
||||
...(effect.poison && typeof effect.poison === "object" ? {
|
||||
poison: {
|
||||
...effect.poison,
|
||||
basePointsPerDose: Number((effect.poison as Record<string, unknown>).basePointsPerDose ?? 0) * percent / 100,
|
||||
attackPowerCoefficientPerDose: Number((effect.poison as Record<string, unknown>).attackPowerCoefficientPerDose ?? 0) * percent / 100,
|
||||
},
|
||||
} : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function tuneEffect(
|
||||
effect: AbilityEffect,
|
||||
override: AbilityBalanceOverride,
|
||||
): AbilityEffect {
|
||||
const damagePercent = override.damagePercent ?? 100;
|
||||
const healingPercent = override.healingPercent ?? 100;
|
||||
const durationPercent = override.durationPercent ?? 100;
|
||||
const radiusPercent = override.radiusPercent ?? 100;
|
||||
let tuned: AbilityEffect = effect;
|
||||
if (["damage", "finisher-damage", "dot", "execute"].includes(effect.kind)) {
|
||||
tuned = tuneAmount(effect as unknown as Record<string, unknown>, damagePercent) as unknown as AbilityEffect;
|
||||
} else if (["heal", "hot", "shield"].includes(effect.kind)) {
|
||||
tuned = tuneAmount(effect as unknown as Record<string, unknown>, healingPercent) as unknown as AbilityEffect;
|
||||
} else if (effect.kind === "summon") {
|
||||
tuned = { ...effect, coefficient: effect.coefficient * damagePercent / 100 };
|
||||
}
|
||||
if (tuned.kind === "dot" || tuned.kind === "hot") {
|
||||
tuned = { ...tuned, ticks: Math.max(1, Math.round(tuned.ticks * durationPercent / 100)) };
|
||||
} else if (tuned.kind === "apply-aura") {
|
||||
tuned = {
|
||||
...tuned,
|
||||
aura: {
|
||||
...tuned.aura,
|
||||
durationMs: tuned.aura.durationMs === null ? null : scale(tuned.aura.durationMs, durationPercent),
|
||||
},
|
||||
};
|
||||
} else if ("durationMs" in tuned && typeof tuned.durationMs === "number") {
|
||||
tuned = { ...tuned, durationMs: scaleDuration(tuned.durationMs, durationPercent) } as AbilityEffect;
|
||||
}
|
||||
if ("radius" in tuned && typeof tuned.radius === "number") {
|
||||
tuned = { ...tuned, radius: tuned.radius * radiusPercent / 100 } as AbilityEffect;
|
||||
}
|
||||
return tuned;
|
||||
}
|
||||
|
||||
function tuneRank(rank: AbilityRankDefinition, override: AbilityBalanceOverride): AbilityRankDefinition {
|
||||
const castTimeMs = scale(rank.castTimeMs, override.castTimePercent ?? 100);
|
||||
const tuneCost = <T extends { readonly amount: number }>(cost: T): T => ({
|
||||
...cost,
|
||||
amount: cost.amount * (override.resourceCostPercent ?? 100) / 100,
|
||||
});
|
||||
return {
|
||||
...rank,
|
||||
castMode: rank.castMode === "channel" ? "channel" : castTimeMs > 0 ? "cast" : "instant",
|
||||
castTimeMs,
|
||||
cooldownMs: scale(rank.cooldownMs, override.cooldownPercent ?? 100),
|
||||
cost: tuneCost(rank.cost),
|
||||
costs: rank.costs?.map(tuneCost),
|
||||
effects: rank.effects.map((effect) => tuneEffect(effect, override)),
|
||||
targeting: rank.targeting ? {
|
||||
...rank.targeting,
|
||||
radius: scaleOptional(rank.targeting.radius, override.radiusPercent ?? 100),
|
||||
jumpRange: scaleOptional(rank.targeting.jumpRange, override.radiusPercent ?? 100),
|
||||
groundRange: scaleOptional(rank.targeting.groundRange, override.radiusPercent ?? 100),
|
||||
} : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function applyAbilityBalanceTuning(
|
||||
ability: AbilityDefinition,
|
||||
policy: BalanceTuningPolicy = currentBalanceTuningPolicy(),
|
||||
): AbilityDefinition {
|
||||
const override = policy.abilityOverrides[ability.id];
|
||||
if (!override) return ability;
|
||||
const castTimeMs = scale(ability.castTimeMs, override.castTimePercent ?? 100);
|
||||
const tuneCost = <T extends { readonly amount: number }>(cost: T): T => ({
|
||||
...cost,
|
||||
amount: cost.amount * (override.resourceCostPercent ?? 100) / 100,
|
||||
});
|
||||
return {
|
||||
...ability,
|
||||
castMode: ability.castMode === "channel" ? "channel" : castTimeMs > 0 ? "cast" : "instant",
|
||||
castTimeMs,
|
||||
cooldownMs: scale(ability.cooldownMs, override.cooldownPercent ?? 100),
|
||||
cost: tuneCost(ability.cost),
|
||||
costs: ability.costs?.map(tuneCost),
|
||||
radius: scaleOptional(ability.radius, override.radiusPercent ?? 100),
|
||||
effects: ability.effects.map((effect) => tuneEffect(effect, override)),
|
||||
targeting: ability.targeting ? {
|
||||
...ability.targeting,
|
||||
radius: scaleOptional(ability.targeting.radius, override.radiusPercent ?? 100),
|
||||
jumpRange: scaleOptional(ability.targeting.jumpRange, override.radiusPercent ?? 100),
|
||||
groundRange: scaleOptional(ability.targeting.groundRange, override.radiusPercent ?? 100),
|
||||
} : undefined,
|
||||
ranks: ability.ranks?.map((rank) => tuneRank(rank, override)),
|
||||
};
|
||||
}
|
||||
|
||||
export function talentBalanceOverride(
|
||||
talentId: string,
|
||||
policy: BalanceTuningPolicy = currentBalanceTuningPolicy(),
|
||||
): TalentBalanceOverride | null {
|
||||
return policy.talentOverrides[talentId] ?? null;
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { abilitiesForClass } from "../abilityCatalog";
|
||||
import { rotationProfileFor } from "../combatRotation";
|
||||
import { createConfiguredPartyMember } from "../partyStore";
|
||||
import { specializationsForClass } from "../specializationCatalog";
|
||||
import { runtimeNormalizeTalentRanks } from "../talentRuntimeCatalog";
|
||||
import {
|
||||
loadSelectableBenchmarkBuilds,
|
||||
@@ -12,7 +15,15 @@ describe("balance-lab selectable builds", () => {
|
||||
"warrior", "paladin", "hunter", "rogue", "priest", "death-knight",
|
||||
"shaman", "mage", "warlock", "druid",
|
||||
]);
|
||||
expect(catalog).toHaveLength(31);
|
||||
const specializations = new Map<string, typeof catalog>();
|
||||
for (const build of catalog) {
|
||||
const key = `${build.classId}:${build.orientation}:${build.role}`;
|
||||
specializations.set(key, [...(specializations.get(key) ?? []), build]);
|
||||
}
|
||||
expect(specializations.size).toBe(31);
|
||||
expect([...specializations.values()].every((profiles) => profiles.length >= 2)).toBe(true);
|
||||
expect(catalog.filter((build) => build.defaultTalentBuild)).toHaveLength(31);
|
||||
expect(catalog.every((build) => build.talentBuildId && build.talentBuildName && build.characterName)).toBe(true);
|
||||
expect(catalog).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ classId: "hunter", orientation: "Beast Mastery", role: "damage" }),
|
||||
expect.objectContaining({ classId: "priest", orientation: "Discipline", role: "healer" }),
|
||||
@@ -29,6 +40,27 @@ describe("balance-lab selectable builds", () => {
|
||||
expect(Object.keys(beastMastery?.talentRanks ?? {})).not.toHaveLength(0);
|
||||
});
|
||||
|
||||
it("gives every selectable WoW specialization an executable priority rotation", () => {
|
||||
const catalog = selectableBenchmarkBuildCatalog(80, [
|
||||
"warrior", "paladin", "hunter", "rogue", "priest", "death-knight",
|
||||
"shaman", "mage", "warlock", "druid",
|
||||
]);
|
||||
const checked = new Set<string>();
|
||||
for (const build of catalog) {
|
||||
const key = `${build.classId}:${build.orientation}`;
|
||||
if (checked.has(key)) continue;
|
||||
checked.add(key);
|
||||
const specialization = specializationsForClass(build.classId)
|
||||
.find((candidate) => candidate.displayName === build.orientation);
|
||||
const profile = rotationProfileFor(build.classId, specialization?.id, build.orientation);
|
||||
expect(profile, key).not.toBeNull();
|
||||
expect(profile!.abilityPriority.length, key).toBeGreaterThan(0);
|
||||
const abilityNames = new Set(abilitiesForClass(build.classId).map((ability) => ability.name));
|
||||
expect(profile!.abilityPriority.some((name) => abilityNames.has(name)), key).toBe(true);
|
||||
}
|
||||
expect(checked.size).toBe(31);
|
||||
});
|
||||
|
||||
it("keeps each CoA specialization tree selectable", async () => {
|
||||
const builds = await loadSelectableBenchmarkBuilds(20, ["witch-doctor"]);
|
||||
expect(builds.map((build) => `${build.orientation}:${build.role}`)).toEqual([
|
||||
|
||||
@@ -1,23 +1,24 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { CLASSES } from "../../app/characterCatalog";
|
||||
import { partyRolesForClass } from "../partyRoles";
|
||||
import { runtimeNormalizeTalentRanks } from "../talentRuntimeCatalog";
|
||||
import { loadCanonicalBenchmarkBuilds } from "./benchmarkBuilds";
|
||||
import {
|
||||
loadCanonicalBenchmarkBuilds,
|
||||
loadSelectableBenchmarkBuilds,
|
||||
selectableBenchmarkBuildCatalog,
|
||||
} from "./benchmarkBuilds";
|
||||
|
||||
describe("canonical benchmark builds", () => {
|
||||
it("covers every declared class-role pair exactly once with legal ranks", async () => {
|
||||
it("defaults to the five Ready class/spec builds with legal ranks", async () => {
|
||||
const builds = await loadCanonicalBenchmarkBuilds();
|
||||
const expectedPairs = CLASSES.flatMap((definition) => (
|
||||
partyRolesForClass(definition.id).map((role) => `${definition.id}:${role}`)
|
||||
));
|
||||
const actualPairs = builds.map((build) => `${build.classId}:${build.role}`);
|
||||
|
||||
expect(builds).toHaveLength(77);
|
||||
expect(new Set(actualPairs).size).toBe(builds.length);
|
||||
expect(new Set(actualPairs)).toEqual(new Set(expectedPairs));
|
||||
expect(builds.map((build) => `${build.classId}:${build.orientation}:${build.role}`)).toEqual([
|
||||
"warrior:Protection:tank",
|
||||
"hunter:Marksmanship:damage",
|
||||
"rogue:Assassination:damage",
|
||||
"priest:Discipline:healer",
|
||||
"mage:Fire:damage",
|
||||
]);
|
||||
for (const build of builds) {
|
||||
expect(build.level).toBe(60);
|
||||
expect(build.gearPresetId).toBe("coefficient-empty");
|
||||
expect(build.gearPresetId).toBe("virtual-heirloom");
|
||||
expect(build.inventory).toEqual([]);
|
||||
expect(build.equipment).toEqual({});
|
||||
expect(runtimeNormalizeTalentRanks(build.classId, build.level, build.talentRanks)).toEqual(build.talentRanks);
|
||||
@@ -33,4 +34,19 @@ describe("canonical benchmark builds", () => {
|
||||
"rom-priest:damage",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps both named classic talent builds and marks Playerbots as the default", async () => {
|
||||
const catalog = selectableBenchmarkBuildCatalog(60, ["mage"]);
|
||||
const builds = await loadSelectableBenchmarkBuilds(60, ["mage"]);
|
||||
expect(catalog).toHaveLength(6);
|
||||
expect(builds).toHaveLength(6);
|
||||
expect(new Set(builds.map((build) => build.characterName)).size).toBe(6);
|
||||
for (const specialization of ["Arcane", "Fire", "Frost"]) {
|
||||
const variants = builds.filter((build) => build.orientation === specialization);
|
||||
expect(variants).toHaveLength(2);
|
||||
expect(variants.filter((build) => build.defaultTalentBuild)).toHaveLength(1);
|
||||
expect(variants.find((build) => build.defaultTalentBuild)?.talentBuildSource)
|
||||
.toBe("azerothcore-playerbots@531282e");
|
||||
}
|
||||
}, 30_000);
|
||||
});
|
||||
@@ -20,9 +20,16 @@ import {
|
||||
import { aiHintsForAbility } from "../abilityRules";
|
||||
import type { EquipmentAssignments } from "../equipment";
|
||||
import { partyRolesForClass, type PartyRole } from "../partyRoles";
|
||||
import { runtimePartyTalentRanksForLevel } from "../partyTalentRuntime";
|
||||
import {
|
||||
runtimeDefaultPartyTalentProfile,
|
||||
runtimePartyTalentProvenanceForLevel,
|
||||
runtimePartyTalentProfiles,
|
||||
runtimePartyTalentRanksForLevel,
|
||||
type PartyTalentRuntimeProfile,
|
||||
} from "../partyTalentRuntime";
|
||||
import { clampPlayerLevel } from "../progression";
|
||||
import {
|
||||
romGearProfile,
|
||||
specializationsForClass,
|
||||
type SpecializationDefinition,
|
||||
} from "../specializationCatalog";
|
||||
@@ -38,9 +45,16 @@ import {
|
||||
type TalentUiTree,
|
||||
} from "../talentUiCatalog";
|
||||
import type { BenchmarkBuildDefinition } from "./benchmarkTypes";
|
||||
import { romFiveIdentity } from "../romFiveIdentities";
|
||||
import { ROM_SECONDARY_UNLOCK_LEVEL } from "../../app/romDualClass";
|
||||
import {
|
||||
READY_COMPANION_SPECIALIZATION_IDS,
|
||||
readinessStatusFor,
|
||||
type CompanionSpecReadinessStatus,
|
||||
} from "../companionSpecReadiness";
|
||||
|
||||
export const CANONICAL_BENCHMARK_LEVEL = 60;
|
||||
export const CANONICAL_BENCHMARK_GEAR_PRESET_ID = "coefficient-empty";
|
||||
export const CANONICAL_BENCHMARK_GEAR_PRESET_ID = "virtual-heirloom";
|
||||
|
||||
const EMPTY_INVENTORY: readonly never[] = Object.freeze([]);
|
||||
const EMPTY_EQUIPMENT: EquipmentAssignments = Object.freeze({});
|
||||
@@ -49,6 +63,7 @@ const EMPTY_TALENT_RANKS: RuntimeTalentRanks = Object.freeze({});
|
||||
const BASE_CLASS_ID_SET: ReadonlySet<ClassId> = new Set(BASE_CLASS_IDS);
|
||||
const COA_CLASS_ID_SET: ReadonlySet<ClassId> = new Set(COA_CLASS_IDS);
|
||||
const ROM_CLASS_ID_SET: ReadonlySet<ClassId> = new Set(ROM_CLASS_IDS);
|
||||
const READY_SPECIALIZATION_ID_SET: ReadonlySet<string> = new Set(READY_COMPANION_SPECIALIZATION_IDS);
|
||||
|
||||
const WOW_ORIENTATION_BY_ROLE: Readonly<
|
||||
Record<BaseClassId, Readonly<Partial<Record<PartyRole, string>>>>
|
||||
@@ -56,8 +71,8 @@ const WOW_ORIENTATION_BY_ROLE: Readonly<
|
||||
warrior: { tank: "Protection", damage: "Fury" },
|
||||
paladin: { tank: "Protection", healer: "Holy", damage: "Retribution" },
|
||||
hunter: { damage: "Marksmanship" },
|
||||
rogue: { damage: "Combat" },
|
||||
priest: { healer: "Holy", damage: "Shadow" },
|
||||
rogue: { damage: "Assassination" },
|
||||
priest: { healer: "Discipline", damage: "Shadow" },
|
||||
"death-knight": { tank: "Blood", damage: "Frost" },
|
||||
shaman: { healer: "Restoration", damage: "Elemental" },
|
||||
mage: { damage: "Fire" },
|
||||
@@ -164,9 +179,13 @@ function benchmarkVariantBuildId(
|
||||
role: PartyRole,
|
||||
orientation: string,
|
||||
level: number,
|
||||
talentProfile?: PartyTalentRuntimeProfile,
|
||||
): string {
|
||||
const variant = orientation.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
||||
return `balance-${contentCategoryForClass(classId)}-${classId}-${role}-${variant}-level-${level}`;
|
||||
const base = `balance-${contentCategoryForClass(classId)}-${classId}-${role}-${variant}-level-${level}`;
|
||||
if (!talentProfile || talentProfile.defaultForSpecialization) return base;
|
||||
const profile = talentProfile.id.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
||||
return `${base}--${profile}`;
|
||||
}
|
||||
|
||||
function commonBuildFields(classId: ClassId, role: PartyRole, level: number) {
|
||||
@@ -192,10 +211,20 @@ function wowBuild(
|
||||
if (!orientation) {
|
||||
throw new Error(`No canonical WoW talent orientation exists for ${classId}:${role}.`);
|
||||
}
|
||||
const talentProfile = runtimeDefaultPartyTalentProfile(classId, orientation);
|
||||
const talentProvenance = runtimePartyTalentProvenanceForLevel(talentProfile, level);
|
||||
return Object.freeze({
|
||||
...commonBuildFields(classId, role, level),
|
||||
orientation,
|
||||
talentRanks: runtimePartyTalentRanksForLevel(classId, orientation, level),
|
||||
characterName: talentProfile.characterName,
|
||||
talentBuildId: talentProfile.id,
|
||||
talentBuildName: talentProfile.name,
|
||||
talentBuildSource: `${talentProfile.source}@${talentProfile.sourceVersion}`,
|
||||
talentBuildSourcePointCount: talentProvenance.sourcePointCount,
|
||||
talentBuildSourcePointsRetained: talentProvenance.sourcePointsRetained,
|
||||
talentBuildFallbackPointCount: talentProvenance.fallbackPointCount,
|
||||
defaultTalentBuild: talentProfile.defaultForSpecialization,
|
||||
talentRanks: runtimePartyTalentRanksForLevel(classId, orientation, level, talentProfile.id),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -374,8 +403,11 @@ function romBuild(
|
||||
role: PartyRole,
|
||||
level: number,
|
||||
): BenchmarkBuildDefinition {
|
||||
const identity = romFiveIdentity(classId, role);
|
||||
return Object.freeze({
|
||||
...commonBuildFields(classId, role, level),
|
||||
secondaryClassId: identity && level >= ROM_SECONDARY_UNLOCK_LEVEL ? identity.secondaryClassId : null,
|
||||
...(identity ? { raceId: identity.raceId, characterName: identity.name } : {}),
|
||||
orientation: "Primary",
|
||||
talentRanks: EMPTY_TALENT_RANKS,
|
||||
});
|
||||
@@ -435,11 +467,18 @@ function selectableClassicBuild(
|
||||
specialization: SpecializationDefinition,
|
||||
level: number,
|
||||
catalog: TalentUiCatalog | null,
|
||||
talentProfile: PartyTalentRuntimeProfile,
|
||||
): BenchmarkBuildDefinition {
|
||||
const classId = specialization.classId as BaseClassId;
|
||||
const talentProvenance = runtimePartyTalentProvenanceForLevel(talentProfile, level);
|
||||
let talentRanks: RuntimeTalentRanks;
|
||||
try {
|
||||
talentRanks = runtimePartyTalentRanksForLevel(classId, specialization.displayName, level);
|
||||
talentRanks = runtimePartyTalentRanksForLevel(
|
||||
classId,
|
||||
specialization.displayName,
|
||||
level,
|
||||
talentProfile.id,
|
||||
);
|
||||
} catch {
|
||||
if (!catalog) throw new Error(`No talent catalog was loaded for ${classId}.`);
|
||||
talentRanks = allocateSingleTreeTalentRanks(
|
||||
@@ -451,8 +490,22 @@ function selectableClassicBuild(
|
||||
}
|
||||
return Object.freeze({
|
||||
...commonBuildFields(classId, specialization.role, level),
|
||||
id: benchmarkVariantBuildId(classId, specialization.role, specialization.displayName, level),
|
||||
id: benchmarkVariantBuildId(
|
||||
classId,
|
||||
specialization.role,
|
||||
specialization.displayName,
|
||||
level,
|
||||
talentProfile,
|
||||
),
|
||||
orientation: specialization.displayName,
|
||||
characterName: talentProfile.characterName,
|
||||
talentBuildId: talentProfile.id,
|
||||
talentBuildName: talentProfile.name,
|
||||
talentBuildSource: `${talentProfile.source}@${talentProfile.sourceVersion}`,
|
||||
talentBuildSourcePointCount: talentProvenance.sourcePointCount,
|
||||
talentBuildSourcePointsRetained: talentProvenance.sourcePointsRetained,
|
||||
talentBuildFallbackPointCount: talentProvenance.fallbackPointCount,
|
||||
defaultTalentBuild: talentProfile.defaultForSpecialization,
|
||||
talentRanks,
|
||||
});
|
||||
}
|
||||
@@ -486,7 +539,17 @@ export interface SelectableBenchmarkBuildCatalogEntry {
|
||||
readonly classId: ClassId;
|
||||
readonly role: PartyRole;
|
||||
readonly orientation: string;
|
||||
readonly specializationId?: string;
|
||||
readonly readinessStatus?: CompanionSpecReadinessStatus;
|
||||
readonly level: number;
|
||||
readonly characterName?: string;
|
||||
readonly talentBuildId?: string;
|
||||
readonly talentBuildName?: string;
|
||||
readonly talentBuildSource?: string;
|
||||
readonly talentBuildSourcePointCount?: number | null;
|
||||
readonly talentBuildSourcePointsRetained?: number | null;
|
||||
readonly talentBuildFallbackPointCount?: number;
|
||||
readonly defaultTalentBuild?: boolean;
|
||||
}
|
||||
|
||||
/** Cheap metadata-only roster used by interactive tools before a build is selected. */
|
||||
@@ -500,22 +563,55 @@ export function selectableBenchmarkBuildCatalog(
|
||||
.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,
|
||||
}));
|
||||
if (isRomClassId(classId)) return partyRolesForClass(classId).map((role) => {
|
||||
const specialization = romGearProfile(classId, role);
|
||||
return {
|
||||
id: benchmarkBuildId(classId, role, safeLevel),
|
||||
source: contentCategoryForClass(classId),
|
||||
classId,
|
||||
role,
|
||||
orientation: "Primary",
|
||||
specializationId: specialization.id,
|
||||
readinessStatus: readinessStatusFor(specialization.id),
|
||||
level: safeLevel,
|
||||
};
|
||||
});
|
||||
return specializationsForClass(classId).flatMap((specialization) => {
|
||||
const profiles = isBaseClassId(classId)
|
||||
? runtimePartyTalentProfiles(classId, specialization.displayName)
|
||||
: [undefined];
|
||||
return profiles.map((talentProfile) => {
|
||||
const talentProvenance = talentProfile
|
||||
? runtimePartyTalentProvenanceForLevel(talentProfile, safeLevel)
|
||||
: null;
|
||||
return {
|
||||
id: benchmarkVariantBuildId(
|
||||
classId,
|
||||
specialization.role,
|
||||
specialization.displayName,
|
||||
safeLevel,
|
||||
talentProfile,
|
||||
),
|
||||
source: contentCategoryForClass(classId),
|
||||
classId,
|
||||
role: specialization.role,
|
||||
orientation: specialization.displayName,
|
||||
specializationId: specialization.id,
|
||||
readinessStatus: readinessStatusFor(specialization.id),
|
||||
level: safeLevel,
|
||||
characterName: talentProfile?.characterName,
|
||||
talentBuildId: talentProfile?.id,
|
||||
talentBuildName: talentProfile?.name,
|
||||
talentBuildSource: talentProfile
|
||||
? `${talentProfile.source}@${talentProfile.sourceVersion}`
|
||||
: undefined,
|
||||
talentBuildSourcePointCount: talentProvenance?.sourcePointCount,
|
||||
talentBuildSourcePointsRetained: talentProvenance?.sourcePointsRetained,
|
||||
talentBuildFallbackPointCount: talentProvenance?.fallbackPointCount,
|
||||
defaultTalentBuild: talentProfile?.defaultForSpecialization,
|
||||
};
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -561,7 +657,14 @@ export async function loadSelectableBenchmarkBuilds(
|
||||
}
|
||||
for (const specialization of specializationsForClass(classId)) {
|
||||
if (isBaseClassId(classId)) {
|
||||
builds.push(selectableClassicBuild(specialization, safeLevel, catalogs.get(classId) ?? null));
|
||||
for (const talentProfile of runtimePartyTalentProfiles(classId, specialization.displayName)) {
|
||||
builds.push(selectableClassicBuild(
|
||||
specialization,
|
||||
safeLevel,
|
||||
catalogs.get(classId) ?? null,
|
||||
talentProfile,
|
||||
));
|
||||
}
|
||||
} else if (isCoaClassId(classId)) {
|
||||
const catalog = catalogs.get(classId);
|
||||
if (!catalog) throw new Error(`No loaded CoA talent catalog exists for ${classId}.`);
|
||||
@@ -573,19 +676,25 @@ export async function loadSelectableBenchmarkBuilds(
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates one stable, legal benchmark build for every declared class-role pair.
|
||||
* A filter changes only membership; output always follows the canonical CLASSES
|
||||
* and partyRolesForClass declaration order.
|
||||
* Creates the five Ready benchmark builds by default. Supplying explicit class
|
||||
* IDs remains a targeted diagnostic and returns those classes' declared roles.
|
||||
* Output follows the canonical CLASSES and partyRolesForClass declaration order.
|
||||
*/
|
||||
export async function loadCanonicalBenchmarkBuilds(
|
||||
level = CANONICAL_BENCHMARK_LEVEL,
|
||||
classIds?: readonly ClassId[],
|
||||
): Promise<BenchmarkBuildDefinition[]> {
|
||||
const safeLevel = clampPlayerLevel(level);
|
||||
const readyOnly = classIds === undefined;
|
||||
const requested = classIds ? new Set(classIds) : null;
|
||||
const selectedClassIds = CLASSES
|
||||
.map((definition) => definition.id)
|
||||
.filter((classId) => !requested || requested.has(classId));
|
||||
.filter((classId) => {
|
||||
if (requested) return requested.has(classId);
|
||||
return isBaseClassId(classId) && specializationsForClass(classId).some((specialization) => (
|
||||
READY_SPECIALIZATION_ID_SET.has(specialization.id)
|
||||
));
|
||||
});
|
||||
const selectedCoaClassIds = selectedClassIds.filter(isCoaClassId);
|
||||
|
||||
await preloadAbilityCatalogsForClasses(selectedCoaClassIds);
|
||||
@@ -600,6 +709,13 @@ export async function loadCanonicalBenchmarkBuilds(
|
||||
for (const classId of selectedClassIds) {
|
||||
for (const role of partyRolesForClass(classId)) {
|
||||
if (isBaseClassId(classId)) {
|
||||
if (readyOnly) {
|
||||
const orientation = WOW_ORIENTATION_BY_ROLE[classId][role];
|
||||
const specialization = specializationsForClass(classId).find((candidate) => (
|
||||
candidate.displayName === orientation
|
||||
));
|
||||
if (!specialization || !READY_SPECIALIZATION_ID_SET.has(specialization.id)) continue;
|
||||
}
|
||||
builds.push(wowBuild(classId, role, safeLevel));
|
||||
} else if (isCoaClassId(classId)) {
|
||||
const catalog = coaCatalogs.get(classId);
|
||||
|
||||
@@ -219,6 +219,10 @@ export class BenchmarkController {
|
||||
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 },
|
||||
@@ -227,6 +231,7 @@ export class BenchmarkController {
|
||||
health: member.health,
|
||||
maxHealth: member.maxHealth,
|
||||
fallen: member.health <= 0,
|
||||
role: member.role,
|
||||
})),
|
||||
],
|
||||
hostileCount: scenario.hostileTargets,
|
||||
|
||||
@@ -279,6 +279,7 @@ export async function runCombatBenchmark(
|
||||
.find((specialization) => specialization.displayName === build.orientation)?.id;
|
||||
combat.initializeCharacter({
|
||||
classId: build.classId,
|
||||
raceId: build.raceId,
|
||||
secondaryClassId: build.secondaryClassId,
|
||||
specializationId,
|
||||
level: build.level,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ClassId } from "../../app/characterCatalog";
|
||||
import type { ClassId, RaceId } from "../../app/characterCatalog";
|
||||
import type { ContentCategoryId } from "../../app/contentCategories";
|
||||
import type { EquipmentAssignments } from "../equipment";
|
||||
import type { ItemStats } from "../itemStats";
|
||||
@@ -47,10 +47,19 @@ export interface BenchmarkBuildDefinition {
|
||||
readonly id: string;
|
||||
readonly source: ContentCategoryId;
|
||||
readonly classId: ClassId;
|
||||
readonly raceId?: RaceId;
|
||||
readonly secondaryClassId?: ClassId | null;
|
||||
readonly role: PartyRole;
|
||||
readonly level: number;
|
||||
readonly orientation: string;
|
||||
readonly characterName?: string;
|
||||
readonly talentBuildId?: string;
|
||||
readonly talentBuildName?: string;
|
||||
readonly talentBuildSource?: string;
|
||||
readonly talentBuildSourcePointCount?: number | null;
|
||||
readonly talentBuildSourcePointsRetained?: number | null;
|
||||
readonly talentBuildFallbackPointCount?: number;
|
||||
readonly defaultTalentBuild?: boolean;
|
||||
readonly talentRanks: Readonly<Record<string, number>>;
|
||||
readonly inventory: readonly never[];
|
||||
readonly equipment: EquipmentAssignments;
|
||||
|
||||
@@ -24,7 +24,7 @@ const scenario: BenchmarkScenarioDefinition = {
|
||||
export async function runDungeonBossBenchmark(
|
||||
build: BenchmarkBuildDefinition, bossId: string, seed: string, mechanics = false,
|
||||
) {
|
||||
await preloadAbilityCatalogsForClasses([build.classId]);
|
||||
await preloadAbilityCatalogsForClasses(build.secondaryClassId ? [build.classId, build.secondaryClassId] : [build.classId]);
|
||||
const dungeon = requireDungeonDefinition("ragefire-chasm");
|
||||
const spawn = dungeon.staticSpawns.find((entry) => entry.id === bossId)!;
|
||||
const entity = dungeon.entities[spawn.entityId];
|
||||
@@ -56,7 +56,7 @@ export async function runDungeonBossBenchmark(
|
||||
});
|
||||
try {
|
||||
const combat = useCombatStore.getState();
|
||||
combat.initializeCharacter({ classId: build.classId, secondaryClassId: build.secondaryClassId,
|
||||
combat.initializeCharacter({ classId: build.classId, raceId: build.raceId, secondaryClassId: build.secondaryClassId,
|
||||
specializationId: specialization?.id, role: build.role, level: build.level,
|
||||
talentRanks: build.talentRanks, inventory: build.inventory, equipment: build.equipment });
|
||||
combat.setPlayerPosition([-2, 0, 0]);
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
activeDungeonCompletedObjectiveIds,
|
||||
advanceDungeonEncounters,
|
||||
createDungeonEncounterSnapshot,
|
||||
reduceDungeonEncounter,
|
||||
resetDungeonEncounters,
|
||||
validateDungeonEncounterDefinition,
|
||||
} from "./dungeonEncounterRuntime";
|
||||
import { BITE_SIZED_STOCKADES_DUNGEON_ID, STORMWIND_STOCKADE_DUNGEON_ID } from "./dungeonContentSource";
|
||||
import { DUNGEON_REGISTRY, dungeonDefinitionById } from "./dungeonRegistry";
|
||||
import { activateDungeonSession, restartActiveDungeonSession } from "./dungeonSession";
|
||||
import { completedDungeonRun, useDungeonRunStore } from "./dungeonRunStore";
|
||||
import {
|
||||
BITE_SIZED_STOCKADES_BOSS_CANDIDATES,
|
||||
createBiteSizedStockadesDefinition,
|
||||
} from "./dungeons/biteSizedStockades";
|
||||
import { validateDungeonWorldObject } from "./dungeonWorldObjects";
|
||||
import { dungeonEnemyScaling } from "./dungeonBossScaling";
|
||||
|
||||
const source = DUNGEON_REGISTRY[STORMWIND_STOCKADE_DUNGEON_ID];
|
||||
|
||||
describe("bite-sized Stormwind Stockades", () => {
|
||||
it("creates a separate central-corridor dungeon without changing Stockades", () => {
|
||||
const originalBossIds = source.bosses.map((boss) => boss.id);
|
||||
const originalSpawnCount = source.staticSpawns.length;
|
||||
const biteSized = createBiteSizedStockadesDefinition(source, "run-a");
|
||||
|
||||
expect(biteSized.id).toBe(BITE_SIZED_STOCKADES_DUNGEON_ID);
|
||||
expect(biteSized).not.toBe(source);
|
||||
expect(source.bosses.map((boss) => boss.id)).toEqual(originalBossIds);
|
||||
expect(source.staticSpawns).toHaveLength(originalSpawnCount);
|
||||
expect(source.bosses).toHaveLength(5);
|
||||
expect(source.serverGameObjects.some((object) => object.id.startsWith("bite-sized-"))).toBe(false);
|
||||
|
||||
const bossId = biteSized.bosses[0].id;
|
||||
expect(BITE_SIZED_STOCKADES_BOSS_CANDIDATES.map((boss) => boss.sourceSpawnId)).toContain(bossId);
|
||||
expect(biteSized.objectiveOrder).toEqual(biteSized.bosses);
|
||||
expect(biteSized.finalBossDurability).toBe("standard");
|
||||
expect(biteSized.staticSpawns.filter((spawn) => spawn.id === bossId)).toHaveLength(1);
|
||||
expect(biteSized.staticSpawns.every((spawn) => Math.abs(spawn.position[2]) <= 15)).toBe(true);
|
||||
expect(biteSized.navigationLinks).toEqual(source.navigationLinks);
|
||||
expect(biteSized.traversalRamps).toEqual(source.traversalRamps);
|
||||
expect(biteSized.roamingPacks).toEqual([]);
|
||||
});
|
||||
|
||||
it("seals both side wings with visible movement and sight blockers", () => {
|
||||
const biteSized = createBiteSizedStockadesDefinition(source, "run-b");
|
||||
const walls = biteSized.serverGameObjects.filter((object) => object.id.startsWith("bite-sized-stockades-"));
|
||||
|
||||
expect(walls.map((wall) => wall.id).sort()).toEqual([
|
||||
"bite-sized-stockades-north-wall",
|
||||
"bite-sized-stockades-south-wall",
|
||||
]);
|
||||
for (const wall of walls) {
|
||||
expect(() => validateDungeonWorldObject(wall)).not.toThrow();
|
||||
expect(wall.states).toEqual([expect.objectContaining({
|
||||
id: "sealed",
|
||||
visible: true,
|
||||
blocksMovement: true,
|
||||
blocksSight: true,
|
||||
})]);
|
||||
}
|
||||
});
|
||||
|
||||
it("chooses all five source bosses deterministically across fresh run ids", () => {
|
||||
const selected = new Set<string>();
|
||||
for (let index = 0; index < 1_000; index += 1) {
|
||||
selected.add(createBiteSizedStockadesDefinition(source, `fresh-run-${index}`).bosses[0].name);
|
||||
}
|
||||
expect([...selected].sort()).toEqual(BITE_SIZED_STOCKADES_BOSS_CANDIDATES.map((boss) => boss.name).sort());
|
||||
const first = createBiteSizedStockadesDefinition(source, "same-run");
|
||||
const second = createBiteSizedStockadesDefinition(source, "same-run");
|
||||
expect(second.bosses[0]).toEqual(first.bosses[0]);
|
||||
expect(second.staticSpawns.find((spawn) => spawn.id === second.bosses[0].id)?.entityId)
|
||||
.toBe(first.staticSpawns.find((spawn) => spawn.id === first.bosses[0].id)?.entityId);
|
||||
});
|
||||
|
||||
it("uses ordinary boss durability for every level-17 roll", () => {
|
||||
const variants = new Map<string, ReturnType<typeof createBiteSizedStockadesDefinition>>();
|
||||
for (let index = 0; index < 1_000 && variants.size < BITE_SIZED_STOCKADES_BOSS_CANDIDATES.length; index += 1) {
|
||||
const definition = createBiteSizedStockadesDefinition(source, `level-17-run-${index}`);
|
||||
variants.set(definition.bosses[0].id, definition);
|
||||
}
|
||||
expect(variants.size).toBe(BITE_SIZED_STOCKADES_BOSS_CANDIDATES.length);
|
||||
|
||||
const ordinaryBoss = source.bosses[0];
|
||||
const ordinarySpawn = source.staticSpawns.find((spawn) => spawn.id === ordinaryBoss.id)!;
|
||||
const ordinaryEntity = source.entities[ordinarySpawn.entityId];
|
||||
for (const characterSource of ["wow", "rom", "coa"] as const) {
|
||||
const ordinaryHealth = dungeonEnemyScaling(
|
||||
source, ordinaryEntity, ordinaryBoss.id, 17, "normal", 1, characterSource,
|
||||
).maxHealth;
|
||||
for (const definition of variants.values()) {
|
||||
const boss = definition.bosses[0];
|
||||
const spawn = definition.staticSpawns.find((entry) => entry.id === boss.id)!;
|
||||
const entity = definition.entities[spawn.entityId];
|
||||
expect(dungeonEnemyScaling(
|
||||
definition, entity, boss.id, 17, "normal", 1, characterSource,
|
||||
).maxHealth, `${characterSource}:${boss.name}`).toBeCloseTo(ordinaryHealth);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("uses the run identity for fresh, restarted, and shared sessions", () => {
|
||||
expect(activateDungeonSession(BITE_SIZED_STOCKADES_DUNGEON_ID, undefined, { runId: "run-a" })).toBe(true);
|
||||
const firstBoss = dungeonDefinitionById(BITE_SIZED_STOCKADES_DUNGEON_ID)!.bosses[0].name;
|
||||
expect(useDungeonRunStore.getState().progress?.id).toBe("run-a");
|
||||
|
||||
expect(restartActiveDungeonSession()).toBe(true);
|
||||
expect(dungeonDefinitionById(BITE_SIZED_STOCKADES_DUNGEON_ID)!.bosses[0].name).toBe(firstBoss);
|
||||
|
||||
expect(activateDungeonSession(BITE_SIZED_STOCKADES_DUNGEON_ID, undefined, { runId: "run-b" })).toBe(true);
|
||||
expect(useDungeonRunStore.getState().progress?.id).toBe("run-b");
|
||||
expect(dungeonDefinitionById(BITE_SIZED_STOCKADES_DUNGEON_ID)!.bosses[0].name).not.toBe(firstBoss);
|
||||
});
|
||||
|
||||
it("completes the one-boss run only through its stable objective", () => {
|
||||
const biteSized = createBiteSizedStockadesDefinition(source, "completion-run");
|
||||
const bossId = biteSized.bosses[0].id;
|
||||
const encounter = biteSized.encounters![0];
|
||||
expect(() => validateDungeonEncounterDefinition(encounter)).not.toThrow();
|
||||
const snapshot = createDungeonEncounterSnapshot(encounter, 1_000);
|
||||
const reduced = reduceDungeonEncounter(encounter, snapshot, {
|
||||
kind: "tick",
|
||||
now: 2_000,
|
||||
facts: {
|
||||
registeredEntityIds: [bossId],
|
||||
deadEntityIds: [bossId],
|
||||
},
|
||||
}).snapshot;
|
||||
|
||||
expect(reduced.completedObjectiveIds).toEqual([bossId]);
|
||||
expect(reduced.flags["dungeon-complete"]).toBe(true);
|
||||
expect(reduced.phase).toBe("complete");
|
||||
});
|
||||
|
||||
it("rebuilds seeded boss rules and records the original timer after a wipe and retry", () => {
|
||||
expect(activateDungeonSession(BITE_SIZED_STOCKADES_DUNGEON_ID, undefined, { runId: "stale-provider-a" })).toBe(true);
|
||||
const firstBossId = dungeonDefinitionById(BITE_SIZED_STOCKADES_DUNGEON_ID)!.bosses[0].id;
|
||||
let nextRunId = "stale-provider-b";
|
||||
for (let index = 0; index < 1_000; index += 1) {
|
||||
const candidate = `stale-provider-${index}`;
|
||||
if (createBiteSizedStockadesDefinition(source, candidate).bosses[0].id !== firstBossId) {
|
||||
nextRunId = candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
expect(activateDungeonSession(BITE_SIZED_STOCKADES_DUNGEON_ID, undefined, { runId: nextRunId })).toBe(true);
|
||||
const rerolledBossId = dungeonDefinitionById(BITE_SIZED_STOCKADES_DUNGEON_ID)!.bosses[0].id;
|
||||
expect(rerolledBossId).not.toBe(firstBossId);
|
||||
|
||||
const party = [{ name: "Tester", classId: "priest", level: 17, role: "healer", npc: false }] as const;
|
||||
const run = useDungeonRunStore.getState();
|
||||
run.start(1_000, party);
|
||||
advanceDungeonEncounters(BITE_SIZED_STOCKADES_DUNGEON_ID, {
|
||||
kind: "tick",
|
||||
now: 2_000,
|
||||
facts: { registeredEntityIds: [rerolledBossId], deadEntityIds: [] },
|
||||
});
|
||||
resetDungeonEncounters("boss-wipe", BITE_SIZED_STOCKADES_DUNGEON_ID, 3_000);
|
||||
expect(useDungeonRunStore.getState().progress?.startedAt).toBe(1_000);
|
||||
|
||||
advanceDungeonEncounters(BITE_SIZED_STOCKADES_DUNGEON_ID, {
|
||||
kind: "tick",
|
||||
now: 9_000,
|
||||
facts: { registeredEntityIds: [rerolledBossId], deadEntityIds: [rerolledBossId] },
|
||||
});
|
||||
run.advance(activeDungeonCompletedObjectiveIds(BITE_SIZED_STOCKADES_DUNGEON_ID), [rerolledBossId], 9_000, party);
|
||||
expect(completedDungeonRun(useDungeonRunStore.getState().progress!)).toMatchObject({
|
||||
id: nextRunId,
|
||||
durationMs: 8_000,
|
||||
killedBossIds: [rerolledBossId],
|
||||
fullClear: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
BOSS_BALANCE_OVERRIDES,
|
||||
resolveBossBalanceOverride,
|
||||
type BossBalanceOverride,
|
||||
} from "./bossBalanceOverrides";
|
||||
|
||||
const overrides: readonly BossBalanceOverride[] = [
|
||||
{ id: "all-levels", dungeonId: "test-dungeon", entityId: "test-boss",
|
||||
healthScale: 0.9, damageScale: 0.8, reason: "test" },
|
||||
{ id: "level-80", dungeonId: "test-dungeon", entityId: "test-boss",
|
||||
minimumLevel: 80, maximumLevel: 80, damageScale: 0.5, reason: "test" },
|
||||
];
|
||||
|
||||
describe("boss-specific balance overrides", () => {
|
||||
it("composes broad and exact-level corrections for one boss", () => {
|
||||
expect(resolveBossBalanceOverride("test-dungeon", "test-boss", 80, overrides)).toEqual({
|
||||
healthScale: 0.9,
|
||||
damageScale: 0.4,
|
||||
matchedOverrideIds: ["all-levels", "level-80"],
|
||||
});
|
||||
expect(resolveBossBalanceOverride("test-dungeon", "test-boss", 70, overrides)).toEqual({
|
||||
healthScale: 0.9,
|
||||
damageScale: 0.8,
|
||||
matchedOverrideIds: ["all-levels"],
|
||||
});
|
||||
});
|
||||
|
||||
it("matches the source dungeon while running in an isolated boss-testing room", () => {
|
||||
expect(resolveBossBalanceOverride(
|
||||
"boss-testing:test-dungeon:test-boss", "test-boss", 80, overrides,
|
||||
).matchedOverrideIds).toEqual(["all-levels", "level-80"]);
|
||||
});
|
||||
|
||||
it("leaves unrelated bosses and levels unchanged", () => {
|
||||
expect(resolveBossBalanceOverride("another-dungeon", "test-boss", 80, overrides)).toEqual({
|
||||
healthScale: 1,
|
||||
damageScale: 1,
|
||||
matchedOverrideIds: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("ships the Drakos correction only at its audited level", () => {
|
||||
expect(resolveBossBalanceOverride(
|
||||
"boss-testing:the-oculus:entry-27654", "entry-27654", 80, BOSS_BALANCE_OVERRIDES,
|
||||
)).toMatchObject({ damageScale: 0.45, matchedOverrideIds: ["level-80-drakos-pressure"] });
|
||||
expect(resolveBossBalanceOverride(
|
||||
"the-oculus", "entry-27654", 79, BOSS_BALANCE_OVERRIDES,
|
||||
)).toMatchObject({ healthScale: 1, damageScale: 1, matchedOverrideIds: [] });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
export interface BossBalanceOverride {
|
||||
readonly id: string;
|
||||
readonly dungeonId: string;
|
||||
readonly entityId: string;
|
||||
readonly minimumLevel?: number;
|
||||
readonly maximumLevel?: number;
|
||||
readonly healthScale?: number;
|
||||
readonly damageScale?: number;
|
||||
readonly reason: string;
|
||||
}
|
||||
|
||||
export interface ResolvedBossBalanceOverride {
|
||||
readonly healthScale: number;
|
||||
readonly damageScale: number;
|
||||
readonly matchedOverrideIds: readonly string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Evidence-based exceptions to the shared boss curve. Entries may cover one
|
||||
* level, a bracket, or every level when both bounds are omitted. Multipliers
|
||||
* compose so a broad boss correction can coexist with a narrower bracket fix.
|
||||
*/
|
||||
export const BOSS_BALANCE_OVERRIDES: readonly BossBalanceOverride[] = Object.freeze([
|
||||
{ id: "level-80-amanitar-pressure", dungeonId: "ahnkahet-old-kingdom", entityId: "entry-30258",
|
||||
minimumLevel: 80, maximumLevel: 80, damageScale: 0.7,
|
||||
reason: "Ready-five audit: 100% wipes and 3.14x median incoming pressure." },
|
||||
{ id: "level-80-warp-splinter-pressure", dungeonId: "botanica", entityId: "entry-17977",
|
||||
minimumLevel: 80, maximumLevel: 80, damageScale: 0.56,
|
||||
reason: "Ready-five audit: 100% wipes and 3.90x median incoming pressure." },
|
||||
{ id: "level-80-maiden-of-grief-pressure", dungeonId: "halls-of-stone", entityId: "entry-27975",
|
||||
minimumLevel: 80, maximumLevel: 80, damageScale: 0.56,
|
||||
reason: "Ready-five audit: 100% wipes and 3.93x median incoming pressure." },
|
||||
{ id: "level-80-ick-pressure", dungeonId: "pit-of-saron", entityId: "entry-36476",
|
||||
minimumLevel: 80, maximumLevel: 80, damageScale: 0.54,
|
||||
reason: "Ready-five audit: 100% wipes and 4.11x median incoming pressure." },
|
||||
{ id: "level-80-murmur-pressure", dungeonId: "shadow-labyrinth", entityId: "entry-18708",
|
||||
minimumLevel: 80, maximumLevel: 80, damageScale: 0.68,
|
||||
reason: "Ready-five audit: 100% wipes and 3.22x median incoming pressure." },
|
||||
{ id: "level-80-keristrasza-pressure", dungeonId: "the-nexus", entityId: "entry-26723",
|
||||
minimumLevel: 80, maximumLevel: 80, damageScale: 0.7,
|
||||
reason: "Ready-five audit: 100% wipes and 3.12x median incoming pressure." },
|
||||
{ id: "level-80-drakos-pressure", dungeonId: "the-oculus", entityId: "entry-27654",
|
||||
minimumLevel: 80, maximumLevel: 80, damageScale: 0.45,
|
||||
reason: "Ready-five audit: 100% wipes and 4.89x median incoming pressure." },
|
||||
{ id: "level-80-black-knight-phase-3-pressure", dungeonId: "trial-of-the-champion",
|
||||
entityId: "wrath-event-black-knight-phase-3", minimumLevel: 80, maximumLevel: 80, damageScale: 0.5,
|
||||
reason: "Ready-five audit: 100% wipes and 4.33x median incoming pressure." },
|
||||
]);
|
||||
|
||||
function sourceDungeonId(dungeonId: string, entityId: string): string {
|
||||
const prefix = "boss-testing:";
|
||||
const suffix = `:${entityId}`;
|
||||
return dungeonId.startsWith(prefix) && dungeonId.endsWith(suffix)
|
||||
? dungeonId.slice(prefix.length, -suffix.length)
|
||||
: dungeonId;
|
||||
}
|
||||
|
||||
function safeScale(value: number | undefined): number {
|
||||
return Number.isFinite(value) ? Math.max(0.05, value!) : 1;
|
||||
}
|
||||
|
||||
export function resolveBossBalanceOverride(
|
||||
dungeonId: string,
|
||||
entityId: string,
|
||||
level: number,
|
||||
overrides: readonly BossBalanceOverride[] = BOSS_BALANCE_OVERRIDES,
|
||||
): ResolvedBossBalanceOverride {
|
||||
const sourceId = sourceDungeonId(dungeonId, entityId);
|
||||
const matching = overrides.filter((entry) => (
|
||||
entry.dungeonId === sourceId
|
||||
&& entry.entityId === entityId
|
||||
&& level >= (entry.minimumLevel ?? Number.NEGATIVE_INFINITY)
|
||||
&& level <= (entry.maximumLevel ?? Number.POSITIVE_INFINITY)
|
||||
));
|
||||
return Object.freeze({
|
||||
healthScale: matching.reduce((scale, entry) => scale * safeScale(entry.healthScale), 1),
|
||||
damageScale: matching.reduce((scale, entry) => scale * safeScale(entry.damageScale), 1),
|
||||
matchedOverrideIds: Object.freeze(matching.map((entry) => entry.id)),
|
||||
});
|
||||
}
|
||||
@@ -50,10 +50,10 @@ describe("boss card rewards", () => {
|
||||
useGameStore.getState().activateDungeon("wailing-caverns");
|
||||
});
|
||||
|
||||
it.each([1, 40, 79, 80])("preserves non-equipment while allowing equipment only at 80 (level %i)", (level) => {
|
||||
it.each([1, 40, 79, 80])("grants boss items only after leveling (level %i)", (level) => {
|
||||
const item = { instanceId: "i", itemId: 1, itemKey: "item", name: "Item", quality: "rare" as const, inventorySlot: "waist", armorType: "cloth", weaponType: null, itemLevel: level, sourceItemLevel: 1, stats: EMPTY_ITEM_STATS, quantity: 1, sourceBossKey: "boss", sourceBossName: "Boss", acquiredAt: 1 };
|
||||
const misc = { ...item, instanceId: "m", inventorySlot: "misc" };
|
||||
expect(allowedLootDrops([item, misc], level)).toEqual(level === 80 ? [item, misc] : [misc]);
|
||||
expect(allowedLootDrops([item, misc], level)).toEqual(level === 80 ? [item, misc] : []);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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(950);
|
||||
expect(campaign.maxHealth).toBe(1050);
|
||||
expect(scale(1).damageMultiplier).toBe(campaign.damageMultiplier);
|
||||
expect(entity.combat?.attacks).toEqual(maraudon.entities[celebras.entityId].combat?.attacks);
|
||||
});
|
||||
@@ -99,6 +99,17 @@ describe("Boss Testing", () => {
|
||||
|
||||
expect(nativeHealth).toBeLessThan(100);
|
||||
expect(testing).toMatchObject({ level: 2, maxHealth: 540 });
|
||||
const level80 = dungeonEnemyScaling(
|
||||
room.definition,
|
||||
entity,
|
||||
zereketh.spawn.id,
|
||||
80,
|
||||
room.definition.defaultDifficultyId,
|
||||
1,
|
||||
);
|
||||
expect(level80.maxHealth).toBeGreaterThanOrEqual(26_400);
|
||||
expect(level80.maxHealth).toBeLessThanOrEqual(59_400);
|
||||
expect(level80.damageMultiplier).toBeCloseTo((entity.combat?.damageMultiplier ?? 1) * 0.75);
|
||||
});
|
||||
|
||||
it("omits the campaign finale bonus even when testing the source dungeon's final boss", () => {
|
||||
@@ -113,7 +124,7 @@ describe("Boss Testing", () => {
|
||||
* (entity.combat?.healthMultiplier ?? 1),
|
||||
540,
|
||||
));
|
||||
expect(campaign.maxHealth).toBe(Math.max(testing.maxHealth, 950 * 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", () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -7,6 +7,7 @@ import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
|
||||
import { clearPartyRuntimeRegistry } from "./partyRuntimeRegistry";
|
||||
import { createConfiguredPartyMember, usePartyStore } from "./partyStore";
|
||||
import { runtimePartyTalentRanksForLevel } from "./partyTalentRuntime";
|
||||
import { subscribeCombatEvents, type StructuredCombatEvent } from "./combatEvents";
|
||||
|
||||
function registerTarget(id = "target", distance = 2): void {
|
||||
useCombatStore.getState().registerMob(id, { name: id, maxHealth: 1_000, xpReward: 0 });
|
||||
@@ -150,20 +151,36 @@ describe("named aura combat runtime", () => {
|
||||
const summonIds = prepared.summons.map((summon) => summon.id);
|
||||
useCombatStore.getState().applyPrecombatPreparation(now + 1_000);
|
||||
expect(useCombatStore.getState().summons.map((summon) => summon.id)).toEqual(summonIds);
|
||||
|
||||
registerTarget("pet-target");
|
||||
useCombatStore.getState().damageMob("pet-target", 1, now + 1_100);
|
||||
const beforePetAttack = useCombatStore.getState().mobs["pet-target"].health;
|
||||
useCombatStore.getState().tick(0.1, now + 2_100);
|
||||
expect(useCombatStore.getState().mobs["pet-target"].health).toBeLessThan(beforePetAttack);
|
||||
});
|
||||
|
||||
it("consumes a named absorb before player health", () => {
|
||||
const now = Date.now();
|
||||
const events: StructuredCombatEvent[] = [];
|
||||
const stop = subscribeCombatEvents((event) => events.push(event));
|
||||
expect(useCombatStore.getState().castAbility(
|
||||
"wow335-priest-power-word-shield",
|
||||
undefined,
|
||||
now,
|
||||
).ok).toBe(true);
|
||||
stop();
|
||||
const beforeHealth = useCombatStore.getState().health;
|
||||
const beforeAbsorb = useCombatStore.getState().auras
|
||||
.flatMap((aura) => aura.absorbs)
|
||||
.reduce((total, absorb) => total + absorb.remaining, 0);
|
||||
expect(beforeAbsorb).toBeGreaterThan(0);
|
||||
expect(events).toContainEqual(expect.objectContaining({
|
||||
kind: "absorb",
|
||||
sourceActorId: PLAYER_AGGRO_ID,
|
||||
targetActorId: PLAYER_AGGRO_ID,
|
||||
abilityId: "wow335-priest-power-word-shield",
|
||||
effectiveAmount: beforeAbsorb,
|
||||
}));
|
||||
expect(useCombatStore.getState().auras.find((aura) => aura.absorbs.length)?.definition.icon).toBe(
|
||||
useCombatStore.getState().abilities.find((ability) => (
|
||||
ability.id === "wow335-priest-power-word-shield"
|
||||
|
||||
+10
-1
@@ -142,6 +142,8 @@ export interface AuraDefinition {
|
||||
/** Null means the aura persists until explicitly removed. */
|
||||
readonly durationMs: number | null;
|
||||
readonly maxStacks?: number;
|
||||
/** Initial stack count for charged buffs such as Inner Fire. */
|
||||
readonly initialStacks?: number;
|
||||
readonly stackBehavior?: AuraStackBehavior;
|
||||
/** Defaults to true. False makes applications from all sources share one aura. */
|
||||
readonly uniqueBySource?: boolean;
|
||||
@@ -172,6 +174,8 @@ export interface ActiveAura {
|
||||
readonly stacks: number;
|
||||
readonly absorbs: readonly AuraAbsorbState[];
|
||||
readonly procStates: readonly AuraProcState[];
|
||||
/** Number of Fire critical hits landed during the current Combustion buff. */
|
||||
readonly combustionCriticalHits?: number;
|
||||
}
|
||||
|
||||
export interface ApplyAuraRequest {
|
||||
@@ -319,13 +323,18 @@ export function applyAura(
|
||||
): AuraApplicationResult {
|
||||
const { definition } = request;
|
||||
const maxStacks = boundedPositiveInteger(definition.maxStacks, 1);
|
||||
const requestedStacks = Math.min(maxStacks, boundedPositiveInteger(request.stacks, 1));
|
||||
const requestedStacks = Math.min(maxStacks, boundedPositiveInteger(request.stacks ?? definition.initialStacks, 1));
|
||||
const instanceId = auraIdentity(definition, request.sourceId, request.targetId);
|
||||
// Mage armors have several DBC effects that coexist until another armor
|
||||
// replaces them. Other exclusive groups retain their single-aura behavior.
|
||||
const exclusiveSpellName = definition.name.replace(/, Aura \d+\)$/, ")");
|
||||
const exclusive = definition.exclusiveGroup
|
||||
? current.filter((aura) => (
|
||||
aura.targetId === request.targetId
|
||||
&& aura.definition.exclusiveGroup === definition.exclusiveGroup
|
||||
&& aura.instanceId !== instanceId
|
||||
&& (definition.exclusiveGroup !== "mage:armor"
|
||||
|| aura.definition.name.replace(/, Aura \d+\)$/, ")") !== exclusiveSpellName)
|
||||
))
|
||||
: [];
|
||||
const exclusiveIds = new Set(exclusive.map((aura) => aura.instanceId));
|
||||
|
||||
Loaded 100 of 248 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user