Release Healer Man 0.1.46

This commit is contained in:
phenom committed 2026-09-16 14:30:58 -04:00
1 parent f82daf1c13
commit 9ff574ac0b
73 files changed
+33486 -174

No files matched your search

+30
View File
@@ -162,6 +162,36 @@ new or changed content objects, builds the compact required-content browser
bundle, and starts the combined static/API/content server. Existing content
objects are reused.
### Optional GM Balance Lab server dependency
To run Balance Lab simulations on TrueNAS instead of the local workstation,
the server container needs Python 3 in addition to the existing Node
development dependencies. Replace the service command with:
```yaml
command: >-
sh -lc "apt-get update && apt-get install -y --no-install-recommends python3 && rm -rf /var/lib/apt/lists/* && npm ci --include=dev && npm run db:init && npm run content:publish && npm run build && npm start"
```
Add or change these environment values:
```yaml
BALANCE_LAB_PYTHON: "python3"
BALANCE_LAB_MAX_CONCURRENT: "1"
BALANCE_LAB_MAX_QUEUED: "8"
```
The app stores job reports in
`/app/runtime-data/balance-lab/jobs/<job-id>/`, so the existing persistent
runtime-data mount also preserves generated reports and the GM app's run
history across container/server restarts. Each directory includes `job.json`
metadata used to rebuild the newest 100 history entries; do not exclude those
files when migrating the runtime-data dataset. An interrupted queued/running
job returns as failed after restart, while any job with a complete
`suite-report.json` returns as complete. Keep concurrency at one
unless the server has enough spare CPU for multiple full combat sweeps. All
Balance Lab routes independently require a server-authorized GM account.
After startup, test on the LAN:
```sh
+76
View File
@@ -3436,3 +3436,79 @@ Vite's existing large-chunk advisory remains informational.
`playtest-artifacts/boss-spell-effects-black-stalker/results.json` and
`03-empty-room.png`. Headless automation verified audio node selection and
fallback behavior but did not perform a subjective loudspeaker/listening test.
## Boss Testing random follow-up (2026-09-16)
- The Boss Testing review shown after victory now includes **Fight another
random boss**. It excludes the boss just completed, preserves the active party
role, downloads missing content through the existing verified pack flow, and
enters the new testing room directly. Review controls remain disabled while
the next boss is being prepared.
- All 15 focused Boss Testing, preparation, review, and display-snapshot tests
passed. The production build, asset audits, and release-bundle validation
passed.
- Production Edge defeated Lady Anacondra, displayed the new victory action,
selected Varos Cloudstrider, and entered his active testing room without
returning to the menu. No browser runtime errors occurred. Evidence is in
`playtest-artifacts/boss-testing/06-victory-review.png`,
`06b-another-random-boss.png`, and `results.json`.
## Eck the Ferocious visibility and healer pressure (2026-09-16)
- Eck's Oracle GLB used a bind-pose-derived `groundOffset` of `11.859`, which
lifted the animated body out of the gameplay camera while the encounter ring
remained visible. The Gundrak adapter now keeps the original model but uses a
reviewed zero ground offset and a seven-unit nameplate height.
- Eck no longer repeats Spring Init and Berserk as 135% damage attacks. The
runtime now follows `boss_eck.cpp`: Bite opens at 5 seconds and repeats every
8–12 seconds, Spit opens in 10–37 seconds and repeats every 8–12 seconds,
Spring targets one random party member every 10–24 seconds, and Berserk is a
one-time non-damaging aura at 90 seconds. The original spell IDs remain bound
so release/impact visual and sound presentation plus mechanic indicators are
preserved; Spring also retains a target-circle warning.
- The reusable `npm run dungeon:boss-pressure:audit` command compares all WoW
bosses at level 80 over 60 seconds and writes the complete ranking plus
per-attack contribution to `dungeon-pipeline/WOW_BOSS_PRESSURE_REPORT.json`
and a top-30 summary to the adjacent Markdown report.
- Production Edge rendered Eck, his nameplate, health bar, mechanic timers, and
target marker with no browser errors. Evidence is in
`playtest-artifacts/eck-final-visual/03-empty-room.png` and `results.json`.
## Balance Lab run history and analytics (2026-09-16)
- Server Balance Lab jobs now write private `job.json` metadata, restore the
newest 100 runs after a restart, import completed pre-history reports, and
mark interrupted work failed instead of losing it from the GM screen.
- The selected completed run now has five in-app graphs for boss-to-party
pressure, party-to-boss damage, kill time, class/spec boss damage, and
class/spec damage taken. Boss, class/spec, and party tabs retain every row and
add source, role, search, metric, and direction filtering plus wipe, defeat,
death, healing, absorb, and tuning-outlier context.
- Seven focused server job tests passed, including restart recovery, legacy
import, and interrupted-run behavior; the combined Balance Lab/server set
passed nine tests. The 8 GB TypeScript check and production Vite build passed.
- Production Edge automation submitted a real Eck simulation through the GM
UI, opened the generated JSON in the dashboard, exercised all tables and
filters, and verified the 480×900 layout with no browser errors. Evidence is
under `playtest-artifacts/balance-lab/`, especially
`04-run-analytics.png` and `05-mobile-analytics.png`.
## Balance Lab class/spec ability breakdown (2026-09-16)
- Each simulated member now exports the full learned, level-appropriate,
executable ability list in addition to observed combat events. Suite reports
aggregate every class/spec ability with active-run coverage, usage rate,
casts, hits, casts/minute, raw/effective damage, DPS, damage share, damage per
hit, healing, overheal, absorbs, AI categories, and cooldowns. Learned
abilities that produce no events remain visible as `available · unused`.
- The GM dashboard adds an **Abilities** tab with class/spec, source, role,
usage-state, free-text, metric, and direction filters; an unused-damage view;
a damage-contribution graph; a complete horizontally scrollable table; and a
downloadable `class_abilities_across_parties.csv` artifact.
- Six Python aggregation tests, nine server tests, the 8 GB TypeScript check,
and the production Vite build passed. Production Edge automation completed a
real Eck run, verified 11 downloadable suite artifacts, exercised the unused
ability filter, and passed desktop plus 480×900 overflow checks with no
browser errors. Evidence is in
`playtest-artifacts/balance-lab/04b-ability-breakdown.png` and
`playtest-artifacts/balance-lab/06-mobile-abilities.png`.
+10
View File
@@ -41,6 +41,16 @@ npm test
npm run build
```
Boss and party balance simulations, including exact, random, and automatic
multi-party WoW, CoA, and RoM sweeps with complete per-boss/member/ability reports, are documented in
[`docs/BALANCE_LAB.md`](docs/BALANCE_LAB.md). Start the guided tool with
`npm run balance:lab`, or use the server-powered **Balance Lab** beneath GM
Tools. The GM screen keeps previous runs across server restarts and provides
filterable in-app boss, class/spec, party, pressure, damage, and kill-time
graphs/tables. The Abilities tab retains every learned/available ability,
including zero-use rows, so missing rotations can be compared with casts, hits,
damage share, healing, and absorbs.
Manastorms, Arenas/PvP, and their GM tools are shelved in normal browser and
Android builds. Their source and saved data remain intact. Use
`npm run dev:shelved` or `npm run build:shelved` for maintenance builds, and
+346
View File
@@ -0,0 +1,346 @@
# Boss and party balance lab
`scripts/balance_lab.py` runs a chosen World of Warcraft, Conquest of Azeroth,
or Runes of Magic five-player party against the authored boss catalog. It is an
interactive Python controller and report generator, not a second combat model.
The actual fights execute through `scripts/balance-lab/runtime.ts`, which imports
the game's live TypeScript combat store, party AI, abilities, talents, gear,
threat, mitigation, healing, boss scaling, and enemy attacks.
This split is intentional. Balance formulas stay in the game runtime. Python
owns selection, repeatable run configuration, aggregation, CSV/Markdown/HTML
output, and outlier flags. Do not copy combat coefficients into the Python
script.
## Quick start
Install the normal project dependencies, then start the guided prompt:
```powershell
npm run balance:lab
```
The prompt selects:
1. one random party, five manual members, or an automatic multi-party sweep;
2. a WoW, CoA, or RoM party catalog; automatic mode can select all three;
3. a level from 1 through 80;
4. how many unique 1-tank/1-healer/3-damage configurations to generate per source;
5. all bosses, WoW bosses, RoM bosses, or a searchable multi-boss selection;
6. how many deterministic simulations to run for every party/boss pairing; and
7. the maximum duration of each fight.
The active member is driven by the shared benchmark policy while the other four
use the live companion AI. The healer is the default active member. Use
`--active-slot` to change it.
## GM app and server execution
A server-authorized GM sees **Balance Lab** directly underneath **GM Tools** on
the main menu. That screen exposes the automatic sweep controls: party source,
level, unique parties per source, simulations per party/boss, duration, random
seed, boss category, and a searchable multi-boss picker.
**Run on server** sends only that allow-listed configuration to the authenticated
GM API. The server places it in a one-at-a-time queue, launches the same Python
controller and live TypeScript runtime used by the command line, and writes
artifacts under:
```text
DATA_DIR/balance-lab/jobs/<job-id>/
```
The browser polls lightweight status records; it does not execute combat ticks.
Closing the browser does not stop a running server job. Completed jobs and their
metadata remain in **Run history** across server restarts, with the newest run
selected automatically. The history shows the most recent 100 jobs and can be
searched or filtered by status. A job that was still queued or running when the
server stopped is restored as failed (it cannot safely resume a half-finished
deterministic sweep) and can be rerun.
Each job directory contains a private `job.json` status record alongside its
report artifacts. On startup, `server/balance-lab-jobs.mjs` rebuilds history from
those records and treats a directory containing `suite-report.json` as complete.
Completed server-job directories from the pre-history implementation are
imported from `suite-report.json` plus `suite-config.json`, then receive a new
`job.json`; their requester is shown as `unknown (legacy run)` because that was
not previously recorded.
Do not add `job.json` to the artifact allow-list: it is server state, not a GM
download. The standalone `npm run balance:lab` path remains the way to spend the
local development device's CPU.
## In-app analysis and previous runs
Select any completed entry in **Run history**, then choose **View graphs and
filterable data**. The app reads that run's `suite-report.json` through the
authenticated artifact endpoint and caches it for the current screen session.
The dashboard includes:
- boss damage to the whole party (incoming DTPS);
- party damage applied directly to each boss;
- average successful kill time;
- boss DPS and damage taken for every class/specialization;
- complete boss tables with pressure versus median, total incoming damage,
defeat/wipe rates, deaths, and health;
- complete class/spec tables with total and boss DPS, HPS, DTPS, absolute
damage taken, damage/healing/absorb totals, deaths, and source/role-median
tuning flags; and
- a complete Abilities tab with learned/available abilities (including zero-use
rows), exact runtime IDs, AI categories, active-run coverage, casts, hits,
casts per minute, effective/raw damage, DPS, damage share, damage per hit,
healing, overheal, absorbs, and cooldown; and
- complete party-composition tables with output, incoming pressure, outcomes,
and deaths.
The overview graphs intentionally show at most the 15 highest visible values so
labels remain readable. They never discard report data: the Bosses, Class/spec,
and Parties tabs retain every matching row. Search, party-source, boss-source,
role, metric, and sort-direction controls filter the display without modifying
the saved report.
Server requirements:
- the normal checkout and development dependencies must be present because the
combat bridge uses `vite-node`;
- Python 3 must be installed, or `BALANCE_LAB_PYTHON` must name its executable;
and
- the signed-in account must have the server-issued `gm` role.
`BALANCE_LAB_MAX_CONCURRENT` defaults to `1` and is capped at four.
`BALANCE_LAB_MAX_QUEUED` defaults to eight and is capped at 50. Raising
concurrency permits parallel server processes but can make both the game server
and simulations less responsive. Requests are capped before launch, subprocess
arguments are passed as an array without a shell, artifact names are
allow-listed, and every job and artifact endpoint authenticates the account's
server-issued GM role.
## Repeatable command-line runs
List exact party build IDs at a level:
```powershell
python scripts/balance_lab.py --source wow --level 60 --list-members
```
List boss keys:
```powershell
python scripts/balance_lab.py --source wow --level 60 --list-bosses
```
Run a random party against every authored boss:
```powershell
python scripts/balance_lab.py --source coa --level 60 --party-mode random --party-seed 42 --seeds 1,2,3 --boss-category all
```
Generate 25 unique WoW parties and run five simulations per party against all
bosses:
```powershell
python scripts/balance_lab.py `
--source wow `
--level 60 `
--party-mode automatic `
--party-configurations 25 `
--simulations-per-party 5 `
--boss-category all
```
Generate ten parties for each of WoW, CoA, and RoM, but test only RoM bosses:
```powershell
python scripts/balance_lab.py `
--source all `
--level 60 `
--party-mode automatic `
--party-configurations 10 `
--simulations-per-party 3 `
--boss-category rom
```
Automatically test a selected group of bosses:
```powershell
python scripts/balance_lab.py `
--source coa `
--party-mode automatic `
--party-configurations 20 `
--simulations-per-party 5 `
--boss "gundrak/entry-29932" `
--boss "utgarde-keep/entry-23954"
```
Run an exact party against Eck only:
```powershell
python scripts/balance_lab.py `
--source wow `
--level 40 `
--party-mode manual `
--member "warrior:Protection" `
--member "priest:Holy" `
--member "mage:Fire" `
--member "rogue:Combat" `
--member "hunter:Marksmanship" `
--active-slot 2 `
--boss "gundrak/entry-29932" `
--seeds 1,2,3 `
--duration 60
```
Manual selectors accept an exact generated build ID or a unique
`class-id:specialization`/`class-name:specialization` value. Repeat `--boss` to
run any selected subset. Omit it to run every boss in the selected boss
category. `--seeds 4,8,15` is an advanced exact-seed override for
`--simulations-per-party`.
## Output
Each run creates `artifacts/balance-lab/<timestamp>/` unless `--output-dir` is
provided.
| File | Contents |
| --- | --- |
| `report.html` | Searchable and sortable tables containing every selected member and every boss. |
| `report.md` | Human-readable complete party and boss tables. |
| `report.json` | Aggregated machine-readable report. |
| `raw-runs.json` | Lossless per-seed, per-boss, per-member, and per-ability runtime results. |
| `bosses.csv` | One aggregate row for every boss, ordered by party damage taken per second. |
| `party_members.csv` | One aggregate row for every selected party member. |
| `member_by_boss.csv` | Every party member against every boss. |
| `member_abilities.csv` | Every observed player/companion ability for every run. |
| `boss_abilities.csv` | Every observed boss ability for every run. |
| `runs.csv` | One row per boss and deterministic seed. |
| `config.json` | Exact rerunnable selection. |
| `catalog.json` | Party and boss choices shown to the controller. |
No report is truncated to a top-N list.
Automatic mode creates a complete report directory for every generated party
under `parties/<party-id>/` and adds these suite-level files:
| File | Contents |
| --- | --- |
| `suite-report.html` | Searchable/sortable tables for every tested class/spec, party configuration, and boss. |
| `suite-report.md` / `suite-report.json` | Complete human- and machine-readable suite summaries. |
| `party_configurations.csv` | Every generated party, its full composition, party DPS, DTPS, deaths, and outcomes. |
| `class_specs_across_parties.csv` | Every class/spec observed across every configuration, compared with its source/role median. |
| `class_abilities_across_parties.csv` | Every learned/available and observed class/spec ability, including unused rows, usage frequency, damage contribution, healing, and absorbs. |
| `class_spec_by_boss.csv` | Every observed class/spec broken down against every boss it fought. |
| `bosses_across_parties.csv` | Every boss aggregated across all generated parties. |
| `boss_by_party.csv` | Every boss and party-configuration pairing. |
| `suite_runs.csv` | Every individual seed run with its party configuration ID. |
| `suite-config.json` | The exact automatic-generation seed, counts, filters, and generated party build IDs. |
## Metrics and interpretation
- Boss `incomingDtps` is effective damage received by all five members divided
by active fight time. Damage from summoned hostile actors is included.
- `averageIncomingDamage` is total healer workload and therefore includes fight
length. Read it beside DTPS, wipe rate, deaths, and time to defeat.
- Member `dps` includes damage to the boss and adds. `bossDps` includes only
direct boss damage. Suite class/spec `dtps` is damage taken per active second;
`totalDamageTaken` is the absolute healer/survivability burden across every
included run.
- Ability `activeRuns` counts simulations with at least one cast, hit, damage,
healing, overheal, or absorb event. `usageRate` divides that by every run for
that build; `damageShare` divides the ability's effective damage by the
class/spec's total effective damage. `available · unused` means the ability
was learned and executable at the tested level/talent setup but emitted no
combat event. This is a review signal, not proof of an AI defect: resurrection,
execute, interrupt, dispel, emergency, and long-cooldown abilities can be
legitimately unused when their condition never occurs.
- Companion AI currently applies several instant attacks directly and therefore
may emit hits/damage without a separate cast event. Use **Active runs** and
**Hits** as the authoritative usage indicators; use **Casts** and **Casts/min**
as additional detail, especially for the active-player benchmark controller.
- Boss pressure flags in a party report compare DTPS with the non-zero median
boss for that exact party. Suite boss flags compare across all generated
parties. Suite class/spec flags compare DPS with every observed spec from the
same source and role. `high` begins at 1.25 times the median and `very
high` at 1.5; `low` begins at 0.75 and `very low` at 0.5.
- Flags are triage signals, not automatic tuning decisions. A boss can have high
DTPS but low total damage because it dies quickly. A member can have low boss
DPS because it spends time healing, tanking, or damaging adds.
The isolated boss room deliberately removes campaign-final health promotion so
all bosses use the same testing-room durability rule at the chosen level.
Runtime IDs include the simulation seed, which deterministically varies the
game's stable target and cooldown rolls.
## Coverage and limitations
The selectable roster includes every authored WoW and CoA specialization plus
every supported RoM class-role pair. Existing generated party talent plans are
used when available. Missing WoW plans are legally allocated through their
matching live talent tree; CoA builds allocate the chosen specialization tree
and class tree through the live essence/prerequisite rules.
Headless simulation executes combat-store boss attacks and their authored
damage, healing, control, aura, summon, threat, and timing effects. It does not
mount the Three.js scene, play visuals or sounds, or run scene-owned world
objects. Encounter rules that require movement through the original dungeon,
clicking an object, or a renderer-owned trigger require a live Boss Testing
playtest in addition to this numeric audit.
One selected member must occupy the active player slot because the production
runtime distinguishes the player from four companions. The report marks that
member. When a suspected class outlier could be policy-sensitive, rerun with a
different `--active-slot`; do not compare active and companion results without
noting that control-policy difference.
## Code map
- `scripts/balance_lab.py`: prompts, command-line parsing, random/manual/automatic
party selection, searchable boss selection, process orchestration, aggregates,
flags, and all party/suite report formats.
- `scripts/balance-lab/runtime.ts`: live headless fight setup, controlled clock,
exact party construction, boss execution, and lossless event attribution.
- `src/ui/BalanceLabScreen.tsx`: GM form, searchable boss selection, persistent
run-history selection, progress display, report loading, and authenticated
artifact opening/downloading.
- `src/ui/BalanceLabResults.tsx`: in-app graphs, report filters, sorting, summary
cards, and complete boss/class-spec/party tables. Add new dashboard metrics
here only after they exist in `suite-report.json`.
- `src/app/balanceLabClient.ts`: typed GM Balance Lab API client.
- `server/balance-lab-jobs.mjs`: validated job specifications, bounded
one-at-a-time process queue, progress parsing, `job.json` persistence/startup
restoration, report-summary extraction, and artifact allow-list.
- `server/server.mjs`: GM-authenticated job/status/artifact HTTP routes.
- `src/game/benchmark/benchmarkBuilds.ts`: selectable spec builds and legal
talent allocation. The older canonical cohort remains unchanged.
- `src/game/partyStore.ts`: `createConfiguredPartyMember`, the narrow factory
that preserves production stats, equipment, resources, and appearance for an
exact companion.
- `src/game/bossTesting.ts`: the authoritative all-boss catalog and isolated
room construction.
When extending the tool, add fields to `raw-runs.json` first, aggregate them in
Python second, and document the metric here. Keep formula ownership in `src/game`
and keep reports complete rather than introducing top-N filtering.
## Validation
For tool changes, run at minimum:
```powershell
python -m py_compile scripts/balance_lab.py
python -m unittest scripts.test_balance_lab
node --test server/balance-lab-jobs.test.mjs server/server.test.mjs
python scripts/balance_lab.py --source wow --level 20 --party-mode automatic --party-configurations 2 --boss "gundrak/entry-29932" --simulations-per-party 1 --duration 5 --output-dir artifacts/balance-lab-smoke
npm test -- --run src/game/benchmark src/game/partyStore.test.ts src/game/partyRuntime.test.ts
npm run build
npm run balance:lab:playtest
npm run release:check
```
The Playwright smoke test uses an isolated temporary database, logs in as a GM,
checks that Balance Lab is below GM Tools, runs one real Eck server simulation,
verifies all eleven suite artifacts, exercises the overview and ability graphs
plus every complete table, checks history, role, class/spec, and unused-ability
filtering, and captures desktop/mobile screenshots
under `playtest-artifacts/balance-lab/`. The server unit tests separately restart
the job service and prove that completed history is restored while interrupted
work is marked failed. Use a longer all-boss run before making production tuning
changes.
+41
View File
@@ -78,6 +78,47 @@ searches:
npm run dungeon:boss-effects:audit
```
For reports that one boss is unusually hard to heal, regenerate the normalized
party-pressure ranking instead of comparing native dungeon levels or relying on
memory:
```powershell
npm run dungeon:boss-pressure:audit
```
This writes `dungeon-pipeline/WOW_BOSS_PRESSURE_REPORT.md` for a quick top-30
view and `dungeon-pipeline/WOW_BOSS_PRESSURE_REPORT.json` for the complete
per-boss, per-attack breakdown. The default comparison holds every boss at
player level 80 for 60 seconds with five party members inside party-wide
effects. It intentionally reports an unmitigated direct-damage ceiling, so use
it to find bad cooldowns, duplicate spells, wrong targets, or accidental damage
effects. It does not claim exact combat-log DPS: armor, resistance, movement,
interrupts, summons, debuff amplification, and phase uptime are excluded.
When a boss is an outlier, inspect its source script before tuning numbers. In
particular, distinguish setup/trigger spells and one-shot enrage auras from
repeatable damaging attacks, and preserve the source `initialCooldownMs`,
`repeatCooldownMs`, `maximumCasts`, and target selection. Re-run the audit after
the fix and record both the boss rank and its per-attack rows.
### Imported animated boss is invisible
If the encounter marker, health bar, or effects render but the creature body
does not, inspect the shipped GLB before replacing the model:
```powershell
.\node_modules\.bin\gltf-transform.cmd inspect public/assets/game/dungeons/<dungeon>/creatures/<model>.glb
```
Compare the scene bounds with the entity's `visual.model.groundOffset`,
`labelHeight`, and `markerRadius`. Skinned bind-pose bounds can include vertices
far below the animated root. The packaging formula normally uses `-bounds.minY`
as the floor offset; for those assets that can lift the posed creature entirely
out of the gameplay camera. Add the narrowest source-reviewed runtime metadata
override in the dungeon content adapter, keep the original model URL, and add a
regression assertion for the corrected offset. Prove the result with a rendered
Boss Testing screenshot—the GLB existing on disk is not visual evidence.
The compiler reads effect, aura, target, duration, radius, and summon-creature
fields from `Spell.dbc`, with `SpellDuration.dbc` and `SpellRadius.dbc` from the
sibling support DBC directory. `spellSemantic()` maps source-backed controls,
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,42 @@
# WoW boss party-pressure audit
Generated 2026-09-16T14:26:06.076Z. This is a deterministic comparison at player level 80: 5 grouped party members, 60 seconds, and live boss attack definitions. It is a pressure ceiling, not a combat-log prediction: mitigation, movement, interrupts, summons, debuff amplification, and phase uptime are excluded.
| Rank | Boss | Dungeon | Party DPS | Tank DPS | Random DPS | Groupwide DPS | vs median |
| ---: | --- | --- | ---: | ---: | ---: | ---: | ---: |
| 1 | Herald Volazj | Ahn'kahet: The Old Kingdom | 31,269 | 25,761 | 0 | 5,508 | 21.3x |
| 2 | Ingvar the Plunderer | Utgarde Keep | 27,004 | 10,339 | 1,341 | 15,325 | 18.4x |
| 3 | Runok Wildmane | Trial of the Champion | 24,872 | 23,424 | 1,448 | 0 | 16.9x |
| 4 | Mokra the Skullcrusher | Trial of the Champion | 22,720 | 21,429 | 1,291 | 0 | 15.5x |
| 5 | Eressea Dawnsinger | Trial of the Champion | 22,222 | 20,963 | 1,259 | 0 | 15.1x |
| 6 | Exarch Maladaar | Auchindoun: Auchenai Crypts | 20,157 | 18,437 | 1,720 | 0 | 13.7x |
| 7 | Quagmirran | Coilfang: The Slave Pens | 18,337 | 7,374 | 0 | 10,963 | 12.5x |
| 8 | Aeonus | The Black Morass | 17,141 | 6,604 | 0 | 10,537 | 11.7x |
| 9 | The Prophet Tharon'ja | Drak'Tharon Keep | 16,462 | 7,357 | 0 | 9,105 | 11.2x |
| 10 | General Bjarngrim | Halls of Lightning | 16,105 | 13,186 | 0 | 2,919 | 11.0x |
| 11 | Varos Cloudstrider | The Oculus | 15,528 | 15,528 | 0 | 0 | 10.6x |
| 12 | Grand Magus Telestra | The Nexus | 14,906 | 11,396 | 0 | 3,510 | 10.1x |
| 13 | Jedoga Shadowseeker | Ahn'kahet: The Old Kingdom | 12,790 | 12,790 | 0 | 0 | 8.7x |
| 14 | Drakos the Interrogator | The Oculus | 12,261 | 5,023 | 0 | 7,238 | 8.3x |
| 15 | King Dred | Drak'Tharon Keep | 12,209 | 8,215 | 0 | 3,994 | 8.3x |
| 16 | King Ymiron | Utgarde Pinnacle | 12,061 | 12,061 | 0 | 0 | 8.2x |
| 17 | The Black Knight | Trial of the Champion | 11,803 | 11,803 | 0 | 0 | 8.0x |
| 18 | Krystallus | Halls of Stone | 11,061 | 7,326 | 0 | 3,735 | 7.5x |
| 19 | Mage-Lord Urom | The Oculus | 10,808 | 4,547 | 1,076 | 5,185 | 7.4x |
| 20 | Swamplord Musel'ek | Coilfang: The Underbog | 10,511 | 10,511 | 0 | 0 | 7.2x |
| 21 | Talon King Ikiss | Auchindoun: Sethekk Halls | 10,456 | 4,039 | 0 | 6,417 | 7.1x |
| 22 | Slad'ran | Gundrak | 10,428 | 3,248 | 0 | 7,180 | 7.1x |
| 23 | Novos the Summoner | Drak'Tharon Keep | 10,175 | 5,571 | 1,140 | 3,463 | 6.9x |
| 24 | Ley-Guardian Eregos | The Oculus | 9,739 | 6,040 | 0 | 3,699 | 6.6x |
| 25 | Gal'darah | Gundrak | 9,683 | 5,904 | 0 | 3,779 | 6.6x |
| 26 | Commander Stoutbeard | The Nexus | 9,680 | 3,662 | 1,491 | 4,527 | 6.6x |
| 27 | Forgemaster Garfrost | Pit of Saron | 9,602 | 8,939 | 663 | 0 | 6.5x |
| 28 | Murmur | Auchindoun: Shadow Labyrinth | 9,560 | 6,206 | 831 | 2,523 | 6.5x |
| 29 | Drakkari Elemental | Gundrak | 9,559 | 5,832 | 0 | 3,727 | 6.5x |
| 30 | Loken | Halls of Lightning | 9,364 | 5,746 | 0 | 3,618 | 6.4x |
## Eck the Ferocious
Eck ranks **97 of 273** at **3,570 raw party DPS** (**2.4x** the median). His corrected schedule contains one 2-second melee swing, Bite every 8–12 seconds, Spit every 8–12 seconds after its delayed opener, Spring every 10–24 seconds against a random party member, and a non-damaging Berserk at 90 seconds.
The complete machine-readable ranking and per-attack breakdown are in `dungeon-pipeline/WOW_BOSS_PRESSURE_REPORT.json`.
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "healer-man",
"version": "0.1.45",
"version": "0.1.46",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "healer-man",
"version": "0.1.45",
"version": "0.1.46",
"dependencies": {
"@capacitor/android": "8.4.1",
"@capacitor/core": "8.4.1",
+5 -1
View File
@@ -1,7 +1,7 @@
{
"name": "healer-man",
"private": true,
"version": "0.1.45",
"version": "0.1.46",
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0",
@@ -57,6 +57,8 @@
"dungeon:five-player:verify": "node scripts/dungeon-pipeline/verify-five-player-dungeon-imports.mjs",
"dungeon:wow:audit": "vite-node scripts/dungeon-pipeline/audit-wow-dungeon-content.mjs",
"dungeon:boss-effects:audit": "node scripts/dungeon-pipeline/audit-boss-spell-effects.mjs",
"dungeon:boss-pressure:audit": "vite-node scripts/dungeon-pipeline/audit-boss-party-pressure.ts",
"dungeon:routes:generate": "node scripts/dungeon-pipeline/generate-dungeon-route-candidates.mjs",
"dungeon:wow:functionality:audit": "vite-node scripts/dungeon-pipeline/audit-wow-dungeon-functionality.ts",
"dungeon:wow:stairs:audit": "node --max-old-space-size=8192 scripts/dungeon-pipeline/audit-wow-stairs.ts",
"dungeon:wow:stairs:refresh": "node --max-old-space-size=8192 scripts/dungeon-pipeline/audit-wow-stairs.ts --write",
@@ -114,6 +116,8 @@
"assets:ktx2:refresh-metadata": "node scripts/asset-pipeline/refresh-ktx2-metadata.mjs",
"assets:performance:audit": "node scripts/asset-pipeline/audit-environment-performance.mjs",
"balance:benchmark": "vite-node scripts/combat-benchmark/run.ts",
"balance:lab": "python scripts/balance_lab.py",
"balance:lab:playtest": "node scripts/playtest-balance-lab.cjs",
"balance:smoke": "vite-node scripts/combat-benchmark/run.ts --class mage --role damage --scenario damage-single-burst --seeds 1 --duration-scale 0.2",
"prebuild": "npm run loot:generate && npm run web:prepare && npm run assets:ktx2:audit && npm run assets:performance:audit",
"build": "node --max-old-space-size=8192 node_modules/typescript/bin/tsc -b && node --max-old-space-size=8192 node_modules/vite/bin/vite.js build",
+2 -2
View File
@@ -1,4 +1,4 @@
{
"versionName": "0.1.45",
"versionCode": 1045
"versionName": "0.1.46",
"versionCode": 1046
}
+635
View File
@@ -0,0 +1,635 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { dirname, resolve } from "node:path";
import { createInterface } from "node:readline";
import {
CLASSES,
classById,
contentCategoryForClass,
type ClassId,
} from "../../src/app/characterCatalog";
import type { ContentCategoryId } from "../../src/app/contentCategories";
import {
abilitiesForClass,
abilityAtLevel,
isAbilityUnlocked,
preloadAbilityCatalogsForClasses,
} from "../../src/game/abilityCatalog";
import { aiHintsForAbility } from "../../src/game/abilityRules";
import { PLAYER_AGGRO_ID } from "../../src/game/aggro";
import { installCombatClock } from "../../src/game/combatClock";
import { subscribeCombatEvents } from "../../src/game/combatEvents";
import { resetCombatSpatialProvider } from "../../src/game/combatSpatial";
import { useCombatStore } from "../../src/game/combatStore";
import { dungeonEnemyScaling } from "../../src/game/dungeonBossScaling";
import { requireDungeonDefinition } from "../../src/game/dungeonRegistry";
import {
BOSS_TESTING_CATALOG,
createBossTestingRoom,
type TestableBoss,
} from "../../src/game/bossTesting";
import { clearMobRuntimeRegistry, setMobPosition } from "../../src/game/mobRuntimeRegistry";
import {
advanceDungeonPartyCombat,
partyCombatRangeBand,
resetPartyRuntimeClock,
} from "../../src/game/partyRuntime";
import {
clearPartyRuntimeRegistry,
registerPartyRuntimePosition,
updatePartyRuntimeTargetVisibility,
} from "../../src/game/partyRuntimeRegistry";
import {
createConfiguredPartyMember,
usePartyStore,
type PartyMember,
} from "../../src/game/partyStore";
import { specializationsForClass } from "../../src/game/specializationCatalog";
import { BenchmarkController } from "../../src/game/benchmark/benchmarkController";
import {
loadSelectableBenchmarkBuilds,
selectableBenchmarkBuildCatalog,
} from "../../src/game/benchmark/benchmarkBuilds";
import type {
BenchmarkBuildDefinition,
BenchmarkScenarioDefinition,
} from "../../src/game/benchmark/benchmarkTypes";
type BossCategory = "all" | "wow" | "rom";
type PartyCatalogSource = ContentCategoryId | "all";
interface BalanceLabConfig {
readonly schemaVersion: 1;
readonly source: ContentCategoryId;
readonly level: number;
readonly durationSeconds: number;
readonly seeds: readonly number[];
readonly partyBuildIds: readonly string[];
readonly activeSlot: number;
readonly bossCategory: BossCategory;
readonly bossKeys?: readonly string[];
}
interface MutableAbilityMetrics {
casts: number;
rawDamage: number;
effectiveDamage: number;
rawHealing: number;
effectiveHealing: number;
overheal: number;
absorbs: number;
hits: number;
}
interface MutableMemberMetrics {
readonly slot: number;
readonly actorId: string;
readonly build: BenchmarkBuildDefinition;
damage: number;
rawDamage: number;
bossDamage: number;
healing: number;
rawHealing: number;
overheal: number;
absorbs: number;
damageTaken: number;
rawDamageTaken: number;
absorbedDamageTaken: number;
deathEvents: number;
casts: number;
minimumHealthPercent: number;
readonly abilities: Map<string, MutableAbilityMetrics>;
}
const scenario = (durationMs: number): BenchmarkScenarioDefinition => ({
id: "balance-lab-boss",
version: 1,
name: "Balance lab boss",
description: "A selected five-member party against one isolated authored boss.",
family: "mechanics",
roles: ["damage", "tank", "healer"],
durationMs,
tickMs: 100,
hostileTargets: 1,
partyLayout: "melee",
attacks: [],
});
function argument(name: string): string | null {
const index = process.argv.indexOf(name);
return index >= 0 ? process.argv[index + 1] ?? null : null;
}
function requiredArgument(name: string): string {
const value = argument(name);
if (!value) throw new Error(`Missing required ${name} argument.`);
return value;
}
function safeNumber(value: unknown, fallback: number): number {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : fallback;
}
function classIdsForSource(source: PartyCatalogSource): readonly ClassId[] {
return CLASSES
.map((definition) => definition.id)
.filter((classId) => source === "all" || contentCategoryForClass(classId) === source);
}
function bossCatalogRows() {
return BOSS_TESTING_CATALOG.map((boss) => ({
key: boss.key,
name: boss.name,
dungeonId: boss.dungeonId,
dungeonTitle: boss.dungeonTitle,
category: boss.category,
}));
}
async function writeJson(path: string, value: unknown): Promise<void> {
const absolute = resolve(path);
await mkdir(dirname(absolute), { recursive: true });
await writeFile(absolute, `${JSON.stringify(value, null, 2)}\n`, "utf8");
}
function catalogPayload(source: PartyCatalogSource, level: number) {
if (!["wow", "coa", "rom", "all"].includes(source)) throw new Error(`Unknown party source: ${source}`);
const builds = selectableBenchmarkBuildCatalog(level, classIdsForSource(source));
return {
schemaVersion: 1,
level: builds[0]?.level ?? level,
source,
builds: builds.map((build) => ({
id: build.id,
source: build.source,
classId: build.classId,
className: classById(build.classId).name,
role: build.role,
specialization: build.orientation,
label: `${classById(build.classId).name} — ${build.orientation} (${build.role})`,
})),
bosses: bossCatalogRows(),
};
}
async function writeCatalog(): Promise<void> {
const level = Math.trunc(safeNumber(argument("--level"), 60));
const source = (argument("--source") ?? "wow") as PartyCatalogSource;
await writeJson(requiredArgument("--output"), catalogPayload(source, level));
}
function selectedBosses(config: BalanceLabConfig): readonly TestableBoss[] {
const requested = new Set(config.bossKeys ?? []);
const rows = BOSS_TESTING_CATALOG.filter((boss) => (
(config.bossCategory === "all" || boss.category === config.bossCategory)
&& (!requested.size || requested.has(boss.key))
));
if (!rows.length) throw new Error("No bosses matched the requested boss filters.");
if (requested.size) {
const found = new Set(rows.map((boss) => boss.key));
const missing = [...requested].filter((key) => !found.has(key));
if (missing.length) throw new Error(`Unknown or category-excluded boss keys: ${missing.join(", ")}`);
}
return rows;
}
function configuredCompanion(
build: BenchmarkBuildDefinition,
slot: number,
level: number,
seed: string,
): PartyMember {
const specialization = specializationsForClass(build.classId)
.find((definition) => definition.displayName === build.orientation);
return createConfiguredPartyMember({
name: `${classById(build.classId).name} ${build.orientation}`,
classId: build.classId,
secondaryClassId: build.secondaryClassId,
specialization: build.orientation,
specializationId: specialization?.id ?? null,
talentTreeId: specialization?.id ?? "",
talentRanks: build.talentRanks,
role: build.role,
}, level, slot, seed);
}
function blankAbility(): MutableAbilityMetrics {
return {
casts: 0,
rawDamage: 0,
effectiveDamage: 0,
rawHealing: 0,
effectiveHealing: 0,
overheal: 0,
absorbs: 0,
hits: 0,
};
}
function abilityMetrics(member: MutableMemberMetrics, abilityId: string | undefined): MutableAbilityMetrics {
const key = abilityId ?? "unknown";
const existing = member.abilities.get(key);
if (existing) return existing;
const created = blankAbility();
member.abilities.set(key, created);
return created;
}
function currentHealth(actorId: string): readonly [number, number] {
const combat = useCombatStore.getState();
if (actorId === PLAYER_AGGRO_ID) return [combat.health, combat.maxHealth];
const member = usePartyStore.getState().members.find((candidate) => candidate.id === actorId);
return [member?.health ?? 0, member?.maxHealth ?? 1];
}
function availableAbilities(build: BenchmarkBuildDefinition) {
return abilitiesForClass(build.classId)
.filter((ability) => !ability.passive && isAbilityUnlocked(ability, build.level))
.map((ability) => {
const learnedRank = ability.talentEntryId === undefined
? undefined
: build.talentRanks[`coa-entry-${ability.talentEntryId}`] ?? 0;
if (ability.talentEntryId !== undefined && !learnedRank) return null;
const ranked = abilityAtLevel(ability, build.level, learnedRank);
if (ranked.effects.every((effect) => effect.kind === "unsupported" || effect.kind === "scripted")) return null;
return {
abilityId: ranked.id,
abilityName: ranked.name,
icon: ranked.icon,
categories: aiHintsForAbility(ranked).categories,
cooldownSeconds: ranked.cooldownMs / 1_000,
};
})
.filter((ability) => ability !== null)
.sort((left, right) => left.abilityName.localeCompare(right.abilityName));
}
function serializedMember(
metrics: MutableMemberMetrics,
activeSeconds: number,
) {
const [health, maxHealth] = currentHealth(metrics.actorId);
return {
slot: metrics.slot,
activePlayer: metrics.actorId === PLAYER_AGGRO_ID,
actorId: metrics.actorId,
buildId: metrics.build.id,
classId: metrics.build.classId,
className: classById(metrics.build.classId).name,
specialization: metrics.build.orientation,
role: metrics.build.role,
damage: metrics.damage,
rawDamage: metrics.rawDamage,
bossDamage: metrics.bossDamage,
dps: metrics.damage / Math.max(0.1, activeSeconds),
bossDps: metrics.bossDamage / Math.max(0.1, activeSeconds),
healing: metrics.healing,
rawHealing: metrics.rawHealing,
overheal: metrics.overheal,
hps: metrics.healing / Math.max(0.1, activeSeconds),
absorbs: metrics.absorbs,
damageTaken: metrics.damageTaken,
rawDamageTaken: metrics.rawDamageTaken,
absorbedDamageTaken: metrics.absorbedDamageTaken,
dtps: metrics.damageTaken / Math.max(0.1, activeSeconds),
deathEvents: metrics.deathEvents,
alive: health > 0,
finalHealth: health,
maxHealth,
minimumHealthPercent: metrics.minimumHealthPercent,
casts: metrics.casts,
availableAbilities: availableAbilities(metrics.build),
abilities: [...metrics.abilities.entries()]
.map(([abilityId, ability]) => ({ abilityId, ...ability }))
.sort((left, right) => right.effectiveDamage - left.effectiveDamage
|| right.effectiveHealing - left.effectiveHealing
|| left.abilityId.localeCompare(right.abilityId)),
};
}
async function runBoss(
config: BalanceLabConfig,
builds: readonly BenchmarkBuildDefinition[],
boss: TestableBoss,
seed: number,
) {
const sourceDungeon = requireDungeonDefinition(boss.dungeonId);
const testingRoom = createBossTestingRoom(sourceDungeon, boss).definition;
const entity = testingRoom.entities[boss.entityId];
if (!entity) throw new Error(`Boss entity ${boss.key} is missing from its testing room.`);
const bossActorId = `${boss.spawn.id}:balance-${seed}`;
const activeBuild = builds[config.activeSlot];
const durationMs = Math.max(1_000, Math.min(600_000, Math.round(config.durationSeconds * 1_000)));
const specialization = specializationsForClass(activeBuild.classId)
.find((definition) => definition.displayName === activeBuild.orientation);
const scaling = dungeonEnemyScaling(
testingRoom,
entity,
bossActorId,
activeBuild.level,
testingRoom.defaultDifficultyId,
1,
activeBuild.source,
);
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
resetPartyRuntimeClock();
resetCombatSpatialProvider();
let now = 1_000_000;
const restoreClock = installCombatClock(() => now);
const companions = builds
.map((build, slot) => ({ build, slot }))
.filter(({ slot }) => slot !== config.activeSlot)
.map(({ build, slot }) => configuredCompanion(build, slot, activeBuild.level, `${seed}:${boss.key}`));
const actorIds = builds.map((_, slot) => (
slot === config.activeSlot
? PLAYER_AGGRO_ID
: companions.find((member) => member.id.includes(`-${slot}-`))?.id ?? ""
));
const memberMetrics = builds.map((build, slot): MutableMemberMetrics => ({
slot,
actorId: actorIds[slot],
build,
damage: 0,
rawDamage: 0,
bossDamage: 0,
healing: 0,
rawHealing: 0,
overheal: 0,
absorbs: 0,
damageTaken: 0,
rawDamageTaken: 0,
absorbedDamageTaken: 0,
deathEvents: 0,
casts: 0,
minimumHealthPercent: 1,
abilities: new Map(),
}));
const byActorId = new Map(memberMetrics.map((metrics) => [metrics.actorId, metrics]));
const bossAbilities = new Map<string, MutableAbilityMetrics>();
const stopEvents = subscribeCombatEvents((event) => {
const source = byActorId.get(event.sourceActorId);
const target = byActorId.get(event.targetActorId);
if (event.kind === "cast" && source) {
source.casts += 1;
abilityMetrics(source, event.abilityId).casts += 1;
} else if (event.kind === "damage") {
if (source && !target) {
source.rawDamage += event.rawAmount;
source.damage += event.effectiveAmount;
if (event.targetActorId === bossActorId) source.bossDamage += event.effectiveAmount;
const ability = abilityMetrics(source, event.abilityId);
ability.rawDamage += event.rawAmount;
ability.effectiveDamage += event.effectiveAmount;
ability.hits += 1;
}
if (target && !source) {
target.rawDamageTaken += event.rawAmount;
target.damageTaken += event.effectiveAmount;
target.absorbedDamageTaken += event.absorbed;
const key = event.abilityId ?? "unknown";
const ability = bossAbilities.get(key) ?? blankAbility();
ability.rawDamage += event.rawAmount;
ability.effectiveDamage += event.effectiveAmount;
ability.absorbs += event.absorbed;
ability.hits += 1;
bossAbilities.set(key, ability);
}
} else if (event.kind === "healing" && source) {
source.rawHealing += event.rawAmount;
source.healing += event.effectiveAmount;
source.overheal += event.overheal;
const ability = abilityMetrics(source, event.abilityId);
ability.rawHealing += event.rawAmount;
ability.effectiveHealing += event.effectiveAmount;
ability.overheal += event.overheal;
ability.hits += 1;
} else if (event.kind === "absorb" && source) {
source.absorbs += event.effectiveAmount;
abilityMetrics(source, event.abilityId).absorbs += event.effectiveAmount;
} else if (event.kind === "death" && target) {
target.deathEvents += 1;
}
});
let elapsedMs = 0;
let outcome: "defeated" | "wipe" | "timeout" = "timeout";
try {
const combat = useCombatStore.getState();
combat.initializeCharacter({
classId: activeBuild.classId,
secondaryClassId: activeBuild.secondaryClassId,
specializationId: specialization?.id,
role: activeBuild.role,
level: activeBuild.level,
talentRanks: activeBuild.talentRanks,
inventory: activeBuild.inventory,
equipment: activeBuild.equipment,
});
combat.setPlayerPosition([-2, 0, 0]);
usePartyStore.setState({
seed: `${seed}:${boss.key}`,
playerRole: activeBuild.role,
partySize: 5,
categoryId: config.source,
members: companions,
command: "defend",
selectedMemberId: null,
selfSelected: false,
routeBlocked: false,
activeMobId: null,
objectiveBossId: null,
memberTargetIds: {},
});
combat.synchronizeActors();
combat.registerMob(bossActorId, {
name: entity.name,
boss: true,
serverEntry: entity.combat?.serverEntry,
...scaling,
attacks: entity.combat?.attacks,
xpReward: 0,
});
setMobPosition(bossActorId, [0, 0, 0]);
for (const member of companions) {
registerPartyRuntimePosition(member.id, [-partyCombatRangeBand(member).preferred, 0, 0]);
updatePartyRuntimeTargetVisibility(member.id, bossActorId, true);
}
combat.selectMob(bossActorId);
const tank = memberMetrics.find((member) => member.build.role === "tank") ?? memberMetrics[0];
combat.damageMob(bossActorId, 1, now, {
actorId: tank.actorId,
role: "tank",
abilityId: "balance-lab-pull",
}, "physical");
const controller = new BenchmarkController();
const runScenario = scenario(durationMs);
for (elapsedMs = 100; elapsedMs <= durationMs; elapsedMs += 100) {
now = 1_000_000 + elapsedMs;
combat.tick(0.1, now);
combat.advanceMobCombat(now);
advanceDungeonPartyCombat(now);
controller.act(now, activeBuild.role, runScenario, false);
for (const metrics of memberMetrics) {
const [health, maxHealth] = currentHealth(metrics.actorId);
metrics.minimumHealthPercent = Math.min(
metrics.minimumHealthPercent,
maxHealth > 0 ? Math.max(0, health / maxHealth) : 0,
);
}
const state = useCombatStore.getState();
if (state.mobs[bossActorId]?.dead) {
outcome = "defeated";
break;
}
if (memberMetrics.every((metrics) => currentHealth(metrics.actorId)[0] <= 0)) {
outcome = "wipe";
break;
}
}
const activeSeconds = Math.min(durationMs, elapsedMs) / 1_000;
const mob = useCombatStore.getState().mobs[bossActorId];
const members = memberMetrics.map((metrics) => serializedMember(metrics, activeSeconds));
const incomingDamage = members.reduce((total, member) => total + member.damageTaken, 0);
const rawIncomingDamage = members.reduce((total, member) => total + member.rawDamageTaken, 0);
const partyDamage = members.reduce((total, member) => total + member.damage, 0);
const bossDamage = members.reduce((total, member) => total + member.bossDamage, 0);
return {
bossKey: boss.key,
bossName: boss.name,
bossCategory: boss.category,
dungeonId: boss.dungeonId,
dungeonTitle: boss.dungeonTitle,
seed,
outcome,
elapsedSeconds: activeSeconds,
timeToDefeatSeconds: outcome === "defeated" ? activeSeconds : null,
bossLevel: scaling.level,
bossMaxHealth: scaling.maxHealth,
bossRemainingHealth: mob?.health ?? 0,
partyDamage,
bossDamage,
partyDps: partyDamage / Math.max(0.1, activeSeconds),
bossDpsTaken: bossDamage / Math.max(0.1, activeSeconds),
incomingDamage,
rawIncomingDamage,
incomingDtps: incomingDamage / Math.max(0.1, activeSeconds),
deaths: members.filter((member) => !member.alive).length,
deathEvents: members.reduce((total, member) => total + member.deathEvents, 0),
bossAttacks: mob?.attackRevision ?? 0,
members,
bossAbilities: [...bossAbilities.entries()]
.map(([abilityId, ability]) => ({ abilityId, ...ability }))
.sort((left, right) => right.effectiveDamage - left.effectiveDamage
|| left.abilityId.localeCompare(right.abilityId)),
};
} finally {
stopEvents();
restoreClock();
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
resetPartyRuntimeClock();
resetCombatSpatialProvider();
}
}
async function simulateConfig(config: BalanceLabConfig, output: string): Promise<void> {
if (config.schemaVersion !== 1) throw new Error(`Unsupported config schema: ${config.schemaVersion}`);
if (config.partyBuildIds.length !== 5) throw new Error("A balance-lab party must contain exactly five builds.");
if (!Number.isInteger(config.activeSlot) || config.activeSlot < 0 || config.activeSlot >= 5) {
throw new Error("activeSlot must be an integer from 0 through 4.");
}
if (!config.seeds.length) throw new Error("At least one simulation seed is required.");
const catalog = selectableBenchmarkBuildCatalog(config.level, classIdsForSource(config.source));
const catalogById = new Map(catalog.map((build) => [build.id, build]));
const selectedClassIds = [...new Set(config.partyBuildIds.map((id) => {
const entry = catalogById.get(id);
if (!entry) throw new Error(`Unknown ${config.source} party build: ${id}`);
return entry.classId;
}))];
const availableBuilds = await loadSelectableBenchmarkBuilds(config.level, selectedClassIds);
const byId = new Map(availableBuilds.map((build) => [build.id, build]));
const builds = config.partyBuildIds.map((id) => {
const build = byId.get(id);
if (!build) throw new Error(`Unknown ${config.source} party build: ${id}`);
return build;
});
await preloadAbilityCatalogsForClasses(builds.map((build) => build.classId));
const bosses = selectedBosses(config);
const runs = [];
let completed = 0;
const total = bosses.length * config.seeds.length;
for (const boss of bosses) {
for (const seed of config.seeds) {
runs.push(await runBoss(config, builds, boss, seed));
completed += 1;
if (completed === 1 || completed === total || completed % 10 === 0) {
process.stderr.write(`[balance-lab] ${completed}/${total} ${boss.name} seed ${seed}\n`);
}
}
}
await writeJson(output, {
schemaVersion: 1,
generatedAt: new Date().toISOString(),
engine: "live-typescript-runtime",
config: {
...config,
level: builds[0]?.level ?? config.level,
},
party: builds.map((build, slot) => ({
slot,
activePlayer: slot === config.activeSlot,
buildId: build.id,
classId: build.classId,
className: classById(build.classId).name,
specialization: build.orientation,
role: build.role,
})),
bossCount: bosses.length,
runCount: runs.length,
runs,
});
}
async function simulate(): Promise<void> {
const config = JSON.parse(await readFile(resolve(requiredArgument("--config")), "utf8")) as BalanceLabConfig;
await simulateConfig(config, requiredArgument("--output"));
}
async function serveSession(): Promise<void> {
const level = Math.trunc(safeNumber(argument("--level"), 60));
const source = (argument("--source") ?? "wow") as PartyCatalogSource;
process.stdout.write(`${JSON.stringify(catalogPayload(source, level))}\n`);
const lines = createInterface({ input: process.stdin, crlfDelay: Infinity });
for await (const line of lines) {
if (!line.trim()) continue;
const request = JSON.parse(line) as {
readonly close?: boolean;
readonly config?: string;
readonly output?: string;
};
if (request.close) {
process.stdout.write(`${JSON.stringify({ ok: true, closed: true })}\n`);
lines.close();
return;
}
if (!request.config || !request.output) throw new Error("A session simulation requires config and output paths.");
const config = JSON.parse(await readFile(resolve(request.config), "utf8")) as BalanceLabConfig;
await simulateConfig(config, request.output);
process.stdout.write(`${JSON.stringify({ ok: true })}\n`);
}
}
async function main(): Promise<void> {
const command = process.argv[2];
if (command === "catalog") await writeCatalog();
else if (command === "simulate") await simulate();
else if (command === "session") await serveSession();
else throw new Error("Usage: vite-node scripts/balance-lab/runtime.ts <catalog|simulate|session> [options]");
}
main().catch((error) => {
console.error(error instanceof Error ? error.stack ?? error.message : error);
process.exitCode = 1;
});
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,79 @@
#!/usr/bin/env node
import { writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import {
BOSS_PRESSURE_PARTY_SIZE,
BOSS_PRESSURE_WINDOW_MS,
rankBossPartyPressure,
} from "../../src/game/bossPressure";
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const playerLevel = Math.max(1, Math.min(80, Number(process.env.BOSS_PRESSURE_LEVEL) || 80));
const { DUNGEON_DEFINITIONS } = await import("../../src/game/dungeonRegistry");
const wowDefinitions = DUNGEON_DEFINITIONS.filter((dungeon) => dungeon.expansion !== "runewaker");
const ranking = rankBossPartyPressure(wowDefinitions, playerLevel);
const median = ranking.length ? ranking[Math.floor(ranking.length / 2)].partyDamagePerSecond : 0;
const entries = ranking.map((entry, index) => ({
rank: index + 1,
pressureVsMedian: median > 0 ? entry.partyDamagePerSecond / median : 0,
...entry,
}));
const eck = entries.find((entry) => entry.serverEntry === 29_932) ?? null;
const report = {
schemaVersion: 1,
generatedAt: new Date().toISOString(),
assumptions: {
playerLevel,
enemyLevelOffset: 2,
durationSeconds: BOSS_PRESSURE_WINDOW_MS / 1_000,
partySize: BOSS_PRESSURE_PARTY_SIZE,
partyLayout: "all five actors inside nearby-party effects",
metric: "unmitigated direct damage from the live boss attack catalog",
excludes: [
"armor and resistance",
"avoidable movement and interrupts",
"summoned creature damage",
"damage-amplifying debuffs",
"healing and absorbs",
"mutually exclusive phase uptime",
],
},
bosses: entries.length,
medianPartyDamagePerSecond: median,
eck,
entries,
};
const number = (value: number): string => Math.round(value).toLocaleString("en-US");
const decimal = (value: number): string => value.toFixed(1);
const top = entries.slice(0, 30);
const markdown = `# WoW boss party-pressure audit
Generated ${report.generatedAt}. This is a deterministic comparison at player level ${playerLevel}: ${BOSS_PRESSURE_PARTY_SIZE} grouped party members, ${BOSS_PRESSURE_WINDOW_MS / 1_000} seconds, and live boss attack definitions. It is a pressure ceiling, not a combat-log prediction: mitigation, movement, interrupts, summons, debuff amplification, and phase uptime are excluded.
| Rank | Boss | Dungeon | Party DPS | Tank DPS | Random DPS | Groupwide DPS | vs median |
| ---: | --- | --- | ---: | ---: | ---: | ---: | ---: |
${top.map((entry) => `| ${entry.rank} | ${entry.boss} | ${entry.dungeonTitle} | ${number(entry.partyDamagePerSecond)} | ${number(entry.tankDamage / entry.windowSeconds)} | ${number(entry.randomTargetDamage / entry.windowSeconds)} | ${number(entry.groupwideDamage / entry.windowSeconds)} | ${decimal(entry.pressureVsMedian)}x |`).join("\n")}
## Eck the Ferocious
${eck ? `Eck ranks **${eck.rank} of ${entries.length}** at **${number(eck.partyDamagePerSecond)} raw party DPS** (**${decimal(eck.pressureVsMedian)}x** the median). His corrected schedule contains one 2-second melee swing, Bite every 8–12 seconds, Spit every 8–12 seconds after its delayed opener, Spring every 10–24 seconds against a random party member, and a non-damaging Berserk at 90 seconds.` : "Eck was not found in the runtime catalog."}
The complete machine-readable ranking and per-attack breakdown are in \`dungeon-pipeline/WOW_BOSS_PRESSURE_REPORT.json\`.
`;
const jsonPath = path.join(projectRoot, "dungeon-pipeline", "WOW_BOSS_PRESSURE_REPORT.json");
const markdownPath = path.join(projectRoot, "dungeon-pipeline", "WOW_BOSS_PRESSURE_REPORT.md");
await Promise.all([
writeFile(jsonPath, `${JSON.stringify(report, null, 2)}\n`, "utf8"),
writeFile(markdownPath, markdown, "utf8"),
]);
console.log(JSON.stringify({
status: "complete",
bosses: entries.length,
playerLevel,
medianPartyDamagePerSecond: Number(median.toFixed(2)),
eck: eck ? { rank: eck.rank, partyDamagePerSecond: Number(eck.partyDamagePerSecond.toFixed(2)), pressureVsMedian: Number(eck.pressureVsMedian.toFixed(2)) } : null,
outputs: [path.relative(projectRoot, jsonPath), path.relative(projectRoot, markdownPath)],
}, null, 2));
@@ -0,0 +1,426 @@
import { createHash } from "node:crypto";
import { execFileSync } from "node:child_process";
import { readFileSync, writeFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { NodeIO } from "@gltf-transform/core";
import { ALL_EXTENSIONS } from "@gltf-transform/extensions";
import { MeshoptDecoder } from "meshoptimizer";
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
const defaultPlayerbotsRoot = path.resolve(projectRoot, "..", "wow-pvt-server-backups-2", "azerothcore-wotlk", "modules", "mod-playerbots");
const argumentIndex = process.argv.indexOf("--playerbots-root");
const playerbotsRoot = path.resolve(argumentIndex >= 0 ? process.argv[argumentIndex + 1] : defaultPlayerbotsRoot);
const sqlRoot = path.join(playerbotsRoot, "data", "sql", "playerbots", "base");
const catalogPath = path.join(projectRoot, "src", "game", "generated", "dungeonCampaignCatalog.json");
const outputPath = path.join(projectRoot, "src", "game", "generated", "dungeonRouteCatalog.generated.json");
const auditPath = path.join(projectRoot, "dungeon-pipeline", "DUNGEON_ROUTE_GENERATION_AUDIT.json");
const gltfIo = new NodeIO()
.registerExtensions(ALL_EXTENSIONS)
.registerDependencies({ "meshopt.decoder": MeshoptDecoder });
function tuples(fileName, pattern, convert) {
return readFileSync(path.join(sqlRoot, fileName), "utf8")
.split(/\r?\n/)
.map((line) => pattern.exec(line.trim()))
.filter(Boolean)
.map(convert);
}
const number = (value) => Number(value);
const nodes = tuples(
"playerbots_travelnode.sql",
/^\((\d+), '((?:[^'\\]|\\.)*)', (\d+), (-?[\d.eE+]+), (-?[\d.eE+]+), (-?[\d.eE+]+), (?:NULL|\d+)\)[,;]?$/,
(match) => ({ id: number(match[1]), mapId: number(match[3]), wow: [number(match[4]), number(match[5]), number(match[6])] }),
);
const links = tuples(
"playerbots_travelnode_link.sql",
/^\((\d+), (\d+), \d+, \d+, (-?[\d.eE+]+),/,
(match) => ({ from: number(match[1]), to: number(match[2]), distance: Math.max(0.001, number(match[3])) }),
);
const nodesById = new Map(nodes.map((node) => [node.id, node]));
function transformPoint(dungeon, wow) {
const base = dungeon.transform.fromWow === "three(x,y,z)=(wow.y,wow.z,wow.x)"
? [wow[1], wow[2], wow[0]]
: [-wow[0], wow[2], wow[1]];
const scale = dungeon.transform.scale ?? 1;
return [
base[0] * scale + (dungeon.transform.position?.[0] ?? 0),
base[1] * scale + (dungeon.transform.position?.[1] ?? 0),
base[2] * scale + (dungeon.transform.position?.[2] ?? 0),
];
}
function distance(left, right) {
return Math.hypot(right[0] - left[0], right[1] - left[1], right[2] - left[2]);
}
function nearestNode(candidates, point) {
return candidates.reduce((best, candidate) => {
const candidateDistance = distance(candidate.three, point);
return !best || candidateDistance < best.distance || (candidateDistance === best.distance && candidate.id < best.node.id)
? { node: candidate, distance: candidateDistance }
: best;
}, null)?.node ?? null;
}
function shortestNodePath(mapNodes, mapLinks, startId, endId) {
if (startId === endId) return [startId];
const allowed = new Set(mapNodes.map((node) => node.id));
const adjacency = new Map(mapNodes.map((node) => [node.id, []]));
for (const link of mapLinks) {
if (allowed.has(link.from) && allowed.has(link.to)) adjacency.get(link.from).push(link);
}
for (const values of adjacency.values()) values.sort((left, right) => left.to - right.to);
const costs = new Map([[startId, 0]]);
const previous = new Map();
const pending = new Set(allowed);
while (pending.size) {
let current = null;
let currentCost = Number.POSITIVE_INFINITY;
for (const id of pending) {
const cost = costs.get(id) ?? Number.POSITIVE_INFINITY;
if (cost < currentCost || (cost === currentCost && (current === null || id < current))) {
current = id;
currentCost = cost;
}
}
if (current === null || !Number.isFinite(currentCost)) break;
pending.delete(current);
if (current === endId) break;
for (const edge of adjacency.get(current) ?? []) {
if (!pending.has(edge.to)) continue;
const nextCost = currentCost + edge.distance;
if (nextCost < (costs.get(edge.to) ?? Number.POSITIVE_INFINITY)) {
costs.set(edge.to, nextCost);
previous.set(edge.to, current);
}
}
}
if (!previous.has(endId)) return null;
const result = [endId];
while (result[0] !== startId) result.unshift(previous.get(result[0]));
return result;
}
function transformByMatrix(point, matrix) {
return [
matrix[0] * point[0] + matrix[4] * point[1] + matrix[8] * point[2] + matrix[12],
matrix[1] * point[0] + matrix[5] * point[1] + matrix[9] * point[2] + matrix[13],
matrix[2] * point[0] + matrix[6] * point[1] + matrix[10] * point[2] + matrix[14],
];
}
async function loadNavigationGraph(dungeon) {
const navigationUrl = dungeon.assets?.navigation?.url;
if (!navigationUrl) throw new Error(`${dungeon.id}: missing navigation asset.`);
const navigationFile = path.join(projectRoot, "public", navigationUrl.replace(/^\//, ""));
const document = await gltfIo.read(navigationFile);
const welded = new Map();
const triangles = [];
const edgeOwners = new Map();
const vertexId = (point) => {
const key = point.map((value) => Math.round(value * 1_000)).join(":");
if (!welded.has(key)) welded.set(key, welded.size);
return welded.get(key);
};
for (const node of document.getRoot().listNodes()) {
const mesh = node.getMesh();
if (!mesh) continue;
const matrix = node.getWorldMatrix();
for (const primitive of mesh.listPrimitives()) {
const positionAccessor = primitive.getAttribute("POSITION");
const positions = positionAccessor?.getArray();
const sourceIndices = primitive.getIndices()?.getArray();
if (!positions) continue;
const indices = sourceIndices ? Array.from(sourceIndices) : Array.from({ length: positions.length / 3 }, (_, index) => index);
for (let offset = 0; offset + 2 < indices.length; offset += 3) {
const points = [indices[offset], indices[offset + 1], indices[offset + 2]].map((index) => transformByMatrix(
positionAccessor.getElement(index, []),
matrix,
));
const ids = points.map(vertexId);
const id = triangles.length;
triangles.push({
centroid: points.reduce((sum, point) => [sum[0] + point[0] / 3, sum[1] + point[1] / 3, sum[2] + point[2] / 3], [0, 0, 0]),
neighbors: new Set(),
});
for (const edge of [[ids[0], ids[1]], [ids[1], ids[2]], [ids[2], ids[0]]]) {
const key = [...edge].sort((left, right) => left - right).join(":");
const previous = edgeOwners.get(key);
if (previous !== undefined) {
triangles[id].neighbors.add(previous);
triangles[previous].neighbors.add(id);
} else edgeOwners.set(key, id);
}
}
}
}
if (!triangles.length) throw new Error(`${dungeon.id}: navigation asset contains no triangles.`);
const nearest = (point) => {
let id = -1;
let snapDistance = Number.POSITIVE_INFINITY;
triangles.forEach((triangle, index) => {
const candidate = distance(point, triangle.centroid);
if (candidate < snapDistance) {
id = index;
snapDistance = candidate;
}
});
return { id, distance: snapDistance, point: triangles[id].centroid };
};
for (const link of dungeon.navigationLinks ?? []) {
const start = nearest(link.start);
const end = nearest(link.end);
if (start.distance <= 5 && end.distance <= 5) {
triangles[start.id].neighbors.add(end.id);
if (link.bidirectional !== false) triangles[end.id].neighbors.add(start.id);
}
}
return { triangles, nearest };
}
class MinimumHeap {
values = [];
push(value) {
this.values.push(value);
let index = this.values.length - 1;
while (index > 0) {
const parent = (index - 1) >> 1;
if (this.values[parent].cost <= value.cost) break;
this.values[index] = this.values[parent];
index = parent;
}
this.values[index] = value;
}
pop() {
if (!this.values.length) return null;
const first = this.values[0];
const last = this.values.pop();
if (this.values.length) {
let index = 0;
while (true) {
const left = index * 2 + 1;
const right = left + 1;
if (left >= this.values.length) break;
const child = right < this.values.length && this.values[right].cost < this.values[left].cost ? right : left;
if (this.values[child].cost >= last.cost) break;
this.values[index] = this.values[child];
index = child;
}
this.values[index] = last;
}
return first;
}
}
function navigationTrianglePath(graph, startId, endId) {
if (startId === endId) return [startId];
const costs = new Map([[startId, 0]]);
const previous = new Map();
const pending = new MinimumHeap();
pending.push({ id: startId, cost: 0 });
while (pending.values.length) {
const current = pending.pop();
const known = costs.get(current.id);
if (current.cost > known + 1e-6) continue;
if (current.id === endId) break;
const origin = graph.triangles[current.id].centroid;
for (const neighborId of graph.triangles[current.id].neighbors) {
const nextCost = known + distance(origin, graph.triangles[neighborId].centroid);
if (nextCost >= (costs.get(neighborId) ?? Number.POSITIVE_INFINITY)) continue;
costs.set(neighborId, nextCost);
previous.set(neighborId, current.id);
pending.push({ id: neighborId, cost: nextCost });
}
}
if (!previous.has(endId)) return null;
const result = [endId];
while (result[0] !== startId) result.unshift(previous.get(result[0]));
return result;
}
function segmentDistanceSquared(point, start, end) {
const delta = [end[0] - start[0], end[1] - start[1], end[2] - start[2]];
const lengthSquared = delta[0] ** 2 + delta[1] ** 2 + delta[2] ** 2;
if (lengthSquared <= 1e-9) return distance(point, start) ** 2;
const t = Math.max(0, Math.min(1, (
(point[0] - start[0]) * delta[0]
+ (point[1] - start[1]) * delta[1]
+ (point[2] - start[2]) * delta[2]
) / lengthSquared));
return distance(point, [start[0] + delta[0] * t, start[1] + delta[1] * t, start[2] + delta[2] * t]) ** 2;
}
function simplify(points, tolerance = 0.75) {
if (points.length <= 2) return points;
let furthest = -1;
let furthestDistance = tolerance ** 2;
for (let index = 1; index < points.length - 1; index += 1) {
const candidate = segmentDistanceSquared(points[index], points[0], points[points.length - 1]);
if (candidate > furthestDistance) {
furthest = index;
furthestDistance = candidate;
}
}
if (furthest < 0) return [points[0], points[points.length - 1]];
return [...simplify(points.slice(0, furthest + 1), tolerance).slice(0, -1), ...simplify(points.slice(furthest), tolerance)];
}
function boundSegments(points, maximum = 7.5) {
const result = [points[0]];
for (let index = 1; index < points.length; index += 1) {
const start = result[result.length - 1];
const end = points[index];
const length = distance(start, end);
const steps = Math.max(1, Math.ceil(length / maximum));
for (let step = 1; step <= steps; step += 1) {
const t = step / steps;
result.push([
start[0] + (end[0] - start[0]) * t,
start[1] + (end[1] - start[1]) * t,
start[2] + (end[2] - start[2]) * t,
]);
}
}
return result;
}
function rounded(point) {
return point.map((coordinate) => Number(coordinate.toFixed(4)));
}
function canonical(value) {
if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
if (value && typeof value === "object") {
return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonical(value[key])}`).join(",")}}`;
}
return JSON.stringify(value);
}
function playerbotsTopologyEvidence(start, end, mapNodes, mapLinks) {
const startNode = nearestNode(mapNodes, start);
const endNode = nearestNode(mapNodes, end);
if (!startNode || !endNode) return { landmarksAvailable: false, connected: false };
return {
landmarksAvailable: true,
connected: Boolean(shortestNodePath(mapNodes, mapLinks, startNode.id, endNode.id)),
};
}
function routeGeometry(graph, start, end) {
const startSnap = graph.nearest(start);
const endSnap = graph.nearest(end);
const trianglePath = navigationTrianglePath(graph, startSnap.id, endSnap.id);
const points = trianglePath
? trianglePath.map((id) => graph.triangles[id].centroid)
: [startSnap.point, endSnap.point];
const checkpoints = boundSegments(simplify(points, 0.35)).map(rounded);
return {
checkpoints,
connected: Boolean(trianglePath),
startSnapDistance: startSnap.distance,
endSnapDistance: endSnap.distance,
};
}
const campaign = JSON.parse(readFileSync(catalogPath, "utf8"));
const stock = campaign.definitions.filter((dungeon) => String(dungeon.provenance?.databaseAdapter ?? "").startsWith("azerothcore-"));
const routes = [];
const routeAudits = [];
for (const dungeon of stock) {
const mapNodes = nodes.filter((node) => node.mapId === dungeon.mapId).map((node) => ({
...node,
three: transformPoint(dungeon, node.wow),
}));
const mapLinks = links.filter((link) => nodesById.get(link.from)?.mapId === dungeon.mapId && nodesById.get(link.to)?.mapId === dungeon.mapId);
const navigationGraph = await loadNavigationGraph(dungeon);
let start = dungeon.entrance.footPosition;
const legAudits = [];
const legs = dungeon.objectiveOrder.map((objective, index) => {
const topology = playerbotsTopologyEvidence(start, objective.position, mapNodes, mapLinks);
const geometry = routeGeometry(navigationGraph, start, objective.position);
const { checkpoints } = geometry;
if (!checkpoints.length) throw new Error(`${dungeon.id}: empty navigation leg to ${objective.id}.`);
const expectedDistance = checkpoints.slice(1).reduce((total, point, pointIndex) => total + distance(checkpoints[pointIndex], point), 0);
legAudits.push({
objectiveId: objective.id,
playerbotsLandmarksAvailable: topology.landmarksAvailable,
playerbotsTopologyConnected: topology.connected,
navigationStartSnapDistance: Number(geometry.startSnapDistance.toFixed(3)),
navigationEndSnapDistance: Number(geometry.endSnapDistance.toFixed(3)),
checkpointCount: checkpoints.length,
valid: geometry.connected && geometry.startSnapDistance <= 15 && geometry.endSnapDistance <= 15,
issues: [
...(!geometry.connected ? ["disconnected-navigation-leg"] : []),
...(geometry.startSnapDistance > 15 ? ["origin-snap-distance-exceeded"] : []),
...(geometry.endSnapDistance > 15 ? ["endpoint-snap-distance-exceeded"] : []),
],
});
start = objective.position;
return {
id: `leg-${String(index + 1).padStart(2, "0")}-${objective.id}`,
objectiveId: objective.id,
objectiveName: objective.name,
checkpoints,
expectedDistance: Number(expectedDistance.toFixed(2)),
corridorPlanarDistance: 10,
corridorVerticalDistance: 4,
actions: [],
};
});
const unhashed = { schemaVersion: 1, id: "primary", dungeonId: dungeon.id, mapId: dungeon.mapId, legs };
const valid = legAudits.every((leg) => leg.valid);
routes.push({
...unhashed,
hash: createHash("sha256").update(canonical(unhashed)).digest("hex"),
generationStatus: valid ? "Candidate" : "Rejected",
generationIssues: [...new Set(legAudits.flatMap((leg) => leg.issues))],
});
routeAudits.push({
dungeonId: dungeon.id,
mapId: dungeon.mapId,
navigationTriangles: navigationGraph.triangles.length,
valid,
disposition: valid ? "candidate" : "rejected-needs-work",
legs: legAudits,
});
}
if (routes.length !== 51) throw new Error(`Expected 51 stock dungeon routes, generated ${routes.length}.`);
const safePlayerbotsRoot = playerbotsRoot.replaceAll("\\", "/");
const playerbotsGit = (...arguments_) => execFileSync(
"git",
["-c", `safe.directory=${safePlayerbotsRoot}`, "-C", playerbotsRoot, ...arguments_],
{ encoding: "utf8" },
).trim();
const playerbotsCommit = playerbotsGit("rev-parse", "HEAD");
const generatedAt = playerbotsGit("show", "-s", "--format=%cI", "HEAD");
const output = {
schemaVersion: 1,
generatedAt,
source: {
kind: "playerbots-landmark-reference",
auditReport: "dungeon-pipeline/DUNGEON_ROUTE_GENERATION_AUDIT.json",
note: "PlayerBots was used only as offline topology evidence. Shipped checkpoint geometry is computed from HealerMan navigation assets.",
},
routes,
};
writeFileSync(outputPath, `${JSON.stringify(output)}\n`);
writeFileSync(auditPath, `${JSON.stringify({
schemaVersion: 1,
generatedAt,
playerbots: {
repository: "azerothcore/mod-playerbots",
commit: playerbotsCommit,
attribution: "AzerothCore PlayerBots travel data was consulted offline as landmark and connectivity evidence; no source code, node IDs, raw node positions, or dense path rows are shipped.",
},
policy: {
anchors: "HealerMan entrance and required objective positions",
geometry: "HealerMan Recast navigation GLBs",
maximumCheckpointSegment: 7.5,
},
routes: routeAudits,
}, null, 2)}\n`);
console.log(`Generated ${routes.length} candidate routes at ${path.relative(projectRoot, outputPath)}.`);
+129
View File
@@ -0,0 +1,129 @@
const { chromium } = require('C:/Users/phenom/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const directory = path.resolve(process.env.BALANCE_LAB_PLAYTEST_DIR || 'playtest-artifacts/balance-lab');
const port = Number(process.env.BALANCE_LAB_PLAYTEST_PORT || 4191);
const base = `http://127.0.0.1:${port}`;
fs.mkdirSync(directory, { recursive: true });
const runtimeData = fs.mkdtempSync(path.join(os.tmpdir(), 'healer-man-balance-ui-'));
(async () => {
const { createGameServer } = await import('../server/server.mjs');
const runtime = createGameServer({
dataDir: runtimeData,
enableShelvedModes: false,
pythonExecutable: process.env.BALANCE_LAB_PYTHON,
});
await new Promise((resolve) => runtime.server.listen(port, '127.0.0.1', resolve));
const browser = await chromium.launch({ channel: 'msedge', headless: true });
const context = await browser.newContext({ viewport: { width: 1440, height: 1000 } });
const page = await context.newPage();
page.setDefaultTimeout(90_000);
const pageErrors = [];
page.on('pageerror', (error) => pageErrors.push(error.message));
const capture = async (name) => {
await page.screenshot({ path: path.join(directory, `${name}.png`), fullPage: true });
console.log(`Captured ${name}`);
};
try {
await page.goto(base, { waitUntil: 'domcontentloaded' });
await page.getByLabel('Username').fill('Phenom');
await page.getByLabel('Password').fill('balance-lab-test');
await page.getByRole('button', { name: /Create account/ }).click();
await page.locator('.character-select-screen').waitFor();
await page.getByRole('button', { name: /Create character/ }).click();
await page.getByRole('button', { name: /Customize & name/ }).click();
await page.getByLabel('Character name').fill('Balancer');
await page.getByRole('button', { name: /Create character/ }).click();
await page.getByRole('button', { name: /Continue/ }).first().click();
await page.locator('.main-menu-screen').waitFor();
const gmTools = page.getByRole('button', { name: /GM Tools/ });
const balanceLab = page.getByRole('button', { name: /Balance Lab/ });
await gmTools.waitFor();
await balanceLab.waitFor();
const gmBox = await gmTools.boundingBox();
const labBox = await balanceLab.boundingBox();
assert(gmBox && labBox && labBox.y > gmBox.y, 'Balance Lab is placed underneath GM Tools');
await capture('01-gm-menu');
await balanceLab.click();
await page.locator('.balance-lab-screen').waitFor();
await page.getByLabel('Party source').selectOption('wow');
await page.getByLabel('Level').fill('20');
await page.getByLabel('Parties per source').fill('1');
await page.getByLabel('Runs per party/boss').fill('1');
await page.getByLabel('Fight limit (seconds)').fill('5');
await page.getByLabel('Bosses').selectOption('specific');
await page.getByLabel('Search Balance Lab bosses').fill('Eck the Ferocious');
await page.getByRole('checkbox', { name: /Eck the Ferocious/ }).check();
const totalSimulations = page.locator('.balance-lab-estimate span').filter({ hasText: 'Total simulations' }).locator('b');
await totalSimulations.waitFor();
assert.equal(await totalSimulations.innerText(), '1');
await capture('02-server-job-config');
await page.getByRole('button', { name: 'Run on server' }).click();
await page.locator('.balance-lab-job-list em.is-complete').waitFor({ timeout: 240_000 });
await page.getByRole('button', { name: 'Open full report' }).waitFor();
assert.equal(await page.locator('.balance-lab-artifacts > button').count(), 12);
await capture('03-server-job-complete');
await page.getByRole('button', { name: 'View graphs and filterable data' }).click();
await page.locator('.balance-results').waitFor();
assert.equal(await page.locator('.balance-results-chart').count(), 5);
assert.match(await page.locator('.balance-results-chart').filter({ hasText: 'Boss damage to party' }).innerText(), /Eck the Ferocious/);
await capture('04-run-analytics');
await page.getByRole('button', { name: 'bosses', exact: true }).click();
assert.equal(await page.locator('.balance-results-table-panel tbody tr').count(), 1);
assert.match(await page.locator('.balance-results-table-panel tbody').innerText(), /Eck the Ferocious/);
await page.getByRole('button', { name: 'Class / spec', exact: true }).click();
const allClassRows = await page.locator('.balance-results-table-panel tbody tr').count();
assert(allClassRows >= 5, 'the complete five-member party should be present in the class/spec table');
await page.getByLabel('Sort class specs').selectOption('totalDamageTaken');
assert.equal(await page.getByLabel('Sort class specs').inputValue(), 'totalDamageTaken');
await page.getByLabel('Class role').selectOption('damage');
const damageRows = await page.locator('.balance-results-table-panel tbody tr').count();
assert(damageRows > 0 && damageRows < allClassRows, 'role filtering should narrow the complete class/spec table');
await page.getByLabel('Class role').selectOption('all');
await page.getByRole('button', { name: 'abilities', exact: true }).click();
const abilityRows = page.locator('.balance-results-abilities tbody tr');
assert(await abilityRows.count() > 0, 'ability breakdown should retain observed and available abilities');
await page.getByLabel('Filter ability usage').selectOption('unused');
assert(await abilityRows.count() > 0, 'available but unused abilities should remain visible');
await page.getByLabel('Filter ability usage').selectOption('all');
await page.getByLabel('Sort abilities').selectOption('effectiveDamage');
await capture('04b-ability-breakdown');
await page.getByRole('button', { name: 'parties', exact: true }).click();
assert.equal(await page.locator('.balance-results-table-panel tbody tr').count(), 1);
await page.getByLabel('Search Balance Lab run history').fill('does-not-match');
await page.getByText('No saved runs match these filters.').waitFor();
await page.getByLabel('Search Balance Lab run history').fill('wow');
assert.equal(await page.locator('.balance-lab-job-list > button').count(), 1);
await page.setViewportSize({ width: 480, height: 900 });
await page.waitForTimeout(250);
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth), true);
await page.locator('#balance-lab-analysis').scrollIntoViewIfNeeded();
await page.getByRole('button', { name: 'overview', exact: true }).click();
await page.getByRole('heading', { name: 'Boss damage to party' }).waitFor();
await page.getByRole('button', { name: 'Main menu' }).waitFor();
await capture('05-mobile-analytics');
await page.getByRole('button', { name: 'abilities', exact: true }).click();
await page.getByText('Every class/spec ability').scrollIntoViewIfNeeded();
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth), true);
await capture('06-mobile-abilities');
assert.deepEqual(pageErrors, []);
console.log('Balance Lab GM playtest passed');
} finally {
await browser.close();
await new Promise((resolve) => runtime.server.close(resolve));
fs.rmSync(runtimeData, { recursive: true, force: true });
}
})().catch((error) => {
console.error(error);
process.exitCode = 1;
});
+21 -1
View File
@@ -118,6 +118,25 @@ const base = process.env.BOSS_TEST_URL || 'http://127.0.0.1:4187';
const reports = runtime.database.listDungeonReports().reports;
assert.equal(reports[0].bossKey, before.boss.key);
assert.equal(runtime.database.listBossTestingReviews().reviews[0].status, 'ready');
await page.evaluate(async (previousKey) => {
window.previousBossTestingKey = previousKey;
window.liveBossTestingStores = await window.bossTestingStores();
}, before.boss.key);
await page.getByRole('button', { name: 'Fight another random boss', exact: true }).click();
await page.waitForFunction(() => {
const { useGameStore, useCombatStore } = window.liveBossTestingStores;
const game = useGameStore.getState();
return game.assetStatus === 'ready' && game.bossTest?.key !== window.previousBossTestingKey
&& game.bossTest?.runtimeIds.every((id) => useCombatStore.getState().mobs[id]);
}, null, { timeout: 120000 });
const nextBoss = await page.evaluate(() => {
const { useGameStore } = window.liveBossTestingStores;
return useGameStore.getState().bossTest;
});
assert.notEqual(nextBoss.key, before.boss.key, 'Post-fight random selection must choose another boss');
await page.waitForTimeout(1000);
await capture('06b-another-random-boss');
await page.keyboard.press('Escape');
await page.getByRole('button', { name: 'Return to main menu', exact: true }).click();
await page.locator('.main-menu-screen').waitFor();
await page.getByRole('button', { name: /Boss Testing/ }).click();
@@ -168,7 +187,8 @@ const base = process.env.BOSS_TEST_URL || 'http://127.0.0.1:4187';
assert.deepEqual(errors, [], 'No browser runtime errors');
fs.writeFileSync(path.join(directory, 'results.json'), `${JSON.stringify({ passed: true, boss: before.boss, classId: before.classId,
characterLevel: before.level, bossLevel: before.bossLevel, bossMaxHealth: before.bossMaxHealth,
initialMobCount: before.count, xpUnchanged: true, profileUnchanged: true, automaticDownload: 'dungeon:ragefire-chasm', report: reports[0], errors }, null, 2)}\n`);
initialMobCount: before.count, nextRandomBoss: nextBoss, xpUnchanged: true, profileUnchanged: true,
automaticDownload: 'dungeon:ragefire-chasm', report: reports[0], errors }, null, 2)}\n`);
console.log('Boss Testing live flow passed');
} catch (error) {
await capture('failure');
+99
View File
@@ -0,0 +1,99 @@
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 path = require('node:path');
const directory = path.resolve('playtest-artifacts', 'dungeon-simulation');
fs.mkdirSync(directory, { recursive: true });
const databaseDirectory = path.join(directory, 'test-database');
fs.rmSync(databaseDirectory, { recursive: true, force: true });
(async () => {
const { createGameServer } = await import('../server/server.mjs');
const runtime = createGameServer({
dataDir: databaseDirectory,
enableShelvedModes: false,
});
await new Promise((resolve) => runtime.server.listen(4189, '127.0.0.1', resolve));
const browser = await chromium.launch({ channel: 'msedge', headless: true });
const context = await browser.newContext({ viewport: { width: 1600, height: 1000 }, acceptDownloads: true });
const page = await context.newPage();
page.setDefaultTimeout(90000);
const errors = [];
page.on('pageerror', (error) => errors.push(error.message));
const capture = (name) => page.screenshot({ path: path.join(directory, `${name}.png`), fullPage: true });
try {
await page.goto('http://127.0.0.1:4189', { waitUntil: 'domcontentloaded' });
await page.getByLabel('Username').fill('Phenom');
await page.getByLabel('Password').fill('dungeon-route-admin');
await page.getByRole('button', { name: /Create account/ }).click();
await page.getByRole('button', { name: /Create character/ }).click();
await page.getByRole('button', { name: /Customize & name/ }).click();
await page.getByLabel('Character name').fill('Routewatcher');
await page.getByRole('button', { name: /Create character/ }).click();
await page.getByRole('button', { name: /Continue/ }).first().click();
await page.getByRole('button', { name: /GM Tools/ }).click();
await page.getByRole('heading', { name: 'Dungeon route simulations' }).waitFor();
await capture('01-gm-route-launch');
const routeSelects = page.locator('.dungeon-sim-admin__catalog select');
await routeSelects.nth(0).selectOption('wailing-caverns');
await routeSelects.nth(1).selectOption('navigation-only');
await page.locator('.dungeon-sim-admin__catalog input').fill('browser-smoke-fixed-seed');
await page.getByRole('button', { name: 'Launch spectate mode' }).click();
await page.locator('canvas').waitFor();
await page.getByLabel('Dungeon simulation controls').waitFor();
await page.waitForTimeout(6000);
await capture('02-live-navigation-simulation');
await page.getByRole('button', { name: 'Pause' }).click();
const pausedTime = await page.locator('.dungeon-sim-hud__header > time').innerText();
await page.waitForTimeout(700);
assert.equal(await page.locator('.dungeon-sim-hud__header > time').innerText(), pausedTime, 'pause freezes simulation time');
await page.getByRole('button', { name: 'Step' }).click();
await page.getByRole('button', { name: 'Step' }).click();
await page.waitForTimeout(200);
assert.notEqual(await page.locator('.dungeon-sim-hud__header > time').innerText(), pausedTime, 'single-step advances simulation time');
await page.getByRole('button', { name: 'Follow camera' }).click();
await page.getByRole('button', { name: 'Free-fly camera' }).waitFor();
await capture('03-paused-free-fly');
await page.getByRole('button', { name: '4×' }).click();
await page.getByRole('button', { name: 'Play' }).click();
await page.waitForTimeout(1200);
await page.getByRole('button', { name: 'Stop & save' }).click();
await page.getByRole('button', { name: 'Return to GM Tools' }).waitFor();
await capture('04-finished-report');
await page.getByRole('button', { name: 'Return to GM Tools' }).click();
await page.getByRole('heading', { name: 'Dungeon route simulations' }).waitFor();
await page.locator('.dungeon-sim-admin__catalog select').nth(0).selectOption('wailing-caverns');
await page.getByText(/1 runs for this route/).waitFor();
await capture('05-saved-replay');
await page.getByRole('button', { name: 'Watch' }).first().click();
await page.getByText('Recorded replay').waitFor();
await page.getByLabel('Replay position').waitFor();
await page.waitForTimeout(6000);
await page.getByRole('button', { name: 'Play' }).click();
await page.waitForTimeout(800);
await capture('06-replay-controls');
await page.getByRole('button', { name: 'Pause' }).click();
const pausedReplayTime = await page.locator('.dungeon-sim-hud__header > time').innerText();
await page.waitForTimeout(500);
assert.equal(await page.locator('.dungeon-sim-hud__header > time').innerText(), pausedReplayTime, 'replay pause freezes playback time');
await page.getByRole('button', { name: '4×' }).click();
await page.getByRole('button', { name: 'Play' }).click();
await page.waitForTimeout(600);
await capture('07-replay-4x');
assert.deepEqual(errors, [], `No browser page errors: ${errors.join('; ')}`);
console.log('Dungeon simulation browser smoke test passed.');
} finally {
await browser.close();
await new Promise((resolve) => runtime.server.close(resolve));
}
})().catch((error) => {
console.error(error);
process.exitCode = 1;
});
+148
View File
@@ -0,0 +1,148 @@
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const { chromium } = require("C:/Users/phenom/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright");
const base = process.env.MOUSE_CAMERA_TEST_URL || "http://127.0.0.1:5176";
const directory = path.resolve("playtest-artifacts/mouse-camera");
fs.mkdirSync(directory, { recursive: true });
(async () => {
const browser = await chromium.launch({ channel: "msedge", headless: true });
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
page.setDefaultTimeout(120_000);
const errors = [];
page.on("pageerror", (error) => errors.push(error.message));
const gameState = () => page.evaluate(async () => {
const storeEntry = performance.getEntriesByType("resource")
.findLast((entry) => new URL(entry.name).pathname === "/src/game/store.ts");
if (!storeEntry) throw new Error("game store module was not loaded");
const { useGameStore } = await import(storeEntry.name);
const state = useGameStore.getState();
return {
cameraLookActive: state.cameraLookActive,
cameraYaw: state.cameraYaw,
pointerLocked: document.pointerLockElement !== null,
selection: window.getSelection()?.toString() ?? "",
};
});
try {
await page.goto(base, { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: /Play offline/ }).click();
await page.getByRole("button", { name: /Create character/ }).click();
await page.getByRole("button", { name: /Customize & name/ }).click();
await page.getByLabel("Character name").fill("Mouselooker");
await page.getByRole("button", { name: /Create character/ }).click();
await page.getByRole("button", { name: /Continue|Enter world/ }).first().click();
await page.locator(".main-menu-screen").waitFor();
await page.getByRole("button", { name: /^Dungeons/ }).click();
await page.getByRole("button", { name: /Choose role/ }).click();
await page.locator(".dungeon-role-option").first().click();
await page.locator(".game-shell").waitFor();
await page.waitForFunction(async () => {
const storeEntry = performance.getEntriesByType("resource")
.findLast((entry) => new URL(entry.name).pathname === "/src/game/store.ts");
if (!storeEntry) return false;
const { useGameStore } = await import(storeEntry.name);
return ["ready", "proxy", "error"].includes(useGameStore.getState().assetStatus);
});
await page.locator(".loading-screen").waitFor({ state: "hidden" });
await page.waitForTimeout(1_000);
const selectionBefore = await page.evaluate(() => {
const fixture = document.createElement("p");
fixture.textContent = "Existing browser selection";
fixture.style.userSelect = "text";
fixture.style.position = "fixed";
fixture.style.left = "-10000px";
document.body.append(fixture);
const range = document.createRange();
range.selectNodeContents(fixture);
const selection = window.getSelection();
if (!selection) throw new Error("browser selection API is unavailable");
selection.removeAllRanges();
selection.addRange(range);
return selection.toString();
});
assert.equal(selectionBefore, "Existing browser selection");
const scene = page.locator(".scene-shell");
const bounds = await scene.boundingBox();
assert(bounds, "3D scene is visible");
const cdp = await page.context().newCDPSession(page);
const start = { x: bounds.x + bounds.width * 0.55, y: bounds.y + bounds.height * 0.55 };
const before = await gameState();
assert.equal(before.cameraLookActive, false);
await page.mouse.move(start.x, start.y);
await page.mouse.down({ button: "right" });
await page.waitForFunction(async () => {
const storeEntry = performance.getEntriesByType("resource")
.findLast((entry) => new URL(entry.name).pathname === "/src/game/store.ts");
if (!storeEntry) return false;
const { useGameStore } = await import(storeEntry.name);
return useGameStore.getState().cameraLookActive;
});
const captured = await gameState();
assert.equal(captured.pointerLocked, false);
assert.equal(captured.selection, "");
await cdp.send("Input.dispatchMouseEvent", {
type: "mouseMoved",
x: start.x + 160,
y: start.y - 55,
button: "right",
buttons: 2,
pointerType: "mouse",
});
await page.waitForTimeout(350);
const moved = await gameState();
assert.notEqual(moved.cameraYaw, before.cameraYaw);
await page.screenshot({ path: path.join(directory, "right-drag-camera.png") });
await page.mouse.up({ button: "right" });
await page.waitForFunction(async () => {
const storeEntry = performance.getEntriesByType("resource")
.findLast((entry) => new URL(entry.name).pathname === "/src/game/store.ts");
if (!storeEntry) return false;
const { useGameStore } = await import(storeEntry.name);
return !useGameStore.getState().cameraLookActive;
});
const released = await gameState();
await page.mouse.move(start.x - 120, start.y + 70, { steps: 8 });
await page.waitForTimeout(350);
const afterReleaseMove = await gameState();
assert.equal(afterReleaseMove.cameraYaw, released.cameraYaw);
assert.equal(afterReleaseMove.pointerLocked, false);
assert.equal(
await page.locator(".game-shell").evaluate((element) => getComputedStyle(element).userSelect),
"none",
);
assert.deepEqual(errors, []);
const result = {
passed: true,
yawBefore: before.cameraYaw,
yawDuringDrag: moved.cameraYaw,
yawAfterRelease: afterReleaseMove.cameraYaw,
pointerLocked: afterReleaseMove.pointerLocked,
selectionCleared: captured.selection === "",
errors,
};
fs.writeFileSync(path.join(directory, "results.json"), `${JSON.stringify(result, null, 2)}\n`);
console.log(JSON.stringify(result, null, 2));
console.log("Mouse camera playtest passed");
} catch (error) {
await page.mouse.up({ button: "right" }).catch(() => {});
await page.screenshot({ path: path.join(directory, "failure.png") });
console.log((await page.locator("body").innerText()).slice(-2_000));
console.log("Browser errors:", errors);
throw error;
} finally {
await browser.close();
}
})().catch((error) => {
console.error(error);
process.exitCode = 1;
});
+122
View File
@@ -0,0 +1,122 @@
import unittest
from scripts.balance_lab import (
aggregate_class_abilities,
aggregate_class_specs,
automatic_parties,
random_party,
search_bosses,
tuning_label,
)
class BalanceLabControllerTests(unittest.TestCase):
def test_random_party_is_repeatable_and_complete(self) -> None:
builds = [
{"id": "tank-a", "role": "tank"},
{"id": "tank-b", "role": "tank"},
{"id": "healer-a", "role": "healer"},
{"id": "healer-b", "role": "healer"},
*({"id": f"damage-{index}", "role": "damage"} for index in range(6)),
]
first = random_party(builds, 17)
second = random_party(builds, 17)
self.assertEqual(first, second)
self.assertEqual([member["role"] for member in first], [
"tank", "healer", "damage", "damage", "damage",
])
self.assertEqual(len({member["id"] for member in first[2:]}), 3)
def test_tuning_flags_keep_both_sides_of_the_median(self) -> None:
self.assertEqual(tuning_label(1.51), "very high")
self.assertEqual(tuning_label(1.25), "high")
self.assertEqual(tuning_label(1.0), "baseline")
self.assertEqual(tuning_label(0.75), "low")
self.assertEqual(tuning_label(0.49), "very low")
self.assertEqual(tuning_label(None), "no baseline")
def test_automatic_parties_are_unique_repeatable_and_source_isolated(self) -> None:
builds = []
for source in ("wow", "coa"):
builds.extend([
{"id": f"{source}-tank-a", "source": source, "role": "tank"},
{"id": f"{source}-tank-b", "source": source, "role": "tank"},
{"id": f"{source}-healer-a", "source": source, "role": "healer"},
{"id": f"{source}-healer-b", "source": source, "role": "healer"},
*({"id": f"{source}-damage-{index}", "source": source, "role": "damage"}
for index in range(6)),
])
first = automatic_parties(builds, ("wow", "coa"), 4, 29)
second = automatic_parties(builds, ("wow", "coa"), 4, 29)
self.assertEqual(first, second)
self.assertEqual(len(first), 8)
self.assertEqual(len({configuration["id"] for configuration in first}), 8)
for configuration in first:
self.assertTrue(all(member["source"] == configuration["source"] for member in configuration["party"]))
self.assertEqual([member["role"] for member in configuration["party"]], [
"tank", "healer", "damage", "damage", "damage",
])
def test_boss_search_matches_name_dungeon_key_and_source(self) -> None:
bosses = [
{"key": "gundrak/eck", "name": "Eck the Ferocious", "dungeonTitle": "Gundrak", "category": "wow"},
{"key": "abbey/duke", "name": "Duke", "dungeonTitle": "Forsaken Abbey", "category": "rom"},
]
self.assertEqual([boss["key"] for boss in search_bosses("ferocious", bosses)], ["gundrak/eck"])
self.assertEqual([boss["key"] for boss in search_bosses("forsaken", bosses)], ["abbey/duke"])
self.assertEqual([boss["key"] for boss in search_bosses("rom", bosses)], ["abbey/duke"])
def test_class_spec_aggregate_keeps_rate_and_absolute_damage_taken(self) -> None:
key = ("wow", "priest-holy", "priest", "Priest", "Holy", "healer")
rows = aggregate_class_specs({key: [
{
"partyConfigurationId": "wow-party-001", "elapsedSeconds": 10,
"damage": 200, "bossDamage": 180, "healing": 500,
"damageTaken": 120, "absorbs": 25, "deathEvents": 0,
},
{
"partyConfigurationId": "wow-party-002", "elapsedSeconds": 20,
"damage": 400, "bossDamage": 360, "healing": 900,
"damageTaken": 330, "absorbs": 50, "deathEvents": 1,
},
]})
self.assertEqual(len(rows), 1)
self.assertEqual(rows[0]["dtps"], 15)
self.assertEqual(rows[0]["totalDamageTaken"], 450)
def test_ability_aggregate_keeps_used_and_available_but_unused_rows(self) -> None:
class_key = ("wow", "mage-fire", "mage", "Mage", "Fire", "damage")
class_runs = [
{"partyConfigurationId": "party-1", "bossKey": "boss-1", "elapsedSeconds": 10, "damage": 100},
{"partyConfigurationId": "party-2", "bossKey": "boss-2", "elapsedSeconds": 10, "damage": 100},
]
base = {
"abilityName": "Fireball", "icon": "/fireball.png", "categories": ["damage"],
"cooldownSeconds": 0, "available": True, "casts": 0, "hits": 0,
"rawDamage": 0, "effectiveDamage": 0, "rawHealing": 0,
"effectiveHealing": 0, "overheal": 0, "absorbs": 0,
}
groups = {
(*class_key, "mage-fireball"): [
{**base, "partyConfigurationId": "party-1", "bossKey": "boss-1", "elapsedSeconds": 10,
"used": True, "casts": 1, "hits": 1, "rawDamage": 120, "effectiveDamage": 100},
{**base, "partyConfigurationId": "party-2", "bossKey": "boss-2", "elapsedSeconds": 10,
"used": False},
],
(*class_key, "mage-unused-blast"): [
{**base, "abilityName": "Unused Blast", "partyConfigurationId": "party-1",
"bossKey": "boss-1", "elapsedSeconds": 10, "used": False},
{**base, "abilityName": "Unused Blast", "partyConfigurationId": "party-2",
"bossKey": "boss-2", "elapsedSeconds": 10, "used": False},
],
}
rows = aggregate_class_abilities(groups, {class_key: class_runs})
by_id = {row["abilityId"]: row for row in rows}
self.assertEqual(by_id["mage-fireball"]["activeRuns"], 1)
self.assertEqual(by_id["mage-fireball"]["usageRate"], 0.5)
self.assertEqual(by_id["mage-fireball"]["damageShare"], 0.5)
self.assertTrue(by_id["mage-unused-blast"]["unusedAvailable"])
if __name__ == "__main__":
unittest.main()
+416
View File
@@ -0,0 +1,416 @@
import { randomUUID } from "node:crypto";
import { spawn } from "node:child_process";
import {
createReadStream,
existsSync,
mkdirSync,
readFileSync,
readdirSync,
statSync,
writeFileSync,
} from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { GameDatabaseError } from "./database.mjs";
const SOURCES = new Set(["wow", "coa", "rom", "all"]);
const BOSS_CATEGORIES = new Set(["all", "wow", "rom"]);
const JOB_STATUSES = new Set(["queued", "running", "complete", "failed"]);
const JOB_METADATA_NAME = "job.json";
const JOB_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
const ARTIFACTS = new Map([
["suite-report.html", "text/html; charset=utf-8"],
["suite-report.json", "application/json; charset=utf-8"],
["suite-report.md", "text/markdown; charset=utf-8"],
["party_configurations.csv", "text/csv; charset=utf-8"],
["class_specs_across_parties.csv", "text/csv; charset=utf-8"],
["class_abilities_across_parties.csv", "text/csv; charset=utf-8"],
["class_spec_by_boss.csv", "text/csv; charset=utf-8"],
["bosses_across_parties.csv", "text/csv; charset=utf-8"],
["boss_by_party.csv", "text/csv; charset=utf-8"],
["suite_runs.csv", "text/csv; charset=utf-8"],
["suite-config.json", "application/json; charset=utf-8"],
]);
function integer(value, name, minimum, maximum) {
const parsed = Number(value);
if (!Number.isInteger(parsed) || parsed < minimum || parsed > maximum) {
throw new GameDatabaseError(`${name} must be a whole number from ${minimum} through ${maximum}.`, 400, "invalid_balance_lab_job");
}
return parsed;
}
export function normalizeBalanceLabJobSpec(input) {
const source = String(input?.source ?? "all").toLowerCase();
const bossCategory = String(input?.bossCategory ?? "all").toLowerCase();
if (!SOURCES.has(source)) {
throw new GameDatabaseError("Party source must be wow, coa, rom, or all.", 400, "invalid_balance_lab_job");
}
if (!BOSS_CATEGORIES.has(bossCategory)) {
throw new GameDatabaseError("Boss category must be all, wow, or rom.", 400, "invalid_balance_lab_job");
}
const rawBossKeys = Array.isArray(input?.bossKeys) ? input.bossKeys : [];
if (rawBossKeys.length > 500) {
throw new GameDatabaseError("Select no more than 500 bosses.", 400, "invalid_balance_lab_job");
}
const bossKeys = [...new Set(rawBossKeys.map((value) => String(value).trim()).filter(Boolean))];
if (bossKeys.some((key) => key.length > 240 || !/^[A-Za-z0-9._:/-]+$/.test(key))) {
throw new GameDatabaseError("A selected boss key is invalid.", 400, "invalid_balance_lab_job");
}
const spec = Object.freeze({
source,
level: integer(input?.level ?? 60, "Level", 1, 80),
partyConfigurations: integer(input?.partyConfigurations ?? 10, "Party configurations", 1, 100),
simulationsPerParty: integer(input?.simulationsPerParty ?? 3, "Simulations per party", 1, 100),
durationSeconds: integer(input?.durationSeconds ?? 60, "Fight duration", 5, 600),
partySeed: integer(input?.partySeed ?? 1, "Party seed", -2_147_483_648, 2_147_483_647),
bossCategory,
bossKeys: Object.freeze(bossKeys),
});
const sourceCount = spec.source === "all" ? 3 : 1;
const bossEstimate = Math.max(1, spec.bossKeys.length || (spec.bossCategory === "all" ? 500 : 300));
const estimatedRuns = sourceCount * spec.partyConfigurations * spec.simulationsPerParty * bossEstimate;
if (estimatedRuns > 250_000) {
throw new GameDatabaseError(
"That job is too large for one server request. Reduce parties, simulations, or selected bosses.",
400,
"balance_lab_job_too_large",
);
}
return spec;
}
export function balanceLabCommand(spec, outputDir, options = {}) {
const projectRoot = path.resolve(options.projectRoot ?? fileURLToPath(new URL("..", import.meta.url)));
const executable = String(options.pythonExecutable
?? process.env.BALANCE_LAB_PYTHON
?? (process.platform === "win32" ? "python" : "python3"));
const args = [
path.join(projectRoot, "scripts", "balance_lab.py"),
"--source", spec.source,
"--level", String(spec.level),
"--party-mode", "automatic",
"--party-seed", String(spec.partySeed),
"--party-configurations", String(spec.partyConfigurations),
"--simulations-per-party", String(spec.simulationsPerParty),
"--duration", String(spec.durationSeconds),
"--boss-category", spec.bossCategory,
"--output-dir", outputDir,
];
for (const bossKey of spec.bossKeys) args.push("--boss", bossKey);
return { executable, args, cwd: projectRoot };
}
function publicJob(job) {
return {
id: job.id,
status: job.status,
createdAt: job.createdAt,
startedAt: job.startedAt,
completedAt: job.completedAt,
requestedBy: job.requestedBy,
spec: job.spec,
progress: { ...job.progress },
artifacts: [...job.artifacts],
error: job.error,
logTail: [...job.logTail],
summary: job.summary ? { ...job.summary } : null,
};
}
function reportSummary(outputDir) {
const reportPath = path.join(outputDir, "suite-report.json");
if (!existsSync(reportPath)) return null;
try {
const summary = JSON.parse(readFileSync(reportPath, "utf8"))?.summary;
if (!summary || typeof summary !== "object") return null;
return {
partyConfigurations: Number(summary.partyConfigurations) || 0,
bosses: Number(summary.bosses) || 0,
runs: Number(summary.runs) || 0,
classSpecs: Number(summary.classSpecs) || 0,
classAbilities: Number(summary.classAbilities) || 0,
bossPressureMedianDtps: Number(summary.bossPressureMedianDtps) || 0,
};
} catch {
return null;
}
}
function lineCollector(onLine) {
let pending = "";
return {
push(chunk) {
pending += String(chunk);
const lines = pending.split(/\r?\n/);
pending = lines.pop() ?? "";
for (const line of lines) if (line.trim()) onLine(line.trim());
},
flush() {
if (pending.trim()) onLine(pending.trim());
pending = "";
},
};
}
export function createBalanceLabJobService(options = {}) {
const projectRoot = path.resolve(options.projectRoot ?? fileURLToPath(new URL("..", import.meta.url)));
const dataDir = path.resolve(options.dataDir ?? process.env.DATA_DIR ?? "data/runtime");
const jobsDir = path.join(dataDir, "balance-lab", "jobs");
const configuredConcurrency = Number(options.maxConcurrent ?? process.env.BALANCE_LAB_MAX_CONCURRENT ?? 1);
const maxConcurrent = Number.isFinite(configuredConcurrency) && configuredConcurrency >= 1
? Math.min(4, Math.floor(configuredConcurrency))
: 1;
const configuredQueueLimit = Number(options.maxQueued ?? process.env.BALANCE_LAB_MAX_QUEUED ?? 8);
const maxQueued = Number.isFinite(configuredQueueLimit) && configuredQueueLimit >= 1
? Math.min(50, Math.floor(configuredQueueLimit))
: 8;
const launch = options.spawnProcess ?? spawn;
const jobs = new Map();
const queue = [];
const children = new Map();
let running = 0;
let closed = false;
mkdirSync(jobsDir, { recursive: true });
function persistJob(job) {
const record = publicJob(job);
writeFileSync(path.join(job.outputDir, JOB_METADATA_NAME), `${JSON.stringify(record, null, 2)}\n`, "utf8");
}
function restoreJobs() {
for (const entry of readdirSync(jobsDir, { withFileTypes: true })) {
if (!entry.isDirectory() || !JOB_ID_PATTERN.test(entry.name)) continue;
const outputDir = path.join(jobsDir, entry.name);
const metadataPath = path.join(outputDir, JOB_METADATA_NAME);
const reportPath = path.join(outputDir, "suite-report.json");
if (!existsSync(metadataPath) && !existsSync(reportPath)) continue;
try {
let stored;
if (existsSync(metadataPath)) {
stored = JSON.parse(readFileSync(metadataPath, "utf8"));
} else {
const legacyReport = JSON.parse(readFileSync(reportPath, "utf8"));
const legacyConfig = JSON.parse(readFileSync(path.join(outputDir, "suite-config.json"), "utf8"));
stored = {
status: "complete",
createdAt: legacyReport.generatedAt,
completedAt: legacyReport.generatedAt,
requestedBy: "unknown (legacy run)",
spec: {
source: legacyConfig.source,
level: legacyConfig.level,
partyConfigurations: legacyConfig.partyConfigurationsPerSource,
simulationsPerParty: legacyConfig.simulationsPerParty,
durationSeconds: legacyConfig.durationSeconds,
partySeed: legacyConfig.partySeed,
bossCategory: legacyConfig.bossCategory,
bossKeys: legacyConfig.bossKeys ?? [],
},
progress: {},
logTail: ["Imported from a Balance Lab report created before run-history persistence."],
};
}
const spec = normalizeBalanceLabJobSpec(stored.spec);
const hasReport = existsSync(reportPath);
const storedStatus = JOB_STATUSES.has(stored.status) ? stored.status : "failed";
const interrupted = !hasReport && (storedStatus === "queued" || storedStatus === "running");
const totalParties = spec.partyConfigurations * (spec.source === "all" ? 3 : 1);
const createdAt = typeof stored.createdAt === "string"
? stored.createdAt
: statSync(outputDir).birthtime.toISOString();
const job = {
id: entry.name,
status: hasReport ? "complete" : interrupted ? "failed" : storedStatus,
createdAt,
startedAt: typeof stored.startedAt === "string" ? stored.startedAt : null,
completedAt: hasReport || interrupted
? (typeof stored.completedAt === "string" ? stored.completedAt : new Date().toISOString())
: null,
requestedBy: typeof stored.requestedBy === "string" ? stored.requestedBy : "unknown",
spec,
progress: {
party: Number(stored.progress?.party) || 0,
partiesCompleted: hasReport ? totalParties : Number(stored.progress?.partiesCompleted) || 0,
partiesTotal: totalParties,
simulationsCompleted: Number(stored.progress?.simulationsCompleted) || 0,
simulationsTotal: Number(stored.progress?.simulationsTotal) || 0,
},
artifacts: hasReport
? [...ARTIFACTS.keys()].filter((name) => existsSync(path.join(outputDir, name)))
: [],
error: interrupted
? "The server restarted before this Balance Lab job completed. Run it again to produce a report."
: (typeof stored.error === "string" ? stored.error : null),
logTail: Array.isArray(stored.logTail)
? stored.logTail.filter((line) => typeof line === "string").slice(-80)
: [],
summary: hasReport ? reportSummary(outputDir) : null,
outputDir,
};
jobs.set(job.id, job);
persistJob(job);
} catch (error) {
console.warn(`[balance-lab] Could not restore ${entry.name}: ${error?.message ?? error}`);
}
}
}
restoreJobs();
function appendLog(job, line) {
job.logTail.push(line.slice(0, 1_000));
if (job.logTail.length > 80) job.logTail.splice(0, job.logTail.length - 80);
const party = /^Party\s+(\d+)\/(\d+):/i.exec(line);
if (party) {
job.progress.party = Number(party[1]);
job.progress.partiesTotal = Number(party[2]);
job.progress.partiesCompleted = Math.max(0, Number(party[1]) - 1);
job.progress.simulationsCompleted = 0;
job.progress.simulationsTotal = 0;
}
const simulations = /^\[balance-lab]\s+(\d+)\/(\d+)\s+/i.exec(line);
if (simulations) {
job.progress.simulationsCompleted = Number(simulations[1]);
job.progress.simulationsTotal = Number(simulations[2]);
}
persistJob(job);
}
function pump() {
if (closed) return;
while (running < maxConcurrent && queue.length) {
const job = queue.shift();
if (!job || job.status !== "queued") continue;
running += 1;
job.status = "running";
job.startedAt = new Date().toISOString();
persistJob(job);
const command = balanceLabCommand(job.spec, job.outputDir, {
projectRoot,
pythonExecutable: options.pythonExecutable,
});
const child = launch(command.executable, command.args, {
cwd: command.cwd,
env: { ...process.env, PYTHONUNBUFFERED: "1" },
windowsHide: true,
stdio: ["ignore", "pipe", "pipe"],
});
children.set(job.id, child);
const stdout = lineCollector((line) => appendLog(job, line));
const stderr = lineCollector((line) => appendLog(job, line));
child.stdout?.on("data", (chunk) => stdout.push(chunk));
child.stderr?.on("data", (chunk) => stderr.push(chunk));
let settled = false;
const finish = (error, code = null) => {
if (settled) return;
settled = true;
stdout.flush();
stderr.flush();
children.delete(job.id);
running = Math.max(0, running - 1);
job.completedAt = new Date().toISOString();
if (!error && code === 0 && existsSync(path.join(job.outputDir, "suite-report.json"))) {
job.status = "complete";
job.progress.partiesCompleted = job.progress.partiesTotal;
job.progress.simulationsCompleted = job.progress.simulationsTotal;
job.artifacts = [...ARTIFACTS.keys()].filter((name) => existsSync(path.join(job.outputDir, name)));
job.summary = reportSummary(job.outputDir);
} else {
job.status = "failed";
job.error = error?.message
?? (code === 0
? "Balance Lab ended without producing its suite report."
: `Balance Lab process exited with code ${code ?? "unknown"}.`);
}
persistJob(job);
pump();
};
child.once("error", (error) => finish(new Error(
error?.code === "ENOENT"
? `Could not start ${command.executable}. Install Python 3 on the server or set BALANCE_LAB_PYTHON.`
: `Could not start the Balance Lab process: ${error?.message ?? error}`,
)));
child.once("close", (code) => finish(null, code));
}
}
function requireJob(id) {
const job = jobs.get(id);
if (!job) throw new GameDatabaseError("Balance Lab job was not found.", 404, "balance_lab_job_not_found");
return job;
}
return {
start(input, account) {
if (closed) throw new GameDatabaseError("Balance Lab runner is shutting down.", 503, "balance_lab_unavailable");
const pending = [...jobs.values()].filter((job) => job.status === "queued" || job.status === "running").length;
if (pending >= maxQueued) {
throw new GameDatabaseError(
"The Balance Lab server queue is full. Wait for a running job to finish.",
429,
"balance_lab_queue_full",
);
}
const spec = normalizeBalanceLabJobSpec(input);
const id = randomUUID();
const outputDir = path.join(jobsDir, id);
mkdirSync(outputDir, { recursive: true });
const totalParties = spec.partyConfigurations * (spec.source === "all" ? 3 : 1);
const job = {
id,
status: "queued",
createdAt: new Date().toISOString(),
startedAt: null,
completedAt: null,
requestedBy: account.username,
spec,
progress: {
party: 0,
partiesCompleted: 0,
partiesTotal: totalParties,
simulationsCompleted: 0,
simulationsTotal: 0,
},
artifacts: [],
error: null,
logTail: [],
summary: null,
outputDir,
};
jobs.set(id, job);
persistJob(job);
queue.push(job);
pump();
return publicJob(job);
},
list() {
return [...jobs.values()]
.sort((left, right) => Date.parse(right.createdAt) - Date.parse(left.createdAt))
.slice(0, 100)
.map(publicJob);
},
get(id) {
return publicJob(requireJob(id));
},
artifact(id, name) {
const job = requireJob(id);
if (!JOB_STATUSES.has(job.status) || job.status !== "complete") {
throw new GameDatabaseError("Balance Lab artifacts are available after the job completes.", 409, "balance_lab_job_incomplete");
}
const contentType = ARTIFACTS.get(name);
if (!contentType || !job.artifacts.includes(name)) {
throw new GameDatabaseError("Balance Lab artifact was not found.", 404, "balance_lab_artifact_not_found");
}
const filePath = path.join(job.outputDir, name);
const stats = statSync(filePath);
return { filePath, contentType, size: stats.size, stream: () => createReadStream(filePath) };
},
close() {
closed = true;
queue.length = 0;
for (const child of children.values()) child.kill();
children.clear();
},
};
}
+250
View File
@@ -0,0 +1,250 @@
import assert from "node:assert/strict";
import { EventEmitter } from "node:events";
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { PassThrough } from "node:stream";
import test from "node:test";
import {
balanceLabCommand,
createBalanceLabJobService,
normalizeBalanceLabJobSpec,
} from "./balance-lab-jobs.mjs";
test("normalizes and bounds server Balance Lab requests", () => {
const spec = normalizeBalanceLabJobSpec({
source: "ALL",
level: 40,
partyConfigurations: 2,
simulationsPerParty: 3,
durationSeconds: 45,
partySeed: 17,
bossCategory: "all",
bossKeys: ["gundrak/entry-29932", "gundrak/entry-29932"],
});
assert.equal(spec.source, "all");
assert.deepEqual(spec.bossKeys, ["gundrak/entry-29932"]);
assert.throws(
() => normalizeBalanceLabJobSpec({ source: "wow", level: 81 }),
/Level must be a whole number/,
);
assert.throws(
() => normalizeBalanceLabJobSpec({ source: "wow", bossKeys: ["boss; shutdown"] }),
/boss key is invalid/,
);
});
test("builds an argument-array command without a shell", () => {
const spec = normalizeBalanceLabJobSpec({
source: "rom", level: 60, partyConfigurations: 4, simulationsPerParty: 2,
durationSeconds: 30, partySeed: 9, bossCategory: "rom", bossKeys: ["abbey/boss-1"],
});
const command = balanceLabCommand(spec, "C:/reports/job", {
projectRoot: "C:/app",
pythonExecutable: "python-custom",
});
assert.equal(command.executable, "python-custom");
assert.equal(command.args.includes("--party-mode"), true);
assert.equal(command.args.includes("automatic"), true);
assert.equal(command.args.includes("abbey/boss-1"), true);
assert.equal(command.cwd, path.resolve("C:/app"));
});
test("queues a job, reports progress, and exposes only allow-listed artifacts", async () => {
const directory = mkdtempSync(path.join(os.tmpdir(), "healer-man-balance-job-"));
try {
const spawnProcess = (_executable, args) => {
const child = new EventEmitter();
child.stdout = new PassThrough();
child.stderr = new PassThrough();
child.kill = () => child.emit("close", null);
const outputIndex = args.indexOf("--output-dir");
const outputDir = args[outputIndex + 1];
setImmediate(() => {
mkdirSync(outputDir, { recursive: true });
writeFileSync(path.join(outputDir, "suite-report.json"), "{}\n");
child.stdout.write("Party 1/1: wow-party-001\n");
child.stderr.write("[balance-lab] 1/1 Eck the Ferocious seed 1\n");
child.stdout.end();
child.stderr.end();
child.emit("close", 0);
});
return child;
};
const service = createBalanceLabJobService({
dataDir: directory,
projectRoot: directory,
spawnProcess,
pythonExecutable: "python-test",
});
const started = service.start({
source: "wow", level: 40, partyConfigurations: 1, simulationsPerParty: 1,
durationSeconds: 5, partySeed: 1, bossCategory: "all", bossKeys: ["gundrak/entry-29932"],
}, { username: "Phenom" });
assert.equal(started.status, "running");
await new Promise((resolve) => setImmediate(resolve));
const completed = service.get(started.id);
assert.equal(completed.status, "complete");
assert.equal(completed.progress.partiesCompleted, 1);
assert.deepEqual(completed.artifacts, ["suite-report.json"]);
assert.equal(service.artifact(started.id, "suite-report.json").size, 3);
assert.throws(() => service.artifact(started.id, "../../game.db"), /artifact was not found/);
service.close();
} finally {
rmSync(directory, { recursive: true, force: true });
}
});
test("restores completed reports as previous runs after a server restart", async () => {
const directory = mkdtempSync(path.join(os.tmpdir(), "healer-man-balance-history-"));
try {
let launches = 0;
const spawnProcess = (_executable, args) => {
launches += 1;
const child = new EventEmitter();
child.stdout = new PassThrough();
child.stderr = new PassThrough();
child.kill = () => child.emit("close", null);
const outputDir = args[args.indexOf("--output-dir") + 1];
setImmediate(() => {
mkdirSync(outputDir, { recursive: true });
writeFileSync(path.join(outputDir, "suite-report.json"), JSON.stringify({
summary: {
partyConfigurations: 2,
bosses: 3,
runs: 6,
classSpecs: 5,
bossPressureMedianDtps: 123.5,
},
}));
child.emit("close", 0);
});
return child;
};
const firstService = createBalanceLabJobService({
dataDir: directory,
projectRoot: directory,
spawnProcess,
pythonExecutable: "python-test",
});
const started = firstService.start({
source: "wow", level: 40, partyConfigurations: 2, simulationsPerParty: 1,
durationSeconds: 5, partySeed: 1, bossCategory: "all", bossKeys: ["gundrak/entry-29932"],
}, { username: "Phenom" });
await new Promise((resolve) => setImmediate(resolve));
assert.equal(firstService.get(started.id).status, "complete");
firstService.close();
const secondService = createBalanceLabJobService({
dataDir: directory,
projectRoot: directory,
spawnProcess: () => { throw new Error("restored jobs must not relaunch"); },
pythonExecutable: "python-test",
});
const restored = secondService.list();
assert.equal(restored.length, 1);
assert.equal(restored[0].id, started.id);
assert.equal(restored[0].status, "complete");
assert.equal(restored[0].summary?.runs, 6);
assert.deepEqual(restored[0].artifacts, ["suite-report.json"]);
assert.equal(secondService.artifact(started.id, "suite-report.json").size > 0, true);
assert.equal(launches, 1);
secondService.close();
} finally {
rmSync(directory, { recursive: true, force: true });
}
});
test("marks an interrupted persisted run as failed when the service returns", () => {
const directory = mkdtempSync(path.join(os.tmpdir(), "healer-man-balance-interrupted-"));
try {
const jobsDir = path.join(directory, "balance-lab", "jobs");
const id = "11111111-1111-4111-8111-111111111111";
const outputDir = path.join(jobsDir, id);
mkdirSync(outputDir, { recursive: true });
writeFileSync(path.join(outputDir, "job.json"), JSON.stringify({
id,
status: "running",
createdAt: "2026-09-15T12:00:00.000Z",
startedAt: "2026-09-15T12:00:01.000Z",
requestedBy: "Phenom",
spec: {
source: "rom", level: 55, partyConfigurations: 1, simulationsPerParty: 1,
durationSeconds: 30, partySeed: 3, bossCategory: "rom", bossKeys: [],
},
progress: { party: 1, partiesCompleted: 0, partiesTotal: 1 },
artifacts: [],
error: null,
logTail: ["Party 1/1: rom-party-001"],
}));
const service = createBalanceLabJobService({ dataDir: directory, projectRoot: directory });
const restored = service.get(id);
assert.equal(restored.status, "failed");
assert.match(restored.error, /server restarted/i);
service.close();
} finally {
rmSync(directory, { recursive: true, force: true });
}
});
test("imports completed report directories created before history metadata existed", () => {
const directory = mkdtempSync(path.join(os.tmpdir(), "healer-man-balance-legacy-"));
try {
const id = "22222222-2222-4222-8222-222222222222";
const outputDir = path.join(directory, "balance-lab", "jobs", id);
mkdirSync(outputDir, { recursive: true });
writeFileSync(path.join(outputDir, "suite-report.json"), JSON.stringify({
generatedAt: "2026-09-14T10:30:00.000Z",
summary: { partyConfigurations: 3, bosses: 2, runs: 12, classSpecs: 8, bossPressureMedianDtps: 50 },
}));
writeFileSync(path.join(outputDir, "suite-config.json"), JSON.stringify({
source: "coa",
level: 35,
partySeed: 12,
partyConfigurationsPerSource: 3,
simulationsPerParty: 2,
durationSeconds: 45,
bossCategory: "wow",
bossKeys: ["gundrak/entry-29932", "gundrak/entry-29304"],
}));
const service = createBalanceLabJobService({ dataDir: directory, projectRoot: directory });
const restored = service.get(id);
assert.equal(restored.status, "complete");
assert.equal(restored.spec.source, "coa");
assert.equal(restored.summary?.runs, 12);
assert.equal(restored.requestedBy, "unknown (legacy run)");
assert.equal(existsSync(path.join(outputDir, "job.json")), true);
service.close();
} finally {
rmSync(directory, { recursive: true, force: true });
}
});
test("bounds the number of pending server jobs", () => {
const directory = mkdtempSync(path.join(os.tmpdir(), "healer-man-balance-queue-"));
try {
const spawnProcess = () => {
const child = new EventEmitter();
child.stdout = new PassThrough();
child.stderr = new PassThrough();
child.kill = () => child.emit("close", null);
return child;
};
const service = createBalanceLabJobService({
dataDir: directory,
projectRoot: directory,
spawnProcess,
pythonExecutable: "python-test",
maxQueued: 1,
});
const request = {
source: "wow", level: 40, partyConfigurations: 1, simulationsPerParty: 1,
durationSeconds: 5, partySeed: 1, bossCategory: "all", bossKeys: ["gundrak/entry-29932"],
};
service.start(request, { username: "Phenom" });
assert.throws(() => service.start(request, { username: "Phenom" }), /server queue is full/);
service.close();
} finally {
rmSync(directory, { recursive: true, force: true });
}
});
+2
View File
@@ -12,6 +12,7 @@ import { arenaRatingBand, calculateArenaRating, clampArenaRating } from "./arena
import { arenaRoleForSpecialization } from "./arena-specializations.mjs";
import { createDungeonReportRepository } from "./dungeon-reports.mjs";
import { createDungeonLeaderboardRepository } from "./dungeon-leaderboards.mjs";
import { createDungeonSimulationRepository } from "./dungeon-simulation.mjs";
import dungeonLeaderboardCatalog from "../src/game/generated/dungeonLeaderboardCatalog.generated.json" with { type: "json" };
const scrypt = promisify(scryptCallback);
@@ -910,6 +911,7 @@ export function openGameDatabase(options = {}) {
databasePath,
...createDungeonReportRepository(database, { isGameMaster, ErrorType: GameDatabaseError }),
...createDungeonLeaderboardRepository(database, { ErrorType: GameDatabaseError }),
...createDungeonSimulationRepository(database, { isGameMaster, ErrorType: GameDatabaseError }),
register,
login,
authenticate,
+100 -1
View File
@@ -1,11 +1,13 @@
import assert from "node:assert/strict";
import { mkdtempSync, rmSync } from "node:fs";
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import { DatabaseSync } from "node:sqlite";
import { GameDatabaseError, openGameDatabase } from "./database.mjs";
const routeCatalog = JSON.parse(readFileSync(new URL("../src/game/generated/dungeonRouteCatalog.generated.json", import.meta.url), "utf8"));
function withDatabase(run) {
const directory = mkdtempSync(path.join(os.tmpdir(), "healer-man-db-test-"));
const database = openGameDatabase({ dataDir: directory, sessionTtlDays: 1 });
@@ -137,6 +139,103 @@ test("persists revisioned Manastorm GM configuration and rejects stale or unauth
}), (error) => error instanceof GameDatabaseError && error.status === 400);
}));
test("stores GM simulation replays and gates exact-hash route approval", () => withDatabase(async (database) => {
const gmAuth = await database.register("Phenom", "storm-admin");
const playerAuth = await database.register("RoutePlayer", "route-player");
const gm = database.authenticate(gmAuth.token);
const player = database.authenticate(playerAuth.token);
const routeHash = routeCatalog.routes.find((route) => route.dungeonId === "deadmines").hash;
const replay = {
schemaVersion: 1,
dungeonId: "deadmines",
routeId: "primary",
routeHash,
mode: "navigation-only",
seed: "deadmines:primary",
result: "completed",
startedAt: 1_700_000_000_000,
durationMs: 42_000,
report: { schemaVersion: 1, findings: [] },
frames: [{ atMs: 0, actors: [] }],
events: [],
};
assert.throws(() => database.saveDungeonSimulationReplay(player, replay), (error) => (
error instanceof GameDatabaseError && error.status === 403
));
assert.throws(() => database.putDungeonRouteReview(gm, "deadmines", "primary", {
routeHash, status: "Approved", expectedRevision: 0,
}), (error) => error instanceof GameDatabaseError && error.code === "route_run_required");
const saved = database.saveDungeonSimulationReplay(gm, replay);
assert.equal(saved.criticalFindings, 0);
assert.equal(database.getDungeonSimulationReplay(gm, saved.id).frames.length, 1);
assert.equal(database.getDungeonSimulationReport(gm, saved.id).schemaVersion, 1);
const review = database.putDungeonRouteReview(gm, "deadmines", "primary", {
routeHash, status: "Approved", expectedRevision: 0, note: "Clean navigation-only pass.",
});
assert.equal(review.revision, 1);
assert.deepEqual(database.listDungeonRouteApprovals().approvals.map(({ reviewedAt: _reviewedAt, ...entry }) => entry), [{
dungeonId: "deadmines", routeId: "primary", routeHash,
}]);
assert.throws(() => database.putDungeonRouteReview(gm, "deadmines", "primary", {
routeHash, status: "NeedsWork", expectedRevision: 0,
}), (error) => error instanceof GameDatabaseError && error.status === 409);
assert.equal(database.deleteDungeonSimulationReplay(gm, saved.id).ok, true);
}));
test("requires an override note when the latest exact-hash route run has critical findings", () => withDatabase(async (database) => {
const gmAuth = await database.register("Phenom", "storm-admin");
const gm = database.authenticate(gmAuth.token);
const routeHash = routeCatalog.routes.find((route) => route.dungeonId === "blackfathom-deeps").hash;
database.saveDungeonSimulationReplay(gm, {
schemaVersion: 1,
dungeonId: "blackfathom-deeps",
routeId: "primary",
routeHash,
mode: "navigation-only",
seed: "route-seed",
result: "completed",
startedAt: 1_700_000_000_000,
durationMs: 10_000,
report: { schemaVersion: 1, findings: [{ severity: "critical", code: "no-path" }] },
frames: [],
events: [],
});
assert.throws(() => database.putDungeonRouteReview(gm, "blackfathom-deeps", "primary", {
routeHash, status: "Approved", expectedRevision: 0,
}), (error) => error instanceof GameDatabaseError && error.code === "route_override_required");
const review = database.putDungeonRouteReview(gm, "blackfathom-deeps", "primary", {
routeHash, status: "Approved", expectedRevision: 0, criticalOverrideNote: "Known scripted lift gap.",
});
assert.equal(review.status, "Approved");
assert.equal(review.criticalOverrideNote, "Known scripted lift gap.");
}));
test("requires the latest exact-hash route run to be completed before approval", () => withDatabase(async (database) => {
const gmAuth = await database.register("Phenom", "storm-admin");
const gm = database.authenticate(gmAuth.token);
const routeHash = routeCatalog.routes.find((route) => route.dungeonId === "razorfen-downs").hash;
database.saveDungeonSimulationReplay(gm, {
schemaVersion: 1,
dungeonId: "razorfen-downs",
routeId: "primary",
routeHash,
mode: "full-combat",
seed: "incomplete-route-seed",
result: "stopped",
startedAt: 1_700_000_000_000,
durationMs: 10_000,
report: { schemaVersion: 1, findings: [] },
frames: [],
events: [],
});
assert.throws(() => database.putDungeonRouteReview(gm, "razorfen-downs", "primary", {
routeHash, status: "Approved", expectedRevision: 0,
}), (error) => error instanceof GameDatabaseError && error.code === "route_completed_run_required");
}));
test("creates online groups through invitations and transfers leadership when the leader leaves", () => withDatabase(async (database) => {
const leaderAuth = await database.register("GroupLeader", "group-pass");
const healerAuth = await database.register("GroupHealer", "healer-pass");
+331
View File
@@ -0,0 +1,331 @@
import { randomUUID } from "node:crypto";
import { gunzipSync, gzipSync } from "node:zlib";
import { readFileSync } from "node:fs";
const routeCatalog = JSON.parse(readFileSync(new URL("../src/game/generated/dungeonRouteCatalog.generated.json", import.meta.url), "utf8"));
const generatedRoutes = new Map(routeCatalog.routes.map((route) => [`${route.dungeonId}:${route.id}:${route.hash}`, route]));
export const DUNGEON_SIMULATION_REPLAY_SCHEMA_VERSION = 1;
export const DUNGEON_ROUTE_REVIEW_STATUSES = Object.freeze(["Candidate", "Approved", "NeedsWork"]);
export const DUNGEON_SIMULATION_RETENTION_MS = 30 * 24 * 60 * 60 * 1_000;
export const DUNGEON_SIMULATION_MAX_DURATION_MS = 2 * 60 * 60 * 1_000;
export const DUNGEON_SIMULATION_MAX_COMPRESSED_BYTES = 32 * 1024 * 1024;
export const DUNGEON_SIMULATION_MAX_DECOMPRESSED_BYTES = 256 * 1024 * 1024;
const MAX_ID_LENGTH = 256;
const MAX_NOTE_LENGTH = 1_000;
const HASH_PATTERN = /^[a-f0-9]{64}$/;
function text(value, label, ErrorType, maximum = MAX_ID_LENGTH) {
if (typeof value !== "string" || !value.trim() || value.length > maximum) {
throw new ErrorType(`${label} must be a non-empty string no longer than ${maximum} characters.`, 400, "invalid_simulation_payload");
}
return value.trim();
}
function optionalNote(value, label, ErrorType) {
if (value === undefined || value === null) return null;
if (typeof value !== "string" || value.length > MAX_NOTE_LENGTH) {
throw new ErrorType(`${label} must be a string no longer than ${MAX_NOTE_LENGTH} characters.`, 400, "invalid_simulation_payload");
}
return value.replace(/\r\n?/g, "\n").trim() || null;
}
function routeHash(value, ErrorType) {
const normalized = text(value, "routeHash", ErrorType, 64).toLowerCase();
if (!HASH_PATTERN.test(normalized)) {
throw new ErrorType("routeHash must be a lowercase SHA-256 hash.", 400, "invalid_route_hash");
}
return normalized;
}
function integer(value, label, ErrorType, minimum, maximum) {
if (!Number.isInteger(value) || value < minimum || value > maximum) {
throw new ErrorType(`${label} must be an integer between ${minimum} and ${maximum}.`, 400, "invalid_simulation_payload");
}
return value;
}
function object(value, label, ErrorType) {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new ErrorType(`${label} must be an object.`, 400, "invalid_simulation_payload");
}
return value;
}
function publicReplay(row) {
return {
id: row.id,
dungeonId: row.dungeon_id,
routeId: row.route_id,
routeHash: row.route_hash,
mode: row.mode,
seed: row.seed,
result: row.result,
startedAt: Number(row.started_at),
durationMs: Number(row.duration_ms),
criticalFindings: Number(row.critical_findings),
submittedBy: row.submitted_by_username,
createdAt: Number(row.created_at),
expiresAt: Number(row.expires_at),
compressedBytes: Number(row.compressed_bytes),
};
}
function publicReview(row) {
return {
dungeonId: row.dungeon_id,
routeId: row.route_id,
routeHash: row.route_hash,
status: row.status,
note: row.note,
criticalOverrideNote: row.critical_override_note,
reviewedBy: row.reviewed_by_username,
reviewedAt: Number(row.reviewed_at),
revision: Number(row.revision),
};
}
function normalizeReplay(input, ErrorType) {
const source = object(input, "replay", ErrorType);
if (source.schemaVersion !== DUNGEON_SIMULATION_REPLAY_SCHEMA_VERSION) {
throw new ErrorType("Unsupported dungeon simulation replay schema.", 400, "invalid_replay_schema");
}
const mode = source.mode;
if (mode !== "full-combat" && mode !== "navigation-only") {
throw new ErrorType("Choose a valid simulation mode.", 400, "invalid_simulation_mode");
}
const result = source.result;
if (!["completed", "wiped", "stopped", "recording-limit"].includes(result)) {
throw new ErrorType("Choose a valid simulation result.", 400, "invalid_simulation_result");
}
const report = object(source.report, "report", ErrorType);
const findings = Array.isArray(report.findings) ? report.findings : [];
const criticalFindings = findings.filter((finding) => finding?.severity === "critical").length;
const normalized = {
...source,
dungeonId: text(source.dungeonId, "dungeonId", ErrorType),
routeId: text(source.routeId, "routeId", ErrorType),
routeHash: routeHash(source.routeHash, ErrorType),
seed: text(source.seed, "seed", ErrorType),
startedAt: integer(source.startedAt, "startedAt", ErrorType, 1, Number.MAX_SAFE_INTEGER),
durationMs: integer(source.durationMs, "durationMs", ErrorType, 0, DUNGEON_SIMULATION_MAX_DURATION_MS),
mode,
result,
report,
};
const serialized = Buffer.from(JSON.stringify(normalized));
if (serialized.length > DUNGEON_SIMULATION_MAX_DECOMPRESSED_BYTES) {
throw new ErrorType("Replay is too large after decompression.", 413, "replay_too_large");
}
const compressed = gzipSync(serialized, { level: 9 });
if (compressed.length > DUNGEON_SIMULATION_MAX_COMPRESSED_BYTES) {
throw new ErrorType("Replay is too large after compression.", 413, "replay_too_large");
}
return { normalized, compressed, criticalFindings };
}
export function createDungeonSimulationRepository(database, { isGameMaster, ErrorType }) {
database.exec(`
CREATE TABLE IF NOT EXISTS dungeon_route_reviews (
dungeon_id TEXT NOT NULL,
route_id TEXT NOT NULL,
route_hash TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN ('Candidate', 'Approved', 'NeedsWork')),
note TEXT,
critical_override_note TEXT,
reviewed_by_account_id TEXT REFERENCES accounts(id) ON DELETE SET NULL,
reviewed_by_username TEXT NOT NULL,
reviewed_at INTEGER NOT NULL,
revision INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (dungeon_id, route_id)
) STRICT;
CREATE TABLE IF NOT EXISTS dungeon_simulation_replays (
id TEXT PRIMARY KEY,
schema_version INTEGER NOT NULL,
dungeon_id TEXT NOT NULL,
route_id TEXT NOT NULL,
route_hash TEXT NOT NULL,
mode TEXT NOT NULL CHECK (mode IN ('full-combat', 'navigation-only')),
seed TEXT NOT NULL,
result TEXT NOT NULL CHECK (result IN ('completed', 'wiped', 'stopped', 'recording-limit')),
started_at INTEGER NOT NULL,
duration_ms INTEGER NOT NULL,
critical_findings INTEGER NOT NULL,
report_json TEXT NOT NULL,
payload_gzip BLOB NOT NULL,
compressed_bytes INTEGER NOT NULL,
submitted_by_account_id TEXT REFERENCES accounts(id) ON DELETE SET NULL,
submitted_by_username TEXT NOT NULL,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL
) STRICT;
CREATE INDEX IF NOT EXISTS dungeon_simulation_replays_route_idx
ON dungeon_simulation_replays(dungeon_id, route_id, route_hash, created_at DESC);
CREATE INDEX IF NOT EXISTS dungeon_simulation_replays_expiry_idx
ON dungeon_simulation_replays(expires_at);
`);
const replayById = database.prepare("SELECT * FROM dungeon_simulation_replays WHERE id = ?");
const reviewByRoute = database.prepare("SELECT * FROM dungeon_route_reviews WHERE dungeon_id = ? AND route_id = ?");
function requireGm(account) {
if (!isGameMaster(account)) {
throw new ErrorType("Game Master access is required.", 403, "gm_required");
}
}
function pruneExpiredDungeonSimulationReplays(now = Date.now()) {
return Number(database.prepare("DELETE FROM dungeon_simulation_replays WHERE expires_at <= ?").run(now).changes);
}
return {
pruneExpiredDungeonSimulationReplays,
listDungeonRouteApprovals() {
return {
approvals: database.prepare(`
SELECT dungeon_id, route_id, route_hash, reviewed_at
FROM dungeon_route_reviews WHERE status = 'Approved'
ORDER BY dungeon_id, route_id
`).all().filter((row) => generatedRoutes.get(`${row.dungeon_id}:${row.route_id}:${row.route_hash}`)?.generationStatus === "Candidate").map((row) => ({
dungeonId: row.dungeon_id,
routeId: row.route_id,
routeHash: row.route_hash,
reviewedAt: Number(row.reviewed_at),
})),
};
},
listDungeonRouteReviews(account) {
requireGm(account);
pruneExpiredDungeonSimulationReplays();
const reviews = database.prepare("SELECT * FROM dungeon_route_reviews ORDER BY dungeon_id, route_id").all();
const latest = database.prepare(`
SELECT * FROM (
SELECT dungeon_simulation_replays.*,
ROW_NUMBER() OVER (
PARTITION BY dungeon_id, route_id, route_hash
ORDER BY created_at DESC, id DESC
) AS route_rank
FROM dungeon_simulation_replays
) WHERE route_rank = 1
`).all();
return {
reviews: reviews.map(publicReview),
latestReplays: latest
.filter((row) => generatedRoutes.has(`${row.dungeon_id}:${row.route_id}:${row.route_hash}`))
.map(publicReplay),
};
},
putDungeonRouteReview(account, dungeonIdValue, routeIdValue, input) {
requireGm(account);
const dungeonId = text(dungeonIdValue, "dungeonId", ErrorType);
const routeId = text(routeIdValue, "routeId", ErrorType);
const hash = routeHash(input?.routeHash, ErrorType);
const status = input?.status;
if (!DUNGEON_ROUTE_REVIEW_STATUSES.includes(status)) {
throw new ErrorType("Choose a valid route review status.", 400, "invalid_route_status");
}
const expectedRevision = integer(input?.expectedRevision, "expectedRevision", ErrorType, 0, Number.MAX_SAFE_INTEGER);
const note = optionalNote(input?.note, "note", ErrorType);
const criticalOverrideNote = optionalNote(input?.criticalOverrideNote, "criticalOverrideNote", ErrorType);
const current = reviewByRoute.get(dungeonId, routeId);
const currentRevision = Number(current?.revision ?? 0);
if (currentRevision !== expectedRevision) {
throw new ErrorType("This route review changed. Reload and try again.", 409, "route_review_conflict");
}
const generatedRoute = generatedRoutes.get(`${dungeonId}:${routeId}:${hash}`);
if (!generatedRoute) {
throw new ErrorType("The route hash does not match the bundled route catalog.", 409, "route_hash_unknown");
}
if (status === "Approved") {
if (generatedRoute.generationStatus !== "Candidate") {
throw new ErrorType("This generated route failed offline navigation validation and cannot be approved.", 409, "route_generation_rejected");
}
const latest = database.prepare(`
SELECT critical_findings, result FROM dungeon_simulation_replays
WHERE dungeon_id = ? AND route_id = ? AND route_hash = ?
ORDER BY created_at DESC, id DESC LIMIT 1
`).get(dungeonId, routeId, hash);
if (!latest) {
throw new ErrorType("Run and save this exact route before approving it.", 409, "route_run_required");
}
if (latest.result !== "completed") {
throw new ErrorType("Approval requires a completed run for this exact route hash.", 409, "route_completed_run_required");
}
if (Number(latest.critical_findings) > 0 && !criticalOverrideNote) {
throw new ErrorType("Critical findings require an override note before approval.", 409, "route_override_required");
}
}
const reviewedAt = Date.now();
if (current) {
database.prepare(`
UPDATE dungeon_route_reviews SET route_hash = ?, status = ?, note = ?, critical_override_note = ?,
reviewed_by_account_id = ?, reviewed_by_username = ?, reviewed_at = ?, revision = revision + 1
WHERE dungeon_id = ? AND route_id = ? AND revision = ?
`).run(hash, status, note, criticalOverrideNote, account.id, account.username, reviewedAt,
dungeonId, routeId, expectedRevision);
} else {
database.prepare(`
INSERT INTO dungeon_route_reviews (
dungeon_id, route_id, route_hash, status, note, critical_override_note,
reviewed_by_account_id, reviewed_by_username, reviewed_at, revision
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1)
`).run(dungeonId, routeId, hash, status, note, criticalOverrideNote,
account.id, account.username, reviewedAt);
}
return publicReview(reviewByRoute.get(dungeonId, routeId));
},
listDungeonSimulationReplays(account, { dungeonId = null } = {}) {
requireGm(account);
pruneExpiredDungeonSimulationReplays();
const rows = database.prepare(`
SELECT id, dungeon_id, route_id, route_hash, mode, seed, result, started_at, duration_ms,
critical_findings, submitted_by_username, created_at, expires_at, compressed_bytes
FROM dungeon_simulation_replays
WHERE (? IS NULL OR dungeon_id = ?)
ORDER BY created_at DESC, id DESC LIMIT 500
`).all(dungeonId, dungeonId);
return { replays: rows.map(publicReplay) };
},
saveDungeonSimulationReplay(account, input) {
requireGm(account);
pruneExpiredDungeonSimulationReplays();
const { normalized, compressed, criticalFindings } = normalizeReplay(input, ErrorType);
const id = randomUUID();
const createdAt = Date.now();
const expiresAt = createdAt + DUNGEON_SIMULATION_RETENTION_MS;
database.prepare(`
INSERT INTO dungeon_simulation_replays (
id, schema_version, dungeon_id, route_id, route_hash, mode, seed, result,
started_at, duration_ms, critical_findings, report_json, payload_gzip, compressed_bytes,
submitted_by_account_id, submitted_by_username, created_at, expires_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(id, normalized.schemaVersion, normalized.dungeonId, normalized.routeId, normalized.routeHash,
normalized.mode, normalized.seed, normalized.result, normalized.startedAt, normalized.durationMs,
criticalFindings, JSON.stringify(normalized.report), compressed, compressed.length,
account.id, account.username, createdAt, expiresAt);
return publicReplay(replayById.get(id));
},
getDungeonSimulationReplay(account, id) {
requireGm(account);
pruneExpiredDungeonSimulationReplays();
const row = replayById.get(text(id, "replay id", ErrorType));
if (!row) throw new ErrorType("Replay not found.", 404, "replay_not_found");
const payload = gunzipSync(row.payload_gzip, { maxOutputLength: DUNGEON_SIMULATION_MAX_DECOMPRESSED_BYTES });
return JSON.parse(payload.toString("utf8"));
},
getDungeonSimulationReport(account, id) {
requireGm(account);
pruneExpiredDungeonSimulationReplays();
const row = replayById.get(text(id, "replay id", ErrorType));
if (!row) throw new ErrorType("Replay not found.", 404, "replay_not_found");
return JSON.parse(row.report_json);
},
deleteDungeonSimulationReplay(account, id) {
requireGm(account);
const result = database.prepare("DELETE FROM dungeon_simulation_replays WHERE id = ?")
.run(text(id, "replay id", ErrorType));
if (!result.changes) throw new ErrorType("Replay not found.", 404, "replay_not_found");
return { ok: true };
},
};
}
+106 -1
View File
@@ -8,6 +8,7 @@ import { publicManastormAdminConfig } from "./manastorm-config.mjs";
import { createOnlineSessionManager } from "./online-session.mjs";
import { upgradeArenaWebSocket } from "./websocket.mjs";
import { createBundledContentHandler } from "./bundled-content.mjs";
import { createBalanceLabJobService } from "./balance-lab-jobs.mjs";
const MIME_TYPES = new Map([
[".css", "text/css; charset=utf-8"], [".glb", "model/gltf-binary"],
@@ -178,6 +179,11 @@ export function createGameServer(options = {}) {
const trustProxy = String(options.trustProxy ?? process.env.TRUST_PROXY ?? "").toLowerCase() === "true";
const enableShelvedModes = options.enableShelvedModes ?? booleanFlag(process.env.HEALER_MAN_ENABLE_SHELVED_MODES);
const gameDatabase = options.database ?? openGameDatabase(options);
const balanceLabJobs = options.balanceLabJobs ?? createBalanceLabJobService({
dataDir: options.dataDir ?? path.dirname(gameDatabase.databasePath),
projectRoot: options.projectRoot,
pythonExecutable: options.pythonExecutable,
});
const onlineSessions = createOnlineSessionManager();
const arenaService = createArenaService({ database: gameDatabase, autoPump: enableShelvedModes });
const allowAuthRequest = createAuthLimiter();
@@ -211,6 +217,60 @@ export function createGameServer(options = {}) {
json(response, 200, { ok: true, service: "healer-man", time: new Date().toISOString() });
return;
}
if (url.pathname === "/api/dungeon-route-approvals" && request.method === "GET") {
json(response, 200, gameDatabase.listDungeonRouteApprovals());
return;
}
if (url.pathname === "/api/gm/dungeon-routes" && request.method === "GET") {
const account = gameDatabase.authenticate(bearerToken(request));
json(response, 200, gameDatabase.listDungeonRouteReviews(account));
return;
}
const dungeonRouteReviewMatch = /^\/api\/gm\/dungeon-routes\/([^/]+)\/([^/]+)\/review$/.exec(url.pathname);
if (dungeonRouteReviewMatch && request.method === "PUT") {
const account = gameDatabase.authenticate(bearerToken(request));
const body = await readJson(request, 64 * 1024);
const review = gameDatabase.putDungeonRouteReview(
account,
decodeURIComponent(dungeonRouteReviewMatch[1]),
decodeURIComponent(dungeonRouteReviewMatch[2]),
body,
);
json(response, 200, { review });
return;
}
if (url.pathname === "/api/gm/dungeon-sim/replays" && request.method === "GET") {
const account = gameDatabase.authenticate(bearerToken(request));
json(response, 200, gameDatabase.listDungeonSimulationReplays(account, {
dungeonId: url.searchParams.get("dungeon"),
}));
return;
}
if (url.pathname === "/api/gm/dungeon-sim/replays" && request.method === "POST") {
const account = gameDatabase.authenticate(bearerToken(request));
const replay = gameDatabase.saveDungeonSimulationReplay(account, await readJson(request, 64 * 1024 * 1024));
json(response, 201, { replay });
return;
}
const dungeonSimulationReportMatch = /^\/api\/gm\/dungeon-sim\/replays\/([^/]+)\/report$/.exec(url.pathname);
if (dungeonSimulationReportMatch && request.method === "GET") {
const account = gameDatabase.authenticate(bearerToken(request));
json(response, 200, gameDatabase.getDungeonSimulationReport(account, dungeonSimulationReportMatch[1]), {
"Content-Disposition": `attachment; filename="dungeon-simulation-${dungeonSimulationReportMatch[1]}.json"`,
});
return;
}
const dungeonSimulationReplayMatch = /^\/api\/gm\/dungeon-sim\/replays\/([^/]+)$/.exec(url.pathname);
if (dungeonSimulationReplayMatch && request.method === "GET") {
const account = gameDatabase.authenticate(bearerToken(request));
json(response, 200, gameDatabase.getDungeonSimulationReplay(account, dungeonSimulationReplayMatch[1]));
return;
}
if (dungeonSimulationReplayMatch && request.method === "DELETE") {
const account = gameDatabase.authenticate(bearerToken(request));
json(response, 200, gameDatabase.deleteDungeonSimulationReplay(account, dungeonSimulationReplayMatch[1]));
return;
}
if (!enableShelvedModes && (
url.pathname === "/api/manastorm-config"
|| url.pathname.startsWith("/api/gm/manastorm-config")
@@ -438,6 +498,50 @@ export function createGameServer(options = {}) {
json(response, 200, result);
return;
}
if (url.pathname === "/api/gm/balance-lab/jobs" && request.method === "GET") {
const account = gameDatabase.authenticate(bearerToken(request));
if (!gameDatabase.isGameMaster(account)) {
throw new GameDatabaseError("Game Master access is required.", 403, "gm_required");
}
json(response, 200, { jobs: balanceLabJobs.list() });
return;
}
if (url.pathname === "/api/gm/balance-lab/jobs" && request.method === "POST") {
const account = gameDatabase.authenticate(bearerToken(request));
if (!gameDatabase.isGameMaster(account)) {
throw new GameDatabaseError("Game Master access is required.", 403, "gm_required");
}
const job = balanceLabJobs.start(await readJson(request, 32 * 1024), account);
console.info(`Balance Lab job ${job.id} queued by ${account.username}`);
json(response, 202, { job });
return;
}
const balanceLabArtifactMatch = /^\/api\/gm\/balance-lab\/jobs\/([0-9a-f-]{36})\/artifacts\/([A-Za-z0-9._-]+)$/.exec(url.pathname);
if (balanceLabArtifactMatch && request.method === "GET") {
const account = gameDatabase.authenticate(bearerToken(request));
if (!gameDatabase.isGameMaster(account)) {
throw new GameDatabaseError("Game Master access is required.", 403, "gm_required");
}
const artifact = balanceLabJobs.artifact(balanceLabArtifactMatch[1], balanceLabArtifactMatch[2]);
response.writeHead(200, {
"Content-Type": artifact.contentType,
"Content-Length": artifact.size,
"Cache-Control": "no-store",
"Content-Disposition": `inline; filename="${balanceLabArtifactMatch[2]}"`,
"X-Content-Type-Options": "nosniff",
});
artifact.stream().pipe(response);
return;
}
const balanceLabJobMatch = /^\/api\/gm\/balance-lab\/jobs\/([0-9a-f-]{36})$/.exec(url.pathname);
if (balanceLabJobMatch && request.method === "GET") {
const account = gameDatabase.authenticate(bearerToken(request));
if (!gameDatabase.isGameMaster(account)) {
throw new GameDatabaseError("Game Master access is required.", 403, "gm_required");
}
json(response, 200, { job: balanceLabJobs.get(balanceLabJobMatch[1]) });
return;
}
if (url.pathname.startsWith("/api/")) {
json(response, 404, { error: "API route not found.", code: "not_found" });
return;
@@ -491,11 +595,12 @@ export function createGameServer(options = {}) {
});
server.on("close", () => {
balanceLabJobs.close?.();
onlineSessions.clear();
arenaService.close();
gameDatabase.close();
});
return { server, database: gameDatabase, arenaService, staticDir, contentDir, enableShelvedModes };
return { server, database: gameDatabase, arenaService, balanceLabJobs, staticDir, contentDir, enableShelvedModes };
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
+21
View File
@@ -237,6 +237,24 @@ test("serves the game and authenticated cloud-save API", async () => {
assert.equal(gmRegistration.status, 201);
const gmAuth = await gmRegistration.json();
assert.deepEqual(gmAuth.account.roles, ["gm"]);
const deniedBalanceLab = await fetch(`${origin}/api/gm/balance-lab/jobs`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${auth.token}` },
body: JSON.stringify({ source: "wow", level: 40 }),
});
assert.equal(deniedBalanceLab.status, 403);
const balanceLabJobs = await fetch(`${origin}/api/gm/balance-lab/jobs`, {
headers: { Authorization: `Bearer ${gmAuth.token}` },
});
assert.equal(balanceLabJobs.status, 200);
assert.deepEqual((await balanceLabJobs.json()).jobs, []);
const invalidBalanceLab = await fetch(`${origin}/api/gm/balance-lab/jobs`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${gmAuth.token}` },
body: JSON.stringify({ source: "wow", level: 81 }),
});
assert.equal(invalidBalanceLab.status, 400);
assert.equal((await invalidBalanceLab.json()).code, "invalid_balance_lab_job");
const current = await (await fetch(`${origin}/api/manastorm-config`)).json();
const published = await fetch(`${origin}/api/gm/manastorm-config`, {
method: "PUT",
@@ -290,6 +308,9 @@ test("disables shelved APIs and Manastorm group creation by default", async () =
assert.equal(response.status, 503);
assert.equal((await response.json()).code, "feature_disabled");
}
const balanceLab = await fetch(`${origin}/api/gm/balance-lab/jobs`);
assert.equal(balanceLab.status, 401);
assert.equal((await balanceLab.json()).code, "authentication_required");
const registration = await fetch(`${origin}/api/auth/register`, {
method: "POST",
+2
View File
@@ -20,6 +20,7 @@ const CharacterSelectScreen = lazy(() => import("./ui/CharacterSelectScreen").th
const ClassHallScreen = lazy(() => import("./ui/ClassHallScreen").then((module) => ({ default: module.ClassHallScreen })));
const DungeonSelectScreen = lazy(() => import("./ui/DungeonSelectScreen").then((module) => ({ default: module.DungeonSelectScreen })));
const MainMenuScreen = lazy(() => import("./ui/MainMenuScreen").then((module) => ({ default: module.MainMenuScreen })));
const DungeonSimulationAdminScreen = lazy(() => import("./ui/DungeonSimulationAdminScreen").then((module) => ({ default: module.DungeonSimulationAdminScreen })));
function ShellTransition() {
return <div className="world-transition"><span>HM</span><strong>Opening expedition</strong><small>Preparing the next screen...</small></div>;
}
@@ -86,6 +87,7 @@ export default function App() {
else if (phase === "main-menu") screen = <Suspense fallback={<ShellTransition />}><MainMenuScreen /></Suspense>;
else if (phase === "class-hall") screen = <Suspense fallback={<ShellTransition />}><ClassHallScreen /></Suspense>;
else if (phase === "dungeons") screen = <Suspense fallback={<ShellTransition />}><DungeonSelectScreen /></Suspense>;
else if (phase === "gm-admin") screen = <Suspense fallback={<ShellTransition />}><DungeonSimulationAdminScreen /></Suspense>;
else if (isShelvedShellPhase(phase)) {
screen = <Suspense fallback={<ShellTransition />}><ShelvedModeRoute phase={phase} session={session} /></Suspense>;
}
+15 -7
View File
@@ -32,6 +32,11 @@ import { useUiLayoutStore } from "./game/uiLayoutStore";
import { profileInputBindings } from "./game/keybindPreferences";
import { KeybindsPanel } from "./ui/KeybindsPanel";
import { lootShelvedBoss, releaseShelvedGameRuntime, ShelvedGameBridges } from "@shelvedGameRuntime";
import { useDungeonSimulationStore } from "./game/dungeonSimulationStore";
import { DungeonSimulationHud } from "./ui/DungeonSimulationHud";
import { useDungeonRouteStore } from "./game/dungeonRouteStore";
import { installCombatClock } from "./game/combatClock";
import { dungeonSimulationNow } from "./game/dungeonSimulationStore";
function InputBridge() {
const overlay = useGameStore((state) => state.overlay);
@@ -97,7 +102,10 @@ function InputBridge() {
export default function GameRuntime() {
useEffect(connectDpsMeter, []);
useEffect(() => installCombatClock(dungeonSimulationNow), []);
useEffect(() => { void useDungeonRouteStore.getState().refresh(); }, []);
const editingControls = useGameStore((state) => state.overlay === "keybinds" || state.overlay === "ui-layout");
const dungeonSimulation = useDungeonSimulationStore((state) => state.sessionMode !== "play");
useEffect(() => () => {
const activeDungeon = requireDungeonDefinition(useGameStore.getState().activeDungeonId);
clearDungeonRuntimeCaches();
@@ -127,13 +135,13 @@ export default function GameRuntime() {
<BossTestingBridge />
<ShelvedGameBridges />
<SceneErrorBoundary><GameScene /></SceneErrorBoundary>
<Hud />
<MapPanel />
<GameplayPanels />
<PauseMenu />
<DungeonRunResults />
<UiLayoutEditor />
<KeybindsPanel />
{dungeonSimulation ? <DungeonSimulationHud /> : <Hud />}
{!dungeonSimulation && <MapPanel />}
{!dungeonSimulation && <GameplayPanels />}
{!dungeonSimulation && <PauseMenu />}
{!dungeonSimulation && <DungeonRunResults />}
{!dungeonSimulation && <UiLayoutEditor />}
{!dungeonSimulation && <KeybindsPanel />}
</main>
}
bottom={<LocalCompanionPanel />}
-2
View File
@@ -5,7 +5,6 @@ import { useManastormAdminStore } from "../game/manastormAdminStore";
const ManastormSelectScreen = lazy(() => import("../ui/ManastormSelectScreen").then((module) => ({ default: module.ManastormSelectScreen })));
const ArenaSelectScreen = lazy(() => import("../ui/ArenaSelectScreen").then((module) => ({ default: module.ArenaSelectScreen })));
const ArenaRuntime = lazy(() => import("../ArenaRuntime"));
const GmAdminScreen = lazy(() => import("../ui/GmAdminScreen").then((module) => ({ default: module.GmAdminScreen })));
function ShelvedTransition() {
return <div className="world-transition"><span>HM</span><strong>Opening expedition</strong><small>Preparing the next screen...</small></div>;
@@ -28,6 +27,5 @@ export function ShelvedModeRoute({
if (phase === "manastorms") screen = <ManastormSelectScreen />;
else if (phase === "arenas") screen = <ArenaSelectScreen />;
else if (phase === "arena-game") screen = <ArenaRuntime />;
else if (phase === "gm-admin") screen = <GmAdminScreen />;
return <Suspense fallback={<ShelvedTransition />}>{screen}</Suspense>;
}
+192
View File
@@ -0,0 +1,192 @@
import { onlineApiOrigin, requestOnlineJson } from "./onlineAccountClient";
export type BalanceLabPartySource = "wow" | "coa" | "rom" | "all";
export type BalanceLabBossCategory = "all" | "wow" | "rom";
export type BalanceLabJobStatus = "queued" | "running" | "complete" | "failed";
export interface BalanceLabJobSpec {
readonly source: BalanceLabPartySource;
readonly level: number;
readonly partyConfigurations: number;
readonly simulationsPerParty: number;
readonly durationSeconds: number;
readonly partySeed: number;
readonly bossCategory: BalanceLabBossCategory;
readonly bossKeys: readonly string[];
}
export interface BalanceLabJob {
readonly id: string;
readonly status: BalanceLabJobStatus;
readonly createdAt: string;
readonly startedAt: string | null;
readonly completedAt: string | null;
readonly requestedBy: string;
readonly spec: BalanceLabJobSpec;
readonly progress: {
readonly party: number;
readonly partiesCompleted: number;
readonly partiesTotal: number;
readonly simulationsCompleted: number;
readonly simulationsTotal: number;
};
readonly artifacts: readonly string[];
readonly error: string | null;
readonly logTail: readonly string[];
readonly summary: BalanceLabReportSummary | null;
}
export interface BalanceLabReportSummary {
readonly partyConfigurations: number;
readonly bosses: number;
readonly runs: number;
readonly classSpecs: number;
readonly classAbilities: number;
readonly bossPressureMedianDtps: number;
}
export interface BalanceLabBossResult {
readonly bossKey: string;
readonly bossName: string;
readonly category: string;
readonly dungeonId: string;
readonly dungeonTitle: string;
readonly runs: number;
readonly incomingDtps: number;
readonly averageIncomingDamage: number;
readonly partyDps: number;
readonly bossDpsTaken: number;
readonly defeatRate: number;
readonly wipeRate: number;
readonly averageDeaths: number;
readonly averageTimeToDefeat: number | null;
readonly bossMaxHealth: number;
readonly pressureVsMedian: number | null;
readonly pressureFlag: string;
}
export interface BalanceLabClassSpecResult {
readonly source: string;
readonly buildId: string;
readonly classId: string;
readonly className: string;
readonly specialization: string;
readonly role: string;
readonly partyConfigurations: number;
readonly runs: number;
readonly dps: number;
readonly bossDps: number;
readonly hps: number;
readonly dtps: number;
readonly totalDamage: number;
readonly totalDamageTaken: number;
readonly totalHealing: number;
readonly totalAbsorbs: number;
readonly deathEvents: number;
readonly damageVsSourceRoleMedian: number | null;
readonly damageFlag: string;
}
export interface BalanceLabPartyResult {
readonly partyConfigurationId: string;
readonly source: string;
readonly members: string;
readonly runs: number;
readonly partyDps: number;
readonly incomingDtps: number;
readonly defeatRate: number;
readonly wipeRate: number;
readonly averageDeaths: number;
readonly reportPath: string;
}
export interface BalanceLabClassAbilityResult {
readonly source: string;
readonly buildId: string;
readonly classId: string;
readonly className: string;
readonly specialization: string;
readonly role: string;
readonly abilityId: string;
readonly abilityName: string;
readonly icon: string;
readonly categories: string;
readonly cooldownSeconds: number | null;
readonly partyConfigurations: number;
readonly bosses: number;
readonly runs: number;
readonly availableRuns: number;
readonly activeRuns: number;
readonly usageRate: number;
readonly casts: number;
readonly castsPerMinute: number;
readonly hits: number;
readonly hitsPerRun: number;
readonly rawDamage: number;
readonly effectiveDamage: number;
readonly dps: number;
readonly damageShare: number;
readonly averageDamagePerHit: number;
readonly rawHealing: number;
readonly effectiveHealing: number;
readonly overheal: number;
readonly absorbs: number;
readonly status: "unused" | "damage" | "support" | "used";
readonly unusedAvailable: boolean;
}
export interface BalanceLabSuiteReport {
readonly schemaVersion: number;
readonly generatedAt: string;
readonly engine: string;
readonly level: number;
readonly summary: BalanceLabReportSummary;
readonly bosses: readonly BalanceLabBossResult[];
readonly classSpecs: readonly BalanceLabClassSpecResult[];
readonly classAbilities?: readonly BalanceLabClassAbilityResult[];
readonly partyConfigurations: readonly BalanceLabPartyResult[];
}
export async function listBalanceLabJobs(token: string): Promise<readonly BalanceLabJob[]> {
return (await requestOnlineJson<{ jobs: BalanceLabJob[] }>(
"/api/gm/balance-lab/jobs",
{},
token,
)).jobs;
}
export async function startBalanceLabJob(spec: BalanceLabJobSpec, token: string): Promise<BalanceLabJob> {
return (await requestOnlineJson<{ job: BalanceLabJob }>(
"/api/gm/balance-lab/jobs",
{ method: "POST", body: JSON.stringify(spec) },
token,
)).job;
}
export async function getBalanceLabJob(id: string, token: string): Promise<BalanceLabJob> {
return (await requestOnlineJson<{ job: BalanceLabJob }>(
`/api/gm/balance-lab/jobs/${encodeURIComponent(id)}`,
{},
token,
)).job;
}
export async function getBalanceLabReport(id: string, token: string): Promise<BalanceLabSuiteReport> {
return requestOnlineJson<BalanceLabSuiteReport>(
`/api/gm/balance-lab/jobs/${encodeURIComponent(id)}/artifacts/suite-report.json`,
{},
token,
);
}
export async function fetchBalanceLabArtifact(id: string, name: string, token: string): Promise<Blob> {
const response = await fetch(
`${onlineApiOrigin()}/api/gm/balance-lab/jobs/${encodeURIComponent(id)}/artifacts/${encodeURIComponent(name)}`,
{ headers: { Authorization: `Bearer ${token}` } },
);
if (!response.ok) {
const body = await response.json().catch(() => ({})) as { error?: string };
throw new Error(body.error ?? `Could not download ${name} (${response.status}).`);
}
return response.blob();
}
+87
View File
@@ -0,0 +1,87 @@
import type { PlayerSession } from "./types";
import { requestOnlineJson } from "./onlineAccountClient";
import type {
DungeonReplaySummary,
DungeonReplayV1,
DungeonRouteApproval,
DungeonRouteReview,
DungeonRouteReviewStatus,
DungeonSimulationReportV1,
} from "../game/dungeonSimulationTypes";
function gmToken(session: PlayerSession | null): string {
if (session?.kind !== "account" || !session.accessToken || !session.roles?.includes("gm")) {
throw new Error("Game Master access is required.");
}
return session.accessToken;
}
export async function fetchDungeonRouteApprovals(): Promise<readonly DungeonRouteApproval[]> {
return (await requestOnlineJson<{ approvals: DungeonRouteApproval[] }>("/api/dungeon-route-approvals")).approvals;
}
export async function fetchGmDungeonRoutes(session: PlayerSession | null): Promise<{
readonly reviews: readonly DungeonRouteReview[];
readonly latestReplays: readonly DungeonReplaySummary[];
}> {
return requestOnlineJson("/api/gm/dungeon-routes", {}, gmToken(session));
}
export async function putDungeonRouteReview(
session: PlayerSession | null,
route: { readonly dungeonId: string; readonly routeId: string; readonly routeHash: string },
update: {
readonly status: DungeonRouteReviewStatus;
readonly expectedRevision: number;
readonly note?: string;
readonly criticalOverrideNote?: string;
},
): Promise<DungeonRouteReview> {
const response = await requestOnlineJson<{ review: DungeonRouteReview }>(
`/api/gm/dungeon-routes/${encodeURIComponent(route.dungeonId)}/${encodeURIComponent(route.routeId)}/review`,
{ method: "PUT", body: JSON.stringify({ ...update, routeHash: route.routeHash }) },
gmToken(session),
);
return response.review;
}
export async function fetchDungeonSimulationReplays(
session: PlayerSession | null,
dungeonId?: string,
): Promise<readonly DungeonReplaySummary[]> {
const query = dungeonId ? `?dungeon=${encodeURIComponent(dungeonId)}` : "";
return (await requestOnlineJson<{ replays: DungeonReplaySummary[] }>(
`/api/gm/dungeon-sim/replays${query}`,
{},
gmToken(session),
)).replays;
}
export async function saveDungeonSimulationReplay(
session: PlayerSession | null,
replay: DungeonReplayV1,
): Promise<DungeonReplaySummary> {
return (await requestOnlineJson<{ replay: DungeonReplaySummary }>(
"/api/gm/dungeon-sim/replays",
{ method: "POST", body: JSON.stringify(replay) },
gmToken(session),
)).replay;
}
export function fetchDungeonSimulationReplay(
session: PlayerSession | null,
replayId: string,
): Promise<DungeonReplayV1> {
return requestOnlineJson(`/api/gm/dungeon-sim/replays/${encodeURIComponent(replayId)}`, {}, gmToken(session));
}
export function fetchDungeonSimulationReport(
session: PlayerSession | null,
replayId: string,
): Promise<DungeonSimulationReportV1> {
return requestOnlineJson(`/api/gm/dungeon-sim/replays/${encodeURIComponent(replayId)}/report`, {}, gmToken(session));
}
export function deleteDungeonSimulationReplay(session: PlayerSession | null, replayId: string): Promise<{ readonly ok: true }> {
return requestOnlineJson(`/api/gm/dungeon-sim/replays/${encodeURIComponent(replayId)}`, { method: "DELETE" }, gmToken(session));
}
+71
View File
@@ -0,0 +1,71 @@
import { touchCharacter } from "./accountRepository";
import { useShellStore } from "./shellStore";
import { activateDungeonSession } from "../game/dungeonSession";
import { dungeonRouteDefinition } from "../game/dungeonRoutes";
import { useDungeonSimulationStore } from "../game/dungeonSimulationStore";
import type { DungeonReplayV1, DungeonSimulationMode } from "../game/dungeonSimulationTypes";
import { defaultPartyRoleForClass } from "../game/partyRoles";
import { useGameStore } from "../game/store";
function gmCharacter() {
const shell = useShellStore.getState();
if (shell.session?.kind !== "account" || !shell.session.accessToken || !shell.session.roles?.includes("gm")) {
shell.setNotice("Game Master access is required.");
return null;
}
const character = shell.selectedCharacterId
? touchCharacter(shell.session.ownerId, shell.selectedCharacterId)
?? shell.characters.find((candidate) => candidate.id === shell.selectedCharacterId)
: null;
if (!character) shell.setNotice("Select a character before launching a simulation.");
return character;
}
export function enterDungeonSimulation(
dungeonId: string,
mode: DungeonSimulationMode,
seed: string,
): boolean {
const character = gmCharacter();
const route = dungeonRouteDefinition(dungeonId);
if (!character || !route || !seed.trim()) return false;
if (!activateDungeonSession(dungeonId, undefined, {
gmTestMode: true,
dungeonSimulation: true,
})) {
useShellStore.getState().setNotice("The dungeon package is unavailable on this client.");
return false;
}
useDungeonSimulationStore.getState().startLive({ dungeonId, route, mode, seed: seed.trim() });
useGameStore.getState().setCompanionOpen(false);
useShellStore.setState({
phase: "game",
activeCharacter: character,
activeDungeonId: dungeonId,
activeDungeonRole: defaultPartyRoleForClass(character.classId),
notice: "",
});
return true;
}
export function enterDungeonSimulationReplay(replay: DungeonReplayV1): boolean {
const character = gmCharacter();
if (!character || !dungeonRouteDefinition(replay.dungeonId, replay.routeId)) return false;
if (!activateDungeonSession(replay.dungeonId, undefined, {
gmTestMode: true,
dungeonSimulation: true,
})) {
useShellStore.getState().setNotice("The replay dungeon package is unavailable on this client.");
return false;
}
useDungeonSimulationStore.getState().startReplay(replay);
useGameStore.getState().setCompanionOpen(false);
useShellStore.setState({
phase: "game",
activeCharacter: character,
activeDungeonId: replay.dungeonId,
activeDungeonRole: defaultPartyRoleForClass(character.classId),
notice: "",
});
return true;
}
+15 -1
View File
@@ -1,13 +1,15 @@
import { describe, expect, it } from "vitest";
import {
isShelvedShellPhase,
sessionCanUseGmTools,
sessionCanUseShelvedGmTools,
SHELVED_MODES_ENABLED,
} from "./releaseFeatures";
describe("release feature boundary", () => {
it("recognizes every legacy shelved phase", () => {
expect(["manastorms", "arenas", "arena-game", "gm-admin"].every(isShelvedShellPhase)).toBe(true);
expect(["manastorms", "arenas", "arena-game"].every(isShelvedShellPhase)).toBe(true);
expect(isShelvedShellPhase("gm-admin")).toBe(false);
expect(isShelvedShellPhase("main-menu")).toBe(false);
expect(isShelvedShellPhase("game")).toBe(false);
});
@@ -24,4 +26,16 @@ describe("release feature boundary", () => {
expect(sessionCanUseShelvedGmTools({ ...session, roles: [] })).toBe(false);
expect(sessionCanUseShelvedGmTools({ ...session, accessToken: "" })).toBe(false);
});
it("exposes dungeon GM tools in normal releases to authenticated GMs", () => {
const session = {
kind: "account",
ownerId: "gm-account",
displayName: "Phenom",
accessToken: "token",
roles: ["gm"],
} as const;
expect(sessionCanUseGmTools(session)).toBe(true);
expect(sessionCanUseGmTools({ ...session, roles: [] })).toBe(false);
});
});
+8 -1
View File
@@ -4,7 +4,6 @@ export const SHELVED_SHELL_PHASES = Object.freeze([
"manastorms",
"arenas",
"arena-game",
"gm-admin",
] as const);
export type ShelvedShellPhase = (typeof SHELVED_SHELL_PHASES)[number];
@@ -21,3 +20,11 @@ export function sessionCanUseShelvedGmTools(session: {
return SHELVED_MODES_ENABLED
&& Boolean(session?.kind === "account" && session.accessToken && session.roles?.includes("gm"));
}
export function sessionCanUseGmTools(session: {
readonly kind: string;
readonly accessToken?: string;
readonly roles?: readonly string[];
} | null): boolean {
return Boolean(session?.kind === "account" && session.accessToken && session.roles?.includes("gm"));
}
+2 -2
View File
@@ -59,7 +59,7 @@ import {
logoutOnlineSession,
refreshOnlineSession,
} from "./onlineAccountClient";
import { sessionCanUseShelvedGmTools, SHELVED_MODES_ENABLED } from "./releaseFeatures";
import { sessionCanUseGmTools, SHELVED_MODES_ENABLED } from "./releaseFeatures";
import { useGameStore } from "../game/store";
import {
normalizeSpecializationId,
@@ -366,7 +366,7 @@ export const useShellStore = create<ShellState>((set, get) => ({
},
openGmAdmin: () => {
const { session, selectedCharacterId, characters } = get();
if (!sessionCanUseShelvedGmTools(session)) {
if (!sessionCanUseGmTools(session)) {
set({ notice: "Game Master access is required." });
return false;
}
+70 -36
View File
@@ -23,6 +23,10 @@ import {
onlineSessionOwnsWorldSimulation,
} from "./onlineSessionRuntime";
import { combatSimulationTimerShouldRun } from "./combatSimulationLifecycle";
import {
DUNGEON_SIMULATION_FIXED_STEP_MS,
useDungeonSimulationStore,
} from "./dungeonSimulationStore";
/**
* Connects the pure combat runtime to the active character and persistent
@@ -75,7 +79,7 @@ export function CombatBridge() {
// after Character Select has already replaced the active character.
const shell = useShellStore.getState();
if (shell.activeCharacter?.id !== characterId || shell.session?.ownerId !== ownerId) return;
if (useGameStore.getState().bossTest) return;
if (useGameStore.getState().gmTestMode) return;
// Persisted collections are replaced immutably by combat actions. A
// reference/primitive comparison therefore detects the same changes as
@@ -121,54 +125,80 @@ export function CombatBridge() {
let previous = performance.now();
let timer: number | null = null;
const advanceOneTick = (delta: number, epochNow: number, spectator: boolean) => {
const game = useGameStore.getState();
const simulation = useDungeonSimulationStore.getState();
const combat = useCombatStore.getState();
combat.setPlayerPosition(game.playerPosition);
combat.tick(delta, epochNow);
if (
onlineSessionOwnsWorldSimulation()
&& game.gameMode === "dungeon"
&& (!spectator || simulation.mode === "full-combat")
) {
useCombatStore.getState().engageNearbyMobs(epochNow, game.playerPosition);
}
if (onlineSessionOwnsWorldSimulation()) {
if (!spectator || simulation.mode === "full-combat") {
useCombatStore.getState().advanceMobCombat(epochNow, game.playerPosition);
}
advanceDungeonPartyCombat(epochNow);
}
useCombatStore.getState().synchronizeActors();
if (onlineSessionOwnsWorldSimulation() && game.gameMode === "dungeon") {
const mobs = useCombatStore.getState().mobs;
const combatEvents = deriveDungeonEncounterCombatEvents(
encounterMobCursor.current,
mobs,
epochNow,
);
encounterMobCursor.current = combatEvents.cursor;
for (const event of combatEvents.events) dispatchActiveDungeonEntityEvent(event);
const registeredEntityIds = Object.keys(mobs);
advanceDungeonEncounters(game.activeDungeonId, {
kind: "tick",
now: epochNow,
facts: {
registeredEntityIds,
deadEntityIds: registeredEntityIds.filter((id) => mobs[id].dead),
},
});
}
};
const advance = () => {
const now = performance.now();
const delta = Math.min(0.25, Math.max(0, (now - previous) / 1_000));
const wallDeltaMs = Math.min(1_000, Math.max(0, now - previous));
const delta = Math.min(0.25, wallDeltaMs / 1_000);
previous = now;
const simulation = useDungeonSimulationStore.getState();
if (simulation.sessionMode === "spectate-replay") {
simulation.consumeTicks(wallDeltaMs);
return;
}
if (simulation.sessionMode === "spectate-live") {
const assetStatus = useGameStore.getState().assetStatus;
if (assetStatus !== "ready" && assetStatus !== "proxy") return;
for (const tickNow of simulation.consumeTicks(wallDeltaMs)) {
advanceOneTick(DUNGEON_SIMULATION_FIXED_STEP_MS / 1_000, tickNow, true);
}
return;
}
const game = useGameStore.getState();
const sharedOnline = onlineSessionIsActive();
if ((!game.paused || sharedOnline) && (!game.companionOpen || sharedOnline)) {
const epochNow = Date.now();
const combat = useCombatStore.getState();
combat.setPlayerPosition(game.playerPosition);
combat.tick(delta, epochNow);
if (onlineSessionOwnsWorldSimulation() && game.gameMode === "dungeon") {
useCombatStore.getState().engageNearbyMobs(epochNow, game.playerPosition);
}
if (onlineSessionOwnsWorldSimulation()) {
useCombatStore.getState().advanceMobCombat(epochNow, game.playerPosition);
advanceDungeonPartyCombat(epochNow);
}
useCombatStore.getState().synchronizeActors();
if (onlineSessionOwnsWorldSimulation() && game.gameMode === "dungeon") {
const mobs = useCombatStore.getState().mobs;
const combatEvents = deriveDungeonEncounterCombatEvents(
encounterMobCursor.current,
mobs,
epochNow,
);
encounterMobCursor.current = combatEvents.cursor;
for (const event of combatEvents.events) dispatchActiveDungeonEntityEvent(event);
const registeredEntityIds = Object.keys(mobs);
advanceDungeonEncounters(game.activeDungeonId, {
kind: "tick",
now: epochNow,
facts: {
registeredEntityIds,
deadEntityIds: registeredEntityIds.filter((id) => mobs[id].dead),
},
});
}
advanceOneTick(delta, Date.now(), false);
}
};
const reconcileTimer = () => {
const game = useGameStore.getState();
const simulation = useDungeonSimulationStore.getState();
const shouldRun = combatSimulationTimerShouldRun({
sharedOnline: onlineSessionIsActive(),
paused: game.paused,
companionOpen: game.companionOpen,
documentHidden: document.hidden,
paused: simulation.sessionMode === "play" ? game.paused : false,
companionOpen: simulation.sessionMode === "play" ? game.companionOpen : false,
documentHidden: simulation.sessionMode === "play" ? document.hidden : false,
});
if (shouldRun && timer === null) {
// Reset the clock on resume so a long pause/background period cannot
@@ -193,6 +223,9 @@ export function CombatBridge() {
// store notification gives the bridge a reactive point at which to
// reconcile while preserving continuous shared-online simulation.
const unsubscribeOnlineSession = useOnlineSessionStore.subscribe(reconcileTimer);
const unsubscribeSimulation = useDungeonSimulationStore.subscribe((state, previousState) => {
if (state.sessionMode !== previousState.sessionMode || state.runState !== previousState.runState) reconcileTimer();
});
document.addEventListener("visibilitychange", reconcileTimer);
reconcileTimer();
@@ -200,6 +233,7 @@ export function CombatBridge() {
if (timer !== null) window.clearInterval(timer);
unsubscribeGame();
unsubscribeOnlineSession();
unsubscribeSimulation();
document.removeEventListener("visibilitychange", reconcileTimer);
};
}, []);
+3 -2
View File
@@ -13,6 +13,7 @@ import type { DungeonEncounterEffectEnvelope } from "./dungeonEncounterTypes";
import { publishOnlinePartyTeleport } from "./onlineSessionRuntime";
import { dungeonDefinitionById } from "./dungeonRegistry";
import { removeMobPosition } from "./mobRuntimeRegistry";
import { dungeonSimulationNow } from "./dungeonSimulationStore";
function grantEncounterLoot(lootTableId: string, now: number): boolean {
const table = LOOT_CATALOG.bosses.find((candidate) => candidate.lootTableId === lootTableId);
@@ -29,14 +30,14 @@ function grantEncounterLoot(lootTableId: string, now: number): boolean {
/** Applies platform effects emitted by the renderer-neutral encounter reducer. */
export function DungeonEncounterEffectBridge() {
useEffect(() => registerDungeonEncounterEffectHandler((envelope) => {
applyDungeonEncounterEffect(envelope, Date.now());
applyDungeonEncounterEffect(envelope, dungeonSimulationNow());
}), []);
return null;
}
export function applyDungeonEncounterEffect(
{ dungeonId, effect }: DungeonEncounterEffectEnvelope,
now = Date.now(),
now = dungeonSimulationNow(),
): void {
if (effect.kind === "signal") {
dispatchDungeonEncounterSignal(effect.signalId, now);
+16 -9
View File
@@ -6,6 +6,7 @@ import { usePartyStore } from "./partyStore";
import { useGameStore } from "./store";
import { useManastormStore } from "./manastormStore";
import { onlineGroupAiPartySize, useOnlineGroupStore } from "../app/onlineGroupStore";
import { useDungeonSimulationStore } from "./dungeonSimulationStore";
/** Owns party lifecycle; the combat interval itself remains in CombatBridge. */
export function PartyBridge() {
@@ -15,6 +16,8 @@ export function PartyBridge() {
const manastormPartySize = useManastormStore((state) => state.partySize);
const onlineActivity = useOnlineGroupStore((state) => state.activeActivity);
const level = useCombatStore((state) => state.level);
const simulationMode = useDungeonSimulationStore((state) => state.sessionMode);
const simulationSeed = useDungeonSimulationStore((state) => state.seed);
const resetRevision = useGameStore((state) => state.resetRevision);
const mountedResetRevision = useRef(resetRevision);
@@ -27,19 +30,23 @@ export function PartyBridge() {
&& onlineActivity.type === (gameMode === "manastorm" ? "manastorm" : "dungeon")
? onlineGroupAiPartySize(onlineActivity.partySize)
: requestedPartySize;
party.initializeParty(
character.id,
combat.level,
combat.health,
playerRole,
aiPartySize,
character.categoryId ?? "wow",
);
if (simulationMode === "play") {
party.initializeParty(
character.id,
combat.level,
combat.health,
playerRole,
aiPartySize,
character.categoryId ?? "wow",
);
} else {
party.initializeSpectatorParty(simulationSeed, combat.level, character.categoryId ?? "wow");
}
usePartyStore.getState().syncEquipment(combat.equipment, combat.inventory);
combat.synchronizeActors();
combat.applyDefaultPartyBuffs();
resetPartyRuntimeClock();
}, [character?.id, gameMode, manastormPartySize, onlineActivity?.id, playerRole]);
}, [character?.id, gameMode, manastormPartySize, onlineActivity?.id, playerRole, simulationMode, simulationSeed]);
useEffect(() => {
const party = usePartyStore.getState();
@@ -0,0 +1,69 @@
import { describe, expect, it } from "vitest";
import { createConfiguredPartyMember } from "../partyStore";
import { runtimeNormalizeTalentRanks } from "../talentRuntimeCatalog";
import {
loadSelectableBenchmarkBuilds,
selectableBenchmarkBuildCatalog,
} from "./benchmarkBuilds";
describe("balance-lab selectable builds", () => {
it("lists every authored WoW specialization instead of the representative cohort", () => {
const catalog = selectableBenchmarkBuildCatalog(60, [
"warrior", "paladin", "hunter", "rogue", "priest", "death-knight",
"shaman", "mage", "warlock", "druid",
]);
expect(catalog).toHaveLength(31);
expect(catalog).toEqual(expect.arrayContaining([
expect.objectContaining({ classId: "hunter", orientation: "Beast Mastery", role: "damage" }),
expect.objectContaining({ classId: "priest", orientation: "Discipline", role: "healer" }),
expect.objectContaining({ classId: "warlock", orientation: "Destruction", role: "damage" }),
expect.objectContaining({ classId: "druid", orientation: "Feral (Bear)", role: "tank" }),
]));
expect(new Set(catalog.map((build) => build.id)).size).toBe(catalog.length);
});
it("legally allocates a previously omitted specialization", async () => {
const builds = await loadSelectableBenchmarkBuilds(20, ["hunter"]);
const beastMastery = builds.find((build) => build.orientation === "Beast Mastery");
expect(beastMastery?.role).toBe("damage");
expect(Object.keys(beastMastery?.talentRanks ?? {})).not.toHaveLength(0);
});
it("keeps each CoA specialization tree selectable", async () => {
const builds = await loadSelectableBenchmarkBuilds(20, ["witch-doctor"]);
expect(builds.map((build) => `${build.orientation}:${build.role}`)).toEqual([
"Shadowhunting:damage",
"Voodoo:damage",
"Brewing:healer",
]);
expect(builds.every((build) => Object.keys(build.talentRanks).length > 0)).toBe(true);
});
it("constructs a legal build for every selectable WoW, CoA, and RoM entry", async () => {
const catalog = selectableBenchmarkBuildCatalog(20);
const builds = await loadSelectableBenchmarkBuilds(20);
expect(builds.map((build) => build.id)).toEqual(catalog.map((build) => build.id));
for (const build of builds) {
expect(runtimeNormalizeTalentRanks(build.classId, build.level, build.talentRanks))
.toEqual(build.talentRanks);
}
}, 60_000);
it("constructs an exact companion without replacing selected talents", () => {
const talents = Object.freeze({ "hunter-beast-mastery-0": 3 });
const member = createConfiguredPartyMember({
name: "Balance Hunter",
classId: "hunter",
specialization: "Beast Mastery",
specializationId: "hunter:beast-mastery",
talentTreeId: "hunter-beast-mastery",
talentRanks: talents,
role: "damage",
}, 20, 2, "balance-test");
expect(member.name).toBe("Balance Hunter");
expect(member.specialization).toBe("Beast Mastery");
expect(member.specializationId).toBe("hunter:beast-mastery");
expect(member.talentRanks).toBe(talents);
expect(member.id).toContain("party-damage-2-hunter");
});
});
+206
View File
@@ -3,6 +3,7 @@ import {
CLASSES,
COA_CLASS_IDS,
ROM_CLASS_IDS,
classById,
contentCategoryForClass,
type BaseClassId,
type ClassId,
@@ -20,6 +21,10 @@ import type { EquipmentAssignments } from "../equipment";
import { partyRolesForClass, type PartyRole } from "../partyRoles";
import { runtimePartyTalentRanksForLevel } from "../partyTalentRuntime";
import { clampPlayerLevel } from "../progression";
import {
specializationsForClass,
type SpecializationDefinition,
} from "../specializationCatalog";
import {
runtimeAllocateTalentPoint,
runtimeNormalizeTalentRanks,
@@ -153,6 +158,16 @@ function benchmarkBuildId(classId: ClassId, role: PartyRole, level: number): str
return `canonical-${contentCategoryForClass(classId)}-${classId}-${role}-level-${level}`;
}
function benchmarkVariantBuildId(
classId: ClassId,
role: PartyRole,
orientation: string,
level: number,
): string {
const variant = orientation.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
return `balance-${contentCategoryForClass(classId)}-${classId}-${role}-${variant}-level-${level}`;
}
function commonBuildFields(classId: ClassId, role: PartyRole, level: number) {
return {
id: benchmarkBuildId(classId, role, level),
@@ -365,6 +380,197 @@ function romBuild(
});
}
function specializationTree(
catalog: TalentUiCatalog,
specialization: SpecializationDefinition,
): TalentUiTree {
const normalized = (value: string) => value.toLowerCase().replace(/[^a-z0-9]+/g, "");
const names = new Set([
normalized(specialization.talentTree),
normalized(specialization.displayName),
]);
const tree = catalog.trees.find((candidate) => names.has(normalized(candidate.name)));
if (!tree) {
throw new Error(
`No talent tree matches ${classById(specialization.classId).name} ${specialization.displayName}.`,
);
}
return tree;
}
function allocateSingleTreeTalentRanks(
classId: BaseClassId,
catalog: TalentUiCatalog,
tree: TalentUiTree,
level: number,
): RuntimeTalentRanks {
const candidates = catalog.nodes
.filter((node) => node.treeId === tree.id)
.sort((left, right) => (
left.row - right.row
|| left.column - right.column
|| left.index - right.index
|| left.id.localeCompare(right.id)
));
const successfulRequests: Record<string, number> = {};
let ranks: RuntimeTalentRanks = EMPTY_TALENT_RANKS;
while (true) {
let allocated = false;
for (const node of candidates) {
if ((successfulRequests[node.id] ?? 0) >= node.maxRank) continue;
const result = runtimeAllocateTalentPoint(ranks, classId, node.id, level);
if (!result.ok) continue;
successfulRequests[node.id] = (successfulRequests[node.id] ?? 0) + 1;
ranks = result.ranks;
allocated = true;
break;
}
if (!allocated) break;
}
return Object.freeze({ ...runtimeNormalizeTalentRanks(classId, level, ranks) });
}
function selectableClassicBuild(
specialization: SpecializationDefinition,
level: number,
catalog: TalentUiCatalog | null,
): BenchmarkBuildDefinition {
const classId = specialization.classId as BaseClassId;
let talentRanks: RuntimeTalentRanks;
try {
talentRanks = runtimePartyTalentRanksForLevel(classId, specialization.displayName, level);
} catch {
if (!catalog) throw new Error(`No talent catalog was loaded for ${classId}.`);
talentRanks = allocateSingleTreeTalentRanks(
classId,
catalog,
specializationTree(catalog, specialization),
level,
);
}
return Object.freeze({
...commonBuildFields(classId, specialization.role, level),
id: benchmarkVariantBuildId(classId, specialization.role, specialization.displayName, level),
orientation: specialization.displayName,
talentRanks,
});
}
function selectableCoaBuild(
specialization: SpecializationDefinition,
level: number,
catalog: TalentUiCatalog,
): BenchmarkBuildDefinition {
const classId = specialization.classId as CoaClassId;
const tree = specializationTree(catalog, specialization);
const abilitiesByNode = coaNodeAbilities(classId);
return Object.freeze({
...commonBuildFields(classId, specialization.role, level),
id: benchmarkVariantBuildId(classId, specialization.role, specialization.displayName, level),
orientation: specialization.displayName,
talentRanks: allocateCoaTalentRanks(
classId,
catalog,
tree,
specialization.role,
level,
abilitiesByNode,
),
});
}
export interface SelectableBenchmarkBuildCatalogEntry {
readonly id: string;
readonly source: ReturnType<typeof contentCategoryForClass>;
readonly classId: ClassId;
readonly role: PartyRole;
readonly orientation: string;
readonly level: number;
}
/** Cheap metadata-only roster used by interactive tools before a build is selected. */
export function selectableBenchmarkBuildCatalog(
level = CANONICAL_BENCHMARK_LEVEL,
classIds?: readonly ClassId[],
): readonly SelectableBenchmarkBuildCatalogEntry[] {
const safeLevel = clampPlayerLevel(level);
const requested = classIds ? new Set(classIds) : null;
return CLASSES
.map((definition) => definition.id)
.filter((classId) => !requested || requested.has(classId))
.flatMap((classId): readonly SelectableBenchmarkBuildCatalogEntry[] => {
if (isRomClassId(classId)) return partyRolesForClass(classId).map((role) => ({
id: benchmarkBuildId(classId, role, safeLevel),
source: contentCategoryForClass(classId),
classId,
role,
orientation: "Primary",
level: safeLevel,
}));
return specializationsForClass(classId).map((specialization) => ({
id: benchmarkVariantBuildId(classId, specialization.role, specialization.displayName, safeLevel),
source: contentCategoryForClass(classId),
classId,
role: specialization.role,
orientation: specialization.displayName,
level: safeLevel,
}));
});
}
/**
* Builds every user-selectable class/spec/role variant for the balance lab.
* This is intentionally broader than the canonical benchmark cohort above,
* which keeps one representative orientation per class-role pair.
*/
export async function loadSelectableBenchmarkBuilds(
level = CANONICAL_BENCHMARK_LEVEL,
classIds?: readonly ClassId[],
): Promise<BenchmarkBuildDefinition[]> {
const safeLevel = clampPlayerLevel(level);
const requested = classIds ? new Set(classIds) : null;
const selectedClassIds = CLASSES
.map((definition) => definition.id)
.filter((classId) => !requested || requested.has(classId));
const catalogClassIds = selectedClassIds.filter((classId) => (
isCoaClassId(classId)
|| (isBaseClassId(classId) && specializationsForClass(classId).some((specialization) => {
try {
runtimePartyTalentRanksForLevel(classId, specialization.displayName, safeLevel);
return false;
} catch {
return true;
}
}))
));
await preloadAbilityCatalogsForClasses(selectedClassIds);
const catalogs = new Map<ClassId, TalentUiCatalog>(await Promise.all(
catalogClassIds.map(async (classId) => [
classId,
await loadTalentUiCatalog(classId),
] as const),
));
const builds: BenchmarkBuildDefinition[] = [];
for (const classId of selectedClassIds) {
if (isRomClassId(classId)) {
for (const role of partyRolesForClass(classId)) builds.push(romBuild(classId, role, safeLevel));
continue;
}
for (const specialization of specializationsForClass(classId)) {
if (isBaseClassId(classId)) {
builds.push(selectableClassicBuild(specialization, safeLevel, catalogs.get(classId) ?? null));
} else if (isCoaClassId(classId)) {
const catalog = catalogs.get(classId);
if (!catalog) throw new Error(`No loaded CoA talent catalog exists for ${classId}.`);
builds.push(selectableCoaBuild(specialization, safeLevel, catalog));
}
}
}
return builds;
}
/**
* Creates one stable, legal benchmark build for every declared class-role pair.
* A filter changes only membership; output always follows the canonical CLASSES
+32
View File
@@ -0,0 +1,32 @@
import { describe, expect, it } from "vitest";
import { estimateBossPartyPressure, rankBossPartyPressure } from "./bossPressure";
import { DUNGEON_DEFINITIONS } from "./dungeonRegistry";
describe("boss party-pressure audit", () => {
it("produces a finite deterministic ranking for every WoW boss", () => {
const definitions = DUNGEON_DEFINITIONS.filter((dungeon) => dungeon.expansion !== "runewaker");
const first = rankBossPartyPressure(definitions);
const second = rankBossPartyPressure(definitions);
expect(first.length).toBeGreaterThan(200);
expect(second).toEqual(first);
expect(first.every((entry) => Number.isFinite(entry.partyDamagePerSecond)
&& entry.partyDamagePerSecond >= 0)).toBe(true);
expect(first.map((entry) => entry.partyDamage)).toEqual(
[...first.map((entry) => entry.partyDamage)].sort((left, right) => right - left),
);
});
it("counts grouped effects across the party and excludes self-only utility", () => {
const gundrak = DUNGEON_DEFINITIONS.find((dungeon) => dungeon.id === "gundrak")!;
const eckId = Object.entries(gundrak.entities)
.find(([, entity]) => entity.combat?.serverEntry === 29_932)![0];
const estimate = estimateBossPartyPressure(gundrak, eckId);
expect(estimate.boss).toBe("Eck the Ferocious");
expect(estimate.attacks.find((attack) => attack.spellId === 55_837)?.partyDamage).toBe(0);
expect(estimate.attacks.find((attack) => attack.spellId === 55_816)?.partyDamage).toBe(0);
expect(estimate.attacks.find((attack) => attack.spellId === 55_815)).toMatchObject({
target: "random-party",
targetsPerCast: 1,
});
});
});
+151
View File
@@ -0,0 +1,151 @@
import { creatureRuntimeBaseStats } from "./azerothCoreReference";
import { scaledDungeonEnemyLevel } from "./dungeonLevelScaling";
import type { DungeonDefinition } from "./dungeonTypes";
import type {
EnemyAbilityEffect,
EnemyAttackDefinition,
EnemyAtomicAbilityEffect,
} from "./enemyAttacks";
import type { PopulationEntityDefinition } from "./mobPopulation";
export const BOSS_PRESSURE_WINDOW_MS = 60_000;
export const BOSS_PRESSURE_PARTY_SIZE = 5;
export interface BossAttackPressureEstimate {
readonly id: string;
readonly name: string;
readonly spellId?: number;
readonly target: EnemyAttackDefinition["target"];
readonly school: EnemyAttackDefinition["school"];
readonly expectedCasts: number;
readonly targetsPerCast: number;
readonly damagePerTarget: number;
readonly partyDamage: number;
}
export interface BossPartyPressureEstimate {
readonly key: string;
readonly dungeonId: string;
readonly dungeonTitle: string;
readonly boss: string;
readonly serverEntry: number | null;
readonly playerLevel: number;
readonly enemyLevel: number;
readonly windowSeconds: number;
readonly attackDamage: number;
readonly partyDamage: number;
readonly partyDamagePerSecond: number;
readonly tankDamage: number;
readonly randomTargetDamage: number;
readonly groupwideDamage: number;
readonly attacks: readonly BossAttackPressureEstimate[];
}
function midpoint(range: readonly [number, number] | undefined, fallback: number): number {
if (!range) return fallback;
return (Math.max(0, range[0]) + Math.max(0, range[1])) / 2;
}
function expectedCasts(attack: EnemyAttackDefinition, windowMs: number): number {
const initial = midpoint(attack.initialCooldownMs, 450);
if (windowMs < initial) return 0;
const repeat = Math.max(1, midpoint(attack.repeatCooldownMs, attack.cooldownMs));
const casts = 1 + (windowMs - initial) / repeat;
return Math.min(attack.maximumCasts ?? Number.POSITIVE_INFINITY, casts);
}
function atomicDamageMultiplier(effect: EnemyAtomicAbilityEffect): number {
if (effect.kind === "damage") return Math.max(0, effect.multiplier);
if (effect.kind === "leech") return Math.max(0, effect.damageMultiplier);
if (effect.kind === "control") return Math.max(0, effect.damageMultiplier ?? 0);
return 0;
}
function damageMultiplier(attack: EnemyAttackDefinition, effect: EnemyAbilityEffect | undefined): number {
if (!effect) return Math.max(0, attack.damageMultiplier);
if (effect.kind === "composite") {
return effect.effects.reduce((total, entry) => total + atomicDamageMultiplier(entry), 0);
}
return atomicDamageMultiplier(effect);
}
function targetsPerCast(attack: EnemyAttackDefinition, partySize: number): number {
if (attack.target === "nearby-party") return partySize;
if (attack.target === "primary" || attack.target === "random-party") return 1;
return 0;
}
/**
* Comparable direct-damage ceiling for one boss with a grouped party. It uses
* live attack definitions and source creature stats, but deliberately excludes
* armor/resistance, avoidable movement, summons, healing, and mutually exclusive
* phase uptime. Those omissions keep the audit deterministic and make inflated
* schedules or accidental party-wide targeting obvious.
*/
export function estimateBossPartyPressure(
dungeon: DungeonDefinition,
entityId: string,
playerLevel = 80,
windowMs = BOSS_PRESSURE_WINDOW_MS,
partySize = BOSS_PRESSURE_PARTY_SIZE,
): BossPartyPressureEstimate {
const entity: PopulationEntityDefinition | undefined = dungeon.entities[entityId];
if (!entity || entity.kind !== "boss") throw new Error(`${dungeon.id}:${entityId} is not a boss`);
const enemyLevel = scaledDungeonEnemyLevel(playerLevel, true);
const serverEntry = entity.combat?.serverEntry ?? null;
const baseDamage = serverEntry
? creatureRuntimeBaseStats(serverEntry, enemyLevel)?.averageAttackDamage
: undefined;
const attackDamage = (baseDamage ?? (12 + enemyLevel * 2)) * (entity.combat?.damageMultiplier ?? 1);
const attacks = (entity.combat?.attacks ?? []).map((attack): BossAttackPressureEstimate => {
const casts = expectedCasts(attack, windowMs);
const targets = targetsPerCast(attack, partySize);
const perTarget = attackDamage * damageMultiplier(attack, attack.effect);
return {
id: attack.id,
name: attack.name,
...(attack.spellId === undefined ? {} : { spellId: attack.spellId }),
target: attack.target,
school: attack.school,
expectedCasts: casts,
targetsPerCast: targets,
damagePerTarget: perTarget,
partyDamage: casts * targets * perTarget,
};
});
const sum = (target: EnemyAttackDefinition["target"]): number => attacks
.filter((attack) => attack.target === target)
.reduce((total, attack) => total + attack.partyDamage, 0);
const partyDamage = attacks.reduce((total, attack) => total + attack.partyDamage, 0);
return {
key: `${dungeon.id}/${entityId}`,
dungeonId: dungeon.id,
dungeonTitle: dungeon.title,
boss: entity.name,
serverEntry,
playerLevel,
enemyLevel,
windowSeconds: windowMs / 1_000,
attackDamage,
partyDamage,
partyDamagePerSecond: partyDamage / Math.max(1, windowMs / 1_000),
tankDamage: sum("primary"),
randomTargetDamage: sum("random-party"),
groupwideDamage: sum("nearby-party"),
attacks,
};
}
export function rankBossPartyPressure(
definitions: readonly DungeonDefinition[],
playerLevel = 80,
windowMs = BOSS_PRESSURE_WINDOW_MS,
partySize = BOSS_PRESSURE_PARTY_SIZE,
): readonly BossPartyPressureEstimate[] {
return definitions.flatMap((dungeon) => Object.entries(dungeon.entities)
.filter(([, entity]) => entity.kind === "boss")
.map(([entityId]) => estimateBossPartyPressure(dungeon, entityId, playerLevel, windowMs, partySize)))
.sort((left, right) => right.partyDamage - left.partyDamage
|| left.boss.localeCompare(right.boss)
|| left.dungeonId.localeCompare(right.dungeonId));
}
+3 -1
View File
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, it } from "vitest";
import { buildBossTestingCatalog, BOSS_TESTING_CATALOG, createBossTestingRoom, randomTestingBoss } from "./bossTesting";
import { anotherRandomTestingBoss, buildBossTestingCatalog, BOSS_TESTING_CATALOG, createBossTestingRoom, randomTestingBoss } from "./bossTesting";
import { clearBossTestingDungeons, DUNGEON_DEFINITIONS, registerBossTestingDungeon, requireDungeonDefinition } from "./dungeonRegistry";
import { activateDungeonSession, restartActiveDungeonSession } from "./dungeonSession";
import { useGameStore } from "./store";
@@ -138,5 +138,7 @@ describe("Boss Testing", () => {
expect(randomTestingBoss(candidates, () => 0)).toBe(candidates[0]);
expect(randomTestingBoss(candidates, () => 1)).toBe(candidates[1]);
expect(randomTestingBoss(candidates, () => Number.NaN)).toBe(candidates[0]);
expect(anotherRandomTestingBoss(candidates, candidates[0].key, () => 0)).toBe(candidates[1]);
expect(anotherRandomTestingBoss([candidates[0]], candidates[0].key)).toBeNull();
});
});
+8
View File
@@ -42,6 +42,14 @@ export function randomTestingBoss(bosses: readonly TestableBoss[], random: () =>
return bosses[index];
}
export function anotherRandomTestingBoss(
bosses: readonly TestableBoss[],
currentKey: string,
random: () => number = Math.random,
): TestableBoss | null {
return randomTestingBoss(bosses.filter((boss) => boss.key !== currentKey), random);
}
function references(value: unknown, ids: ReadonlySet<string>): boolean {
if (typeof value === "string") return ids.has(value);
if (Array.isArray(value)) return value.some((entry) => references(entry, ids));
+10 -5
View File
@@ -1,5 +1,6 @@
import { create } from "zustand";
import { combatNow } from "./combatClock";
import { useDungeonSimulationStore } from "./dungeonSimulationStore";
import { useGameStore } from "./store";
import {
baseClassFor,
@@ -2633,7 +2634,7 @@ function damageMobInWork(
const health = mob.health - applied;
const killed = health <= 0;
const canPlayWoundAnimation = !killed && now >= mob.nextWoundAnimationAt;
const testing = Boolean(useGameStore.getState().bossTest);
const testing = useGameStore.getState().gmTestMode;
const canAward = killed && !mob.experienceGranted && !testing;
const beforeLevel = work.progression.level;
const pendingLoot = testing ? [] : killed && mob.lootSourceId
@@ -3365,7 +3366,7 @@ function combatActorSnapshots(state: CombatState): Readonly<Record<string, Comba
state.lastResourceSpentAt[id as ResourceType] ?? 0,
)]),
);
actors[PLAYER_AURA_ENTITY_ID] = {
if (useDungeonSimulationStore.getState().sessionMode === "play") actors[PLAYER_AURA_ENTITY_ID] = {
id: PLAYER_AURA_ENTITY_ID,
threatActorId: PLAYER_AURA_ENTITY_ID,
kind: "player",
@@ -4168,12 +4169,13 @@ export const useCombatStore = create<CombatState>((set, get) => ({
get().synchronizeActors();
const state = get();
const partyMembers = usePartyStore.getState().members;
const includePlayer = useDungeonSimulationStore.getState().sessionMode === "play";
const sources = [
{
...(includePlayer ? [{
id: PLAYER_AURA_ENTITY_ID,
level: state.level,
abilities: state.abilities,
},
}] : []),
...partyMembers.filter((member) => member.health > 0).map((member) => ({
id: member.id,
level: member.level,
@@ -4200,7 +4202,10 @@ export const useCombatStore = create<CombatState>((set, get) => ({
selected.set(family, { sourceId: source.id, ability, effects, potency });
}
}
const recipients = [PLAYER_AURA_ENTITY_ID, ...partyMembers.filter((member) => member.health > 0).map((member) => member.id)];
const recipients = [
...(includePlayer ? [PLAYER_AURA_ENTITY_ID] : []),
...partyMembers.filter((member) => member.health > 0).map((member) => member.id),
];
for (const [family, buff] of selected) {
for (const recipientId of recipients) {
for (const effect of buff.effects) {
+143
View File
@@ -0,0 +1,143 @@
import { dispatchDungeonWorldObjectInteraction } from "./dungeonWorldInteraction";
import { requireDungeonDefinition } from "./dungeonRegistry";
import { useDungeonRouteStore } from "./dungeonRouteStore";
import {
dungeonRouteDefinition,
dungeonRouteLeg,
} from "./dungeonRoutes";
import { useDungeonSimulationStore } from "./dungeonSimulationStore";
import type { DungeonRouteDefinitionV1, DungeonRouteLeg } from "./dungeonSimulationTypes";
import type { Vector3Tuple } from "./dungeonTypes";
export interface ActiveDungeonRouteTarget {
readonly route: DungeonRouteDefinitionV1;
readonly leg: DungeonRouteLeg;
readonly checkpointIndex: number;
readonly target: Vector3Tuple;
readonly remaining: readonly Vector3Tuple[];
}
let activeDungeonId: string | null = null;
let activeObjectiveId: string | null = null;
let checkpointIndex = 0;
const completedActions = new Set<string>();
function distance(left: Vector3Tuple, right: Vector3Tuple): number {
return Math.hypot(right[0] - left[0], right[1] - left[1], right[2] - left[2]);
}
export function resetDungeonRouteRuntime(): void {
activeDungeonId = null;
activeObjectiveId = null;
checkpointIndex = 0;
completedActions.clear();
}
export function routeForActiveDungeon(dungeonId: string): DungeonRouteDefinitionV1 | null {
const route = dungeonRouteDefinition(dungeonId);
if (!route) return null;
const simulation = useDungeonSimulationStore.getState();
if (simulation.sessionMode === "spectate-live") return route;
return useDungeonRouteStore.getState().isApproved(route) ? route : null;
}
function executeReachedActions(
dungeonId: string,
leg: DungeonRouteLeg,
reachedIndex: number,
actorId: string | undefined,
now: number,
): void {
const definition = requireDungeonDefinition(dungeonId);
for (const action of leg.actions) {
if (action.checkpointIndex > reachedIndex || completedActions.has(action.id)) continue;
const object = definition.serverGameObjects.find((candidate) => candidate.id === action.objectId);
if (!object?.interaction) {
useDungeonSimulationStore.getState().addFinding({
id: `invalid-action:${leg.id}:${action.id}`,
atMs: useDungeonSimulationStore.getState().elapsedMs,
severity: "critical",
code: "invalid-required-interaction",
message: `Route action ${action.id} references unavailable object ${action.objectId}.`,
objectiveId: leg.objectiveId,
checkpointIndex: action.checkpointIndex,
});
completedActions.add(action.id);
continue;
}
dispatchDungeonWorldObjectInteraction(object, actorId, now);
if (useDungeonSimulationStore.getState().sessionMode === "spectate-live") {
useDungeonSimulationStore.getState().addFinding({
id: `route-action:${leg.id}:${action.id}`,
atMs: useDungeonSimulationStore.getState().elapsedMs,
severity: "info",
code: "route-action",
message: `Performed required route action ${action.id}.`,
objectiveId: leg.objectiveId,
checkpointIndex: action.checkpointIndex,
actionId: action.id,
});
}
completedActions.add(action.id);
}
}
function advanceNavigationOnlyObjective(route: DungeonRouteDefinitionV1, leg: DungeonRouteLeg): void {
const simulation = useDungeonSimulationStore.getState();
if (simulation.sessionMode !== "spectate-live" || simulation.mode !== "navigation-only") return;
const index = route.legs.findIndex((candidate) => candidate.id === leg.id);
const next = route.legs[index + 1] ?? null;
simulation.setRouteProgress(next?.objectiveId ?? null, 0);
activeObjectiveId = next?.objectiveId ?? null;
checkpointIndex = 0;
}
export function activeDungeonRouteTarget(
dungeonId: string,
objectiveId: string | null,
position: Vector3Tuple,
actorId: string | undefined,
now: number,
): ActiveDungeonRouteTarget | null {
const route = routeForActiveDungeon(dungeonId);
const simulation = useDungeonSimulationStore.getState();
const routedObjectiveId = simulation.sessionMode === "spectate-live" && simulation.mode === "navigation-only"
? simulation.objectiveId
: objectiveId;
const leg = dungeonRouteLeg(route, routedObjectiveId);
if (!route || !leg || !leg.checkpoints.length) return null;
if (activeDungeonId !== dungeonId || activeObjectiveId !== leg.objectiveId) {
activeDungeonId = dungeonId;
activeObjectiveId = leg.objectiveId;
checkpointIndex = 0;
}
while (
checkpointIndex < leg.checkpoints.length
&& distance(position, leg.checkpoints[checkpointIndex]) <= 1.25
) {
executeReachedActions(dungeonId, leg, checkpointIndex, actorId, now);
checkpointIndex += 1;
}
if (checkpointIndex >= leg.checkpoints.length) {
advanceNavigationOnlyObjective(route, leg);
return null;
}
if (simulation.sessionMode === "spectate-live") {
simulation.setRouteProgress(leg.objectiveId, checkpointIndex);
}
return {
route,
leg,
checkpointIndex,
target: leg.checkpoints[checkpointIndex],
remaining: leg.checkpoints.slice(checkpointIndex),
};
}
export function activeDungeonRouteProgress(): {
readonly dungeonId: string | null;
readonly objectiveId: string | null;
readonly checkpointIndex: number;
} {
return { dungeonId: activeDungeonId, objectiveId: activeObjectiveId, checkpointIndex };
}
+35
View File
@@ -0,0 +1,35 @@
import { create } from "zustand";
import { fetchDungeonRouteApprovals } from "../app/dungeonSimulationClient";
import type { DungeonRouteApproval, DungeonRouteDefinitionV1 } from "./dungeonSimulationTypes";
import {
cacheDungeonRouteApprovals,
readCachedDungeonRouteApprovals,
routeHasExactApproval,
} from "./dungeonRoutes";
export interface DungeonRouteState {
approvals: readonly DungeonRouteApproval[];
loading: boolean;
refreshedAt: number | null;
refresh: () => Promise<void>;
isApproved: (route: DungeonRouteDefinitionV1 | null) => boolean;
}
export const useDungeonRouteStore = create<DungeonRouteState>((set, get) => ({
approvals: readCachedDungeonRouteApprovals(),
loading: false,
refreshedAt: null,
refresh: async () => {
if (get().loading) return;
set({ loading: true });
try {
const approvals = await fetchDungeonRouteApprovals();
cacheDungeonRouteApprovals(approvals);
set({ approvals, refreshedAt: Date.now(), loading: false });
} catch {
// An exact-hash cached approval is intentionally usable during outages.
set({ loading: false });
}
},
isApproved: (route) => routeHasExactApproval(route, get().approvals),
}));
+108
View File
@@ -0,0 +1,108 @@
import rawCatalog from "./generated/dungeonRouteCatalog.generated.json";
import type {
DungeonRouteApproval,
DungeonRouteCatalogV1,
DungeonRouteDefinitionV1,
DungeonRouteLeg,
} from "./dungeonSimulationTypes";
import type { Vector3Tuple } from "./dungeonTypes";
export const DUNGEON_ROUTE_CATALOG = rawCatalog as unknown as DungeonRouteCatalogV1;
export const DUNGEON_ROUTE_APPROVAL_CACHE_KEY = "healer-man.dungeon-route-approvals.v1";
const routesByDungeon = new Map(
DUNGEON_ROUTE_CATALOG.routes.map((route) => [route.dungeonId, route] as const),
);
export function dungeonRouteDefinition(dungeonId: string, routeId = "primary"): DungeonRouteDefinitionV1 | null {
const route = routesByDungeon.get(dungeonId) ?? null;
return route?.id === routeId ? route : null;
}
export function dungeonRouteLeg(
route: DungeonRouteDefinitionV1 | null,
objectiveId: string | null,
): DungeonRouteLeg | null {
if (!route || !objectiveId) return null;
return route.legs.find((leg) => leg.objectiveId === objectiveId) ?? null;
}
export function routeDistance(points: readonly Vector3Tuple[]): number {
let total = 0;
for (let index = 1; index < points.length; index += 1) {
total += Math.hypot(
points[index][0] - points[index - 1][0],
points[index][1] - points[index - 1][1],
points[index][2] - points[index - 1][2],
);
}
return total;
}
export function routePointDistanceSquared(point: Vector3Tuple, start: Vector3Tuple, end: Vector3Tuple): {
readonly planarSquared: number;
readonly vertical: number;
readonly progress: number;
} {
const dx = end[0] - start[0];
const dz = end[2] - start[2];
const lengthSquared = dx * dx + dz * dz;
const progress = lengthSquared > 1e-9
? Math.max(0, Math.min(1, ((point[0] - start[0]) * dx + (point[2] - start[2]) * dz) / lengthSquared))
: 0;
const projectedX = start[0] + dx * progress;
const projectedY = start[1] + (end[1] - start[1]) * progress;
const projectedZ = start[2] + dz * progress;
return {
planarSquared: (point[0] - projectedX) ** 2 + (point[2] - projectedZ) ** 2,
vertical: Math.abs(point[1] - projectedY),
progress,
};
}
export function positionAlongRouteCorridor(
point: Vector3Tuple,
checkpoints: readonly Vector3Tuple[],
planarLimit = 10,
verticalLimit = 4,
): boolean {
for (let index = 1; index < checkpoints.length; index += 1) {
const distance = routePointDistanceSquared(point, checkpoints[index - 1], checkpoints[index]);
if (distance.planarSquared <= planarLimit ** 2 && distance.vertical <= verticalLimit) return true;
}
return checkpoints.length === 1
&& Math.hypot(point[0] - checkpoints[0][0], point[2] - checkpoints[0][2]) <= planarLimit
&& Math.abs(point[1] - checkpoints[0][1]) <= verticalLimit;
}
export function readCachedDungeonRouteApprovals(): readonly DungeonRouteApproval[] {
if (typeof localStorage === "undefined") return [];
try {
const parsed = JSON.parse(localStorage.getItem(DUNGEON_ROUTE_APPROVAL_CACHE_KEY) ?? "null");
if (!Array.isArray(parsed?.approvals)) return [];
return parsed.approvals.filter((approval: unknown): approval is DungeonRouteApproval => {
if (!approval || typeof approval !== "object") return false;
const value = approval as Partial<DungeonRouteApproval>;
return typeof value.dungeonId === "string" && typeof value.routeId === "string"
&& /^[a-f0-9]{64}$/.test(value.routeHash ?? "") && Number.isFinite(value.reviewedAt);
});
} catch {
return [];
}
}
export function cacheDungeonRouteApprovals(approvals: readonly DungeonRouteApproval[]): void {
if (typeof localStorage === "undefined") return;
localStorage.setItem(DUNGEON_ROUTE_APPROVAL_CACHE_KEY, JSON.stringify({ approvals }));
}
export function routeHasExactApproval(
route: DungeonRouteDefinitionV1 | null,
approvals: readonly DungeonRouteApproval[],
): boolean {
return Boolean(route?.generationStatus === "Candidate" && approvals.some((approval) => (
approval.dungeonId === route.dungeonId
&& approval.routeId === route.id
&& approval.routeHash === route.hash
)));
}
+9 -1
View File
@@ -21,12 +21,16 @@ import { dungeonContentIsUsable } from "../content/contentManager";
import { useDungeonRunStore } from "./dungeonRunStore";
import type { DungeonRunProgress } from "./dungeonRunTypes";
import { validBossDifficulty } from "./dungeonDifficulty";
import { resetDungeonRouteRuntime } from "./dungeonRouteRuntime";
import { useDungeonSimulationStore } from "./dungeonSimulationStore";
import { dungeonRouteDefinition } from "./dungeonRoutes";
export interface DungeonSessionOptions {
readonly bossDifficulty?: number;
readonly gameMode?: GameMode;
readonly spawn?: WorldSpawn;
readonly gmTestMode?: boolean;
readonly dungeonSimulation?: boolean;
readonly bossTest?: BossTestSession | null;
readonly gmReviewTarget?: GmReviewTarget | null;
/** Used by in-game encounter transitions so character progression and inventory survive. */
@@ -50,7 +54,8 @@ export function activateDungeonSession(
const definition = dungeonDefinitionById(dungeonId);
const testingSource = bossTestingSourceDungeonId(dungeonId);
const testing = Boolean(testingSource && options.gmTestMode && options.bossTest?.sourceDungeonId === testingSource);
if (!definition || (!testing && !dungeonCanEnter(dungeonId))
const dungeonSimulation = Boolean(options.dungeonSimulation && options.gmTestMode && dungeonRouteDefinition(dungeonId));
if (!definition || (!testing && !dungeonSimulation && !dungeonCanEnter(dungeonId))
|| !dungeonContentIsUsable(testing ? testingSource! : dungeonId)) {
return false;
}
@@ -72,6 +77,8 @@ export function activateDungeonSession(
resetDungeonEncounters("dungeon", dungeonId);
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
resetDungeonRouteRuntime();
useDungeonSimulationStore.getState().leave();
clearDungeonRuntimeCaches();
resetPartyRuntimeClock();
if (options.preserveCharacter) {
@@ -97,6 +104,7 @@ export function activateDungeonSession(
});
usePartyStore.setState({
seed: null,
spectatorParty: false,
members: [],
command: "defend",
selectedMemberId: null,
+70
View File
@@ -0,0 +1,70 @@
import { beforeEach, describe, expect, it } from "vitest";
import {
DUNGEON_SIMULATION_FIXED_STEP_MS,
fixedSimulationTickCount,
useDungeonSimulationStore,
} from "./dungeonSimulationStore";
import { createSpectatorDungeonParty } from "./partyStore";
import { routeHasExactApproval } from "./dungeonRoutes";
import type { DungeonRouteDefinitionV1, DungeonSimulationSpeed } from "./dungeonSimulationTypes";
const route: DungeonRouteDefinitionV1 = {
schemaVersion: 1,
id: "primary",
dungeonId: "test-dungeon",
mapId: 1,
hash: "a".repeat(64),
generationStatus: "Candidate",
generationIssues: [],
legs: [{
id: "leg-1",
objectiveId: "boss-1",
objectiveName: "Boss",
checkpoints: [[0, 0, 0], [4, 0, 0]],
expectedDistance: 4,
corridorPlanarDistance: 10,
corridorVerticalDistance: 4,
actions: [],
}],
};
describe("dungeon simulation fixed clock", () => {
beforeEach(() => useDungeonSimulationStore.getState().leave());
it("produces the same ordered ticks at every playback speed", () => {
const speeds: readonly DungeonSimulationSpeed[] = [0.5, 1, 2, 4];
for (const speed of speeds) {
const wallDuration = 500 / speed;
const result = fixedSimulationTickCount(0, wallDuration, speed);
expect(result.ticks).toBe(500 / DUNGEON_SIMULATION_FIXED_STEP_MS);
expect(result.accumulatorMs).toBe(0);
}
});
it("freezes while paused and advances exactly one tick per step", () => {
const store = useDungeonSimulationStore.getState();
store.startLive({ dungeonId: route.dungeonId, route, mode: "navigation-only", seed: "clock", startedAt: 10_000 });
store.setRunning(false);
expect(useDungeonSimulationStore.getState().consumeTicks(5_000)).toEqual([]);
expect(useDungeonSimulationStore.getState().elapsedMs).toBe(0);
useDungeonSimulationStore.getState().step();
expect(useDungeonSimulationStore.getState().consumeTicks(0)).toEqual([10_050]);
expect(useDungeonSimulationStore.getState().elapsedMs).toBe(50);
});
});
describe("spectator roster and exact-hash approvals", () => {
it("creates a stable tank, healer, and three damage dealers", () => {
const first = createSpectatorDungeonParty("same-seed", 80, "wow");
const second = createSpectatorDungeonParty("same-seed", 80, "wow");
expect(second).toEqual(first);
expect(first.map((member) => member.role)).toEqual(["tank", "healer", "damage", "damage", "damage"]);
});
it("rejects stale hashes and generator-rejected routes", () => {
const approval = { dungeonId: route.dungeonId, routeId: route.id, routeHash: route.hash, reviewedAt: 1 };
expect(routeHasExactApproval(route, [approval])).toBe(true);
expect(routeHasExactApproval({ ...route, hash: "b".repeat(64) }, [approval])).toBe(false);
expect(routeHasExactApproval({ ...route, generationStatus: "Rejected" }, [approval])).toBe(false);
});
});
+274
View File
@@ -0,0 +1,274 @@
import { create } from "zustand";
import type {
DungeonReplayFrame,
DungeonReplayV1,
DungeonRouteDefinitionV1,
DungeonSessionMode,
DungeonSimulationFinding,
DungeonSimulationMode,
DungeonSimulationPull,
DungeonSimulationReportV1,
DungeonSimulationResult,
DungeonSimulationSpeed,
DungeonSpectatorCameraMode,
} from "./dungeonSimulationTypes";
export const DUNGEON_SIMULATION_FIXED_STEP_MS = 50;
export const DUNGEON_REPLAY_SAMPLE_MS = 100;
export const DUNGEON_REPLAY_KEYFRAME_MS = 5_000;
export const DUNGEON_SIMULATION_MAX_DURATION_MS = 2 * 60 * 60 * 1_000;
const MAX_TICKS_PER_ADVANCE = 80;
export interface StartDungeonSimulationOptions {
readonly dungeonId: string;
readonly route: DungeonRouteDefinitionV1;
readonly mode: DungeonSimulationMode;
readonly seed: string;
readonly startedAt?: number;
}
export interface DungeonSimulationState {
sessionMode: DungeonSessionMode;
mode: DungeonSimulationMode;
runState: "idle" | "running" | "paused" | "finished";
speed: DungeonSimulationSpeed;
cameraMode: DungeonSpectatorCameraMode;
dungeonId: string | null;
routeId: string | null;
routeHash: string | null;
seed: string;
startedAt: number;
elapsedMs: number;
accumulatorMs: number;
pendingSteps: number;
objectiveId: string | null;
checkpointIndex: number;
result: DungeonSimulationResult | null;
findings: readonly DungeonSimulationFinding[];
pulls: readonly DungeonSimulationPull[];
recoveries: number;
frames: readonly DungeonReplayFrame[];
replay: DungeonReplayV1 | null;
startLive: (options: StartDungeonSimulationOptions) => void;
startReplay: (replay: DungeonReplayV1) => void;
leave: () => void;
setRunning: (running: boolean) => void;
toggleRunning: () => void;
setSpeed: (speed: DungeonSimulationSpeed) => void;
setCameraMode: (mode: DungeonSpectatorCameraMode) => void;
step: () => void;
seekReplay: (elapsedMs: number) => void;
consumeTicks: (wallDeltaMs: number) => readonly number[];
setRouteProgress: (objectiveId: string | null, checkpointIndex: number) => void;
addFinding: (finding: DungeonSimulationFinding) => void;
addPull: (pull: DungeonSimulationPull) => void;
addRecovery: () => void;
addFrame: (frame: DungeonReplayFrame) => void;
finish: (result: DungeonSimulationResult, roster: DungeonSimulationReportV1["roster"]) => DungeonReplayV1 | null;
}
const initialState = {
sessionMode: "play" as DungeonSessionMode,
mode: "full-combat" as DungeonSimulationMode,
runState: "idle" as const,
speed: 1 as DungeonSimulationSpeed,
cameraMode: "follow-leader" as DungeonSpectatorCameraMode,
dungeonId: null as string | null,
routeId: null as string | null,
routeHash: null as string | null,
seed: "",
startedAt: 0,
elapsedMs: 0,
accumulatorMs: 0,
pendingSteps: 0,
objectiveId: null as string | null,
checkpointIndex: 0,
result: null as DungeonSimulationResult | null,
findings: [] as readonly DungeonSimulationFinding[],
pulls: [] as readonly DungeonSimulationPull[],
recoveries: 0,
frames: [] as readonly DungeonReplayFrame[],
replay: null as DungeonReplayV1 | null,
};
function clampWallDelta(value: number): number {
return Math.max(0, Math.min(1_000, Number.isFinite(value) ? value : 0));
}
export function fixedSimulationTickCount(
accumulatorMs: number,
wallDeltaMs: number,
speed: DungeonSimulationSpeed,
): { readonly ticks: number; readonly accumulatorMs: number } {
const total = Math.max(0, accumulatorMs) + clampWallDelta(wallDeltaMs) * speed;
const ticks = Math.min(MAX_TICKS_PER_ADVANCE, Math.floor(total / DUNGEON_SIMULATION_FIXED_STEP_MS));
return { ticks, accumulatorMs: total - ticks * DUNGEON_SIMULATION_FIXED_STEP_MS };
}
export const useDungeonSimulationStore = create<DungeonSimulationState>((set, get) => ({
...initialState,
startLive: ({ dungeonId, route, mode, seed, startedAt = Date.now() }) => set({
...initialState,
sessionMode: "spectate-live",
mode,
runState: "running",
dungeonId,
routeId: route.id,
routeHash: route.hash,
seed,
startedAt,
objectiveId: route.legs[0]?.objectiveId ?? null,
}),
startReplay: (replay) => set({
...initialState,
sessionMode: "spectate-replay",
mode: replay.mode,
runState: "paused",
dungeonId: replay.dungeonId,
routeId: replay.routeId,
routeHash: replay.routeHash,
seed: replay.seed,
startedAt: replay.startedAt,
result: replay.result,
findings: replay.report.findings,
pulls: replay.report.pulls,
recoveries: replay.report.recoveries,
frames: replay.frames,
replay,
}),
leave: () => set(initialState),
setRunning: (running) => set((state) => state.sessionMode === "play" || state.runState === "finished"
? state
: { runState: running ? "running" : "paused", accumulatorMs: 0 }),
toggleRunning: () => {
const state = get();
state.setRunning(state.runState !== "running");
},
setSpeed: (speed) => set({ speed }),
setCameraMode: (cameraMode) => set({ cameraMode }),
step: () => set((state) => {
if (state.runState !== "paused") return state;
if (state.sessionMode === "spectate-replay" && state.replay) {
const next = state.replay.frames.find((frame) => frame.atMs > state.elapsedMs);
return { elapsedMs: next?.atMs ?? state.elapsedMs };
}
return { pendingSteps: state.pendingSteps + 1 };
}),
seekReplay: (elapsedMs) => set((state) => state.sessionMode !== "spectate-replay" || !state.replay
? state
: { elapsedMs: Math.max(0, Math.min(state.replay.durationMs, elapsedMs)), accumulatorMs: 0 }),
consumeTicks: (wallDeltaMs) => {
const state = get();
if (state.sessionMode === "play" || state.runState === "finished") return [];
if (state.sessionMode === "spectate-replay") {
if (state.runState !== "running" || !state.replay) return [];
const advanced = Math.min(state.replay.durationMs, state.elapsedMs + clampWallDelta(wallDeltaMs) * state.speed);
set({ elapsedMs: advanced, runState: advanced >= state.replay.durationMs ? "finished" : "running" });
return [];
}
let ticks = 0;
let accumulatorMs = state.accumulatorMs;
let pendingSteps = state.pendingSteps;
if (state.runState === "running") {
const advance = fixedSimulationTickCount(accumulatorMs, wallDeltaMs, state.speed);
ticks = advance.ticks;
accumulatorMs = advance.accumulatorMs;
} else if (state.runState === "paused" && pendingSteps > 0) {
ticks = 1;
pendingSteps -= 1;
accumulatorMs = 0;
}
const availableTicks = Math.max(0, Math.floor((DUNGEON_SIMULATION_MAX_DURATION_MS - state.elapsedMs) / DUNGEON_SIMULATION_FIXED_STEP_MS));
ticks = Math.min(ticks, availableTicks);
const elapsedMs = state.elapsedMs + ticks * DUNGEON_SIMULATION_FIXED_STEP_MS;
set({ elapsedMs, accumulatorMs, pendingSteps });
return Array.from({ length: ticks }, (_, index) => state.startedAt + state.elapsedMs + (index + 1) * DUNGEON_SIMULATION_FIXED_STEP_MS);
},
setRouteProgress: (objectiveId, checkpointIndex) => set((state) => {
const normalized = Math.max(0, Math.trunc(checkpointIndex));
return state.objectiveId === objectiveId && state.checkpointIndex === normalized
? state
: { objectiveId, checkpointIndex: normalized };
}),
addFinding: (finding) => set((state) => state.findings.some((candidate) => candidate.id === finding.id)
? state
: { findings: [...state.findings, finding] }),
addPull: (pull) => set((state) => ({ pulls: [...state.pulls, pull] })),
addRecovery: () => set((state) => ({ recoveries: state.recoveries + 1 })),
addFrame: (frame) => set((state) => {
if (state.frames[state.frames.length - 1]?.atMs === frame.atMs) return state;
return { frames: [...state.frames, frame] };
}),
finish: (result, roster) => {
const state = get();
if (state.sessionMode !== "spectate-live" || state.runState === "finished" || !state.dungeonId || !state.routeId || !state.routeHash) return null;
const objectiveIds = [...new Set(state.frames.map((frame) => frame.objectiveId).filter((id): id is string => Boolean(id)))];
const objectiveTimings = objectiveIds.map((objectiveId) => {
const frames = state.frames.filter((frame) => frame.objectiveId === objectiveId);
const laterObjective = state.frames.find((frame) => frame.atMs > (frames.at(-1)?.atMs ?? 0) && frame.objectiveId !== objectiveId);
return {
objectiveId,
startedAtMs: frames[0]?.atMs ?? 0,
completedAtMs: laterObjective?.atMs ?? (state.objectiveId === objectiveId ? null : frames.at(-1)?.atMs ?? null),
};
});
const pathDistances = objectiveIds.map((objectiveId) => {
const frames = state.frames.filter((frame) => frame.objectiveId === objectiveId);
let actualDistance = 0;
for (let index = 1; index < frames.length; index += 1) {
const previous = frames[index - 1].actors[0]?.position;
const current = frames[index].actors[0]?.position;
if (previous && current) actualDistance += Math.hypot(
current[0] - previous[0], current[1] - previous[1], current[2] - previous[2],
);
}
return { objectiveId, actualDistance };
});
const report: DungeonSimulationReportV1 = {
schemaVersion: 1,
dungeonId: state.dungeonId,
routeId: state.routeId,
routeHash: state.routeHash,
seed: state.seed,
mode: state.mode,
startedAt: state.startedAt,
durationMs: state.elapsedMs,
result,
roster,
findings: state.findings,
pulls: state.pulls,
recoveries: state.recoveries,
objectiveTimings,
actionTimings: state.findings.filter((finding) => finding.code === "route-action" && finding.actionId).map((finding) => ({
actionId: finding.actionId!,
objectiveId: finding.objectiveId ?? null,
atMs: finding.atMs,
})),
pathDistances,
finalObjectiveId: state.objectiveId,
finalCheckpointIndex: state.checkpointIndex,
finalState: state.frames.at(-1)?.actors ?? [],
};
const replay: DungeonReplayV1 = {
schemaVersion: 1,
dungeonId: state.dungeonId,
routeId: state.routeId,
routeHash: state.routeHash,
mode: state.mode,
seed: state.seed,
result,
startedAt: state.startedAt,
durationMs: state.elapsedMs,
report,
frames: state.frames,
events: state.findings,
};
set({ runState: "finished", result, replay });
return replay;
},
}));
export function dungeonSimulationNow(): number {
const state = useDungeonSimulationStore.getState();
return state.sessionMode === "spectate-live" ? state.startedAt + state.elapsedMs : Date.now();
}
+172
View File
@@ -0,0 +1,172 @@
import type { Vector3Tuple } from "./dungeonTypes";
export type DungeonSessionMode = "play" | "spectate-live" | "spectate-replay";
export type DungeonSimulationMode = "full-combat" | "navigation-only";
export type DungeonSimulationSpeed = 0.5 | 1 | 2 | 4;
export type DungeonSpectatorCameraMode = "follow-leader" | "free-fly";
export type DungeonRouteReviewStatus = "Candidate" | "Approved" | "NeedsWork";
export type DungeonSimulationResult = "completed" | "wiped" | "stopped" | "recording-limit";
export type DungeonSimulationFindingSeverity = "info" | "warning" | "critical";
export interface DungeonRouteAction {
readonly id: string;
readonly checkpointIndex: number;
readonly kind: "interact-world-object";
readonly objectId: string;
}
export interface DungeonRouteLeg {
readonly id: string;
readonly objectiveId: string;
readonly objectiveName: string;
readonly checkpoints: readonly Vector3Tuple[];
readonly expectedDistance: number;
readonly corridorPlanarDistance: number;
readonly corridorVerticalDistance: number;
readonly actions: readonly DungeonRouteAction[];
}
export interface DungeonRouteDefinitionV1 {
readonly schemaVersion: 1;
readonly id: string;
readonly dungeonId: string;
readonly mapId: number;
readonly hash: string;
readonly generationStatus: "Candidate" | "Rejected";
readonly generationIssues: readonly string[];
readonly legs: readonly DungeonRouteLeg[];
}
export interface DungeonRouteCatalogV1 {
readonly schemaVersion: 1;
readonly generatedAt: string;
readonly source: {
readonly kind: "playerbots-landmark-reference";
readonly auditReport: string;
readonly note: string;
};
readonly routes: readonly DungeonRouteDefinitionV1[];
}
export interface DungeonRouteApproval {
readonly dungeonId: string;
readonly routeId: string;
readonly routeHash: string;
readonly reviewedAt: number;
}
export interface DungeonRouteReview {
readonly dungeonId: string;
readonly routeId: string;
readonly routeHash: string;
readonly status: DungeonRouteReviewStatus;
readonly note: string | null;
readonly criticalOverrideNote: string | null;
readonly reviewedBy: string;
readonly reviewedAt: number;
readonly revision: number;
}
export interface DungeonSimulationFinding {
readonly id: string;
readonly atMs: number;
readonly severity: DungeonSimulationFindingSeverity;
readonly code: string;
readonly message: string;
readonly position?: Vector3Tuple;
readonly actorId?: string;
readonly objectiveId?: string;
readonly checkpointIndex?: number;
readonly actionId?: string;
}
export interface DungeonSimulationPull {
readonly atMs: number;
readonly mobIds: readonly string[];
readonly routeLegId: string | null;
readonly position: Vector3Tuple;
readonly classification: "expected" | "off-route" | "theoretical";
}
export interface DungeonSimulationActorSnapshot {
readonly id: string;
readonly position: Vector3Tuple;
readonly yaw: number;
readonly health: number;
readonly maxHealth: number;
readonly targetId: string | null;
readonly status: string;
}
export interface DungeonReplayFrame {
readonly atMs: number;
readonly keyframe: boolean;
readonly actors: readonly DungeonSimulationActorSnapshot[];
readonly objectiveId: string | null;
readonly checkpointIndex: number;
}
export interface DungeonSimulationReportV1 {
readonly schemaVersion: 1;
readonly dungeonId: string;
readonly routeId: string;
readonly routeHash: string;
readonly seed: string;
readonly mode: DungeonSimulationMode;
readonly startedAt: number;
readonly durationMs: number;
readonly result: DungeonSimulationResult;
readonly roster: readonly { readonly id: string; readonly name: string; readonly role: string; readonly classId: string }[];
readonly findings: readonly DungeonSimulationFinding[];
readonly pulls: readonly DungeonSimulationPull[];
readonly recoveries: number;
readonly objectiveTimings: readonly {
readonly objectiveId: string;
readonly startedAtMs: number;
readonly completedAtMs: number | null;
}[];
readonly actionTimings: readonly {
readonly actionId: string;
readonly objectiveId: string | null;
readonly atMs: number;
}[];
readonly pathDistances: readonly {
readonly objectiveId: string;
readonly actualDistance: number;
}[];
readonly finalObjectiveId: string | null;
readonly finalCheckpointIndex: number;
readonly finalState: readonly DungeonSimulationActorSnapshot[];
}
export interface DungeonReplayV1 {
readonly schemaVersion: 1;
readonly dungeonId: string;
readonly routeId: string;
readonly routeHash: string;
readonly mode: DungeonSimulationMode;
readonly seed: string;
readonly result: DungeonSimulationResult;
readonly startedAt: number;
readonly durationMs: number;
readonly report: DungeonSimulationReportV1;
readonly frames: readonly DungeonReplayFrame[];
readonly events: readonly DungeonSimulationFinding[];
}
export interface DungeonReplaySummary {
readonly id: string;
readonly dungeonId: string;
readonly routeId: string;
readonly routeHash: string;
readonly mode: DungeonSimulationMode;
readonly seed: string;
readonly result: DungeonSimulationResult;
readonly startedAt: number;
readonly durationMs: number;
readonly criticalFindings: number;
readonly submittedBy: string;
readonly createdAt: number;
readonly expiresAt: number;
readonly compressedBytes: number;
}
@@ -0,0 +1 @@
{"schemaVersion":1,"generatedAt":"2026-05-08T10:36:36-07:00","source":{"kind":"playerbots-landmark-reference","auditReport":"dungeon-pipeline/DUNGEON_ROUTE_GENERATION_AUDIT.json","note":"PlayerBots was used only as offline topology evidence. Shipped checkpoint geometry is computed from HealerMan navigation assets."},"routes":[{"schemaVersion":1,"id":"primary","dungeonId":"shadowfang-keep","mapId":33,"legs":[{"id":"leg-01-creature-16242","objectiveId":"creature-16242","objectiveName":"Rethilgore","checkpoints":[[2108.6162,76.9903,-228.5817],[2112.0329,76.9903,-228.419],[2111.7075,76.9903,-227.6055],[2109.4839,76.9903,-226.6293],[2111.816,76.9903,-225.4362],[2111.2736,76.9903,-223.9719],[2109.3755,76.9903,-223.1584],[2110.189,76.9903,-221.1518],[2108.7247,76.9903,-220.501],[2109.267,76.9903,-219.579],[2107.8569,76.9903,-218.4401],[2107.7485,76.9903,-216.4334],[2106.5553,76.9903,-215.1861],[2106.8265,76.9903,-213.3964],[2104.6572,76.9903,-213.5591],[2105.308,76.9903,-211.6067],[2103.2471,76.9903,-208.9492],[2100.5897,76.9903,-207.7019],[2100.21,76.9903,-204.1225],[2098.9627,76.9903,-203.4174],[2099.7762,76.9903,-202.2785],[2100.9693,76.9903,-202.4955],[2101.132,77.0445,-201.1939],[2101.7286,77.153,-201.7904],[2104.4945,78.7257,-201.682],[2105.308,78.6715,-200.2719],[2106.0672,78.6715,-200.6515],[2106.013,78.9969,-202.6582],[2108.0739,80.407,-205.0987],[2108.7789,80.5154,-205.0987],[2108.9958,81.0578,-206.4003],[2110.0805,81.112,-207.7561],[2111.0567,81.0578,-207.4307],[2112.5752,81.0578,-208.6781],[2115.3954,81.0578,-207.3222],[2117.2936,81.0578,-205.5868],[2114.365,81.0578,-205.4241],[2120.2764,81.2205,-204.2852],[2120.4391,81.3289,-203.4174],[2121.1441,81.4374,-203.2005],[2121.0899,81.4916,-202.1701],[2123.1508,81.5459,-201.9531],[2122.5542,81.5459,-199.9465],[2127.4352,81.5459,-198.8618],[2125.5913,81.5459,-196.5298],[2131.8823,81.5459,-196.0417],[2132.9128,81.5459,-194.957],[2127.7606,81.5459,-194.1977],[2132.5874,81.5459,-193.5469],[2127.7606,81.5459,-193.2215],[2127.8148,81.5459,-192.7334],[2134.2144,81.5459,-191.703],[2132.8043,81.5459,-191.1064],[2135.9499,81.6543,-190.9437],[2136.9803,82.034,-190.5641],[2137.0345,82.1967,-189.7506],[2137.6853,82.6848,-189.2083],[2138.7158,82.9559,-190.0218],[2139.4208,83.2271,-189.2083],[2141.2105,83.1729,-189.2625],[2140.8851,83.2271,-189.9133],[2141.5359,83.4983,-190.0218],[2141.102,83.6067,-190.7268],[2141.4274,84.6372,-192.625],[2143.3798,85.8303,-194.1435],[2143.0544,86.1557,-195.0112],[2143.651,86.5353,-196.1501],[2144.4645,86.5896,-196.0417],[2144.4645,86.6438,-198.8076],[2145.7661,86.8065,-199.8922],[2144.7899,87.0777,-200.3803],[2145.6034,87.4573,-201.1396],[2145.6034,87.8912,-202.0074],[2144.8441,88.3793,-202.6582],[2145.1153,89.0301,-203.8513],[2144.1933,89.7351,-204.7733],[2144.2476,90.0063,-205.8037],[2143.9764,90.2774,-206.346],[2143.3798,90.4401,-206.0749],[2143.1629,90.7655,-206.6714],[2142.6748,90.7113,-206.6172],[2143.4883,90.6571,-209.7627],[2142.6748,90.6571,-211.9863],[2144.356,90.7113,-215.5386],[2146.0373,90.7655,-219.0909],[2148.2066,90.9282,-219.0909],[2149.6167,91.0367,-221.6941],[2151.4064,90.9282,-226.087],[2149.1828,90.7113,-230.4257],[2151.4606,90.7113,-230.8053],[2150.5386,90.7113,-233.1373],[2148.3151,90.6028,-233.1373],[2149.0201,90.3859,-236.0659],[2148.0981,90.169,-234.8728],[2148.4235,90.0063,-236.554],[2147.2304,89.7893,-235.0897],[2145.9288,89.1928,-235.6863],[2144.9526,88.3793,-237.476],[2145.0611,88.1081,-239.2657],[2142.8375,87.2404,-239.7538],[2142.6206,87.2404,-240.5131],[2142.1325,87.2404,-240.3504],[2142.1325,87.0777,-243.7671],[2143.2714,87.1319,-245.8822],[2143.3798,87.1319,-247.5092],[2144.4645,87.1861,-248.9193],[2143.7052,87.1319,-248.4854],[2144.4645,87.1861,-249.4616],[2143.7052,87.2404,-250.0582],[2142.8917,87.1861,-250.2751],[2142.5663,87.0777,-249.4074],[2142.1325,86.915,-250.2751],[2141.4817,86.6438,-249.787],[2139.3123,85.3422,-251.1971],[2136.7634,84.1491,-251.5225],[2133.8348,82.5763,-252.8241],[2134.1059,82.5221,-253.3664],[2133.4009,82.0882,-253.6918],[2130.6892,81.2205,-253.6918],[2129.6588,81.2205,-254.5595],[2124.2897,81.2205,-254.1257],[2124.8862,81.2205,-253.041],[2121.3611,81.2205,-252.4444],[2126.3505,81.2205,-251.8479],[2124.3439,81.2205,-250.9259]],"expectedDistance":261.42,"corridorPlanarDistance":10,"corridorVerticalDistance":4,"actions":[]},{"id":"leg-02-creature-16254","objectiveId":"creature-16254","objectiveName":"Baron Silverlaine","checkpoints":[[2124.3439,81.2205,-250.9259],[2126.3505,81.2205,-251.8479],[2121.3611,81.2205,-252.4444],[2124.8862,81.2205,-253.041],[2124.2897,81.2205,-254.1257],[2129.6588,81.2205,-254.5595],[2130.6892,81.2205,-253.6918],[2133.4009,82.0882,-253.6918],[2134.1059,82.5221,-253.3664],[2133.8348,82.5763,-252.8241],[2136.7634,84.1491,-251.5225],[2139.3123,85.3422,-251.1971],[2141.4817,86.6438,-249.787],[2142.1325,86.915,-250.2751],[2142.5663,87.0777,-249.4074],[2142.8917,87.1861,-250.2751],[2143.7052,87.2404,-250.0582],[2144.4645,87.1861,-249.4616],[2143.7052,87.1319Line truncated
+36 -3
View File
@@ -12,6 +12,7 @@ import {
MAX_MOUSE_DELTA_PER_FRAME,
POINTER_LOCK_MOUSE_GRACE_MS,
beginMouseLookDrag,
clearBrowserSelection,
clearJumpRequest,
consumeJumpRequest,
consumeMouseLook,
@@ -31,9 +32,10 @@ import {
import { normalizeInputPreferences, replaceInputBinding } from "../input/keybinds";
import { setCapturingInput } from "../input/keybindRuntime";
function stubInputEventTargets(): void {
function stubInputEventTargets(selection: Pick<Selection, "removeAllRanges"> | null = null): void {
const windowTarget = new EventTarget();
const documentTarget = new EventTarget();
Object.defineProperty(windowTarget, "getSelection", { configurable: true, value: () => selection });
Object.defineProperty(documentTarget, "pointerLockElement", { configurable: true, value: null });
vi.stubGlobal("window", windowTarget);
vi.stubGlobal("document", documentTarget);
@@ -362,15 +364,19 @@ describe("gameplay input bindings", () => {
});
it("turns the camera from a right-mouse drag without pointer lock", () => {
stubInputEventTargets();
const selection = { removeAllRanges: vi.fn() };
stubInputEventTargets(selection);
const nextActions = inputActionSpies();
const dispose = installInput(nextActions);
beginMouseLookDrag(100, 80);
const move = new Event("mousemove") as MouseEvent;
expect(selection.removeAllRanges).toHaveBeenCalledOnce();
const move = new Event("pointermove") as PointerEvent;
Object.defineProperties(move, {
clientX: { value: 114 },
clientY: { value: 73 },
pointerType: { value: "mouse" },
movementX: { value: 9_999 },
movementY: { value: 9_999 },
});
@@ -387,6 +393,33 @@ describe("gameplay input bindings", () => {
dispose();
});
it("prevents page selection only while right-mouse camera drag is active", () => {
stubInputEventTargets();
const dispose = installInput(inputActionSpies());
const idleSelection = new Event("selectstart", { cancelable: true });
document.dispatchEvent(idleSelection);
expect(idleSelection.defaultPrevented).toBe(false);
beginMouseLookDrag(20, 30);
const cameraSelection = new Event("selectstart", { cancelable: true });
document.dispatchEvent(cameraSelection);
expect(cameraSelection.defaultPrevented).toBe(true);
endMouseLookDrag();
const releasedSelection = new Event("selectstart", { cancelable: true });
document.dispatchEvent(releasedSelection);
expect(releasedSelection.defaultPrevented).toBe(false);
dispose();
});
it("can clear an existing browser selection without a camera drag", () => {
const selection = { removeAllRanges: vi.fn() };
stubInputEventTargets(selection);
clearBrowserSelection();
expect(selection.removeAllRanges).toHaveBeenCalledOnce();
});
it("contains malformed and stale pointer-lock movement", () => {
expect(sanitizeMouseDelta(Number.NaN)).toBe(0);
expect(sanitizeMouseDelta(Number.POSITIVE_INFINITY)).toBe(0);
+27 -13
View File
@@ -131,11 +131,18 @@ const mouseLookBuffer = createMouseLookBuffer();
let mouseLookDragActive = false;
let previousMousePosition: { x: number; y: number } | null = null;
/** Clears browser text selection before gameplay takes ownership of a mouse drag. */
export function clearBrowserSelection(): void {
if (typeof window === "undefined") return;
window.getSelection?.()?.removeAllRanges();
}
/**
* Starts popup-free desktop camera look. Unlike Pointer Lock, this retains the
* normal browser cursor and only consumes movement while right mouse is held.
*/
export function beginMouseLookDrag(clientX: number, clientY: number): void {
clearBrowserSelection();
mouseLookDragActive = true;
previousMousePosition = Number.isFinite(clientX) && Number.isFinite(clientY)
? { x: clientX, y: clientY }
@@ -317,20 +324,20 @@ export function installInput(nextActions: InputActions): () => void {
}
publishActionLayer();
};
const onPointerMove = (event: PointerEvent) => {
if (!mouseLookDragActive || event.pointerType !== "mouse") return;
const current = Number.isFinite(event.clientX) && Number.isFinite(event.clientY)
? { x: event.clientX, y: event.clientY }
: null;
if (!current) return;
const previous = previousMousePosition;
previousMousePosition = current;
if (
previous
&& mouseLookBuffer.push(current.x - previous.x, current.y - previous.y)
) actions?.setInputMode("keyboard");
};
const onMouseMove = (event: MouseEvent) => {
if (mouseLookDragActive) {
const current = Number.isFinite(event.clientX) && Number.isFinite(event.clientY)
? { x: event.clientX, y: event.clientY }
: null;
if (!current) return;
const previous = previousMousePosition;
previousMousePosition = current;
if (
previous
&& mouseLookBuffer.push(current.x - previous.x, current.y - previous.y)
) actions?.setInputMode("keyboard");
return;
}
if (
document.pointerLockElement
&& mouseLookBuffer.push(event.movementX, event.movementY)
@@ -341,6 +348,9 @@ export function installInput(nextActions: InputActions): () => void {
mouseLookBuffer.reset(locked ? POINTER_LOCK_MOUSE_GRACE_MS : 0);
nextActions.setCameraLookActive(locked || mouseLookDragActive);
};
const onSelectStart = (event: Event) => {
if (mouseLookDragActive) event.preventDefault();
};
const clear = () => {
keys.clear();
keyboardModifiers.clear();
@@ -355,15 +365,19 @@ export function installInput(nextActions: InputActions): () => void {
window.addEventListener("keydown", onKeyDown);
window.addEventListener("keyup", onKeyUp);
window.addEventListener("blur", clear);
document.addEventListener("pointermove", onPointerMove);
document.addEventListener("mousemove", onMouseMove);
document.addEventListener("pointerlockchange", onPointerLockChange);
document.addEventListener("selectstart", onSelectStart);
return () => {
window.removeEventListener("keydown", onKeyDown);
window.removeEventListener("keyup", onKeyUp);
window.removeEventListener("blur", clear);
document.removeEventListener("pointermove", onPointerMove);
document.removeEventListener("mousemove", onMouseMove);
document.removeEventListener("pointerlockchange", onPointerLockChange);
document.removeEventListener("selectstart", onSelectStart);
unsubscribeController();
clear();
actions = null;
+45 -6
View File
@@ -64,6 +64,7 @@ import {
isPositionInFacingArc,
isSourceBehindTarget,
} from "./combatSpatial";
import { useDungeonSimulationStore } from "./dungeonSimulationStore";
const OUT_OF_COMBAT_REGEN_FRACTION = 0.06;
const ATTACK_ACQUISITION_RANGE = 30;
@@ -128,6 +129,16 @@ export function activePartyBossObjectives(): readonly PartyBossObjective[] {
}] : [];
}
const game = useGameStore.getState();
const simulation = useDungeonSimulationStore.getState();
if (
simulation.sessionMode === "spectate-live"
&& simulation.mode === "navigation-only"
&& simulation.objectiveId
) {
const objective = requireDungeonDefinition(game.activeDungeonId).objectiveOrder
.find((candidate) => candidate.id === simulation.objectiveId);
return objective ? [objective] : [];
}
const completed = new Set(activeDungeonCompletedObjectiveIds(game.activeDungeonId));
const mobs = useCombatStore.getState().mobs;
const objectives = requireDungeonDefinition(game.activeDungeonId).objectiveOrder
@@ -143,6 +154,10 @@ export function activePartyBossObjectives(): readonly PartyBossObjective[] {
));
}
function simulatedPartyExcludesPlayer(): boolean {
return useDungeonSimulationStore.getState().sessionMode !== "play";
}
export function resetPartyRuntimeClock(): void {
lastAdvanceAt = 0;
nextRegenAt = 0;
@@ -799,7 +814,10 @@ function partyHealingTargets(
): readonly string[] {
const combat = useCombatStore.getState();
const targeting = targetingRulesForAbility(ability);
const living = [PLAYER_AGGRO_ID, ...members.map((member) => member.id)].filter((id) => combat.actors[id]?.alive);
const living = [
...(simulatedPartyExcludesPlayer() ? [] : [PLAYER_AGGRO_ID]),
...members.map((member) => member.id),
].filter((id) => combat.actors[id]?.alive);
const healthFraction = (id: string) => {
const actor = combat.actors[id];
return actor ? actor.health / Math.max(1, actor.maxHealth) : 1;
@@ -850,7 +868,7 @@ function choosePartySupportIntent(
): PartySupportIntent | null {
const combat = useCombatStore.getState();
const fallenTargetId = [
...(combat.health <= 0 ? [PLAYER_AGGRO_ID] : []),
...(!simulatedPartyExcludesPlayer() && combat.health <= 0 ? [PLAYER_AGGRO_ID] : []),
...members
.filter((candidate) => candidate.health <= 0)
.sort((left, right) => (
@@ -866,7 +884,7 @@ function choosePartySupportIntent(
}
if (member.role !== "healer") return null;
const dispelTargetId = [
...(combat.health > 0 ? [PLAYER_AGGRO_ID] : []),
...(!simulatedPartyExcludesPlayer() && combat.health > 0 ? [PLAYER_AGGRO_ID] : []),
...members.filter((candidate) => candidate.health > 0).map((candidate) => candidate.id),
].find((id) => combat.auras.some((aura) => (
aura.targetId === id
@@ -880,7 +898,7 @@ function choosePartySupportIntent(
}
}
const injured = [
...(combat.health > 0 && combat.health < combat.maxHealth
...(!simulatedPartyExcludesPlayer() && combat.health > 0 && combat.health < combat.maxHealth
? [{ id: PLAYER_AGGRO_ID, health: combat.health, maxHealth: combat.maxHealth, role: "healer" as const }]
: []),
...members.filter((candidate) => candidate.health > 0 && candidate.health < candidate.maxHealth),
@@ -1024,7 +1042,10 @@ function executePartyAbility(
}
} else if (effect.kind === "apply-aura") {
const recipients = effect.scope === "party"
? [PLAYER_AGGRO_ID, ...members.filter((candidate) => candidate.health > 0).map((candidate) => candidate.id)]
? [
...(simulatedPartyExcludesPlayer() ? [] : [PLAYER_AGGRO_ID]),
...members.filter((candidate) => candidate.health > 0).map((candidate) => candidate.id),
]
: effect.recipient === "caster"
? [member.id]
: combat.mobs[targetId] ? hostileTargets : friendlyTargets;
@@ -1036,7 +1057,7 @@ function executePartyAbility(
}
} else if (effect.kind === "dispel") {
const recipients = effect.scope === "party"
? [PLAYER_AGGRO_ID, ...members.map((candidate) => candidate.id)]
? [...(simulatedPartyExcludesPlayer() ? [] : [PLAYER_AGGRO_ID]), ...members.map((candidate) => candidate.id)]
: [targetId];
for (const recipientId of recipients) {
effectiveAmount += useCombatStore.getState().dispelActor(recipientId, effect.categories, effect.maxCount);
@@ -1134,6 +1155,24 @@ export function advanceDungeonPartyCombat(now = Date.now()): void {
const authoritativeMembers: readonly PartyMember[] = party.members.map((member) => (
memberFromCompanionActor(member, combat.actors[member.id])
));
const simulation = useDungeonSimulationStore.getState();
if (simulation.sessionMode === "spectate-live" && simulation.mode === "navigation-only") {
const members = authoritativeMembers.map((member) => ({
...member,
status: member.health > 0 ? "advancing" as const : "down" as const,
activeCast: null,
activeCastTargetId: null,
nextActionAt: 0,
}));
party.commitRuntime({
members,
activeMobId: null,
objectiveBossId: simulation.objectiveId,
memberTargetIds: {},
});
useCombatStore.getState().commitCompanionActors(members);
return;
}
let members: readonly PartyMember[] = authoritativeMembers;
const partyOrigin = partyRuntimeCenter(
members.filter((member) => !partyRuntimeIsRejoining(member.id)).map((member) => member.id),
+82 -4
View File
@@ -89,6 +89,17 @@ interface PartyTemplate {
readonly talentTreeId: string;
}
export interface ConfiguredPartyMemberSeed {
readonly name: string;
readonly classId: ClassId;
readonly secondaryClassId?: ClassId | null;
readonly specialization: string;
readonly specializationId?: string | null;
readonly talentTreeId?: string;
readonly talentRanks?: TalentRanks;
readonly role: PartyRole;
}
const TANK_TEMPLATES: readonly PartyTemplate[] = Object.freeze([
{ name: "Garrick", classId: "warrior", specialization: "Protection", talentTreeId: "warrior-protection" },
{ name: "Morvane", classId: "death-knight", specialization: "Blood", talentTreeId: "death-knight-blood" },
@@ -241,6 +252,7 @@ function createMember(
level: number,
index: number,
seed: string,
configuredTalentRanks?: TalentRanks,
): PartyMember {
// Appearance has its own keyed stream so adding customization fields later
// cannot reshuffle which class/spec templates make up this character's party.
@@ -282,9 +294,9 @@ function createMember(
specialization: template.specialization,
specializationId,
talentTreeId: template.talentTreeId,
talentRanks: isRomClassId(template.classId)
talentRanks: configuredTalentRanks ?? (isRomClassId(template.classId)
? Object.freeze({})
: runtimePartyTalentRanksForLevel(template.classId, template.specialization, level),
: runtimePartyTalentRanksForLevel(template.classId, template.specialization, level)),
role,
level: Math.max(1, Math.trunc(level)),
health: maxHealth,
@@ -310,6 +322,27 @@ function createMember(
};
}
/** Creates one exact companion while retaining the live party stat/equipment path. */
export function createConfiguredPartyMember(
configuration: ConfiguredPartyMemberSeed,
level: number,
index: number,
seed: string,
): PartyMember {
const member = createMember({
name: configuration.name,
classId: configuration.classId,
specialization: configuration.specialization,
talentTreeId: configuration.talentTreeId ?? configuration.specializationId ?? "",
}, configuration.role, level, index, seed, configuration.talentRanks);
return Object.freeze({
...member,
secondaryClassId: configuration.secondaryClassId ?? null,
specializationId: configuration.specializationId ?? member.specializationId,
talentRanks: configuration.talentRanks ?? member.talentRanks,
});
}
/**
* Builds the four AI slots around the player's selected role. The resulting
* group is always a classic five-player composition including the player.
@@ -352,6 +385,29 @@ export function createDungeonParty(
].slice(0, Math.max(0, partySize - 1));
}
/** Builds a complete deterministic NPC group for GM spectator simulations. */
export function createSpectatorDungeonParty(
seed: string,
level: number,
categoryId: ContentCategoryId = "wow",
): readonly PartyMember[] {
const random = { value: hashSeed(seed || "healer-man-spectator") };
const tankTemplates = categoryId === "rom" ? ROM_TANK_TEMPLATES : TANK_TEMPLATES;
const healerTemplates = categoryId === "rom" ? ROM_HEALER_TEMPLATES : HEALER_TEMPLATES;
const damagePool = [...(categoryId === "rom" ? ROM_DAMAGE_TEMPLATES : DAMAGE_TEMPLATES)];
for (let index = damagePool.length - 1; index > 0; index -= 1) {
const swapIndex = Math.floor(nextRandom(random) * (index + 1));
[damagePool[index], damagePool[swapIndex]] = [damagePool[swapIndex], damagePool[index]];
}
const tank = tankTemplates[Math.floor(nextRandom(random) * tankTemplates.length)] ?? tankTemplates[0];
const healer = healerTemplates[Math.floor(nextRandom(random) * healerTemplates.length)] ?? healerTemplates[0];
return [
createMember(tank, "tank", level, 0, seed),
createMember(healer, "healer", level, 1, seed),
...damagePool.slice(0, 3).map((template, index) => createMember(template, "damage", level, index + 2, seed)),
];
}
export interface PartyRuntimeCommit {
readonly members: readonly PartyMember[];
readonly activeMobId: string | null;
@@ -361,6 +417,7 @@ export interface PartyRuntimeCommit {
export interface PartyState {
seed: string | null;
spectatorParty: boolean;
playerRole: PartyRole;
partySize: PartySize;
categoryId: ContentCategoryId;
@@ -381,6 +438,7 @@ export interface PartyState {
partySize?: PartySize,
categoryId?: ContentCategoryId,
) => void;
initializeSpectatorParty: (seed: string, level: number, categoryId?: ContentCategoryId) => void;
resetCombat: (level: number, playerHealth: number) => void;
syncLevel: (level: number) => void;
syncEquipment: (equipment: EquipmentAssignments, inventory: readonly InventoryItem[]) => void;
@@ -424,6 +482,7 @@ export interface PartyState {
export const usePartyStore = create<PartyState>((set, get) => ({
seed: null,
spectatorParty: false,
playerRole: "healer",
partySize: 5,
categoryId: "wow",
@@ -439,6 +498,7 @@ export const usePartyStore = create<PartyState>((set, get) => ({
initializeParty: (seed, level, playerHealth, playerRole = "healer", partySize = 5, categoryId = "wow") => set({
seed,
spectatorParty: false,
playerRole,
partySize,
categoryId,
@@ -452,10 +512,28 @@ export const usePartyStore = create<PartyState>((set, get) => ({
memberTargetIds: {},
}),
initializeSpectatorParty: (seed, level, categoryId = "wow") => set({
seed,
spectatorParty: true,
playerRole: "healer",
partySize: 5,
categoryId,
members: createSpectatorDungeonParty(seed, level, categoryId),
command: "attack",
selectedMemberId: null,
selfSelected: false,
routeBlocked: false,
activeMobId: null,
objectiveBossId: null,
memberTargetIds: {},
}),
resetCombat: (level, playerHealth) => set((state) => {
return {
members: createDungeonParty(state.seed ?? "healer-man", level, state.playerRole, state.partySize, state.categoryId),
command: "defend",
members: state.spectatorParty
? createSpectatorDungeonParty(state.seed ?? "healer-man-spectator", level, state.categoryId)
: createDungeonParty(state.seed ?? "healer-man", level, state.playerRole, state.partySize, state.categoryId),
command: state.spectatorParty ? "attack" : "defend",
selectedMemberId: null,
selfSelected: false,
routeBlocked: false,
@@ -525,6 +525,32 @@ describe("source-reviewed Wrath encounter batch two", () => {
]));
});
it("keeps Eck's source cadence without turning setup and Berserk spells into repeat damage", () => {
const gundrak = adaptedDefinition("gundrak");
const eck = Object.values(gundrak.entities).find((entity) => entity.combat?.serverEntry === 29_932);
expect(eck?.name).toBe("Eck the Ferocious");
expect(eck?.visual.model).toMatchObject({ groundOffset: 0, labelHeight: 7 });
const attacks = new Map(eck?.combat?.attacks.map((attack) => [attack.spellId ?? 0, attack]));
expect(attacks.get(55_813)).toMatchObject({
name: "Eck Bite", target: "primary", initialCooldownMs: [5_000, 5_000],
repeatCooldownMs: [8_000, 12_000], damageMultiplier: 1.35,
});
expect(attacks.get(55_814)).toMatchObject({
name: "Eck Spit", target: "primary", initialCooldownMs: [10_000, 37_000],
repeatCooldownMs: [8_000, 12_000], damageMultiplier: 1.35,
});
expect(attacks.get(55_815)).toMatchObject({
name: "Eck Spring", target: "random-party", initialCooldownMs: [10_000, 24_000],
repeatCooldownMs: [10_000, 24_000], damageMultiplier: 1.35,
telegraph: { shape: "circle", anchor: "target", warningMs: 750 },
});
expect(attacks.get(55_837)).toMatchObject({ target: "self", maximumCasts: 1, damageMultiplier: 0 });
expect(attacks.get(55_816)).toMatchObject({
target: "self", initialCooldownMs: [90_000, 90_000], maximumCasts: 1,
damageMultiplier: 0, effect: { kind: "aura", aura: "enrage" },
});
});
it("runs every required lifecycle through its reviewed gates and completes without renderer state", () => {
const halls = adaptedDefinition("halls-of-stone");
const hallsRun = finishEncounter(halls, adaptedEncounter(halls));
+73 -2
View File
@@ -20,6 +20,7 @@ import type {
PopulationEntityDefinition,
StaticMobSpawnDefinition,
} from "./mobPopulation";
import type { EnemyAttackDefinition } from "./enemyAttacks";
export interface WowWrathEncounterBatchTwoSourceFile {
readonly path: string;
@@ -71,6 +72,43 @@ const UTGARDE_PINNACLE_FILES = Object.freeze([
const GUNDRAK_FILES = Object.freeze([
`${SOURCE_ROOT}/Northrend/Gundrak/instance_gundrak.cpp`,
`${SOURCE_ROOT}/Northrend/Gundrak/gundrak.h`,
`${SOURCE_ROOT}/Northrend/Gundrak/boss_eck.cpp`,
]);
const ECK_ATTACKS: readonly EnemyAttackDefinition[] = Object.freeze([
Object.freeze({
id: "basic-attack", name: "Basic Attack", delivery: "melee", target: "primary",
school: "physical", animation: "attack", range: 4.5, cooldownMs: 2_000,
initialCooldownMs: [450, 450] as const, repeatCooldownMs: [2_000, 2_000] as const, damageMultiplier: 1,
}),
Object.freeze({
id: "spell-55837", name: "Eck Spring Init", spellId: 55_837, delivery: "area", target: "self",
school: "physical", animation: "cast", castTimeMs: 0, range: 0, cooldownMs: 1,
initialCooldownMs: [450, 450] as const, repeatCooldownMs: [1, 1] as const, maximumCasts: 1, damageMultiplier: 0,
effect: { kind: "aura", aura: "eck-spring-init", durationMs: 1_000 } as const,
}),
Object.freeze({
id: "spell-55813", name: "Eck Bite", spellId: 55_813, delivery: "melee", target: "primary",
school: "physical", animation: "attack", range: 5.5, cooldownMs: 10_000,
initialCooldownMs: [5_000, 5_000] as const, repeatCooldownMs: [8_000, 12_000] as const, damageMultiplier: 1.35,
}),
Object.freeze({
id: "spell-55814", name: "Eck Spit", spellId: 55_814, delivery: "projectile", target: "primary",
school: "nature", animation: "cast", castTimeMs: 0, range: 28, cooldownMs: 10_000,
initialCooldownMs: [10_000, 37_000] as const, repeatCooldownMs: [8_000, 12_000] as const, damageMultiplier: 1.35,
}),
Object.freeze({
id: "spell-55815", name: "Eck Spring", spellId: 55_815, delivery: "projectile", target: "random-party",
school: "physical", animation: "cast", castTimeMs: 750, range: 30, cooldownMs: 17_000,
initialCooldownMs: [10_000, 24_000] as const, repeatCooldownMs: [10_000, 24_000] as const, damageMultiplier: 1.35,
telegraph: { shape: "circle", anchor: "target", warningMs: 750, radius: 3 } as const,
}),
Object.freeze({
id: "spell-55816", name: "Eck Berserk", spellId: 55_816, delivery: "area", target: "self",
school: "physical", animation: "cast", castTimeMs: 0, range: 0, cooldownMs: 90_000,
initialCooldownMs: [90_000, 90_000] as const, repeatCooldownMs: [90_000, 90_000] as const, maximumCasts: 1,
damageMultiplier: 0, effect: { kind: "aura", aura: "enrage", durationMs: 0, magnitude: 1 } as const,
}),
]);
export const WOW_WRATH_ENCOUNTER_BATCH_TWO_REVIEWS: readonly WowWrathEncounterBatchTwoReview[] = Object.freeze([
@@ -239,11 +277,20 @@ export const WOW_WRATH_ENCOUNTER_BATCH_TWO_REVIEWS: readonly WowWrathEncounterBa
sha256: "ec0477724b5343879ecfd713060c408796373d3f6fbeaf16dcef84f1393c6356",
anchors: ["boss, altar, statue, bridge, collision, Eck, and Gal'darah door ids (lines 27-73)"],
},
{
path: GUNDRAK_FILES[2],
sha256: "ef6f4736b921c5f654260cb0c61f2590e77270c8e5a4c4f0935db4c5f0478e07",
anchors: [
"Eck Spring initialization and threat handoff (lines 51-72)",
"Bite, Spit, Spring, and 90-second Berserk schedules (lines 75-90)",
],
},
],
reviewedBehavior: "Slad'ran, Moorabi, and the Drakkari Colossus independently enable their exact altars; using all three rotates the exact statues and bridge collision after five seconds, exposing Gal'darah; Moorabi releases the heroic Eck route.",
adaptations: [
"The bridge and statues change discrete procedural states without native rotation animation; the source five-second delay is retained.",
"The optional Eck actor is activated after Moorabi for deterministic recovery rather than after the source Ruins Dweller formation; its actor spawn remains heroic-mask-only, so normal rendering filters it out.",
"Eck keeps the source's independent Bite, Spit, Spring, and 90-second Berserk timings; Spring uses a short target warning because the runtime does not yet execute creature jump motion.",
"Gal'darah's room door closes on engagement and all three source Gal'darah doors open on death; no native door animation is claimed.",
"Exact imported altar, statue, bridge, collision, Eck-door, underwater-gate, and Gal'darah-door rows are replaced by procedural stateful objects at their source transforms.",
],
@@ -1529,9 +1576,33 @@ function buildGundrak(definition: DungeonDefinition): DungeonDefinition {
const galDarah = objectiveForEntry(definition, 29_306);
const eckBase = objectiveForEntry(definition, 29_932);
const eck = eventObjective(definition, eckBase.id);
const entities = ensureEntities(definition, []);
const eckEntityId = entityIdForEntry(entities, 29_932);
const baseEntities = ensureEntities(definition, []);
const eckEntityId = entityIdForEntry(baseEntities, 29_932);
if (!eckEntityId) throw new Error("gundrak is missing Eck the Ferocious entry 29932");
const sourceEck = baseEntities[eckEntityId];
if (!sourceEck.combat) throw new Error("gundrak Eck the Ferocious is missing combat data");
if (!sourceEck.visual.model) throw new Error("gundrak Eck the Ferocious is missing its source model");
const entities = Object.freeze({
...baseEntities,
[eckEntityId]: Object.freeze({
...sourceEck,
visual: Object.freeze({
...sourceEck.visual,
model: Object.freeze({
...sourceEck.visual.model,
// The skinned Oracle bind pose extends far below its animated root.
// Treating that minimum as the floor lifted Eck out of the camera.
groundOffset: 0,
labelHeight: 7,
}),
}),
combat: Object.freeze({
...sourceEck.combat,
mechanicIds: Object.freeze(ECK_ATTACKS.slice(1).map((attack) => attack.id)),
attacks: ECK_ATTACKS,
}),
}),
});
const eckGroup: DungeonEncounterSpawnGroupDefinition = Object.freeze({
id: "gundrak:eck",
spawns: Object.freeze([spawnAt("gundrak:eck-actor", eckEntityId, eck.position, [0, 0], 2)]),
+458
View File
@@ -0,0 +1,458 @@
import { Line } from "@react-three/drei";
import { useFrame } from "@react-three/fiber";
import { useMemo, useRef } from "react";
import { useShellStore } from "../app/shellStore";
import { saveDungeonSimulationReplay } from "../app/dungeonSimulationClient";
import { useCombatStore } from "../game/combatStore";
import { combatSpatialActor } from "../game/combatSpatial";
import { activeDungeonCompletedObjectiveIds } from "../game/dungeonEncounterRuntime";
import { getMobPosition, updateMobRuntimePosition } from "../game/mobRuntimeRegistry";
import { getPartyRuntimePosition, updatePartyRuntimePosition } from "../game/partyRuntimeRegistry";
import { usePartyStore, type PartyMemberStatus } from "../game/partyStore";
import { dungeonRouteDefinition, positionAlongRouteCorridor, routePointDistanceSquared } from "../game/dungeonRoutes";
import {
DUNGEON_REPLAY_KEYFRAME_MS,
DUNGEON_REPLAY_SAMPLE_MS,
DUNGEON_SIMULATION_MAX_DURATION_MS,
useDungeonSimulationStore,
} from "../game/dungeonSimulationStore";
import type {
DungeonReplayFrame,
DungeonSimulationResult,
DungeonRouteLeg,
DungeonSimulationActorSnapshot,
} from "../game/dungeonSimulationTypes";
import type { Vector3Tuple } from "../game/dungeonTypes";
let savingRunKey: string | null = null;
function simulationRoster() {
return usePartyStore.getState().members.map((member) => ({
id: member.id,
name: member.name,
role: member.role,
classId: member.classId,
}));
}
export async function finalizeDungeonSimulation(result: DungeonSimulationResult): Promise<void> {
const simulation = useDungeonSimulationStore.getState();
if (simulation.sessionMode !== "spectate-live" || simulation.runState === "finished") return;
const replay = simulation.finish(result, simulationRoster());
if (!replay) return;
const key = `${replay.dungeonId}:${replay.routeHash}:${replay.startedAt}`;
if (savingRunKey === key) return;
savingRunKey = key;
try {
await saveDungeonSimulationReplay(useShellStore.getState().session, replay);
} catch (error) {
console.error("Unable to upload dungeon simulation replay", error);
}
}
function actorSnapshots(): readonly DungeonSimulationActorSnapshot[] {
const party = usePartyStore.getState();
const companions = party.members.map((member) => ({
id: member.id,
position: getPartyRuntimePosition(member.id) ?? [0, 0, 0],
yaw: combatSpatialActor(member.id)?.yaw ?? 0,
health: member.health,
maxHealth: member.maxHealth,
targetId: party.memberTargetIds[member.id] ?? party.activeMobId,
status: member.status,
}));
const mobs = Object.values(useCombatStore.getState().mobs).map((mob) => ({
id: mob.id,
position: getMobPosition(mob.id) ?? [0, 0, 0] as Vector3Tuple,
yaw: combatSpatialActor(mob.id)?.yaw ?? 0,
health: mob.health,
maxHealth: mob.maxHealth,
targetId: mob.targetActorId,
status: mob.dead ? "dead" : mob.engaged ? "engaged" : "idle",
}));
return [...companions, ...mobs];
}
function routeProgress(position: Vector3Tuple, leg: DungeonRouteLeg): number {
let cumulative = 0;
let bestDistance = Number.POSITIVE_INFINITY;
let bestProgress = 0;
for (let index = 1; index < leg.checkpoints.length; index += 1) {
const start = leg.checkpoints[index - 1];
const end = leg.checkpoints[index];
const segmentLength = Math.hypot(end[0] - start[0], end[1] - start[1], end[2] - start[2]);
const projection = routePointDistanceSquared(position, start, end);
const score = projection.planarSquared + projection.vertical ** 2;
if (score < bestDistance) {
bestDistance = score;
bestProgress = cumulative + segmentLength * projection.progress;
}
cumulative += segmentLength;
}
return bestProgress;
}
interface DiagnosticCursor {
runKey: string;
nextSampleAt: number;
lastLeaderPosition: Vector3Tuple | null;
lastProgressAt: number;
offCorridorAt: number | null;
routeBlockedAt: number | null;
splitAt: Map<string, number>;
observedPulls: Set<string>;
objectiveId: string | null;
objectiveDistance: number;
maximumRouteProgress: number;
finalCheckpointAt: number | null;
replayFrameAt: number;
}
function newCursor(runKey: string): DiagnosticCursor {
return {
runKey,
nextSampleAt: 0,
lastLeaderPosition: null,
lastProgressAt: 0,
offCorridorAt: null,
routeBlockedAt: null,
splitAt: new Map(),
observedPulls: new Set(),
objectiveId: null,
objectiveDistance: 0,
maximumRouteProgress: 0,
finalCheckpointAt: null,
replayFrameAt: -1,
};
}
function addFinding(
id: string,
severity: "info" | "warning" | "critical",
code: string,
message: string,
position?: Vector3Tuple,
actorId?: string,
): void {
const simulation = useDungeonSimulationStore.getState();
simulation.addFinding({
id,
atMs: simulation.elapsedMs,
severity,
code,
message,
position,
actorId,
objectiveId: simulation.objectiveId ?? undefined,
checkpointIndex: simulation.checkpointIndex,
});
}
function applyReplayFrame(frame: DungeonReplayFrame): void {
const party = usePartyStore.getState();
const combat = useCombatStore.getState();
const snapshots = new Map(frame.actors.map((actor) => [actor.id, actor] as const));
const members = party.members.map((member) => {
const snapshot = snapshots.get(member.id);
if (!snapshot) return member;
updatePartyRuntimePosition(member.id, ...snapshot.position);
return {
...member,
health: snapshot.health,
maxHealth: snapshot.maxHealth,
status: snapshot.status as PartyMemberStatus,
};
});
const mobs = { ...combat.mobs };
for (const snapshot of frame.actors) {
const mob = mobs[snapshot.id];
if (!mob) continue;
updateMobRuntimePosition(snapshot.id, ...snapshot.position, snapshot.yaw);
mobs[snapshot.id] = {
...mob,
health: snapshot.health,
maxHealth: snapshot.maxHealth,
dead: snapshot.status === "dead" || snapshot.health <= 0,
engaged: snapshot.status === "engaged",
targetActorId: snapshot.targetId,
};
}
party.commitRuntime({
members,
activeMobId: null,
objectiveBossId: frame.objectiveId,
memberTargetIds: Object.fromEntries(frame.actors.flatMap((actor) => actor.targetId ? [[actor.id, actor.targetId]] : [])),
});
useCombatStore.setState({ mobs });
combat.commitCompanionActors(members);
useDungeonSimulationStore.getState().setRouteProgress(frame.objectiveId, frame.checkpointIndex);
}
function recordPulls(cursor: DiagnosticCursor, leaderPosition: Vector3Tuple, leg: DungeonRouteLeg | null): void {
const simulation = useDungeonSimulationStore.getState();
const partyPositions = usePartyStore.getState().members
.map((member) => getPartyRuntimePosition(member.id))
.filter((position): position is Vector3Tuple => Boolean(position));
for (const mob of Object.values(useCombatStore.getState().mobs).sort((left, right) => left.id.localeCompare(right.id))) {
if (mob.dead || mob.despawned || cursor.observedPulls.has(mob.id)) continue;
const position = getMobPosition(mob.id);
if (!position) continue;
const theoretical = simulation.mode === "navigation-only" && partyPositions.some((partyPosition) => (
Math.hypot(position[0] - partyPosition[0], position[1] - partyPosition[1], position[2] - partyPosition[2])
<= Math.max(0, mob.aggroRange)
));
if (!mob.engaged && !theoretical) continue;
cursor.observedPulls.add(mob.id);
const classification = theoretical
? "theoretical" as const
: leg && positionAlongRouteCorridor(position, leg.checkpoints, leg.corridorPlanarDistance, leg.corridorVerticalDistance)
? "expected" as const
: "off-route" as const;
simulation.addPull({
atMs: simulation.elapsedMs,
mobIds: [mob.id],
routeLegId: leg?.id ?? null,
position: [leaderPosition[0], leaderPosition[1], leaderPosition[2]],
classification,
});
}
}
function inspectLiveSimulation(cursor: DiagnosticCursor): void {
const simulation = useDungeonSimulationStore.getState();
const party = usePartyStore.getState();
const leader = party.members.find((member) => member.health > 0);
const leaderPosition = leader ? getPartyRuntimePosition(leader.id) : null;
if (!leader || !leaderPosition) return;
const route = simulation.dungeonId ? dungeonRouteDefinition(simulation.dungeonId, simulation.routeId ?? "primary") : null;
const leg = route?.legs.find((candidate) => candidate.objectiveId === simulation.objectiveId) ?? null;
if (cursor.objectiveId !== simulation.objectiveId) {
if (cursor.objectiveId && route) {
const previousLeg = route.legs.find((candidate) => candidate.objectiveId === cursor.objectiveId);
if (previousLeg && cursor.objectiveDistance > previousLeg.expectedDistance * 1.5
&& cursor.objectiveDistance > previousLeg.expectedDistance + 10) {
addFinding(
`detour:${cursor.objectiveId}`,
"warning",
"excessive-detour",
`Leg ${previousLeg.id} travelled ${cursor.objectiveDistance.toFixed(1)} m for an expected ${previousLeg.expectedDistance.toFixed(1)} m.`,
leaderPosition,
leader.id,
);
}
}
cursor.objectiveId = simulation.objectiveId;
cursor.objectiveDistance = 0;
cursor.maximumRouteProgress = 0;
cursor.finalCheckpointAt = null;
}
if (cursor.lastLeaderPosition) {
const moved = Math.hypot(
leaderPosition[0] - cursor.lastLeaderPosition[0],
leaderPosition[1] - cursor.lastLeaderPosition[1],
leaderPosition[2] - cursor.lastLeaderPosition[2],
);
cursor.objectiveDistance += moved;
if (moved >= 0.1) cursor.lastProgressAt = simulation.elapsedMs;
} else cursor.lastProgressAt = simulation.elapsedMs;
cursor.lastLeaderPosition = [leaderPosition[0], leaderPosition[1], leaderPosition[2]];
if (party.command === "attack" && simulation.objectiveId && simulation.elapsedMs - cursor.lastProgressAt >= 2_000) {
addFinding(
`stall:${simulation.objectiveId}:${simulation.checkpointIndex}`,
"warning",
"movement-stall",
`${leader.name} made no movement progress for at least two seconds.`,
leaderPosition,
leader.id,
);
}
if (party.routeBlocked) {
cursor.routeBlockedAt ??= simulation.elapsedMs;
if (simulation.elapsedMs - cursor.routeBlockedAt >= 1_000) {
addFinding(
`no-path:${simulation.objectiveId}:${simulation.checkpointIndex}`,
"critical",
"no-path",
`No collision-valid path to checkpoint ${simulation.checkpointIndex}.`,
leaderPosition,
leader.id,
);
}
} else cursor.routeBlockedAt = null;
if (leg) {
const inside = positionAlongRouteCorridor(
leaderPosition,
leg.checkpoints,
leg.corridorPlanarDistance,
leg.corridorVerticalDistance,
);
if (!inside) {
cursor.offCorridorAt ??= simulation.elapsedMs;
if (simulation.elapsedMs - cursor.offCorridorAt >= 1_000) {
addFinding(
`off-corridor:${leg.id}:${simulation.checkpointIndex}`,
"warning",
"off-corridor",
`${leader.name} remained outside the ${leg.corridorPlanarDistance} m / ${leg.corridorVerticalDistance} m route corridor for one second.`,
leaderPosition,
leader.id,
);
}
} else cursor.offCorridorAt = null;
const progress = routeProgress(leaderPosition, leg);
cursor.maximumRouteProgress = Math.max(cursor.maximumRouteProgress, progress);
if (cursor.maximumRouteProgress - progress >= 8) {
addFinding(
`backtrack:${leg.id}:${Math.floor(cursor.maximumRouteProgress / 8)}`,
"warning",
"backtracking",
`${leader.name} backtracked at least 8 m on ${leg.id}.`,
leaderPosition,
leader.id,
);
}
if (simulation.checkpointIndex >= leg.checkpoints.length - 1) {
cursor.finalCheckpointAt ??= simulation.elapsedMs;
if (
simulation.mode === "navigation-only"
&& simulation.elapsedMs - cursor.finalCheckpointAt >= 5_000
) {
addFinding(
`scripted-progression:${leg.id}`,
"critical",
"failed-scripted-progression",
`Navigation-only progression did not advance after reaching ${leg.objectiveName}.`,
leaderPosition,
leader.id,
);
}
} else cursor.finalCheckpointAt = null;
}
for (const follower of party.members.filter((member) => member.health > 0 && member.id !== leader.id)) {
const position = getPartyRuntimePosition(follower.id);
if (!position) continue;
const distance = Math.hypot(
position[0] - leaderPosition[0],
position[1] - leaderPosition[1],
position[2] - leaderPosition[2],
);
if (distance > 12) {
const separatedAt = cursor.splitAt.get(follower.id) ?? simulation.elapsedMs;
cursor.splitAt.set(follower.id, separatedAt);
if (simulation.elapsedMs - separatedAt >= 2_000) {
addFinding(
`separation:${follower.id}:${simulation.objectiveId}`,
"warning",
"party-separation",
`${follower.name} remained more than 12 m from the leader for two seconds.`,
position,
follower.id,
);
}
} else cursor.splitAt.delete(follower.id);
}
if (simulation.recoveries >= 2) {
addFinding(
`repeated-recovery:${simulation.objectiveId}`,
"critical",
"repeated-recovery",
"The simulator required repeated position recovery teleports.",
leaderPosition,
leader.id,
);
}
recordPulls(cursor, leaderPosition, leg);
while (simulation.elapsedMs >= cursor.nextSampleAt) {
const atMs = cursor.nextSampleAt;
simulation.addFrame({
atMs,
keyframe: atMs % DUNGEON_REPLAY_KEYFRAME_MS === 0,
actors: actorSnapshots(),
objectiveId: simulation.objectiveId,
checkpointIndex: simulation.checkpointIndex,
});
cursor.nextSampleAt += DUNGEON_REPLAY_SAMPLE_MS;
}
if (simulation.elapsedMs >= DUNGEON_SIMULATION_MAX_DURATION_MS) {
void finalizeDungeonSimulation("recording-limit");
} else if (!party.members.some((member) => member.health > 0)) {
void finalizeDungeonSimulation("wiped");
} else if (simulation.mode === "navigation-only" && simulation.objectiveId === null) {
void finalizeDungeonSimulation("completed");
} else if (
simulation.mode === "full-combat"
&& simulation.elapsedMs >= 1_000
&& route
&& route.legs.every((candidate) => activeDungeonCompletedObjectiveIds(route.dungeonId).includes(candidate.objectiveId))
) {
void finalizeDungeonSimulation("completed");
}
}
export function DungeonSimulationSceneBridge() {
const sessionMode = useDungeonSimulationStore((state) => state.sessionMode);
const dungeonId = useDungeonSimulationStore((state) => state.dungeonId);
const routeId = useDungeonSimulationStore((state) => state.routeId);
const objectiveId = useDungeonSimulationStore((state) => state.objectiveId);
const checkpointIndex = useDungeonSimulationStore((state) => state.checkpointIndex);
const frames = useDungeonSimulationStore((state) => state.frames);
const cursorRef = useRef<DiagnosticCursor>(newCursor(""));
const route = useMemo(
() => dungeonId ? dungeonRouteDefinition(dungeonId, routeId ?? "primary") : null,
[dungeonId, routeId],
);
const leg = route?.legs.find((candidate) => candidate.objectiveId === objectiveId) ?? null;
const trace = useMemo(() => frames
.map((frame) => frame.actors[0]?.position)
.filter((position): position is Vector3Tuple => Boolean(position))
.slice(-2_000), [frames]);
useFrame(() => {
const simulation = useDungeonSimulationStore.getState();
if (simulation.sessionMode === "play") return;
const runKey = `${simulation.sessionMode}:${simulation.dungeonId}:${simulation.startedAt}`;
if (cursorRef.current.runKey !== runKey) cursorRef.current = newCursor(runKey);
if (simulation.sessionMode === "spectate-replay") {
const replay = simulation.replay;
if (!replay?.frames.length) return;
const frame = [...replay.frames].reverse().find((candidate) => candidate.atMs <= simulation.elapsedMs)
?? replay.frames[0];
if (frame.atMs !== cursorRef.current.replayFrameAt) {
cursorRef.current.replayFrameAt = frame.atMs;
applyReplayFrame(frame);
}
return;
}
if (simulation.runState === "finished") return;
inspectLiveSimulation(cursorRef.current);
}, 1);
if (sessionMode === "play") return null;
return (
<group name="dungeon-simulation-route-overlay">
{leg && leg.checkpoints.length > 1 && (
<Line points={leg.checkpoints} color="#60d9ff" lineWidth={2} transparent opacity={0.72} />
)}
{leg?.checkpoints.map((checkpoint, index) => (
<mesh key={`${leg.id}:${index}`} position={checkpoint}>
<sphereGeometry args={[index === checkpointIndex ? 0.28 : 0.14, 8, 8]} />
<meshBasicMaterial color={index === checkpointIndex ? "#ffe56c" : "#59bde8"} transparent opacity={0.85} />
</mesh>
))}
{trace.length > 1 && (
<Line points={trace} color="#ff9f43" lineWidth={1.5} transparent opacity={0.8} />
)}
</group>
);
}
+86
View File
@@ -0,0 +1,86 @@
import { useFrame, useThree } from "@react-three/fiber";
import { useRapier } from "@react-three/rapier";
import { useEffect, useRef } from "react";
import { Vector3 } from "three";
import { consumeMouseLook, readInputSnapshot } from "../game/inputManager";
import { getPartyRuntimePosition } from "../game/partyRuntimeRegistry";
import { usePartyStore } from "../game/partyStore";
import { useDungeonSimulationStore } from "../game/dungeonSimulationStore";
import { useGameStore } from "../game/store";
const FOLLOW_DISTANCE = 7;
const FOLLOW_HEIGHT = 2.4;
const LOOK_SENSITIVITY = 0.0021;
export function DungeonSpectatorCamera() {
const camera = useThree((state) => state.camera);
const { rapier, world } = useRapier();
const yawRef = useRef(useGameStore.getState().cameraYaw);
const pitchRef = useRef(-0.22);
const freePositionRef = useRef(new Vector3().copy(camera.position));
const boostRef = useRef(false);
const initializedFreeRef = useRef(false);
useEffect(() => {
const onKey = (event: KeyboardEvent) => {
boostRef.current = event.shiftKey;
};
window.addEventListener("keydown", onKey);
window.addEventListener("keyup", onKey);
return () => {
window.removeEventListener("keydown", onKey);
window.removeEventListener("keyup", onKey);
};
}, []);
useFrame((_, delta) => {
const simulation = useDungeonSimulationStore.getState();
if (simulation.sessionMode === "play") return;
const input = readInputSnapshot();
const [mouseX, mouseY] = consumeMouseLook();
yawRef.current -= mouseX * LOOK_SENSITIVITY;
pitchRef.current = Math.max(-1.35, Math.min(1.15, pitchRef.current - mouseY * LOOK_SENSITIVITY));
yawRef.current -= input.lookX * delta * 2.25;
pitchRef.current = Math.max(-1.35, Math.min(1.15, pitchRef.current - input.lookY * delta * 1.65));
useGameStore.setState({ cameraYaw: yawRef.current });
if (simulation.cameraMode === "free-fly") {
if (!initializedFreeRef.current) {
freePositionRef.current.copy(camera.position);
initializedFreeRef.current = true;
}
const forward = new Vector3(
Math.sin(yawRef.current) * Math.cos(pitchRef.current),
Math.sin(pitchRef.current),
Math.cos(yawRef.current) * Math.cos(pitchRef.current),
);
const right = new Vector3(Math.cos(yawRef.current), 0, -Math.sin(yawRef.current));
const speed = boostRef.current ? 28 : 10;
freePositionRef.current.addScaledVector(forward, input.moveForward * speed * delta);
freePositionRef.current.addScaledVector(right, input.moveX * speed * delta);
camera.position.copy(freePositionRef.current);
camera.lookAt(new Vector3().copy(camera.position).add(forward));
return;
}
initializedFreeRef.current = false;
const party = usePartyStore.getState();
const leader = party.members.find((member) => member.health > 0) ?? party.members[0];
const leaderPosition = leader ? getPartyRuntimePosition(leader.id) : null;
if (!leaderPosition) return;
const anchor = new Vector3(leaderPosition[0], leaderPosition[1] + FOLLOW_HEIGHT, leaderPosition[2]);
const backward = new Vector3(
-Math.sin(yawRef.current) * Math.cos(pitchRef.current),
-Math.sin(pitchRef.current),
-Math.cos(yawRef.current) * Math.cos(pitchRef.current),
).normalize();
const ray = new rapier.Ray(anchor, backward);
const hit = world.castRay(ray, FOLLOW_DISTANCE, true, rapier.QueryFilterFlags.EXCLUDE_SENSORS);
const distance = hit ? Math.max(0.8, hit.timeOfImpact - 0.25) : FOLLOW_DISTANCE;
const desired = anchor.clone().addScaledVector(backward, distance);
camera.position.lerp(desired, 1 - Math.exp(-12 * Math.min(delta, 0.1)));
camera.lookAt(anchor);
});
return null;
}
+13 -4
View File
@@ -69,6 +69,9 @@ import {
activeEncounterSpawnGroupSpawns,
suppressCompletedDungeonPopulation,
} from "../game/dungeonEncounterSpawns";
import { useDungeonSimulationStore } from "../game/dungeonSimulationStore";
import { DungeonSpectatorCamera } from "./DungeonSpectatorCamera";
import { DungeonSimulationSceneBridge } from "./DungeonSimulationSceneBridge";
function DungeonCameraRange({ far }: { readonly far: number }) {
const camera = useThree((state) => state.camera);
@@ -170,6 +173,8 @@ export function GameScene() {
const paused = useGameStore((state) => state.paused);
const mapOpen = useGameStore((state) => state.mapOpen);
const companionOpen = useGameStore((state) => state.companionOpen);
const simulationSessionMode = useDungeonSimulationStore((state) => state.sessionMode);
const simulationRunState = useDungeonSimulationStore((state) => state.runState);
const gameplayFrameRate = useCombatStore((state) => state.settings.gameplayFrameRate);
const sharedOnline = useOnlineGroupStore((state) => state.activeActivity !== null);
const onlineAuthority = useOnlineSessionStore((state) => state.snapshot?.authority ?? true);
@@ -383,15 +388,19 @@ export function GameScene() {
<Physics
key={encounterWorldKey(activeDungeonId, sessionRevision)}
gravity={[0, PLAYER_GRAVITY, 0]}
paused={simulationBlocked}
paused={simulationBlocked || (simulationSessionMode === "spectate-live" && simulationRunState !== "running")}
timeStep={1 / 60}
>
<DungeonEnvironment onReady={markEnvironmentReady} />
{worldReady && (
<>
<PlayerRig />
<OnlinePlayerPopulation active={!simulationBlocked} />
<PartyPopulation active={!simulationBlocked} simulate={!sharedOnline || onlineAuthority} />
{simulationSessionMode === "play" ? <PlayerRig /> : <DungeonSpectatorCamera />}
{simulationSessionMode === "play" && <OnlinePlayerPopulation active={!simulationBlocked} />}
<PartyPopulation
active={!simulationBlocked}
simulate={simulationSessionMode !== "spectate-replay" && (!sharedOnline || onlineAuthority)}
/>
<DungeonSimulationSceneBridge />
<GroundEffectTelegraphs />
{gameMode === "dungeon" && (
<DungeonWorldObjects definitions={dungeon.serverGameObjects} />
+66 -9
View File
@@ -57,6 +57,7 @@ import {
samplePackFormation,
} from "../game/mobPopulation";
import { useGameStore } from "../game/store";
import { dungeonSimulationNow, useDungeonSimulationStore } from "../game/dungeonSimulationStore";
import {
manastormExperienceReward,
manastormRuntimeScaling,
@@ -913,7 +914,7 @@ function ProxyMob({
const attackRevision = mob?.attackRevision ?? 0;
const attackAnimation = mob?.attackAnimation ?? "attack";
const woundRevision = mob?.woundRevision ?? 0;
const statusNow = Date.now();
const statusNow = dungeonSimulationNow();
const movementLocked = mob?.statuses.some((status) => status.endsAt > statusNow
&& MOVEMENT_LOCKING_MOB_STATUSES.has(status.kind)) ?? false;
const feared = mob?.statuses.some((status) => status.endsAt > statusNow
@@ -1106,7 +1107,7 @@ function MobHealthPlate({ instanceId, height, boss }: {
if (plateRef.current) plateRef.current.lookAt(camera.position);
if (castFillRef.current && mob?.activeCast) {
const duration = Math.max(1, mob.activeCast.completesAt - mob.activeCast.startedAt);
const now = Date.now();
const now = dungeonSimulationNow();
const progress = Math.max(0.001, Math.min(1, (now - mob.activeCast.startedAt) / duration));
castFillRef.current.scale.x = progress;
castFillRef.current.position.x = -width * (1 - progress) / 2;
@@ -1212,6 +1213,7 @@ function RoamingPack({
const nearby = usePopulationProximity(scratchPositions);
const memberRefs = useRef<Array<Group | null>>([]);
const chasingRefs = useRef<boolean[]>(definition.members.map(() => false));
const simulationElapsedRef = useRef(0);
useEffect(() => {
const cleanups = definition.members.map((member, index) => registerMobRuntime(
@@ -1226,8 +1228,13 @@ function RoamingPack({
if (!nearby) return;
const combat = useCombatStore.getState();
const simulation = useDungeonSimulationStore.getState();
const movementDelta = simulation.sessionMode === "spectate-live"
? Math.max(0, (simulation.elapsedMs - simulationElapsedRef.current) / 1_000)
: simulation.sessionMode === "spectate-replay" ? 0 : delta;
simulationElapsedRef.current = simulation.elapsedMs;
const playerPosition = useGameStore.getState().playerPosition;
const now = Date.now();
const now = dungeonSimulationNow();
const anyEngaged = definition.members.some((member) => combat.mobs[`${definition.id}:${member.id}`]?.engaged);
if (active && !anyEngaged) {
let movementMultiplier = 1;
@@ -1242,7 +1249,7 @@ function RoamingPack({
const slow = statuses.find((status) => status.endsAt > now && status.kind === "slow");
if (slow) movementMultiplier = Math.min(movementMultiplier, 1 - (slow.magnitude ?? 0.5));
}
distanceRef.current = (distanceRef.current + Math.min(delta, 0.05) * definition.speed * movementMultiplier)
distanceRef.current = (distanceRef.current + Math.min(movementDelta, 0.5) * definition.speed * movementMultiplier)
% path.totalLength;
}
@@ -1259,6 +1266,20 @@ function RoamingPack({
const forward = scratchForwards[index];
const member = definition.members[index];
const instanceId = `${definition.id}:${member.id}`;
if (simulation.sessionMode === "spectate-replay") {
const replayTransform = getMobRuntimeTransform(instanceId);
if (replayTransform) {
const object = memberRefs.current[index];
if (object) {
object.position.set(...replayTransform.position);
object.rotation.y = replayTransform.yaw;
}
chasePositions[index][0] = replayTransform.position[0];
chasePositions[index][1] = replayTransform.position[1];
chasePositions[index][2] = replayTransform.position[2];
}
continue;
}
const synchronizedTransform = onlineSynchronizedMobTransform(instanceId);
if (synchronizedTransform) {
const object = memberRefs.current[index];
@@ -1389,8 +1410,27 @@ function StaticPopulationMember({ spawn, entities, active, showLabels, useOrigin
const entity = entities[spawn.entityId];
const groupRef = useRef<Group>(null);
const positionRef = useRef<MutableMobVector3>([...spawn.position]);
const simulationElapsedRef = useRef(0);
useEffect(() => registerMobRuntime(spawn.id, spawn.position, spawn.yaw ?? 0), [spawn.id, spawn.position, spawn.yaw]);
useFrame((_, delta) => {
const simulation = useDungeonSimulationStore.getState();
const movementDelta = simulation.sessionMode === "spectate-live"
? Math.max(0, (simulation.elapsedMs - simulationElapsedRef.current) / 1_000)
: simulation.sessionMode === "spectate-replay" ? 0 : delta;
simulationElapsedRef.current = simulation.elapsedMs;
if (simulation.sessionMode === "spectate-replay") {
const replayTransform = getMobRuntimeTransform(spawn.id);
if (replayTransform) {
positionRef.current[0] = replayTransform.position[0];
positionRef.current[1] = replayTransform.position[1];
positionRef.current[2] = replayTransform.position[2];
if (groupRef.current) {
groupRef.current.position.set(...replayTransform.position);
groupRef.current.rotation.y = replayTransform.yaw;
}
}
return;
}
const synchronizedTransform = onlineSynchronizedMobTransform(spawn.id);
if (synchronizedTransform) {
positionRef.current[0] = synchronizedTransform.position[0];
@@ -1413,7 +1453,7 @@ function StaticPopulationMember({ spawn, entities, active, showLabels, useOrigin
const position = positionRef.current;
const player = useGameStore.getState().playerPosition;
const targetPosition = mobTargetPosition(mob?.targetActorId, player);
const now = Date.now();
const now = dungeonSimulationNow();
let feared = false;
let movementLocked = false;
if (mob?.dead) {
@@ -1423,7 +1463,7 @@ function StaticPopulationMember({ spawn, entities, active, showLabels, useOrigin
&& MOVEMENT_LOCKING_MOB_STATUSES.has(status.kind));
feared = mob.statuses.some((status) => status.endsAt > now && status.kind === "fear");
const slow = mob.statuses.find((status) => status.endsAt > now && status.kind === "slow")?.magnitude ?? 0;
const maximumStep = Math.min(delta, 0.05) * mob.moveSpeed * Math.max(0.1, 1 - slow);
const maximumStep = Math.min(movementDelta, 0.5) * mob.moveSpeed * Math.max(0.1, 1 - slow);
if (active && !movementLocked && feared) {
stepFearedMobAway(
position,
@@ -1438,7 +1478,7 @@ function StaticPopulationMember({ spawn, entities, active, showLabels, useOrigin
maximumStep, active, now, movementLocked || feared);
} else if (mob?.combatPhase === "returning") {
navigateMob(navigator, mob, position, targetPosition, mob.homePosition ?? spawn.position,
Math.min(delta, 0.05) * mob.moveSpeed * 1.25, active, now);
Math.min(movementDelta, 0.5) * mob.moveSpeed * 1.25, active, now);
} else {
position[0] = spawn.position[0];
position[1] = spawn.position[1];
@@ -1531,18 +1571,35 @@ function SummonedPopulationMember({
const groupRef = useRef<Group>(null);
const initial = getMobRuntimeTransform(instanceId)?.position ?? [0, 0, 0];
const positionRef = useRef<MutableMobVector3>([...initial]);
const simulationElapsedRef = useRef(0);
useFrame((_, delta) => {
const simulation = useDungeonSimulationStore.getState();
const movementDelta = simulation.sessionMode === "spectate-live"
? Math.max(0, (simulation.elapsedMs - simulationElapsedRef.current) / 1_000)
: simulation.sessionMode === "spectate-replay" ? 0 : delta;
simulationElapsedRef.current = simulation.elapsedMs;
if (simulation.sessionMode === "spectate-replay") {
const replayTransform = getMobRuntimeTransform(instanceId);
if (replayTransform && groupRef.current) {
positionRef.current[0] = replayTransform.position[0];
positionRef.current[1] = replayTransform.position[1];
positionRef.current[2] = replayTransform.position[2];
groupRef.current.position.set(...replayTransform.position);
groupRef.current.rotation.y = replayTransform.yaw;
}
return;
}
const mob = useCombatStore.getState().mobs[instanceId];
if (!mob || mob.dead || mob.despawned) return;
const player = useGameStore.getState().playerPosition;
const targetPosition = mobTargetPosition(mob.targetActorId, player);
const position = positionRef.current;
const now = Date.now();
const now = dungeonSimulationNow();
const movementLocked = mob.statuses.some((status) => status.endsAt > now
&& MOVEMENT_LOCKING_MOB_STATUSES.has(status.kind));
const feared = mob.statuses.some((status) => status.endsAt > now && status.kind === "fear");
const slow = mob.statuses.find((status) => status.endsAt > now && status.kind === "slow")?.magnitude ?? 0;
const maximumStep = Math.min(delta, 0.05) * mob.moveSpeed * Math.max(0.1, 1 - slow);
const maximumStep = Math.min(movementDelta, 0.5) * mob.moveSpeed * Math.max(0.1, 1 - slow);
if (active && !movementLocked && feared) {
stepFearedMobAway(
position,
+105 -33
View File
@@ -106,6 +106,13 @@ import {
PARTY_FLOOR_RAY_DEPTH,
PARTY_FLOOR_RAY_HEIGHT,
} from "./partyTraversal";
import {
activeDungeonRouteTarget,
routeForActiveDungeon,
type ActiveDungeonRouteTarget,
} from "../game/dungeonRouteRuntime";
import { useDungeonSimulationStore } from "../game/dungeonSimulationStore";
import { useDungeonRouteStore } from "../game/dungeonRouteStore";
const PARTY_MOVE_SPEED = 5.25;
const PARTY_CATCH_UP_SPEED = 7.5;
@@ -363,6 +370,7 @@ function PartyActors({
const movementProfilesRef = useRef(new Map<string, PartyMovementProfile>());
const directFollowMovingRef = useRef(new Map<string, boolean>());
const combatAnchorsRef = useRef(new Map<string, PartyCombatAnchor>());
const lastSimulationElapsedRef = useRef(0);
const memberIdKey = members.map((member) => member.id).join("|");
useEffect(() => registerCombatSpatialProvider({
actor: (actorId) => {
@@ -611,10 +619,15 @@ function PartyActors({
return;
}
const game = useGameStore.getState();
const simulation = useDungeonSimulationStore.getState();
const playerNavigation = game.playerNavigationPosition;
appendPartyBreadcrumb(playerTrailRef.current, playerNavigation);
const routeNow = clock.elapsedTime * 1_000;
const combatNow = Date.now();
const routeNow = simulation.sessionMode === "spectate-live"
? simulation.elapsedMs
: clock.elapsedTime * 1_000;
const combatNow = simulation.sessionMode === "spectate-live"
? simulation.startedAt + simulation.elapsedMs
: Date.now();
for (const member of party.members) {
if (member.health <= 0) {
finishPartyRuntimeRejoin(member.id);
@@ -673,7 +686,13 @@ function PartyActors({
}
const objective = objectives.find((candidate) => candidate.id === party.objectiveBossId) ?? null;
const leaderIndex = party.members.findIndex((member) => member.health > 0 && !partyRuntimeIsRejoining(member.id));
const safeDelta = Math.min(delta, 0.05);
const simulationDelta = simulation.sessionMode === "spectate-live"
? Math.max(0, (simulation.elapsedMs - lastSimulationElapsedRef.current) / 1_000)
: delta;
lastSimulationElapsedRef.current = simulation.elapsedMs;
const safeDelta = simulation.sessionMode === "spectate-live"
? Math.min(simulationDelta, 0.5)
: Math.min(delta, 0.05);
const desired: MutablePartyWorldPosition = [0, 0, 0];
const nextPosition: MutablePartyWorldPosition = [0, 0, 0];
const leaderDestination: MutablePartyWorldPosition = [0, 0, 0];
@@ -696,6 +715,7 @@ function PartyActors({
let leaderFollowingPlayer = false;
let leaderPlayerTrailBlocked = false;
let forceLeaderBreadcrumb = false;
let authoredRouteTarget: ActiveDungeonRouteTarget | null = null;
const leaderMember = leaderIndex >= 0 ? party.members[leaderIndex] : null;
const leaderPosition = leaderMember ? positionsRef.current.get(leaderMember.id) : null;
@@ -763,18 +783,28 @@ function PartyActors({
// Imported source spawns can sit on decorative or discarded Recast
// islands. Route to the nearest retained walkable surface and let the
// normal aggro/combat path take over when the encounter engages.
const objectiveApproach = navigationGraph
authoredRouteTarget = activeDungeonRouteTarget(
game.activeDungeonId,
objective.id,
leaderPosition,
leader.id,
combatNow,
);
const objectiveApproach = authoredRouteTarget?.target ?? (navigationGraph
? findNearestPartyNavigationSurface(
navigationGraph,
objective.position,
PARTY_OBJECTIVE_APPROACH_SNAP_DISTANCE,
)?.position
: null;
: null);
const objectiveDestination = objectiveApproach ?? objective.position;
leaderDestination[0] = objectiveDestination[0];
leaderDestination[1] = objectiveDestination[1];
leaderDestination[2] = objectiveDestination[2];
goalKey = `leader:${leader.id}|objective:${objective.id}`;
goalKey = authoredRouteTarget
? `leader:${leader.id}|route:${authoredRouteTarget.route.hash}|${authoredRouteTarget.leg.id}|${authoredRouteTarget.checkpointIndex}`
: `leader:${leader.id}|objective:${objective.id}`;
if (authoredRouteTarget) leaderLocalFinishDistance = 0.3;
} else {
leaderFollowingPlayer = true;
leaderDestination[0] = playerNavigation[0];
@@ -918,27 +948,38 @@ function PartyActors({
leaderDestination,
routeNow,
)) {
routeRef.current = planPartyRoute(
navigationGraph,
goalKey,
leaderPosition,
leaderDestination,
routeNow,
{
maxOriginSnapDistance: PARTY_NAVIGATION_SNAP_DISTANCE,
maxDestinationSnapDistance: 5,
localFinishDistance: leaderLocalFinishDistance,
directSegmentAllowed,
routeSegmentAllowed: navigationRouteSegmentAllowed,
destinationReached: targetPosition ? (surface) => {
const band = personalizedPartyCombatRangeBand(partyCombatRangeBand(leaderMember),
movementProfileFor(leaderMember.id, leaderIndex));
const distance = fullDistance(surface, targetPosition);
return distance >= band.minimum && distance <= band.maximum
&& hasStaticLineOfSight(surface, targetPosition);
} : undefined,
},
);
routeRef.current = authoredRouteTarget
&& !navigationGraph
&& directSegmentAllowed(leaderPosition, leaderDestination)
? {
goalKey,
destination: [leaderDestination[0], leaderDestination[1], leaderDestination[2]],
waypoints: [[leaderDestination[0], leaderDestination[1], leaderDestination[2]]],
cursor: 0,
plannedAt: routeNow,
blocked: false,
}
: planPartyRoute(
navigationGraph,
goalKey,
leaderPosition,
leaderDestination,
routeNow,
{
maxOriginSnapDistance: PARTY_NAVIGATION_SNAP_DISTANCE,
maxDestinationSnapDistance: 5,
localFinishDistance: leaderLocalFinishDistance,
directSegmentAllowed,
routeSegmentAllowed: navigationRouteSegmentAllowed,
destinationReached: targetPosition ? (surface) => {
const band = personalizedPartyCombatRangeBand(partyCombatRangeBand(leaderMember),
movementProfileFor(leaderMember.id, leaderIndex));
const distance = fullDistance(surface, targetPosition);
return distance >= band.minimum && distance <= band.maximum
&& hasStaticLineOfSight(surface, targetPosition);
} : undefined,
},
);
}
leaderHasWaypoint = nextPartyRouteWaypoint(routeRef.current, leaderPosition, desired);
if (!leaderHasWaypoint && routeRef.current.blocked) {
@@ -960,6 +1001,19 @@ function PartyActors({
desired[2] = leaderDestination[2];
leaderHasWaypoint = true;
}
if (
!leaderHasWaypoint
&& authoredRouteTarget
&& routeRef.current.blocked
&& simulation.sessionMode === "play"
&& directSegmentAllowed(leaderPosition, playerNavigation)
) {
desired[0] = playerNavigation[0];
desired[1] = playerNavigation[1];
desired[2] = playerNavigation[2];
leaderHasWaypoint = true;
leaderFollowingPlayer = true;
}
}
}
if (leaderHasWaypoint) {
@@ -980,12 +1034,12 @@ function PartyActors({
&& !targetPosition
&& leaderPosition
&& !routeRef.current.blocked
&& routeRef.current.waypoints.length > routeRef.current.cursor
&& (Boolean(authoredRouteTarget) || routeRef.current.waypoints.length > routeRef.current.cursor)
) {
updatePartyRuntimeAttackCorridor(
leaderPosition,
routeRef.current.waypoints,
routeRef.current.cursor,
authoredRouteTarget?.remaining ?? routeRef.current.waypoints,
authoredRouteTarget ? 0 : routeRef.current.cursor,
);
} else {
clearPartyRuntimeAttackCorridor();
@@ -998,7 +1052,7 @@ function PartyActors({
&& (
leaderFollowingPlayer
? leaderPlayerTrailBlocked
: navigationFailed || Boolean(navigationGraph && routeRef.current.blocked)
: navigationFailed || Boolean((navigationGraph || authoredRouteTarget) && routeRef.current.blocked)
),
);
@@ -1376,6 +1430,20 @@ function PartyActors({
trailPoints: [...leaderTrailRef.current.points, ...playerTrailRef.current.points].map((point) => point.position),
validate: recoveryPointAllowed,
});
if (landing && simulation.sessionMode === "spectate-live") {
simulation.addFinding({
id: `recovery:${member.id}:${simulation.elapsedMs}`,
atMs: simulation.elapsedMs,
severity: "critical",
code: "position-recovery",
message: `${member.name} required a position-only recovery teleport.`,
position: [current[0], current[1], current[2]],
actorId: member.id,
objectiveId: simulation.objectiveId ?? undefined,
checkpointIndex: simulation.checkpointIndex,
});
simulation.addRecovery();
}
if (landing && applyPartyStuckRecoveryLanding(positionsRef.current, member.id, landing.position, stuckProgress, routeNow)) {
updatePartyRuntimePosition(member.id, current[0], current[1], current[2]);
actor.position.set(current[0], presentationY(current), current[2]);
@@ -1504,6 +1572,8 @@ export function PartyPopulation({
const sessionRevision = useGameStore((state) => state.sessionRevision);
const wailingPhase = useWailingEncounterStore((state) => state.phase);
const manastormEncounter = useManastormStore((state) => state.currentEncounter);
const simulationSessionMode = useDungeonSimulationStore((state) => state.sessionMode);
useDungeonRouteStore((state) => state.approvals);
const dungeon = requireDungeonDefinition(activeDungeonId);
const navigationReviewed = dungeon.validation.status === "green"
|| dungeon.validation.navigation === "green";
@@ -1520,13 +1590,15 @@ export function PartyPopulation({
// fragmented Recast islands do not claim autonomous route fidelity. Until
// reviewed links make navigation green, the player leads and the party
// follows/engages locally instead of displaying a false blocked route.
: navigationReviewed
: simulationSessionMode === "spectate-live" || Boolean(routeForActiveDungeon(activeDungeonId))
? dungeon.objectiveOrder.filter((objective) => !objective.optional)
: navigationReviewed
? activeDungeonId === "wailing-caverns"
&& wailingPhase !== "mutanus"
&& wailingPhase !== "complete"
? dungeon.objectiveOrder.filter((objective) => !objective.eventOnly)
: dungeon.objectiveOrder.filter((objective) => !objective.optional)
: [];
: [];
const assets = useMemo(
() => manastormPackage
? resolveManastormStageAssets(manastormPackage)
+2 -1
View File
@@ -7,6 +7,7 @@ import {
wailingDisciplePosition,
} from "../game/wailingCavernsEncounter";
import { WorldNpcPrompt } from "./WorldNpcPrompt";
import { dungeonSimulationNow } from "../game/dungeonSimulationStore";
export function WailingNaralexEvent() {
const activeDungeonId = useGameStore((state) => state.activeDungeonId);
@@ -17,7 +18,7 @@ export function WailingNaralexEvent() {
useFrame(() => {
if (activeDungeonId !== "wailing-caverns" || gameMode !== "dungeon") return;
const now = Date.now();
const now = dungeonSimulationNow();
const position = wailingDisciplePosition(
useWailingEncounterStore.getState().phase,
useWailingEncounterStore.getState().phaseStartedAt,
+252
View File
@@ -58,6 +58,18 @@ button { font: inherit; }
height: 100%;
}
.game-shell,
.arena-runtime {
-webkit-user-select: none;
user-select: none;
}
.game-shell :is(input, textarea, [contenteditable]:not([contenteditable="false"])),
.arena-runtime :is(input, textarea, [contenteditable]:not([contenteditable="false"])) {
-webkit-user-select: text;
user-select: text;
}
.scene-shell {
z-index: 0;
isolation: isolate;
@@ -2076,6 +2088,7 @@ html[data-display-layout="thor-preview"], html[data-display-layout="thor-preview
.main-menu-modes > header h2 { margin: 3px 0 4px; font: 28px Georgia, serif; font-weight: 400; }
.main-menu-modes > header > p:last-child { max-width: 570px; margin: 0; color: var(--front-dim); font-size: 9px; line-height: 1.45; }
.main-menu-mode-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-top: 14px; }
.main-menu-gm-stack { display: grid; gap: 8px; }
.main-menu-mode {
position: relative;
display: grid;
@@ -3200,6 +3213,166 @@ button.equipment-slot:hover { background: rgba(174,191,131,.1); }
.gm-boss-actions { display: grid; gap: 7px; }
.gm-admin-denied { display: grid; width: min(470px, calc(100% - 30px)); margin: auto; gap: 12px; padding: 26px; border: 1px solid var(--front-line); background: rgba(3, 10, 7, .75); }
/* GM server-side balance simulations */
.balance-lab-screen { display: grid; grid-template-rows: auto minmax(0, 1fr); }
.balance-lab-screen .gm-admin-header__actions .front-button:not(.front-button--primary) { display: block; }
.balance-lab-layout { display: grid; grid-template-columns: minmax(290px, .9fr) minmax(245px, .72fr) minmax(270px, .82fr); grid-template-rows: minmax(420px, 56vh) auto; min-height: 0; overflow: auto; align-content: start; }
.balance-lab-config, .balance-lab-jobs, .balance-lab-detail { min-width: 0; min-height: 0; padding: 16px; overflow: auto; }
.balance-lab-config, .balance-lab-jobs { border-right: 1px solid var(--front-line); }
.balance-lab-config h2, .balance-lab-jobs h2, .balance-lab-detail h2 { margin: 4px 0 10px; font-family: Georgia, serif; font-size: 20px; font-weight: 400; }
.balance-lab-help, .balance-lab-local-note, .balance-lab-detail > p:not(.front-kicker), .balance-lab-empty { color: var(--front-dim); font-size: 8px; line-height: 1.45; }
.balance-lab-form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; margin-top: 14px; }
.balance-lab-form-grid label { display: grid; gap: 4px; color: var(--front-dim); font-size: 8px; }
.balance-lab-form-grid__wide { grid-column: 1 / -1; }
.balance-lab-form-grid input, .balance-lab-form-grid select, .balance-lab-boss-picker input {
width: 100%; min-width: 0; padding: 8px; border: 1px solid var(--front-line); border-radius: 6px; outline: 0; color: var(--front-cream); background: rgba(2, 9, 6, .88); font: inherit;
}
.balance-lab-form-grid input:focus, .balance-lab-form-grid select:focus, .balance-lab-boss-picker input:focus { border-color: rgba(169, 211, 132, .65); }
.balance-lab-boss-picker { margin-top: 10px; border: 1px solid var(--front-line); border-radius: 8px; overflow: hidden; }
.balance-lab-boss-picker > header { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 8px; padding: 8px; border-bottom: 1px solid var(--front-line); }
.balance-lab-boss-picker > header strong { color: #d7c68c; font-size: 8px; white-space: nowrap; }
.balance-lab-boss-picker > div { display: grid; max-height: 220px; overflow: auto; }
.balance-lab-boss-picker label { display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: center; gap: 8px; padding: 7px 8px; border-bottom: 1px solid rgba(190, 213, 172, .08); cursor: pointer; }
.balance-lab-boss-picker label:hover, .balance-lab-boss-picker label.is-selected { background: rgba(149, 190, 118, .1); }
.balance-lab-boss-picker label input { width: 15px; height: 15px; accent-color: #91bc75; }
.balance-lab-boss-picker label span, .balance-lab-boss-picker label strong, .balance-lab-boss-picker label small { display: block; min-width: 0; }
.balance-lab-boss-picker label strong { overflow: hidden; font-size: 9px; text-overflow: ellipsis; white-space: nowrap; }
.balance-lab-boss-picker label small { margin-top: 2px; color: var(--front-dim); font-size: 7px; }
.balance-lab-estimate { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 5px; margin-top: 10px; }
.balance-lab-estimate span { display: grid; gap: 3px; padding: 8px; border: 1px solid rgba(190, 213, 172, .11); border-radius: 6px; color: var(--front-dim); font-size: 7px; }
.balance-lab-estimate b { color: var(--front-cream); font-size: 11px; }
.balance-lab-run { width: 100%; margin-top: 10px; }
.balance-lab-local-note { margin: 8px 0 0; }
.balance-lab-local-note code { color: #d7c68c; }
.balance-lab-jobs { display: grid; grid-template-rows: auto auto minmax(0, 1fr); }
.balance-lab-jobs > header { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.balance-lab-jobs > header button { padding: 6px 9px; border: 1px solid var(--front-line); border-radius: 6px; color: var(--front-cream); background: transparent; cursor: pointer; font-size: 8px; }
.balance-lab-history-filters { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 6px; margin-bottom: 8px; }
.balance-lab-history-filters input, .balance-lab-history-filters select { min-width: 0; padding: 7px 8px; border: 1px solid var(--front-line); border-radius: 6px; outline: 0; color: var(--front-cream); background: rgba(2, 9, 6, .88); font: inherit; font-size: 8px; }
.balance-lab-history-filters input:focus, .balance-lab-history-filters select:focus { border-color: rgba(169, 211, 132, .65); }
.balance-lab-job-list { min-height: 0; overflow: auto; border: 1px solid var(--front-line); border-radius: 8px; }
.balance-lab-job-list > button { display: grid; width: 100%; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 8px; padding: 10px; border: 0; border-bottom: 1px solid rgba(190, 213, 172, .08); color: var(--front-cream); background: transparent; cursor: pointer; text-align: left; }
.balance-lab-job-list > button:hover, .balance-lab-job-list > button.is-selected { background: rgba(149, 190, 118, .1); }
.balance-lab-job-list > button.is-selected { box-shadow: inset 3px 0 #9dcc7c; }
.balance-lab-job-list span, .balance-lab-job-list strong, .balance-lab-job-list small { display: block; min-width: 0; }
.balance-lab-job-list strong { font-size: 9px; }
.balance-lab-job-list small { margin-top: 3px; color: var(--front-dim); font-size: 7px; }
.balance-lab-job-list em { padding: 4px 6px; border-radius: 4px; color: #e8e3c8; background: rgba(173, 144, 76, .18); font-size: 7px; font-style: normal; text-transform: uppercase; }
.balance-lab-job-list em.is-complete { color: #c9f0b6; background: rgba(85, 146, 78, .24); }
.balance-lab-job-list em.is-failed { color: #f0b6ac; background: rgba(151, 62, 53, .24); }
.balance-lab-job-list em.is-running { color: #cce8ff; background: rgba(68, 125, 158, .24); }
.balance-lab-empty { padding: 12px; }
.balance-lab-detail { background: rgba(3, 10, 7, .48); }
.balance-lab-progress { height: 8px; margin: 12px 0; overflow: hidden; border: 1px solid var(--front-line); border-radius: 999px; background: rgba(0,0,0,.32); }
.balance-lab-progress span { display: block; height: 100%; background: linear-gradient(90deg, #4e8f64, #92c889); transition: width 180ms linear; }
.balance-lab-detail dl div { display: flex; justify-content: space-between; gap: 8px; padding: 7px 0; border-top: 1px solid rgba(190, 213, 172, .09); font-size: 8px; }
.balance-lab-detail dd { margin: 0; color: #d7c68c; text-align: right; }
.balance-lab-artifacts { display: grid; gap: 6px; margin-top: 14px; }
.balance-lab-artifacts button { text-align: left; }
.balance-lab-log { margin-top: 14px; padding-top: 10px; border-top: 1px solid var(--front-line); }
.balance-lab-log summary { color: var(--front-dim); cursor: pointer; font-size: 8px; }
.balance-lab-log pre { max-height: 210px; overflow: auto; padding: 8px; border-radius: 6px; color: #bad3ae; background: rgba(0,0,0,.28); font: 7px/1.45 monospace; white-space: pre-wrap; }
/* Balance Lab historical run analysis */
.balance-lab-analysis { grid-column: 1 / -1; min-width: 0; border-top: 1px solid var(--front-line); background: linear-gradient(180deg, rgba(4, 14, 10, .94), rgba(2, 9, 7, .98)); }
.balance-results { min-width: 0; padding: 20px; }
.balance-results-loading { min-height: 180px; margin: 0; padding: 30px 20px; color: var(--front-dim); font: 10px Georgia, serif; }
.balance-results__header { display: grid; grid-template-columns: minmax(240px, .72fr) minmax(460px, 1.28fr); gap: 20px; align-items: end; }
.balance-results__header h2 { margin: 4px 0 5px; font: 25px/1.1 Georgia, serif; }
.balance-results__header > div > p:not(.front-kicker) { margin: 0; color: var(--front-dim); font-size: 8px; line-height: 1.5; }
.balance-results__filters { display: grid; grid-template-columns: minmax(165px, 1.4fr) repeat(3, minmax(110px, .75fr)); gap: 7px; }
.balance-results__filters label { display: grid; min-width: 0; gap: 4px; color: var(--front-dim); font-size: 7px; }
.balance-results__filters input, .balance-results__filters select, .balance-results-sort select { width: 100%; min-width: 0; padding: 8px; border: 1px solid var(--front-line); border-radius: 6px; outline: 0; color: var(--front-cream); background: rgba(1, 7, 5, .94); font: inherit; font-size: 8px; }
.balance-results__filters input:focus, .balance-results__filters select:focus, .balance-results-sort select:focus { border-color: rgba(169, 211, 132, .65); }
.balance-results__tabs { display: flex; gap: 4px; margin: 18px 0 14px; padding-bottom: 8px; border-bottom: 1px solid var(--front-line); }
.balance-results__tabs button { padding: 7px 12px; border: 1px solid transparent; border-radius: 6px; color: var(--front-dim); background: transparent; cursor: pointer; font: inherit; font-size: 8px; text-transform: capitalize; }
.balance-results__tabs button:hover { color: var(--front-cream); background: rgba(149, 190, 118, .06); }
.balance-results__tabs button.is-active { border-color: rgba(157, 204, 124, .34); color: #dcefcf; background: rgba(110, 159, 84, .16); box-shadow: inset 0 -2px #83b76c; }
.balance-results-overview { display: grid; gap: 12px; }
.balance-results-summary { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 7px; }
.balance-results-summary article { display: grid; gap: 5px; padding: 11px 12px; border: 1px solid rgba(190, 213, 172, .12); border-radius: 7px; background: rgba(9, 24, 17, .46); }
.balance-results-summary span { color: var(--front-dim); font-size: 7px; text-transform: uppercase; letter-spacing: .05em; }
.balance-results-summary strong { color: #e6dfbf; font: 17px/1 Georgia, serif; }
.balance-results-summary small { color: var(--front-dim); font: 7px system-ui, sans-serif; }
.balance-results-charts { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; align-items: start; }
.balance-results-chart { min-width: 0; overflow: hidden; border: 1px solid rgba(190, 213, 172, .13); border-radius: 8px; background: rgba(2, 10, 7, .72); }
.balance-results-chart > header { display: flex; align-items: start; justify-content: space-between; gap: 10px; padding: 12px; border-bottom: 1px solid rgba(190, 213, 172, .1); }
.balance-results-chart h4 { margin: 0; font: 15px/1.2 Georgia, serif; }
.balance-results-chart header p { margin: 4px 0 0; color: var(--front-dim); font-size: 7px; line-height: 1.4; }
.balance-results-chart header > strong { min-width: 24px; padding: 4px 6px; border-radius: 999px; color: #d6e8c9; background: rgba(126, 176, 96, .14); font-size: 7px; text-align: center; }
.balance-results-chart__plot { display: grid; max-height: 328px; gap: 2px; overflow-y: auto; padding: 9px 11px; }
.balance-results-bar { display: grid; grid-template-columns: minmax(105px, 34%) minmax(70px, 1fr) minmax(44px, auto); align-items: center; gap: 8px; min-height: 18px; }
.balance-results-bar__label { display: block; min-width: 0; }
.balance-results-bar__label b, .balance-results-bar__label small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.balance-results-bar__label b { color: #dce5d5; font-size: 7px; font-weight: 600; }
.balance-results-bar__label small { margin-top: 1px; color: #718173; font-size: 6px; }
.balance-results-bar__track { height: 7px; overflow: hidden; border-radius: 999px; background: rgba(255,255,255,.045); }
.balance-results-bar__track i { display: block; height: 100%; border-radius: inherit; background: linear-gradient(90deg, #8a3f37, #d47c65); box-shadow: 0 0 8px rgba(213, 113, 88, .2); }
.balance-results-chart.is-party .balance-results-bar__track i { background: linear-gradient(90deg, #366e52, #79bd7a); }
.balance-results-chart.is-time .balance-results-bar__track i { background: linear-gradient(90deg, #766130, #d1aa51); }
.balance-results-chart.is-class .balance-results-bar__track i { background: linear-gradient(90deg, #395e76, #6faac4); }
.balance-results-chart.is-taken .balance-results-bar__track i { background: linear-gradient(90deg, #6b3d70, #b376b5); }
.balance-results-bar > strong { color: #d8d2b5; font-size: 7px; text-align: right; white-space: nowrap; }
.balance-results-chart > footer { padding: 7px 11px; border-top: 1px solid rgba(190, 213, 172, .08); color: var(--front-dim); font-size: 6px; }
.balance-results-empty { padding: 18px 8px; color: var(--front-dim); font-size: 8px; text-align: center; }
.balance-results-table-panel { min-width: 0; }
.balance-results-table-panel > header { display: flex; align-items: end; justify-content: space-between; gap: 12px; margin-bottom: 9px; }
.balance-results-table-panel h3 { margin: 0; font: 17px/1.2 Georgia, serif; }
.balance-results-table-panel header p { margin: 4px 0 0; color: var(--front-dim); font-size: 7px; }
.balance-results-sort { display: grid; grid-template-columns: minmax(145px, 1fr) auto; gap: 6px; min-width: min(330px, 46vw); }
.balance-results-sort button { padding: 7px 9px; border: 1px solid var(--front-line); border-radius: 6px; color: var(--front-cream); background: rgba(1, 7, 5, .8); cursor: pointer; font: inherit; font-size: 7px; white-space: nowrap; }
.balance-results-table-wrap { max-height: min(620px, 66vh); overflow: auto; border: 1px solid var(--front-line); border-radius: 8px; background: rgba(1, 7, 5, .56); }
.balance-results-table { width: 100%; }
.balance-results table { width: 100%; border-collapse: collapse; font-size: 7px; }
.balance-results th, .balance-results td { padding: 8px 9px; border-bottom: 1px solid rgba(190, 213, 172, .075); text-align: right; white-space: nowrap; }
.balance-results th { position: sticky; z-index: 1; top: 0; color: #aebca8; background: #09120e; font-size: 6px; letter-spacing: .035em; text-transform: uppercase; }
.balance-results th:first-child, .balance-results td:first-child, .balance-results td.balance-results-members { text-align: left; }
.balance-results tbody tr:hover { background: rgba(146, 195, 112, .055); }
.balance-results td strong, .balance-results td small { display: block; }
.balance-results td strong { color: #dfe7d7; font-size: 8px; }
.balance-results td small { max-width: 180px; margin-top: 2px; overflow: hidden; color: var(--front-dim); font-size: 6px; text-overflow: ellipsis; }
.balance-results td.balance-results-members { min-width: 300px; max-width: 540px; white-space: normal; line-height: 1.5; }
.balance-results-flag { display: inline-block; padding: 3px 5px; border-radius: 4px; color: #cbd7c5; background: rgba(116, 138, 107, .14); }
.balance-results-flag.is-high { color: #efb8aa; background: rgba(155, 65, 50, .18); }
.balance-results-flag.is-low { color: #b8d6ee; background: rgba(60, 111, 147, .18); }
.balance-results-abilities { display: grid; min-width: 0; gap: 10px; }
.balance-results-abilities > .balance-results-chart { width: 100%; }
.balance-results-summary--abilities article:nth-child(3) strong { color: #efb8aa; }
.balance-results-ability-controls { display: grid; grid-template-columns: minmax(170px, 1.15fr) minmax(145px, .9fr) minmax(145px, .9fr) auto; gap: 6px; min-width: min(680px, 60vw); }
.balance-results-ability-controls select { width: 100%; min-width: 0; padding: 7px 8px; border: 1px solid var(--front-line); border-radius: 6px; outline: 0; color: var(--front-cream); background: rgba(1, 7, 5, .8); font: inherit; font-size: 7px; }
.balance-results-ability-controls select:focus { border-color: rgba(169, 211, 132, .65); }
.balance-results-ability-controls button { padding: 7px 9px; border: 1px solid var(--front-line); border-radius: 6px; color: var(--front-cream); background: rgba(1, 7, 5, .8); cursor: pointer; font: inherit; font-size: 7px; white-space: nowrap; }
.balance-results-abilities th:nth-child(2), .balance-results-abilities td:nth-child(2) { text-align: left; }
.balance-results-ability-name { display: flex; align-items: center; gap: 7px; min-width: 150px; }
.balance-results-ability-name > img { width: 24px; height: 24px; flex: 0 0 auto; border: 1px solid rgba(210, 226, 194, .2); border-radius: 4px; object-fit: cover; background: #07100b; }
.balance-results-ability-name > span { display: block; min-width: 0; }
.balance-results-legacy-note { padding: 18px; border: 1px solid rgba(210, 184, 106, .24); border-radius: 8px; color: #d6c991; background: rgba(89, 69, 24, .12); font-size: 8px; line-height: 1.5; }
@media (max-width: 900px) {
.balance-lab-layout { grid-template-columns: minmax(290px, 1fr) minmax(250px, .8fr); }
.balance-lab-detail { grid-column: 1 / -1; border-top: 1px solid var(--front-line); }
.balance-results__header { grid-template-columns: 1fr; }
.balance-results__filters { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 600px) {
.balance-lab-layout { display: block; overflow: auto; }
.balance-lab-screen .gm-admin-header { grid-template-columns: auto minmax(0, 1fr) auto; gap: 9px; padding-inline: 10px; }
.balance-lab-screen .gm-admin-header__actions .front-button { padding-inline: 7px; font-size: 8px; }
.balance-lab-config, .balance-lab-jobs { border-right: 0; border-bottom: 1px solid var(--front-line); }
.balance-lab-form-grid { grid-template-columns: 1fr; }
.balance-lab-form-grid__wide { grid-column: auto; }
.balance-lab-analysis { border-top: 1px solid var(--front-line); }
.balance-results { padding: 14px 10px 20px; }
.balance-results__filters, .balance-results-summary, .balance-results-charts { grid-template-columns: 1fr; }
.balance-results__tabs { overflow-x: auto; }
.balance-results__tabs button { white-space: nowrap; }
.balance-results-table-panel > header { display: grid; }
.balance-results-sort { width: 100%; min-width: 0; }
.balance-results-ability-controls { width: 100%; min-width: 0; grid-template-columns: 1fr; }
.balance-results-bar { grid-template-columns: minmax(90px, 40%) minmax(55px, 1fr) minmax(40px, auto); gap: 5px; }
}
.gm-review-card {
position: absolute;
top: max(276px, calc(env(safe-area-inset-top) + 264px));
@@ -3830,3 +4003,82 @@ button.equipment-slot:hover { background: rgba(174,191,131,.1); }
.arena-combat-log { position: absolute; left: .7rem; top: 47%; color: #e4e3cc; font-size: .75rem; pointer-events: none; max-width: 18rem; }
.arena-team-frames button.is-selected { outline: 2px solid #ffda8b; }
@media (max-width: 760px) { .arena-toolbar { max-width: 100%; left: .5rem; justify-content: center; } .arena-toolbar button { font-size: .6rem; padding: .25rem; } }
.dungeon-sim-hud {
position: fixed;
inset: 16px 16px auto auto;
z-index: 70;
width: min(520px, calc(100vw - 32px));
max-height: calc(100vh - 32px);
overflow: auto;
color: #e8f7ff;
background: rgba(7, 14, 22, 0.92);
border: 1px solid rgba(99, 211, 255, 0.42);
border-radius: 12px;
box-shadow: 0 20px 70px rgba(0, 0, 0, 0.55);
padding: 14px;
backdrop-filter: blur(12px);
}
.dungeon-sim-hud__header,
.dungeon-sim-hud__controls,
.dungeon-sim-hud__status div,
.dungeon-sim-hud__findings header,
.dungeon-sim-hud__findings li {
display: flex;
align-items: center;
gap: 8px;
}
.dungeon-sim-hud__header { justify-content: space-between; }
.dungeon-sim-hud__header div { display: grid; }
.dungeon-sim-hud__header small { color: #86b7ca; text-transform: uppercase; letter-spacing: 0.08em; }
.dungeon-sim-hud__header strong { font-size: 1.08rem; }
.dungeon-sim-hud__header > time { color: #ffe578; font: 700 1.15rem/1 monospace; }
.dungeon-sim-hud__controls { flex-wrap: wrap; margin: 12px 0; }
.dungeon-sim-hud button { color: #dff7ff; background: #122a39; border: 1px solid #315b6e; border-radius: 6px; padding: 6px 9px; }
.dungeon-sim-hud button:hover:not(:disabled), .dungeon-sim-hud button.is-active { background: #1d526b; border-color: #70d7ff; }
.dungeon-sim-hud button.is-danger { color: #ffd8d8; border-color: #a94848; background: #502121; }
.dungeon-sim-hud button:disabled { opacity: 0.45; }
.dungeon-sim-hud__seek { display: grid; gap: 4px; margin-bottom: 10px; color: #9cc6d5; }
.dungeon-sim-hud__seek input { width: 100%; }
.dungeon-sim-hud__status { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 6px 14px; margin: 0 0 12px; }
.dungeon-sim-hud__status div { justify-content: space-between; min-width: 0; }
.dungeon-sim-hud__status dt { color: #82aaba; }
.dungeon-sim-hud__status dd { margin: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.dungeon-sim-hud__findings header { justify-content: space-between; border-top: 1px solid rgba(139, 210, 236, 0.2); padding-top: 10px; }
.dungeon-sim-hud__findings ol { display: grid; gap: 6px; list-style: none; margin: 8px 0 0; padding: 0; }
.dungeon-sim-hud__findings li { align-items: flex-start; color: #bdd5df; font-size: 0.82rem; }
.dungeon-sim-hud__findings li time { flex: 0 0 auto; color: #759aaa; font-family: monospace; }
.dungeon-sim-hud__findings li[data-severity="critical"] { color: #ffaaaa; }
.dungeon-sim-hud__findings li[data-severity="warning"] { color: #ffe09b; }
.dungeon-sim-admin { min-height: 100vh; padding: clamp(18px, 3vw, 42px); }
.dungeon-sim-admin__header { display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: 18px; margin-bottom: 24px; }
.dungeon-sim-admin__header h1, .dungeon-sim-admin__details h2 { margin: 0; }
.dungeon-sim-admin__header-actions { display: flex; gap: 8px; }
.dungeon-sim-admin__layout { display: grid; grid-template-columns: minmax(300px, 430px) minmax(420px, 1fr); gap: 22px; }
.dungeon-sim-admin__catalog, .dungeon-sim-admin__details { background: rgba(7, 14, 22, 0.8); border: 1px solid rgba(108, 189, 220, 0.28); border-radius: 14px; padding: 18px; }
.dungeon-sim-admin label { display: grid; gap: 5px; margin-bottom: 11px; color: #a7c6d2; }
.dungeon-sim-admin input, .dungeon-sim-admin select, .dungeon-sim-admin textarea { width: 100%; color: #e8f7ff; background: #0c1c27; border: 1px solid #315164; border-radius: 6px; padding: 8px; }
.dungeon-sim-admin textarea { min-height: 70px; resize: vertical; }
.dungeon-sim-admin fieldset { display: grid; gap: 8px; border: 1px solid rgba(111, 190, 220, 0.25); border-radius: 10px; margin: 16px 0 0; padding: 14px; }
.dungeon-sim-admin legend { color: #d6f2ff; padding: 0 6px; }
.dungeon-sim-admin__route-meta { display: grid; gap: 5px; margin-top: 14px; }
.dungeon-sim-admin__route-meta code { color: #7fb7c9; font-size: 0.68rem; overflow-wrap: anywhere; }
.route-review-status { width: fit-content; border: 1px solid #647b85; border-radius: 999px; padding: 3px 8px; font-size: 0.72rem; text-transform: uppercase; }
.route-review-status--approved { color: #9cf3b8; border-color: #3c9c61; }
.route-review-status--needswork { color: #ffb1a4; border-color: #b8564a; }
.dungeon-sim-admin__replays { display: grid; gap: 9px; margin: 14px 0 24px; }
.dungeon-sim-admin__replays article { display: flex; justify-content: space-between; gap: 12px; padding: 11px; background: rgba(18, 42, 57, 0.66); border-radius: 8px; }
.dungeon-sim-admin__replays article > div { display: grid; gap: 4px; }
.dungeon-sim-admin__replays article > div:last-child { display: flex; align-items: center; }
.dungeon-sim-admin__replays small { color: #8db0bd; }
.dungeon-sim-admin__legs { display: grid; gap: 7px; padding-left: 22px; }
.dungeon-sim-admin__legs li { padding: 7px 9px; background: rgba(18, 42, 57, 0.45); }
.dungeon-sim-admin__legs li > * { display: block; }
.dungeon-sim-admin__legs span, .dungeon-sim-admin__legs small { color: #91b4c1; }
@media (max-width: 900px) {
.dungeon-sim-admin__layout { grid-template-columns: 1fr; }
.dungeon-sim-admin__header { grid-template-columns: 1fr; }
.dungeon-sim-admin__replays article { flex-direction: column; }
}
+263
View File
@@ -0,0 +1,263 @@
import { useMemo, useState } from "react";
import type {
BalanceLabBossResult,
BalanceLabClassAbilityResult,
BalanceLabClassSpecResult,
BalanceLabPartyResult,
BalanceLabSuiteReport,
} from "../app/balanceLabClient";
type ResultsTab = "overview" | "bosses" | "classes" | "abilities" | "parties";
type PartySourceFilter = "all" | "wow" | "coa" | "rom";
type BossSourceFilter = "all" | "wow" | "rom";
interface ChartDatum {
readonly key: string;
readonly label: string;
readonly detail: string;
readonly value: number;
}
const number = new Intl.NumberFormat(undefined, { maximumFractionDigits: 1 });
const wholeNumber = new Intl.NumberFormat(undefined, { maximumFractionDigits: 0 });
function formatNumber(value: number | null | undefined): string {
return value === null || value === undefined || !Number.isFinite(value) ? "—" : number.format(value);
}
function formatWhole(value: number | null | undefined): string {
return value === null || value === undefined || !Number.isFinite(value) ? "—" : wholeNumber.format(value);
}
function formatPercent(value: number | null | undefined): string {
return value === null || value === undefined || !Number.isFinite(value) ? "—" : `${number.format(value * 100)}%`;
}
function formatSeconds(value: number | null | undefined): string {
return value === null || value === undefined || !Number.isFinite(value) ? "—" : `${number.format(value)}s`;
}
function formatRatio(value: number | null | undefined): string {
return value === null || value === undefined || !Number.isFinite(value) ? "—" : `${number.format(value)}×`;
}
function flagClass(flag: string): string {
if (flag === "high" || flag === "very high") return "is-high";
if (flag === "low" || flag === "very low") return "is-low";
return "is-baseline";
}
function BarChart({
title,
description,
rows,
format = formatNumber,
tone = "damage",
}: {
readonly title: string;
readonly description: string;
readonly rows: readonly ChartDatum[];
readonly format?: (value: number) => string;
readonly tone?: "damage" | "party" | "time" | "class" | "taken";
}) {
const sorted = [...rows].filter((row) => Number.isFinite(row.value)).sort((left, right) => right.value - left.value);
const visible = sorted.slice(0, 15);
const maximum = Math.max(1, ...visible.map((row) => row.value));
return (
<article className={`balance-results-chart is-${tone}`} aria-label={title}>
<header><div><h4>{title}</h4><p>{description}</p></div><strong>{rows.length}</strong></header>
<div className="balance-results-chart__plot">
{visible.map((row) => (
<div className="balance-results-bar" key={row.key} title={`${row.label}: ${format(row.value)}`}>
<span className="balance-results-bar__label"><b>{row.label}</b><small>{row.detail}</small></span>
<span className="balance-results-bar__track"><i style={{ width: `${Math.max(1.5, (row.value / maximum) * 100)}%` }} /></span>
<strong>{format(row.value)}</strong>
</div>
))}
{visible.length === 0 && <p className="balance-results-empty">No matching data for this graph.</p>}
</div>
{sorted.length > visible.length && <footer>Showing the highest 15 of {sorted.length}. The complete filtered set is available in the tables.</footer>}
</article>
);
}
function sortNumeric<T>(rows: readonly T[], read: (row: T) => number | null, ascending: boolean): T[] {
return [...rows].sort((left, right) => {
const leftValue = read(left);
const rightValue = read(right);
const leftComparable = leftValue === null || !Number.isFinite(leftValue) ? Number.NEGATIVE_INFINITY : leftValue;
const rightComparable = rightValue === null || !Number.isFinite(rightValue) ? Number.NEGATIVE_INFINITY : rightValue;
return (leftComparable - rightComparable) * (ascending ? 1 : -1);
});
}
export function BalanceLabResults({ report }: { readonly report: BalanceLabSuiteReport }) {
const [tab, setTab] = useState<ResultsTab>("overview");
const [search, setSearch] = useState("");
const [partySource, setPartySource] = useState<PartySourceFilter>("all");
const [bossSource, setBossSource] = useState<BossSourceFilter>("all");
const [role, setRole] = useState("all");
const [bossSort, setBossSort] = useState<keyof BalanceLabBossResult>("incomingDtps");
const [classSort, setClassSort] = useState<keyof BalanceLabClassSpecResult>("bossDps");
const [abilityBuild, setAbilityBuild] = useState("all");
const [abilityStatus, setAbilityStatus] = useState("all");
const [abilitySort, setAbilitySort] = useState<keyof BalanceLabClassAbilityResult>("effectiveDamage");
const [partySort, setPartySort] = useState<keyof BalanceLabPartyResult>("partyDps");
const [ascending, setAscending] = useState(false);
const query = search.trim().toLowerCase();
const bosses = useMemo(() => report.bosses.filter((boss) => (
(bossSource === "all" || boss.category === bossSource)
&& (!query || `${boss.bossName} ${boss.dungeonTitle} ${boss.bossKey} ${boss.pressureFlag}`.toLowerCase().includes(query))
)), [bossSource, query, report.bosses]);
const classes = useMemo(() => report.classSpecs.filter((row) => (
(partySource === "all" || row.source === partySource)
&& (role === "all" || row.role === role)
&& (!query || `${row.className} ${row.specialization} ${row.role} ${row.source} ${row.damageFlag}`.toLowerCase().includes(query))
)), [partySource, query, report.classSpecs, role]);
const parties = useMemo(() => report.partyConfigurations.filter((row) => (
(partySource === "all" || row.source === partySource)
&& (!query || `${row.partyConfigurationId} ${row.source} ${row.members}`.toLowerCase().includes(query))
)), [partySource, query, report.partyConfigurations]);
const allAbilities = report.classAbilities ?? [];
const abilityBuildOptions = useMemo(() => {
const unique = new Map<string, BalanceLabClassAbilityResult>();
for (const row of allAbilities) unique.set(`${row.source}/${row.buildId}`, row);
return [...unique.entries()].sort((left, right) => (
left[1].source.localeCompare(right[1].source)
|| left[1].className.localeCompare(right[1].className)
|| left[1].specialization.localeCompare(right[1].specialization)
));
}, [allAbilities]);
const abilities = useMemo(() => allAbilities.filter((row) => {
const buildKey = `${row.source}/${row.buildId}`;
const matchesStatus = abilityStatus === "all"
|| (abilityStatus === "used" && row.activeRuns > 0)
|| (abilityStatus === "unused" && row.unusedAvailable)
|| (abilityStatus === "unused-damage" && row.unusedAvailable && /damage|execute|debuff/i.test(row.categories))
|| (abilityStatus === "damage" && row.effectiveDamage > 0)
|| (abilityStatus === "support" && (row.effectiveHealing > 0 || row.absorbs > 0));
return (partySource === "all" || row.source === partySource)
&& (role === "all" || row.role === role)
&& (abilityBuild === "all" || buildKey === abilityBuild)
&& matchesStatus
&& (!query || `${row.className} ${row.specialization} ${row.role} ${row.source} ${row.abilityName} ${row.abilityId} ${row.categories} ${row.status}`.toLowerCase().includes(query));
}), [abilityBuild, abilityStatus, allAbilities, partySource, query, role]);
const sortedBosses = useMemo(() => sortNumeric(bosses, (row) => {
const value = row[bossSort];
return typeof value === "number" ? value : null;
}, ascending), [ascending, bossSort, bosses]);
const sortedClasses = useMemo(() => sortNumeric(classes, (row) => {
const value = row[classSort];
return typeof value === "number" ? value : null;
}, ascending), [ascending, classSort, classes]);
const sortedAbilities = useMemo(() => sortNumeric(abilities, (row) => {
const value = row[abilitySort];
return typeof value === "number" ? value : null;
}, ascending), [abilities, abilitySort, ascending]);
const sortedParties = useMemo(() => sortNumeric(parties, (row) => {
const value = row[partySort];
return typeof value === "number" ? value : null;
}, ascending), [ascending, parties, partySort]);
const bossChart = (metric: keyof BalanceLabBossResult): ChartDatum[] => bosses.map((boss) => ({
key: boss.bossKey,
label: boss.bossName,
detail: boss.dungeonTitle,
value: typeof boss[metric] === "number" ? boss[metric] as number : Number.NaN,
}));
const classChart = (metric: keyof BalanceLabClassSpecResult): ChartDatum[] => classes.map((row) => ({
key: `${row.source}/${row.buildId}/${metric}`,
label: `${row.className} · ${row.specialization}`,
detail: `${row.source.toUpperCase()} · ${row.role}`,
value: typeof row[metric] === "number" ? row[metric] as number : Number.NaN,
}));
const abilityChart = (metric: keyof BalanceLabClassAbilityResult): ChartDatum[] => abilities.map((row) => ({
key: `${row.source}/${row.buildId}/${row.abilityId}/${metric}`,
label: row.abilityName,
detail: `${row.className} · ${row.specialization}`,
value: typeof row[metric] === "number" ? row[metric] as number : Number.NaN,
}));
const availableAbilityCount = abilities.filter((row) => row.availableRuns > 0).length;
const unusedAbilityCount = abilities.filter((row) => row.unusedAvailable).length;
const usedAbilityCount = abilities.filter((row) => row.activeRuns > 0).length;
const filteredAbilityDamage = abilities.reduce((total, row) => total + row.effectiveDamage, 0);
return (
<section className="balance-results" aria-label="Balance Lab report analysis">
<header className="balance-results__header">
<div>
<p className="front-kicker">Run analysis</p>
<h2>Level {report.level} balance report</h2>
<p>{new Date(report.generatedAt).toLocaleString()} · {report.summary.runs.toLocaleString()} simulations · every filtered row remains available below.</p>
</div>
<div className="balance-results__filters">
<label><span>Find anything</span><input type="search" value={search} onChange={(event) => setSearch(event.currentTarget.value)} placeholder="Boss, dungeon, class, spec, party…" /></label>
<label><span>Party source</span><select value={partySource} onChange={(event) => setPartySource(event.currentTarget.value as PartySourceFilter)}><option value="all">All party sources</option><option value="wow">WoW</option><option value="coa">CoA</option><option value="rom">RoM</option></select></label>
<label><span>Boss source</span><select value={bossSource} onChange={(event) => setBossSource(event.currentTarget.value as BossSourceFilter)}><option value="all">All boss sources</option><option value="wow">WoW</option><option value="rom">RoM</option></select></label>
<label><span>Class role</span><select value={role} onChange={(event) => setRole(event.currentTarget.value)}><option value="all">All roles</option><option value="tank">Tank</option><option value="healer">Healer</option><option value="damage">Damage</option></select></label>
</div>
</header>
<nav className="balance-results__tabs" aria-label="Balance report sections">
{(["overview", "bosses", "classes", "abilities", "parties"] as const).map((value) => <button key={value} type="button" className={tab === value ? "is-active" : ""} onClick={() => setTab(value)}>{value === "classes" ? "Class / spec" : value}</button>)}
</nav>
{tab === "overview" && <div className="balance-results-overview">
<div className="balance-results-summary">
<article><span>Party configurations</span><strong>{report.summary.partyConfigurations.toLocaleString()}</strong></article>
<article><span>Bosses tested</span><strong>{report.summary.bosses.toLocaleString()}</strong></article>
<article><span>Class/spec builds</span><strong>{report.summary.classSpecs.toLocaleString()}</strong></article>
<article><span>Median boss pressure</span><strong>{formatNumber(report.summary.bossPressureMedianDtps)} <small>DTPS</small></strong></article>
</div>
<div className="balance-results-charts">
<BarChart title="Boss damage to party" description="Incoming party damage per second; fastest way to spot healer-pressure outliers." rows={bossChart("incomingDtps")} tone="damage" />
<BarChart title="Party damage to boss" description="Damage per second applied directly to each boss, excluding unrelated targets." rows={bossChart("bossDpsTaken")} tone="party" />
<BarChart title="Kill times" description="Average seconds to defeat; bosses never defeated are listed in the boss table as —." rows={bossChart("averageTimeToDefeat")} format={formatSeconds} tone="time" />
<BarChart title="Class/spec boss damage" description="Every tested build ranked by DPS applied directly to bosses." rows={classChart("bossDps")} tone="class" />
<BarChart title="Damage taken by class/spec" description="Incoming damage per second by build; useful for survivability and healer-load checks." rows={classChart("dtps")} tone="taken" />
</div>
</div>}
{tab === "bosses" && <div className="balance-results-table-panel">
<header><div><h3>Every boss</h3><p>{sortedBosses.length} matching bosses · pressure, outgoing damage, party output, kill time, survival, and outcomes.</p></div><div className="balance-results-sort"><select aria-label="Sort bosses" value={bossSort} onChange={(event) => setBossSort(event.currentTarget.value as keyof BalanceLabBossResult)}><option value="incomingDtps">Boss → party DTPS</option><option value="averageIncomingDamage">Average party damage</option><option value="bossDpsTaken">Party → boss DPS</option><option value="partyDps">Total party DPS</option><option value="averageTimeToDefeat">Kill time</option><option value="wipeRate">Wipe rate</option><option value="defeatRate">Defeat rate</option><option value="averageDeaths">Average deaths</option></select><button type="button" onClick={() => setAscending((value) => !value)}>{ascending ? "Low → high" : "High → low"}</button></div></header>
<div className="balance-results-table-wrap"><table><thead><tr><th>Boss</th><th>Source</th><th>Runs</th><th>Boss → party DTPS</th><th>Avg party damage</th><th>Pressure</th><th>Party → boss DPS</th><th>Total party DPS</th><th>Kill time</th><th>Defeat</th><th>Wipe</th><th>Avg deaths</th><th>Boss health</th></tr></thead><tbody>{sortedBosses.map((boss) => <tr key={boss.bossKey}><td><strong>{boss.bossName}</strong><small>{boss.dungeonTitle}</small></td><td>{boss.category.toUpperCase()}</td><td>{boss.runs.toLocaleString()}</td><td>{formatNumber(boss.incomingDtps)}</td><td>{formatWhole(boss.averageIncomingDamage)}</td><td><span className={`balance-results-flag ${flagClass(boss.pressureFlag)}`}>{formatRatio(boss.pressureVsMedian)} · {boss.pressureFlag}</span></td><td>{formatNumber(boss.bossDpsTaken)}</td><td>{formatNumber(boss.partyDps)}</td><td>{formatSeconds(boss.averageTimeToDefeat)}</td><td>{formatPercent(boss.defeatRate)}</td><td>{formatPercent(boss.wipeRate)}</td><td>{formatNumber(boss.averageDeaths)}</td><td>{formatWhole(boss.bossMaxHealth)}</td></tr>)}</tbody></table></div>
</div>}
{tab === "classes" && <div className="balance-results-table-panel">
<header><div><h3>Every class and specialization</h3><p>{sortedClasses.length} matching builds · output, healing, mitigation, survivability, and role-relative tuning.</p></div><div className="balance-results-sort"><select aria-label="Sort class specs" value={classSort} onChange={(event) => setClassSort(event.currentTarget.value as keyof BalanceLabClassSpecResult)}><option value="bossDps">Boss DPS</option><option value="dps">Total DPS</option><option value="hps">HPS</option><option value="dtps">Damage taken / sec</option><option value="totalDamageTaken">Total damage taken</option><option value="totalDamage">Total damage dealt</option><option value="totalHealing">Total healing</option><option value="totalAbsorbs">Total absorbs</option><option value="deathEvents">Deaths</option></select><button type="button" onClick={() => setAscending((value) => !value)}>{ascending ? "Low → high" : "High → low"}</button></div></header>
<div className="balance-results-table-wrap"><table><thead><tr><th>Class / specialization</th><th>Source</th><th>Role</th><th>Parties</th><th>Runs</th><th>Boss DPS</th><th>Total DPS</th><th>HPS</th><th>Damage taken / sec</th><th>Total damage taken</th><th>Role median</th><th>Total damage dealt</th><th>Total healing</th><th>Absorbs</th><th>Deaths</th></tr></thead><tbody>{sortedClasses.map((row) => <tr key={`${row.source}/${row.buildId}`}><td><strong>{row.className}</strong><small>{row.specialization}</small></td><td>{row.source.toUpperCase()}</td><td>{row.role}</td><td>{row.partyConfigurations.toLocaleString()}</td><td>{row.runs.toLocaleString()}</td><td>{formatNumber(row.bossDps)}</td><td>{formatNumber(row.dps)}</td><td>{formatNumber(row.hps)}</td><td>{formatNumber(row.dtps)}</td><td>{formatWhole(row.totalDamageTaken)}</td><td><span className={`balance-results-flag ${flagClass(row.damageFlag)}`}>{formatRatio(row.damageVsSourceRoleMedian)} · {row.damageFlag}</span></td><td>{formatWhole(row.totalDamage)}</td><td>{formatWhole(row.totalHealing)}</td><td>{formatWhole(row.totalAbsorbs)}</td><td>{row.deathEvents.toLocaleString()}</td></tr>)}</tbody></table></div>
</div>}
{tab === "abilities" && <div className="balance-results-abilities">
{allAbilities.length === 0 ? <p className="balance-results-legacy-note">This saved run predates ability-level reporting. Run the same configuration again to populate ability use, unused abilities, damage share, healing, and absorbs.</p> : <>
<div className="balance-results-summary balance-results-summary--abilities">
<article><span>Matching abilities</span><strong>{abilities.length.toLocaleString()}</strong></article>
<article><span>Used in at least one run</span><strong>{usedAbilityCount.toLocaleString()}</strong></article>
<article><span>Available but unused</span><strong>{unusedAbilityCount.toLocaleString()}</strong></article>
<article><span>Filtered effective damage</span><strong>{formatWhole(filteredAbilityDamage)}</strong></article>
</div>
<BarChart title="Ability damage contribution" description="Effective damage by ability for the selected source, role, class/spec, status, and text filters." rows={abilityChart("effectiveDamage")} tone="class" />
<div className="balance-results-table-panel">
<header><div><h3>Every class/spec ability</h3><p>{sortedAbilities.length} matching abilities · {availableAbilityCount} are available to the tested builds. Zero-use rows are deliberately retained.</p></div><div className="balance-results-ability-controls">
<select aria-label="Filter ability class spec" value={abilityBuild} onChange={(event) => setAbilityBuild(event.currentTarget.value)}><option value="all">All class/specs</option>{abilityBuildOptions.map(([key, row]) => <option key={key} value={key}>{row.source.toUpperCase()} · {row.className} — {row.specialization}</option>)}</select>
<select aria-label="Filter ability usage" value={abilityStatus} onChange={(event) => setAbilityStatus(event.currentTarget.value)}><option value="all">All ability states</option><option value="used">Used in any run</option><option value="unused">Available but unused</option><option value="unused-damage">Unused damage abilities</option><option value="damage">Dealt damage</option><option value="support">Healed or absorbed</option></select>
<select aria-label="Sort abilities" value={abilitySort} onChange={(event) => setAbilitySort(event.currentTarget.value as keyof BalanceLabClassAbilityResult)}><option value="effectiveDamage">Effective damage</option><option value="damageShare">Damage share</option><option value="dps">DPS</option><option value="averageDamagePerHit">Damage per hit</option><option value="activeRuns">Active runs</option><option value="usageRate">Usage rate</option><option value="casts">Casts</option><option value="castsPerMinute">Casts per minute</option><option value="hits">Hits</option><option value="effectiveHealing">Effective healing</option><option value="overheal">Overheal</option><option value="absorbs">Absorbs</option></select>
<button type="button" onClick={() => setAscending((value) => !value)}>{ascending ? "Low → high" : "High → low"}</button>
</div></header>
<div className="balance-results-table-wrap"><table><thead><tr><th>Ability</th><th>Class / specialization</th><th>Source</th><th>Role</th><th>AI category</th><th>Usage state</th><th>Active runs</th><th>Usage rate</th><th>Casts</th><th>Casts / min</th><th>Hits</th><th>Effective damage</th><th>Damage share</th><th>DPS</th><th>Damage / hit</th><th>Raw damage</th><th>Effective healing</th><th>Overheal</th><th>Absorbs</th><th>Cooldown</th></tr></thead><tbody>{sortedAbilities.map((row) => <tr key={`${row.source}/${row.buildId}/${row.abilityId}`}>
<td><span className="balance-results-ability-name">{row.icon && <img src={row.icon} alt="" />}<span><strong>{row.abilityName}</strong><small>{row.abilityId}</small></span></span></td>
<td><strong>{row.className}</strong><small>{row.specialization}</small></td><td>{row.source.toUpperCase()}</td><td>{row.role}</td><td>{row.categories || "—"}</td><td><span className={`balance-results-flag ${row.unusedAvailable ? "is-high" : row.status === "support" ? "is-low" : "is-baseline"}`}>{row.unusedAvailable ? "available · unused" : row.status}</span></td><td>{row.activeRuns}/{row.runs}</td><td>{formatPercent(row.usageRate)}</td><td>{row.casts.toLocaleString()}</td><td>{formatNumber(row.castsPerMinute)}</td><td>{row.hits.toLocaleString()}</td><td>{formatWhole(row.effectiveDamage)}</td><td>{formatPercent(row.damageShare)}</td><td>{formatNumber(row.dps)}</td><td>{formatNumber(row.averageDamagePerHit)}</td><td>{formatWhole(row.rawDamage)}</td><td>{formatWhole(row.effectiveHealing)}</td><td>{formatWhole(row.overheal)}</td><td>{formatWhole(row.absorbs)}</td><td>{row.cooldownSeconds === null ? "—" : formatSeconds(row.cooldownSeconds)}</td>
</tr>)}</tbody></table></div>
</div>
</>}
</div>}
{tab === "parties" && <div className="balance-results-table-panel">
<header><div><h3>Every party configuration</h3><p>{sortedParties.length} matching parties · compare composition output, healer pressure, outcomes, and deaths.</p></div><div className="balance-results-sort"><select aria-label="Sort parties" value={partySort} onChange={(event) => setPartySort(event.currentTarget.value as keyof BalanceLabPartyResult)}><option value="partyDps">Party DPS</option><option value="incomingDtps">Incoming DTPS</option><option value="defeatRate">Defeat rate</option><option value="wipeRate">Wipe rate</option><option value="averageDeaths">Average deaths</option><option value="runs">Runs</option></select><button type="button" onClick={() => setAscending((value) => !value)}>{ascending ? "Low → high" : "High → low"}</button></div></header>
<div className="balance-results-table-wrap"><table><thead><tr><th>Party</th><th>Source</th><th>Members</th><th>Runs</th><th>Party DPS</th><th>Incoming DTPS</th><th>Defeat</th><th>Wipe</th><th>Avg deaths</th></tr></thead><tbody>{sortedParties.map((row) => <tr key={row.partyConfigurationId}><td><strong>{row.partyConfigurationId}</strong></td><td>{row.source.toUpperCase()}</td><td className="balance-results-members">{row.members}</td><td>{row.runs.toLocaleString()}</td><td>{formatNumber(row.partyDps)}</td><td>{formatNumber(row.incomingDtps)}</td><td>{formatPercent(row.defeatRate)}</td><td>{formatPercent(row.wipeRate)}</td><td>{formatNumber(row.averageDeaths)}</td></tr>)}</tbody></table></div>
</div>}
</section>
);
}
+296
View File
@@ -0,0 +1,296 @@
import { useEffect, useMemo, useState } from "react";
import { sessionCanUseGmTools } from "../app/releaseFeatures";
import { useShellStore } from "../app/shellStore";
import {
fetchBalanceLabArtifact,
getBalanceLabJob,
getBalanceLabReport,
listBalanceLabJobs,
startBalanceLabJob,
type BalanceLabBossCategory,
type BalanceLabJob,
type BalanceLabPartySource,
type BalanceLabSuiteReport,
} from "../app/balanceLabClient";
import { BOSS_TESTING_CATALOG } from "../game/bossTesting";
import { BalanceLabResults } from "./BalanceLabResults";
import { BrandMark, FrontSurface } from "./FrontSurface";
type BossScope = BalanceLabBossCategory | "specific";
const ARTIFACT_LABELS: Readonly<Record<string, string>> = {
"suite-report.html": "Open full report",
"suite-report.json": "Download report JSON",
"suite-report.md": "Download report Markdown",
"party_configurations.csv": "Party configurations CSV",
"class_specs_across_parties.csv": "Class/spec totals CSV",
"class_abilities_across_parties.csv": "Class/spec ability breakdown CSV",
"class_spec_by_boss.csv": "Class/spec by boss CSV",
"bosses_across_parties.csv": "Boss totals CSV",
"boss_by_party.csv": "Boss by party CSV",
"suite_runs.csv": "Every simulation CSV",
"suite-config.json": "Exact rerun configuration",
};
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : "The Balance Lab request failed.";
}
function progressPercent(job: BalanceLabJob): number {
if (job.status === "complete") return 100;
if (job.status === "queued" || job.progress.partiesTotal < 1) return 0;
const currentFraction = job.progress.simulationsTotal > 0
? job.progress.simulationsCompleted / job.progress.simulationsTotal
: 0;
return Math.max(0, Math.min(99, ((job.progress.partiesCompleted + currentFraction) / job.progress.partiesTotal) * 100));
}
function describeJob(job: BalanceLabJob): string {
if (job.status === "queued") return "Waiting for the current server job to finish.";
if (job.status === "failed") return job.error ?? "The server job failed.";
if (job.status === "complete") return job.summary
? `Completed ${job.summary.runs.toLocaleString()} simulations across ${job.summary.bosses.toLocaleString()} bosses and ${job.summary.partyConfigurations.toLocaleString()} party configurations.`
: `Completed ${job.progress.partiesTotal} party configurations.`;
const party = job.progress.party || job.progress.partiesCompleted + 1;
const simulation = job.progress.simulationsTotal > 0
? ` · current party ${job.progress.simulationsCompleted}/${job.progress.simulationsTotal} boss simulations`
: "";
return `Party ${party}/${job.progress.partiesTotal}${simulation}`;
}
export function BalanceLabScreen({ onClose }: { readonly onClose: () => void }) {
const session = useShellStore((state) => state.session);
const token = session?.accessToken ?? "";
const [source, setSource] = useState<BalanceLabPartySource>("all");
const [level, setLevel] = useState(60);
const [partyConfigurations, setPartyConfigurations] = useState(10);
const [simulationsPerParty, setSimulationsPerParty] = useState(3);
const [durationSeconds, setDurationSeconds] = useState(60);
const [partySeed, setPartySeed] = useState(1);
const [bossScope, setBossScope] = useState<BossScope>("all");
const [bossSearch, setBossSearch] = useState("");
const [selectedBossKeys, setSelectedBossKeys] = useState<readonly string[]>([]);
const [jobs, setJobs] = useState<readonly BalanceLabJob[]>([]);
const [selectedJobId, setSelectedJobId] = useState<string | null>(null);
const [jobSearch, setJobSearch] = useState("");
const [jobStatus, setJobStatus] = useState<"all" | BalanceLabJob["status"]>("all");
const [reports, setReports] = useState<Readonly<Record<string, BalanceLabSuiteReport>>>({});
const [reportLoadingId, setReportLoadingId] = useState<string | null>(null);
const [reportError, setReportError] = useState("");
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState("");
const selectedJob = jobs.find((job) => job.id === selectedJobId) ?? jobs[0] ?? null;
const selectedReport = selectedJob ? reports[selectedJob.id] ?? null : null;
const visibleJobs = useMemo(() => {
const query = jobSearch.trim().toLowerCase();
return jobs.filter((job) => (
(jobStatus === "all" || job.status === jobStatus)
&& (!query || `${job.requestedBy} ${job.spec.source} ${job.spec.level} ${job.id} ${job.status}`.toLowerCase().includes(query))
));
}, [jobSearch, jobStatus, jobs]);
const filteredBosses = useMemo(() => {
const query = bossSearch.trim().toLowerCase();
return BOSS_TESTING_CATALOG.filter((boss) => !query
|| `${boss.name} ${boss.dungeonTitle} ${boss.key} ${boss.category}`.toLowerCase().includes(query));
}, [bossSearch]);
const selectedBossSet = useMemo(() => new Set(selectedBossKeys), [selectedBossKeys]);
const bossCount = bossScope === "specific"
? selectedBossKeys.length
: BOSS_TESTING_CATALOG.filter((boss) => bossScope === "all" || boss.category === bossScope).length;
const partyCount = partyConfigurations * (source === "all" ? 3 : 1);
const estimatedRuns = partyCount * bossCount * simulationsPerParty;
useEffect(() => {
if (!token) return;
let cancelled = false;
setLoading(true);
void listBalanceLabJobs(token).then((nextJobs) => {
if (cancelled) return;
setJobs(nextJobs);
setSelectedJobId((current) => current ?? nextJobs[0]?.id ?? null);
setError("");
}).catch((reason) => {
if (!cancelled) setError(errorMessage(reason));
}).finally(() => {
if (!cancelled) setLoading(false);
});
return () => { cancelled = true; };
}, [token]);
useEffect(() => {
if (!token || !selectedJob || (selectedJob.status !== "queued" && selectedJob.status !== "running")) return;
let cancelled = false;
const timer = window.setInterval(() => {
void getBalanceLabJob(selectedJob.id, token).then((updated) => {
if (cancelled) return;
setJobs((current) => [updated, ...current.filter((job) => job.id !== updated.id)]);
}).catch((reason) => {
if (!cancelled) setError(errorMessage(reason));
});
}, 1_500);
return () => { cancelled = true; window.clearInterval(timer); };
}, [selectedJob, token]);
useEffect(() => {
if (!token || !selectedJob || selectedJob.status !== "complete" || selectedReport
|| !selectedJob.artifacts.includes("suite-report.json")) return;
let cancelled = false;
setReportLoadingId(selectedJob.id);
setReportError("");
void getBalanceLabReport(selectedJob.id, token).then((report) => {
if (!cancelled) setReports((current) => ({ ...current, [selectedJob.id]: report }));
}).catch((reason) => {
if (!cancelled) setReportError(errorMessage(reason));
}).finally(() => {
if (!cancelled) setReportLoadingId((current) => current === selectedJob.id ? null : current);
});
return () => { cancelled = true; };
}, [selectedJob, selectedReport, token]);
if (!sessionCanUseGmTools(session)) {
return (
<FrontSurface className="gm-admin-screen" ariaLabel="Balance Lab access denied">
<section className="gm-admin-denied">
<BrandMark />
<h1>Game Master access required</h1>
<p>Server simulations are available only to server-authorized GM accounts.</p>
<button className="front-button" type="button" onClick={onClose}>Back to main menu</button>
</section>
</FrontSurface>
);
}
const toggleBoss = (key: string) => {
setSelectedBossKeys((current) => current.includes(key)
? current.filter((candidate) => candidate !== key)
: [...current, key]);
};
const run = async () => {
if (bossScope === "specific" && selectedBossKeys.length === 0) {
setError("Select at least one boss for a specific-boss run.");
return;
}
setSubmitting(true);
setError("");
try {
const job = await startBalanceLabJob({
source,
level,
partyConfigurations,
simulationsPerParty,
durationSeconds,
partySeed,
bossCategory: bossScope === "specific" ? "all" : bossScope,
bossKeys: bossScope === "specific" ? selectedBossKeys : [],
}, token);
setJobs((current) => [job, ...current.filter((candidate) => candidate.id !== job.id)]);
setSelectedJobId(job.id);
} catch (reason) {
setError(errorMessage(reason));
} finally {
setSubmitting(false);
}
};
const openArtifact = async (job: BalanceLabJob, name: string) => {
const preview = name.endsWith(".html") ? window.open("", "_blank") : null;
setError("");
try {
const blob = await fetchBalanceLabArtifact(job.id, name, token);
const url = URL.createObjectURL(blob);
if (preview) preview.location.href = url;
else {
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = name;
anchor.click();
}
window.setTimeout(() => URL.revokeObjectURL(url), 60_000);
} catch (reason) {
preview?.close();
setError(errorMessage(reason));
}
};
return (
<FrontSurface className="balance-lab-screen" ariaLabel="GM Balance Lab">
<header className="gm-admin-header">
<BrandMark compact />
<div><span>Server-authorized GM tool</span><strong>Balance Lab</strong></div>
<div className="gm-admin-header__actions">
<button className="front-button" type="button" onClick={onClose}>Main menu</button>
</div>
</header>
<main className="balance-lab-layout">
<section className="balance-lab-config">
<p className="front-kicker">New automatic sweep</p>
<h2>Simulation setup</h2>
<p className="balance-lab-help">This mode runs on the game server. The browser submits the job and may be closed while the server continues.</p>
<div className="balance-lab-form-grid">
<label><span>Party source</span><select value={source} onChange={(event) => setSource(event.currentTarget.value as BalanceLabPartySource)}>
<option value="all">WoW + CoA + RoM</option><option value="wow">WoW only</option><option value="coa">CoA only</option><option value="rom">RoM only</option>
</select></label>
<label><span>Level</span><input type="number" min={1} max={80} value={level} onChange={(event) => setLevel(event.currentTarget.valueAsNumber)} /></label>
<label><span>Parties per source</span><input type="number" min={1} max={100} value={partyConfigurations} onChange={(event) => setPartyConfigurations(event.currentTarget.valueAsNumber)} /></label>
<label><span>Runs per party/boss</span><input type="number" min={1} max={100} value={simulationsPerParty} onChange={(event) => setSimulationsPerParty(event.currentTarget.valueAsNumber)} /></label>
<label><span>Fight limit (seconds)</span><input type="number" min={5} max={600} value={durationSeconds} onChange={(event) => setDurationSeconds(event.currentTarget.valueAsNumber)} /></label>
<label><span>Party seed</span><input type="number" value={partySeed} onChange={(event) => setPartySeed(event.currentTarget.valueAsNumber)} /></label>
<label className="balance-lab-form-grid__wide"><span>Bosses</span><select value={bossScope} onChange={(event) => setBossScope(event.currentTarget.value as BossScope)}>
<option value="all">All bosses</option><option value="wow">WoW bosses</option><option value="rom">RoM bosses</option><option value="specific">Specific selection</option>
</select></label>
</div>
{bossScope === "specific" && (
<section className="balance-lab-boss-picker">
<header><input aria-label="Search Balance Lab bosses" value={bossSearch} onChange={(event) => setBossSearch(event.currentTarget.value)} placeholder="Search boss, dungeon, or key" /><strong>{selectedBossKeys.length} selected</strong></header>
<div role="listbox" aria-label="Balance Lab bosses" aria-multiselectable="true">
{filteredBosses.map((boss) => <label key={boss.key} role="option" aria-selected={selectedBossSet.has(boss.key)} className={selectedBossSet.has(boss.key) ? "is-selected" : ""}>
<input type="checkbox" checked={selectedBossSet.has(boss.key)} onChange={() => toggleBoss(boss.key)} />
<span><strong>{boss.name}</strong><small>{boss.dungeonTitle} · {boss.category.toUpperCase()}</small></span>
</label>)}
</div>
</section>
)}
<div className="balance-lab-estimate"><span>Party configurations <b>{partyCount.toLocaleString()}</b></span><span>Bosses <b>{bossCount.toLocaleString()}</b></span><span>Total simulations <b>{estimatedRuns.toLocaleString()}</b></span></div>
<button className="front-button front-button--primary balance-lab-run" type="button" disabled={submitting || !Number.isFinite(estimatedRuns) || estimatedRuns < 1} onClick={() => void run()}>{submitting ? "Queuing…" : "Run on server"}</button>
<p className="balance-lab-local-note">To spend this device's CPU instead, run <code>npm run balance:lab</code> from the project checkout.</p>
{error && <p className="front-notice" role="alert">{error}</p>}
</section>
<section className="balance-lab-jobs">
<header><div><p className="front-kicker">Saved on server</p><h2>Run history</h2></div><button type="button" disabled={loading} onClick={() => {
setLoading(true);
void listBalanceLabJobs(token).then(setJobs).catch((reason) => setError(errorMessage(reason))).finally(() => setLoading(false));
}}>Refresh</button></header>
<div className="balance-lab-history-filters"><input type="search" aria-label="Search Balance Lab run history" placeholder="Find source, level, GM…" value={jobSearch} onChange={(event) => setJobSearch(event.currentTarget.value)} /><select aria-label="Filter Balance Lab run status" value={jobStatus} onChange={(event) => setJobStatus(event.currentTarget.value as "all" | BalanceLabJob["status"])}><option value="all">All statuses</option><option value="complete">Complete</option><option value="running">Running</option><option value="queued">Queued</option><option value="failed">Failed</option></select></div>
<div className="balance-lab-job-list">
{visibleJobs.map((job) => <button key={job.id} type="button" className={job.id === selectedJob?.id ? "is-selected" : ""} onClick={() => setSelectedJobId(job.id)}>
<span><strong>{job.spec.source.toUpperCase()} level {job.spec.level}</strong><small>{new Date(job.createdAt).toLocaleString()} · {job.requestedBy}</small>{job.summary && <small>{job.summary.bosses.toLocaleString()} bosses · {job.summary.runs.toLocaleString()} simulations</small>}</span><em className={`is-${job.status}`}>{job.status}</em>
</button>)}
{!loading && visibleJobs.length === 0 && <p className="balance-lab-empty">No saved runs match these filters.</p>}
</div>
</section>
<aside className="balance-lab-detail">
{selectedJob ? <>
<p className="front-kicker">Selected job</p><h2>{selectedJob.spec.source.toUpperCase()} level {selectedJob.spec.level}</h2>
<p>{describeJob(selectedJob)}</p>
<div className="balance-lab-progress" aria-label={`${progressPercent(selectedJob).toFixed(0)} percent complete`}><span style={{ width: `${progressPercent(selectedJob)}%` }} /></div>
<dl>
<div><dt>Status</dt><dd>{selectedJob.status}</dd></div><div><dt>Parties</dt><dd>{selectedJob.progress.partiesTotal}</dd></div><div><dt>Runs per pairing</dt><dd>{selectedJob.spec.simulationsPerParty}</dd></div><div><dt>Boss filter</dt><dd>{selectedJob.spec.bossKeys.length ? `${selectedJob.spec.bossKeys.length} selected` : selectedJob.spec.bossCategory}</dd></div><div><dt>Fight limit</dt><dd>{selectedJob.spec.durationSeconds}s</dd></div>
</dl>
{selectedJob.status === "complete" && <div className="balance-lab-artifacts"><button type="button" className="front-button front-button--primary" onClick={() => document.getElementById("balance-lab-analysis")?.scrollIntoView({ behavior: "smooth", block: "start" })}>View graphs and filterable data</button>{selectedJob.artifacts.map((name) => <button key={name} type="button" className="front-button" onClick={() => void openArtifact(selectedJob, name)}>{ARTIFACT_LABELS[name] ?? name}</button>)}</div>}
{selectedJob.logTail.length > 0 && <details className="balance-lab-log"><summary>Server progress log</summary><pre>{selectedJob.logTail.slice(-20).join("\n")}</pre></details>}
</> : <p className="balance-lab-empty">Select a job to inspect its progress and reports.</p>}
</aside>
{selectedJob?.status === "complete" && <div className="balance-lab-analysis" id="balance-lab-analysis">
{reportLoadingId === selectedJob.id && <p className="balance-results-loading">Loading this run's complete analysis…</p>}
{reportError && !selectedReport && <p className="front-notice" role="alert">{reportError}</p>}
{selectedReport && <BalanceLabResults report={selectedReport} />}
</div>}
</main>
</FrontSurface>
);
}
+26 -6
View File
@@ -2,6 +2,8 @@ import { useEffect, useRef, useState } from "react";
import { useShellStore } from "../app/shellStore";
import { BOSS_TESTING_STATUSES, BOSS_TESTING_STATUS_LABELS, useBossTestingReviews, type BossTestingStatus } from "../app/bossTestingReviews";
import { bossTestIsDefeated } from "../game/BossTestingBridge";
import { enterBossTesting, prepareBossTesting } from "../app/bossTestingRuntime";
import { anotherRandomTestingBoss, BOSS_TESTING_CATALOG } from "../game/bossTesting";
import { useGameStore } from "../game/store";
import { useMenuController, type MenuAction } from "../input/useMenuController";
import { ControllerButton } from "./ControllerButton";
@@ -14,6 +16,7 @@ export function BossTestingReviewPanel() {
const save = useBossTestingReviews((state) => state.save);
const refresh = useBossTestingReviews((state) => state.refresh);
const [saving, setSaving] = useState(false);
const [startingRandom, setStartingRandom] = useState(false);
const [message, setMessage] = useState("");
const busy = useRef(false);
const mark = async (status: BossTestingStatus) => {
@@ -30,21 +33,37 @@ export function BossTestingReviewPanel() {
return () => window.removeEventListener("online", reconnect);
}, [refresh, session]);
const defeated = bossTestIsDefeated();
const disabled = saving || startingRandom;
const back = () => { if (!busy.current) useGameStore.getState().openOverlay("pause"); };
const retry = () => useGameStore.getState().resetAtEntrance();
const leave = () => useShellStore.getState().returnToMainMenu();
const fightRandom = async () => {
if (busy.current || !boss) return;
const next = anotherRandomTestingBoss(BOSS_TESTING_CATALOG, boss.key);
if (!next) { setMessage("No other bosses are available."); return; }
busy.current = true; setStartingRandom(true); setMessage(`Preparing ${next.name}…`);
try {
await prepareBossTesting(next);
enterBossTesting(next, useShellStore.getState().activeDungeonRole ?? undefined);
} catch (failure) {
setMessage(failure instanceof Error ? failure.message : "The next boss could not be started. Please retry.");
} finally {
busy.current = false; setStartingRandom(false);
}
};
const actions: MenuAction[] = [
...BOSS_TESTING_STATUSES.map((status) => ({ id: `boss-review-${status}`, run: () => void mark(status), enabled: !saving })),
{ id: "boss-review-report", run: () => useGameStore.getState().openOverlay("report-issue"), enabled: !saving },
{ id: "boss-review-retry", run: retry, enabled: !saving },
{ id: "boss-review-leave", run: leave, enabled: !saving },
{ id: "boss-review-back", run: back, enabled: !saving },
...BOSS_TESTING_STATUSES.map((status) => ({ id: `boss-review-${status}`, run: () => void mark(status), enabled: !disabled })),
{ id: "boss-review-report", run: () => useGameStore.getState().openOverlay("report-issue"), enabled: !disabled },
{ id: "boss-review-retry", run: retry, enabled: !disabled },
{ id: "boss-review-random", run: () => void fightRandom(), enabled: !disabled },
{ id: "boss-review-leave", run: leave, enabled: !disabled },
{ id: "boss-review-back", run: back, enabled: !disabled },
];
const controller = useMenuController(actions, { initialId: "boss-review-needs-testing", onBack: back });
if (!boss) return null;
const button = (controlId: string, label: string, run: () => void, pressed?: boolean) =>
<ControllerButton controlId={controlId} selectedId={controller.selectedId} select={controller.select}
type="button" className="button" onClick={run} disabled={saving} aria-pressed={pressed}>{label}</ControllerButton>;
type="button" className="button" onClick={run} disabled={disabled} aria-pressed={pressed}>{label}</ControllerButton>;
return <div className="modal-backdrop" role="presentation">
<section className="pause-menu boss-testing-review" role="dialog" aria-modal="true" aria-labelledby="boss-review-title">
<p className="eyebrow">{defeated ? "Boss defeated" : "Boss Testing paused"}</p><h2 id="boss-review-title">{boss.name}</h2>
@@ -56,6 +75,7 @@ export function BossTestingReviewPanel() {
<div className="pause-menu__actions">
{button("boss-review-report", "Submit a boss report", () => useGameStore.getState().openOverlay("report-issue"))}
{button("boss-review-retry", "Fight this boss again", retry)}
{button("boss-review-random", startingRandom ? "Preparing random boss…" : "Fight another random boss", () => void fightRandom())}
{button("boss-review-leave", "Return to main menu", leave)}
{button("boss-review-back", "Back to pause menu", back)}
</div>
+249
View File
@@ -0,0 +1,249 @@
import { useEffect, useMemo, useState } from "react";
import {
deleteDungeonSimulationReplay,
fetchDungeonSimulationReplay,
fetchDungeonSimulationReplays,
fetchDungeonSimulationReport,
fetchGmDungeonRoutes,
putDungeonRouteReview,
} from "../app/dungeonSimulationClient";
import { enterDungeonSimulation, enterDungeonSimulationReplay } from "../app/dungeonSimulationRuntime";
import { sessionCanUseGmTools } from "../app/releaseFeatures";
import { useShellStore } from "../app/shellStore";
import { DUNGEON_CATALOG } from "../game/dungeonCatalog";
import { DUNGEON_ROUTE_CATALOG } from "../game/dungeonRoutes";
import type {
DungeonReplaySummary,
DungeonRouteReview,
DungeonRouteReviewStatus,
DungeonSimulationMode,
DungeonSimulationReportV1,
} from "../game/dungeonSimulationTypes";
import { BrandMark, FrontSurface } from "./FrontSurface";
function saveReport(report: DungeonSimulationReportV1): void {
const url = URL.createObjectURL(new Blob([JSON.stringify(report, null, 2)], { type: "application/json" }));
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = `${report.dungeonId}-${report.seed}-SimulationReportV1.json`;
anchor.click();
URL.revokeObjectURL(url);
}
export function DungeonSimulationAdminScreen() {
const session = useShellStore((state) => state.session);
const returnToMainMenu = useShellStore((state) => state.returnToMainMenu);
const [selectedDungeonId, setSelectedDungeonId] = useState(DUNGEON_ROUTE_CATALOG.routes[0]?.dungeonId ?? "");
const [mode, setMode] = useState<DungeonSimulationMode>("navigation-only");
const [seed, setSeed] = useState("route-review-001");
const [reviews, setReviews] = useState<readonly DungeonRouteReview[]>([]);
const [replays, setReplays] = useState<readonly DungeonReplaySummary[]>([]);
const [reviewStatus, setReviewStatus] = useState<DungeonRouteReviewStatus>("Candidate");
const [reviewNote, setReviewNote] = useState("");
const [overrideNote, setOverrideNote] = useState("");
const [busy, setBusy] = useState(false);
const [message, setMessage] = useState("");
const route = DUNGEON_ROUTE_CATALOG.routes.find((candidate) => candidate.dungeonId === selectedDungeonId)
?? DUNGEON_ROUTE_CATALOG.routes[0];
const dungeon = DUNGEON_CATALOG.find((candidate) => candidate.id === route?.dungeonId);
const storedReview = reviews.find((candidate) => candidate.dungeonId === route?.dungeonId
&& candidate.routeId === route?.id) ?? null;
const review = storedReview?.routeHash === route?.hash ? storedReview : null;
const routeReplays = useMemo(() => replays.filter((replay) => (
replay.dungeonId === route?.dungeonId && replay.routeId === route?.id && replay.routeHash === route?.hash
)), [replays, route?.dungeonId, route?.hash, route?.id]);
const refresh = async () => {
if (!sessionCanUseGmTools(session)) return;
setBusy(true);
try {
const [routes, recordings] = await Promise.all([
fetchGmDungeonRoutes(session),
fetchDungeonSimulationReplays(session),
]);
setReviews(routes.reviews);
setReplays(recordings);
setMessage("");
} catch (error) {
setMessage(error instanceof Error ? error.message : "Unable to load route reviews.");
} finally {
setBusy(false);
}
};
useEffect(() => { void refresh(); }, [session]);
useEffect(() => {
setReviewStatus(review?.status ?? (route?.generationStatus === "Rejected" ? "NeedsWork" : "Candidate"));
setReviewNote(review?.note ?? "");
setOverrideNote(review?.criticalOverrideNote ?? "");
}, [review?.revision, route?.hash]);
if (!sessionCanUseGmTools(session)) {
return (
<FrontSurface className="gm-admin-screen" ariaLabel="GM access denied">
<section className="gm-admin-denied">
<BrandMark />
<h1>Game Master access required</h1>
<p>Dungeon simulation artifacts require a server-authenticated GM account.</p>
<button className="front-button" type="button" onClick={returnToMainMenu}>Return to main menu</button>
</section>
</FrontSurface>
);
}
const saveReview = async () => {
if (!route) return;
setBusy(true);
try {
const updated = await putDungeonRouteReview(session, {
dungeonId: route.dungeonId,
routeId: route.id,
routeHash: route.hash,
}, {
status: reviewStatus,
expectedRevision: storedReview?.revision ?? 0,
note: reviewNote,
criticalOverrideNote: overrideNote,
});
setReviews((current) => [...current.filter((candidate) => !(
candidate.dungeonId === updated.dungeonId && candidate.routeId === updated.routeId
)), updated]);
setMessage(`Review saved at revision ${updated.revision}.`);
} catch (error) {
setMessage(error instanceof Error ? error.message : "Unable to save review.");
} finally {
setBusy(false);
}
};
const openReplay = async (id: string) => {
setBusy(true);
try {
const replay = await fetchDungeonSimulationReplay(session, id);
if (!enterDungeonSimulationReplay(replay)) setMessage("The replay could not be opened.");
} catch (error) {
setMessage(error instanceof Error ? error.message : "Unable to load replay.");
setBusy(false);
}
};
const removeReplay = async (id: string) => {
setBusy(true);
try {
await deleteDungeonSimulationReplay(session, id);
setReplays((current) => current.filter((candidate) => candidate.id !== id));
setMessage("Recording deleted.");
} catch (error) {
setMessage(error instanceof Error ? error.message : "Unable to delete replay.");
} finally {
setBusy(false);
}
};
const downloadReplayReport = async (id: string) => {
setBusy(true);
try {
saveReport(await fetchDungeonSimulationReport(session, id));
setMessage("");
} catch (error) {
setMessage(error instanceof Error ? error.message : "Unable to download report.");
} finally {
setBusy(false);
}
};
return (
<FrontSurface className="dungeon-sim-admin" ariaLabel="Dungeon simulation route review">
<header className="dungeon-sim-admin__header">
<BrandMark />
<div><p className="eyebrow">Game Master tools</p><h1>Dungeon route simulations</h1></div>
<div className="dungeon-sim-admin__header-actions">
<button className="front-button" type="button" onClick={() => void refresh()} disabled={busy}>Refresh</button>
<button className="front-button" type="button" onClick={returnToMainMenu}>Main menu</button>
</div>
</header>
<main className="dungeon-sim-admin__layout">
<section className="dungeon-sim-admin__catalog">
<label>
<span>Dungeon</span>
<select value={selectedDungeonId} onChange={(event) => setSelectedDungeonId(event.target.value)}>
{DUNGEON_ROUTE_CATALOG.routes.map((candidate) => {
const entry = DUNGEON_CATALOG.find((item) => item.id === candidate.dungeonId);
return <option key={candidate.dungeonId} value={candidate.dungeonId}>{entry?.title ?? candidate.dungeonId}</option>;
})}
</select>
</label>
<div className="dungeon-sim-admin__route-meta">
<span className={`route-review-status route-review-status--${(
review?.status ?? (route?.generationStatus === "Rejected" ? "NeedsWork" : "Candidate")
).toLowerCase()}`}>
{review?.status ?? (route?.generationStatus === "Rejected" ? "NeedsWork" : "Candidate")}
</span>
<strong>{dungeon?.title ?? route?.dungeonId}</strong>
<code>{route?.hash}</code>
<small>{route?.legs.length ?? 0} required legs · {route?.legs.reduce((sum, leg) => sum + leg.checkpoints.length, 0) ?? 0} checkpoints</small>
{route?.generationStatus === "Rejected" && <small>Offline rejection: {route.generationIssues.join(", ")}</small>}
</div>
<fieldset>
<legend>Launch live simulation</legend>
<label><span>Mode</span><select value={mode} onChange={(event) => setMode(event.target.value as DungeonSimulationMode)}>
<option value="navigation-only">Navigation only</option>
<option value="full-combat">Full combat</option>
</select></label>
<label><span>Deterministic seed</span><input value={seed} maxLength={96} onChange={(event) => setSeed(event.target.value)} /></label>
<button
className="front-button front-button--primary"
type="button"
disabled={busy || !route || !seed.trim()}
onClick={() => route && enterDungeonSimulation(route.dungeonId, mode, seed)}
>Launch spectate mode</button>
</fieldset>
<fieldset>
<legend>Exact-hash review</legend>
<label><span>Status</span><select value={reviewStatus} onChange={(event) => setReviewStatus(event.target.value as DungeonRouteReviewStatus)}>
<option>Candidate</option><option disabled={route?.generationStatus === "Rejected"}>Approved</option><option>NeedsWork</option>
</select></label>
<label><span>Reviewer note</span><textarea value={reviewNote} maxLength={1_000} onChange={(event) => setReviewNote(event.target.value)} /></label>
<label><span>Critical-finding override</span><textarea value={overrideNote} maxLength={1_000} placeholder="Required only when approving a run with critical findings." onChange={(event) => setOverrideNote(event.target.value)} /></label>
<button className="front-button" type="button" onClick={() => void saveReview()} disabled={busy || !route}>Save review</button>
</fieldset>
{message && <p className="front-notice" role="status">{message}</p>}
</section>
<section className="dungeon-sim-admin__details">
<header><div><p className="eyebrow">Server recordings</p><h2>{routeReplays.length} runs for this route</h2></div></header>
<div className="dungeon-sim-admin__replays">
{routeReplays.map((replay) => (
<article key={replay.id}>
<div>
<strong>{replay.mode.replace("-", " ")} · {replay.result}</strong>
<small>{new Date(replay.createdAt).toLocaleString()} · seed {replay.seed}</small>
<small>{Math.round(replay.durationMs / 100) / 10}s · {replay.criticalFindings} critical · {(replay.compressedBytes / 1024).toFixed(1)} KiB</small>
</div>
<div>
<button className="front-button" type="button" disabled={busy} onClick={() => void openReplay(replay.id)}>Watch</button>
<button className="front-button" type="button" disabled={busy} onClick={() => void downloadReplayReport(replay.id)}>JSON</button>
<button className="front-button" type="button" disabled={busy} onClick={() => void removeReplay(replay.id)}>Delete</button>
</div>
</article>
))}
{!routeReplays.length && <p className="front-notice">No recordings exist for this route hash yet.</p>}
</div>
<h2>Canonical route legs</h2>
<ol className="dungeon-sim-admin__legs">
{route?.legs.map((leg) => (
<li key={leg.id}>
<strong>{leg.objectiveName}</strong>
<span>{leg.checkpoints.length} checkpoints · {leg.expectedDistance.toFixed(1)} m</span>
<small>{leg.actions.length ? `${leg.actions.length} required actions` : "No explicit interaction"}</small>
</li>
))}
</ol>
</section>
</main>
</FrontSurface>
);
}
+112
View File
@@ -0,0 +1,112 @@
import { useDungeonSimulationStore } from "../game/dungeonSimulationStore";
import { dungeonRouteDefinition } from "../game/dungeonRoutes";
import type { DungeonSimulationSpeed } from "../game/dungeonSimulationTypes";
import { finalizeDungeonSimulation } from "../scene/DungeonSimulationSceneBridge";
import { useShellStore } from "../app/shellStore";
const SPEEDS: readonly DungeonSimulationSpeed[] = [0.5, 1, 2, 4];
function formatTime(elapsedMs: number): string {
const seconds = Math.floor(elapsedMs / 1_000);
return `${String(Math.floor(seconds / 60)).padStart(2, "0")}:${String(seconds % 60).padStart(2, "0")}.${Math.floor((elapsedMs % 1_000) / 100)}`;
}
function downloadReport(): void {
const simulation = useDungeonSimulationStore.getState();
const report = simulation.replay?.report;
if (!report) return;
const url = URL.createObjectURL(new Blob([JSON.stringify(report, null, 2)], { type: "application/json" }));
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = `${report.dungeonId}-${report.seed}-SimulationReportV1.json`;
anchor.click();
URL.revokeObjectURL(url);
}
export function DungeonSimulationHud() {
const simulation = useDungeonSimulationStore();
const route = simulation.dungeonId
? dungeonRouteDefinition(simulation.dungeonId, simulation.routeId ?? "primary")
: null;
const leg = route?.legs.find((candidate) => candidate.objectiveId === simulation.objectiveId) ?? null;
const replayDuration = simulation.replay?.durationMs ?? 0;
const returnToGmTools = () => {
useDungeonSimulationStore.getState().leave();
useShellStore.getState().openGmAdmin();
};
return (
<aside className="dungeon-sim-hud" aria-label="Dungeon simulation controls">
<div className="dungeon-sim-hud__header">
<div>
<small>{simulation.sessionMode === "spectate-replay" ? "Recorded replay" : simulation.mode.replace("-", " ")}</small>
<strong>{simulation.dungeonId ?? "Dungeon simulation"}</strong>
</div>
<time>{formatTime(simulation.elapsedMs)}</time>
</div>
<div className="dungeon-sim-hud__controls">
<button type="button" onClick={simulation.toggleRunning} disabled={simulation.runState === "finished"}>
{simulation.runState === "running" ? "Pause" : "Play"}
</button>
<button type="button" onClick={simulation.step} disabled={simulation.runState !== "paused"}>Step</button>
{SPEEDS.map((speed) => (
<button
type="button"
key={speed}
className={simulation.speed === speed ? "is-active" : undefined}
onClick={() => simulation.setSpeed(speed)}
>
{speed}×
</button>
))}
<button type="button" onClick={() => simulation.setCameraMode(
simulation.cameraMode === "follow-leader" ? "free-fly" : "follow-leader",
)}>
{simulation.cameraMode === "follow-leader" ? "Follow camera" : "Free-fly camera"}
</button>
{simulation.sessionMode === "spectate-live" && simulation.runState !== "finished" ? (
<button type="button" className="is-danger" onClick={() => void finalizeDungeonSimulation("stopped")}>
Stop & save
</button>
) : null}
{simulation.replay && <button type="button" onClick={downloadReport}>Download JSON</button>}
{simulation.runState === "finished" && <button type="button" onClick={returnToGmTools}>Return to GM Tools</button>}
</div>
{simulation.sessionMode === "spectate-replay" && (
<label className="dungeon-sim-hud__seek">
<span>Replay position</span>
<input
type="range"
min={0}
max={replayDuration}
step={100}
value={Math.min(simulation.elapsedMs, replayDuration)}
onChange={(event) => simulation.seekReplay(Number(event.target.value))}
/>
</label>
)}
<dl className="dungeon-sim-hud__status">
<div><dt>State</dt><dd>{simulation.runState}{simulation.result ? ` · ${simulation.result}` : ""}</dd></div>
<div><dt>Objective</dt><dd>{leg?.objectiveName ?? simulation.objectiveId ?? "Complete"}</dd></div>
<div><dt>Checkpoint</dt><dd>{leg ? `${Math.min(simulation.checkpointIndex + 1, leg.checkpoints.length)} / ${leg.checkpoints.length}` : "—"}</dd></div>
<div><dt>Route</dt><dd>{simulation.routeHash?.slice(0, 12) ?? "—"}</dd></div>
</dl>
<section className="dungeon-sim-hud__findings">
<header><strong>Findings</strong><span>{simulation.findings.length}</span></header>
<ol>
{[...simulation.findings].reverse().slice(0, 12).map((finding) => (
<li key={finding.id} data-severity={finding.severity}>
<time>{formatTime(finding.atMs)}</time>
<span>{finding.message}</span>
</li>
))}
</ol>
</section>
</aside>
);
}
+39 -18
View File
@@ -9,11 +9,12 @@ import { ControllerButton } from "./ControllerButton";
import { ContentManagerScreen } from "./ContentManagerScreen";
import { BrandMark, ControllerLegend, FrontSurface } from "./FrontSurface";
import { OnlineGroupPanel } from "./OnlineGroupPanel";
import { sessionCanUseShelvedGmTools, SHELVED_MODES_ENABLED } from "../app/releaseFeatures";
import { sessionCanUseGmTools, SHELVED_MODES_ENABLED } from "../app/releaseFeatures";
import { DungeonReportsScreen } from "./DungeonReportsScreen";
import { MainMenuCharacterEditor, type MainMenuEditor } from "./MainMenuCharacterEditor";
const BossCardCollectionScreen = lazy(() => import("./BossCardCollection"));
const BossTestingScreen = lazy(() => import("./BossTestingScreen").then((module) => ({ default: module.BossTestingScreen })));
const BalanceLabScreen = lazy(() => import("./BalanceLabScreen").then((module) => ({ default: module.BalanceLabScreen })));
const GAME_MODES = [
{
@@ -71,20 +72,23 @@ export function MainMenuScreen() {
const [reportsOpen, setReportsOpen] = useState(false);
const [cardsOpen, setCardsOpen] = useState(false);
const [bossTestingOpen, setBossTestingOpen] = useState(false);
const [balanceLabOpen, setBalanceLabOpen] = useState(false);
const [editor, setEditor] = useState<MainMenuEditor | null>(null);
if (editor) return <MainMenuCharacterEditor editor={editor} onClose={() => setEditor(null)} />;
if (bossTestingOpen) return <Suspense fallback={<p role="status">Opening Boss Testing…</p>}><BossTestingScreen onClose={() => setBossTestingOpen(false)} /></Suspense>;
if (balanceLabOpen) return <Suspense fallback={<p role="status">Opening Balance Lab…</p>}><BalanceLabScreen onClose={() => setBalanceLabOpen(false)} /></Suspense>;
if (cardsOpen) return <Suspense fallback={<p role="status">Opening collection log…</p>}><BossCardCollectionScreen onClose={() => setCardsOpen(false)} /></Suspense>;
if (contentManagerOpen) return <ContentManagerScreen onClose={() => setContentManagerOpen(false)} />;
if (reportsOpen) return <DungeonReportsScreen onClose={() => setReportsOpen(false)} />;
return <MainMenuDestinations onOpenDownloads={() => setContentManagerOpen(true)} onOpenReports={() => setReportsOpen(true)} onOpenCards={() => setCardsOpen(true)} onOpenBossTesting={() => setBossTestingOpen(true)} onOpenEditor={setEditor} />;
return <MainMenuDestinations onOpenDownloads={() => setContentManagerOpen(true)} onOpenReports={() => setReportsOpen(true)} onOpenCards={() => setCardsOpen(true)} onOpenBossTesting={() => setBossTestingOpen(true)} onOpenBalanceLab={() => setBalanceLabOpen(true)} onOpenEditor={setEditor} />;
}
function MainMenuDestinations({ onOpenDownloads, onOpenReports, onOpenCards, onOpenBossTesting, onOpenEditor }: {
function MainMenuDestinations({ onOpenDownloads, onOpenReports, onOpenCards, onOpenBossTesting, onOpenBalanceLab, onOpenEditor }: {
readonly onOpenDownloads: () => void;
readonly onOpenReports: () => void;
readonly onOpenCards: () => void;
readonly onOpenBossTesting: () => void;
readonly onOpenBalanceLab: () => void;
readonly onOpenEditor: (editor: MainMenuEditor) => void;
}) {
const activeCharacter = useShellStore((state) => state.activeCharacter);
@@ -99,7 +103,7 @@ function MainMenuDestinations({ onOpenDownloads, onOpenReports, onOpenCards, onO
const openGmAdmin = useShellStore((state) => state.openGmAdmin);
const character = activeCharacter ?? rosterCharacter;
const isRom = character?.categoryId === "rom";
const isGm = sessionCanUseShelvedGmTools(session);
const isGm = sessionCanUseGmTools(session);
const actions = useMemo<MenuAction[]>(() => [
{
@@ -132,11 +136,14 @@ function MainMenuDestinations({ onOpenDownloads, onOpenReports, onOpenCards, onO
{ id: "mode-keybinds", run: () => onOpenEditor("keybinds") },
{ id: "mode-bindings", run: () => onOpenEditor("bindings") },
] : []),
...(isGm ? [{ id: "mode-gm-tools", run: openGmAdmin }] : []),
...(isGm ? [
{ id: "mode-gm-tools", run: openGmAdmin },
{ id: "mode-balance-lab", run: onOpenBalanceLab },
] : []),
{ id: "mode-reports", run: onOpenReports },
{ id: "mode-cards", run: onOpenCards },
{ id: "mode-boss-testing", run: onOpenBossTesting },
], [character, isGm, onOpenBossTesting, onOpenCards, onOpenDownloads, onOpenEditor, onOpenReports, openArenas, openClassHall, openDungeons, openGmAdmin, openManastorms, returnToCharacters]);
], [character, isGm, onOpenBalanceLab, onOpenBossTesting, onOpenCards, onOpenDownloads, onOpenEditor, onOpenReports, openArenas, openClassHall, openDungeons, openGmAdmin, openManastorms, returnToCharacters]);
const controller = useMenuController(actions, {
initialId: "mode-dungeons",
onBack: returnToCharacters,
@@ -221,18 +228,32 @@ function MainMenuDestinations({ onOpenDownloads, onOpenReports, onOpenCards, onO
<em>Open</em>
</ControllerButton>
{isGm && (
<ControllerButton
controlId="mode-gm-tools"
selectedId={controller.selectedId}
select={controller.select}
className="front-button main-menu-mode main-menu-mode--available main-menu-mode--gm"
type="button"
onClick={openGmAdmin}
>
<b aria-hidden="true">GM</b>
<span><strong>GM Tools</strong><small>Manage Manastorm rules, boss readiness, and encounter placement.</small></span>
<em>Authorized</em>
</ControllerButton>
<div className="main-menu-gm-stack">
<ControllerButton
controlId="mode-gm-tools"
selectedId={controller.selectedId}
select={controller.select}
className="front-button main-menu-mode main-menu-mode--available main-menu-mode--gm"
type="button"
onClick={openGmAdmin}
>
<b aria-hidden="true">GM</b>
<span><strong>GM Tools</strong><small>Run dungeon route simulations, inspect findings, and review recordings.</small></span>
<em>Authorized</em>
</ControllerButton>
<ControllerButton
controlId="mode-balance-lab"
selectedId={controller.selectedId}
select={controller.select}
className="front-button main-menu-mode main-menu-mode--available main-menu-mode--gm"
type="button"
onClick={onOpenBalanceLab}
>
<b aria-hidden="true">BL</b>
<span><strong>Balance Lab</strong><small>Queue automatic party and boss simulations on the game server.</small></span>
<em>Server</em>
</ControllerButton>
</div>
)}
{character && (
<ControllerButton