Release Healer Man 0.1.46
This commit is contained in:
1 parent
f82daf1c13
commit
9ff574ac0b
73 files changed
+33486
-174
No files matched your search
@@ -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
|
||||
|
||||
@@ -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`.
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
Generated
+2
-2
@@ -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
@@ -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",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"versionName": "0.1.45",
|
||||
"versionCode": 1045
|
||||
"versionName": "0.1.46",
|
||||
"versionCode": 1046
|
||||
}
|
||||
@@ -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)}.`);
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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()
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
@@ -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
@@ -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");
|
||||
|
||||
@@ -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
@@ -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)) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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 />}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import type { PlayerSession } from "./types";
|
||||
import { requestOnlineJson } from "./onlineAccountClient";
|
||||
import type {
|
||||
DungeonReplaySummary,
|
||||
DungeonReplayV1,
|
||||
DungeonRouteApproval,
|
||||
DungeonRouteReview,
|
||||
DungeonRouteReviewStatus,
|
||||
DungeonSimulationReportV1,
|
||||
} from "../game/dungeonSimulationTypes";
|
||||
|
||||
function gmToken(session: PlayerSession | null): string {
|
||||
if (session?.kind !== "account" || !session.accessToken || !session.roles?.includes("gm")) {
|
||||
throw new Error("Game Master access is required.");
|
||||
}
|
||||
return session.accessToken;
|
||||
}
|
||||
|
||||
export async function fetchDungeonRouteApprovals(): Promise<readonly DungeonRouteApproval[]> {
|
||||
return (await requestOnlineJson<{ approvals: DungeonRouteApproval[] }>("/api/dungeon-route-approvals")).approvals;
|
||||
}
|
||||
|
||||
export async function fetchGmDungeonRoutes(session: PlayerSession | null): Promise<{
|
||||
readonly reviews: readonly DungeonRouteReview[];
|
||||
readonly latestReplays: readonly DungeonReplaySummary[];
|
||||
}> {
|
||||
return requestOnlineJson("/api/gm/dungeon-routes", {}, gmToken(session));
|
||||
}
|
||||
|
||||
export async function putDungeonRouteReview(
|
||||
session: PlayerSession | null,
|
||||
route: { readonly dungeonId: string; readonly routeId: string; readonly routeHash: string },
|
||||
update: {
|
||||
readonly status: DungeonRouteReviewStatus;
|
||||
readonly expectedRevision: number;
|
||||
readonly note?: string;
|
||||
readonly criticalOverrideNote?: string;
|
||||
},
|
||||
): Promise<DungeonRouteReview> {
|
||||
const response = await requestOnlineJson<{ review: DungeonRouteReview }>(
|
||||
`/api/gm/dungeon-routes/${encodeURIComponent(route.dungeonId)}/${encodeURIComponent(route.routeId)}/review`,
|
||||
{ method: "PUT", body: JSON.stringify({ ...update, routeHash: route.routeHash }) },
|
||||
gmToken(session),
|
||||
);
|
||||
return response.review;
|
||||
}
|
||||
|
||||
export async function fetchDungeonSimulationReplays(
|
||||
session: PlayerSession | null,
|
||||
dungeonId?: string,
|
||||
): Promise<readonly DungeonReplaySummary[]> {
|
||||
const query = dungeonId ? `?dungeon=${encodeURIComponent(dungeonId)}` : "";
|
||||
return (await requestOnlineJson<{ replays: DungeonReplaySummary[] }>(
|
||||
`/api/gm/dungeon-sim/replays${query}`,
|
||||
{},
|
||||
gmToken(session),
|
||||
)).replays;
|
||||
}
|
||||
|
||||
export async function saveDungeonSimulationReplay(
|
||||
session: PlayerSession | null,
|
||||
replay: DungeonReplayV1,
|
||||
): Promise<DungeonReplaySummary> {
|
||||
return (await requestOnlineJson<{ replay: DungeonReplaySummary }>(
|
||||
"/api/gm/dungeon-sim/replays",
|
||||
{ method: "POST", body: JSON.stringify(replay) },
|
||||
gmToken(session),
|
||||
)).replay;
|
||||
}
|
||||
|
||||
export function fetchDungeonSimulationReplay(
|
||||
session: PlayerSession | null,
|
||||
replayId: string,
|
||||
): Promise<DungeonReplayV1> {
|
||||
return requestOnlineJson(`/api/gm/dungeon-sim/replays/${encodeURIComponent(replayId)}`, {}, gmToken(session));
|
||||
}
|
||||
|
||||
export function fetchDungeonSimulationReport(
|
||||
session: PlayerSession | null,
|
||||
replayId: string,
|
||||
): Promise<DungeonSimulationReportV1> {
|
||||
return requestOnlineJson(`/api/gm/dungeon-sim/replays/${encodeURIComponent(replayId)}/report`, {}, gmToken(session));
|
||||
}
|
||||
|
||||
export function deleteDungeonSimulationReplay(session: PlayerSession | null, replayId: string): Promise<{ readonly ok: true }> {
|
||||
return requestOnlineJson(`/api/gm/dungeon-sim/replays/${encodeURIComponent(replayId)}`, { method: "DELETE" }, gmToken(session));
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { touchCharacter } from "./accountRepository";
|
||||
import { useShellStore } from "./shellStore";
|
||||
import { activateDungeonSession } from "../game/dungeonSession";
|
||||
import { dungeonRouteDefinition } from "../game/dungeonRoutes";
|
||||
import { useDungeonSimulationStore } from "../game/dungeonSimulationStore";
|
||||
import type { DungeonReplayV1, DungeonSimulationMode } from "../game/dungeonSimulationTypes";
|
||||
import { defaultPartyRoleForClass } from "../game/partyRoles";
|
||||
import { useGameStore } from "../game/store";
|
||||
|
||||
function gmCharacter() {
|
||||
const shell = useShellStore.getState();
|
||||
if (shell.session?.kind !== "account" || !shell.session.accessToken || !shell.session.roles?.includes("gm")) {
|
||||
shell.setNotice("Game Master access is required.");
|
||||
return null;
|
||||
}
|
||||
const character = shell.selectedCharacterId
|
||||
? touchCharacter(shell.session.ownerId, shell.selectedCharacterId)
|
||||
?? shell.characters.find((candidate) => candidate.id === shell.selectedCharacterId)
|
||||
: null;
|
||||
if (!character) shell.setNotice("Select a character before launching a simulation.");
|
||||
return character;
|
||||
}
|
||||
|
||||
export function enterDungeonSimulation(
|
||||
dungeonId: string,
|
||||
mode: DungeonSimulationMode,
|
||||
seed: string,
|
||||
): boolean {
|
||||
const character = gmCharacter();
|
||||
const route = dungeonRouteDefinition(dungeonId);
|
||||
if (!character || !route || !seed.trim()) return false;
|
||||
if (!activateDungeonSession(dungeonId, undefined, {
|
||||
gmTestMode: true,
|
||||
dungeonSimulation: true,
|
||||
})) {
|
||||
useShellStore.getState().setNotice("The dungeon package is unavailable on this client.");
|
||||
return false;
|
||||
}
|
||||
useDungeonSimulationStore.getState().startLive({ dungeonId, route, mode, seed: seed.trim() });
|
||||
useGameStore.getState().setCompanionOpen(false);
|
||||
useShellStore.setState({
|
||||
phase: "game",
|
||||
activeCharacter: character,
|
||||
activeDungeonId: dungeonId,
|
||||
activeDungeonRole: defaultPartyRoleForClass(character.classId),
|
||||
notice: "",
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
export function enterDungeonSimulationReplay(replay: DungeonReplayV1): boolean {
|
||||
const character = gmCharacter();
|
||||
if (!character || !dungeonRouteDefinition(replay.dungeonId, replay.routeId)) return false;
|
||||
if (!activateDungeonSession(replay.dungeonId, undefined, {
|
||||
gmTestMode: true,
|
||||
dungeonSimulation: true,
|
||||
})) {
|
||||
useShellStore.getState().setNotice("The replay dungeon package is unavailable on this client.");
|
||||
return false;
|
||||
}
|
||||
useDungeonSimulationStore.getState().startReplay(replay);
|
||||
useGameStore.getState().setCompanionOpen(false);
|
||||
useShellStore.setState({
|
||||
phase: "game",
|
||||
activeCharacter: character,
|
||||
activeDungeonId: replay.dungeonId,
|
||||
activeDungeonRole: defaultPartyRoleForClass(character.classId),
|
||||
notice: "",
|
||||
});
|
||||
return true;
|
||||
}
|
||||
@@ -1,13 +1,15 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
isShelvedShellPhase,
|
||||
sessionCanUseGmTools,
|
||||
sessionCanUseShelvedGmTools,
|
||||
SHELVED_MODES_ENABLED,
|
||||
} from "./releaseFeatures";
|
||||
|
||||
describe("release feature boundary", () => {
|
||||
it("recognizes every legacy shelved phase", () => {
|
||||
expect(["manastorms", "arenas", "arena-game", "gm-admin"].every(isShelvedShellPhase)).toBe(true);
|
||||
expect(["manastorms", "arenas", "arena-game"].every(isShelvedShellPhase)).toBe(true);
|
||||
expect(isShelvedShellPhase("gm-admin")).toBe(false);
|
||||
expect(isShelvedShellPhase("main-menu")).toBe(false);
|
||||
expect(isShelvedShellPhase("game")).toBe(false);
|
||||
});
|
||||
@@ -24,4 +26,16 @@ describe("release feature boundary", () => {
|
||||
expect(sessionCanUseShelvedGmTools({ ...session, roles: [] })).toBe(false);
|
||||
expect(sessionCanUseShelvedGmTools({ ...session, accessToken: "" })).toBe(false);
|
||||
});
|
||||
|
||||
it("exposes dungeon GM tools in normal releases to authenticated GMs", () => {
|
||||
const session = {
|
||||
kind: "account",
|
||||
ownerId: "gm-account",
|
||||
displayName: "Phenom",
|
||||
accessToken: "token",
|
||||
roles: ["gm"],
|
||||
} as const;
|
||||
expect(sessionCanUseGmTools(session)).toBe(true);
|
||||
expect(sessionCanUseGmTools({ ...session, roles: [] })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,6 @@ export const SHELVED_SHELL_PHASES = Object.freeze([
|
||||
"manastorms",
|
||||
"arenas",
|
||||
"arena-game",
|
||||
"gm-admin",
|
||||
] as const);
|
||||
|
||||
export type ShelvedShellPhase = (typeof SHELVED_SHELL_PHASES)[number];
|
||||
@@ -21,3 +20,11 @@ export function sessionCanUseShelvedGmTools(session: {
|
||||
return SHELVED_MODES_ENABLED
|
||||
&& Boolean(session?.kind === "account" && session.accessToken && session.roles?.includes("gm"));
|
||||
}
|
||||
|
||||
export function sessionCanUseGmTools(session: {
|
||||
readonly kind: string;
|
||||
readonly accessToken?: string;
|
||||
readonly roles?: readonly string[];
|
||||
} | null): boolean {
|
||||
return Boolean(session?.kind === "account" && session.accessToken && session.roles?.includes("gm"));
|
||||
}
|
||||
@@ -59,7 +59,7 @@ import {
|
||||
logoutOnlineSession,
|
||||
refreshOnlineSession,
|
||||
} from "./onlineAccountClient";
|
||||
import { sessionCanUseShelvedGmTools, SHELVED_MODES_ENABLED } from "./releaseFeatures";
|
||||
import { sessionCanUseGmTools, SHELVED_MODES_ENABLED } from "./releaseFeatures";
|
||||
import { useGameStore } from "../game/store";
|
||||
import {
|
||||
normalizeSpecializationId,
|
||||
@@ -366,7 +366,7 @@ export const useShellStore = create<ShellState>((set, get) => ({
|
||||
},
|
||||
openGmAdmin: () => {
|
||||
const { session, selectedCharacterId, characters } = get();
|
||||
if (!sessionCanUseShelvedGmTools(session)) {
|
||||
if (!sessionCanUseGmTools(session)) {
|
||||
set({ notice: "Game Master access is required." });
|
||||
return false;
|
||||
}
|
||||
|
||||
+70
-36
@@ -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);
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -13,6 +13,7 @@ import type { DungeonEncounterEffectEnvelope } from "./dungeonEncounterTypes";
|
||||
import { publishOnlinePartyTeleport } from "./onlineSessionRuntime";
|
||||
import { dungeonDefinitionById } from "./dungeonRegistry";
|
||||
import { removeMobPosition } from "./mobRuntimeRegistry";
|
||||
import { dungeonSimulationNow } from "./dungeonSimulationStore";
|
||||
|
||||
function grantEncounterLoot(lootTableId: string, now: number): boolean {
|
||||
const table = LOOT_CATALOG.bosses.find((candidate) => candidate.lootTableId === lootTableId);
|
||||
@@ -29,14 +30,14 @@ function grantEncounterLoot(lootTableId: string, now: number): boolean {
|
||||
/** Applies platform effects emitted by the renderer-neutral encounter reducer. */
|
||||
export function DungeonEncounterEffectBridge() {
|
||||
useEffect(() => registerDungeonEncounterEffectHandler((envelope) => {
|
||||
applyDungeonEncounterEffect(envelope, Date.now());
|
||||
applyDungeonEncounterEffect(envelope, dungeonSimulationNow());
|
||||
}), []);
|
||||
return null;
|
||||
}
|
||||
|
||||
export function applyDungeonEncounterEffect(
|
||||
{ dungeonId, effect }: DungeonEncounterEffectEnvelope,
|
||||
now = Date.now(),
|
||||
now = dungeonSimulationNow(),
|
||||
): void {
|
||||
if (effect.kind === "signal") {
|
||||
dispatchDungeonEncounterSignal(effect.signalId, now);
|
||||
|
||||
@@ -6,6 +6,7 @@ import { usePartyStore } from "./partyStore";
|
||||
import { useGameStore } from "./store";
|
||||
import { useManastormStore } from "./manastormStore";
|
||||
import { onlineGroupAiPartySize, useOnlineGroupStore } from "../app/onlineGroupStore";
|
||||
import { useDungeonSimulationStore } from "./dungeonSimulationStore";
|
||||
|
||||
/** Owns party lifecycle; the combat interval itself remains in CombatBridge. */
|
||||
export function PartyBridge() {
|
||||
@@ -15,6 +16,8 @@ export function PartyBridge() {
|
||||
const manastormPartySize = useManastormStore((state) => state.partySize);
|
||||
const onlineActivity = useOnlineGroupStore((state) => state.activeActivity);
|
||||
const level = useCombatStore((state) => state.level);
|
||||
const simulationMode = useDungeonSimulationStore((state) => state.sessionMode);
|
||||
const simulationSeed = useDungeonSimulationStore((state) => state.seed);
|
||||
const resetRevision = useGameStore((state) => state.resetRevision);
|
||||
const mountedResetRevision = useRef(resetRevision);
|
||||
|
||||
@@ -27,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");
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { estimateBossPartyPressure, rankBossPartyPressure } from "./bossPressure";
|
||||
import { DUNGEON_DEFINITIONS } from "./dungeonRegistry";
|
||||
|
||||
describe("boss party-pressure audit", () => {
|
||||
it("produces a finite deterministic ranking for every WoW boss", () => {
|
||||
const definitions = DUNGEON_DEFINITIONS.filter((dungeon) => dungeon.expansion !== "runewaker");
|
||||
const first = rankBossPartyPressure(definitions);
|
||||
const second = rankBossPartyPressure(definitions);
|
||||
expect(first.length).toBeGreaterThan(200);
|
||||
expect(second).toEqual(first);
|
||||
expect(first.every((entry) => Number.isFinite(entry.partyDamagePerSecond)
|
||||
&& entry.partyDamagePerSecond >= 0)).toBe(true);
|
||||
expect(first.map((entry) => entry.partyDamage)).toEqual(
|
||||
[...first.map((entry) => entry.partyDamage)].sort((left, right) => right - left),
|
||||
);
|
||||
});
|
||||
|
||||
it("counts grouped effects across the party and excludes self-only utility", () => {
|
||||
const gundrak = DUNGEON_DEFINITIONS.find((dungeon) => dungeon.id === "gundrak")!;
|
||||
const eckId = Object.entries(gundrak.entities)
|
||||
.find(([, entity]) => entity.combat?.serverEntry === 29_932)![0];
|
||||
const estimate = estimateBossPartyPressure(gundrak, eckId);
|
||||
expect(estimate.boss).toBe("Eck the Ferocious");
|
||||
expect(estimate.attacks.find((attack) => attack.spellId === 55_837)?.partyDamage).toBe(0);
|
||||
expect(estimate.attacks.find((attack) => attack.spellId === 55_816)?.partyDamage).toBe(0);
|
||||
expect(estimate.attacks.find((attack) => attack.spellId === 55_815)).toMatchObject({
|
||||
target: "random-party",
|
||||
targetsPerCast: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
import { creatureRuntimeBaseStats } from "./azerothCoreReference";
|
||||
import { scaledDungeonEnemyLevel } from "./dungeonLevelScaling";
|
||||
import type { DungeonDefinition } from "./dungeonTypes";
|
||||
import type {
|
||||
EnemyAbilityEffect,
|
||||
EnemyAttackDefinition,
|
||||
EnemyAtomicAbilityEffect,
|
||||
} from "./enemyAttacks";
|
||||
import type { PopulationEntityDefinition } from "./mobPopulation";
|
||||
|
||||
export const BOSS_PRESSURE_WINDOW_MS = 60_000;
|
||||
export const BOSS_PRESSURE_PARTY_SIZE = 5;
|
||||
|
||||
export interface BossAttackPressureEstimate {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly spellId?: number;
|
||||
readonly target: EnemyAttackDefinition["target"];
|
||||
readonly school: EnemyAttackDefinition["school"];
|
||||
readonly expectedCasts: number;
|
||||
readonly targetsPerCast: number;
|
||||
readonly damagePerTarget: number;
|
||||
readonly partyDamage: number;
|
||||
}
|
||||
|
||||
export interface BossPartyPressureEstimate {
|
||||
readonly key: string;
|
||||
readonly dungeonId: string;
|
||||
readonly dungeonTitle: string;
|
||||
readonly boss: string;
|
||||
readonly serverEntry: number | null;
|
||||
readonly playerLevel: number;
|
||||
readonly enemyLevel: number;
|
||||
readonly windowSeconds: number;
|
||||
readonly attackDamage: number;
|
||||
readonly partyDamage: number;
|
||||
readonly partyDamagePerSecond: number;
|
||||
readonly tankDamage: number;
|
||||
readonly randomTargetDamage: number;
|
||||
readonly groupwideDamage: number;
|
||||
readonly attacks: readonly BossAttackPressureEstimate[];
|
||||
}
|
||||
|
||||
function midpoint(range: readonly [number, number] | undefined, fallback: number): number {
|
||||
if (!range) return fallback;
|
||||
return (Math.max(0, range[0]) + Math.max(0, range[1])) / 2;
|
||||
}
|
||||
|
||||
function expectedCasts(attack: EnemyAttackDefinition, windowMs: number): number {
|
||||
const initial = midpoint(attack.initialCooldownMs, 450);
|
||||
if (windowMs < initial) return 0;
|
||||
const repeat = Math.max(1, midpoint(attack.repeatCooldownMs, attack.cooldownMs));
|
||||
const casts = 1 + (windowMs - initial) / repeat;
|
||||
return Math.min(attack.maximumCasts ?? Number.POSITIVE_INFINITY, casts);
|
||||
}
|
||||
|
||||
function atomicDamageMultiplier(effect: EnemyAtomicAbilityEffect): number {
|
||||
if (effect.kind === "damage") return Math.max(0, effect.multiplier);
|
||||
if (effect.kind === "leech") return Math.max(0, effect.damageMultiplier);
|
||||
if (effect.kind === "control") return Math.max(0, effect.damageMultiplier ?? 0);
|
||||
return 0;
|
||||
}
|
||||
|
||||
function damageMultiplier(attack: EnemyAttackDefinition, effect: EnemyAbilityEffect | undefined): number {
|
||||
if (!effect) return Math.max(0, attack.damageMultiplier);
|
||||
if (effect.kind === "composite") {
|
||||
return effect.effects.reduce((total, entry) => total + atomicDamageMultiplier(entry), 0);
|
||||
}
|
||||
return atomicDamageMultiplier(effect);
|
||||
}
|
||||
|
||||
function targetsPerCast(attack: EnemyAttackDefinition, partySize: number): number {
|
||||
if (attack.target === "nearby-party") return partySize;
|
||||
if (attack.target === "primary" || attack.target === "random-party") return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Comparable direct-damage ceiling for one boss with a grouped party. It uses
|
||||
* live attack definitions and source creature stats, but deliberately excludes
|
||||
* armor/resistance, avoidable movement, summons, healing, and mutually exclusive
|
||||
* phase uptime. Those omissions keep the audit deterministic and make inflated
|
||||
* schedules or accidental party-wide targeting obvious.
|
||||
*/
|
||||
export function estimateBossPartyPressure(
|
||||
dungeon: DungeonDefinition,
|
||||
entityId: string,
|
||||
playerLevel = 80,
|
||||
windowMs = BOSS_PRESSURE_WINDOW_MS,
|
||||
partySize = BOSS_PRESSURE_PARTY_SIZE,
|
||||
): BossPartyPressureEstimate {
|
||||
const entity: PopulationEntityDefinition | undefined = dungeon.entities[entityId];
|
||||
if (!entity || entity.kind !== "boss") throw new Error(`${dungeon.id}:${entityId} is not a boss`);
|
||||
const enemyLevel = scaledDungeonEnemyLevel(playerLevel, true);
|
||||
const serverEntry = entity.combat?.serverEntry ?? null;
|
||||
const baseDamage = serverEntry
|
||||
? creatureRuntimeBaseStats(serverEntry, enemyLevel)?.averageAttackDamage
|
||||
: undefined;
|
||||
const attackDamage = (baseDamage ?? (12 + enemyLevel * 2)) * (entity.combat?.damageMultiplier ?? 1);
|
||||
const attacks = (entity.combat?.attacks ?? []).map((attack): BossAttackPressureEstimate => {
|
||||
const casts = expectedCasts(attack, windowMs);
|
||||
const targets = targetsPerCast(attack, partySize);
|
||||
const perTarget = attackDamage * damageMultiplier(attack, attack.effect);
|
||||
return {
|
||||
id: attack.id,
|
||||
name: attack.name,
|
||||
...(attack.spellId === undefined ? {} : { spellId: attack.spellId }),
|
||||
target: attack.target,
|
||||
school: attack.school,
|
||||
expectedCasts: casts,
|
||||
targetsPerCast: targets,
|
||||
damagePerTarget: perTarget,
|
||||
partyDamage: casts * targets * perTarget,
|
||||
};
|
||||
});
|
||||
const sum = (target: EnemyAttackDefinition["target"]): number => attacks
|
||||
.filter((attack) => attack.target === target)
|
||||
.reduce((total, attack) => total + attack.partyDamage, 0);
|
||||
const partyDamage = attacks.reduce((total, attack) => total + attack.partyDamage, 0);
|
||||
return {
|
||||
key: `${dungeon.id}/${entityId}`,
|
||||
dungeonId: dungeon.id,
|
||||
dungeonTitle: dungeon.title,
|
||||
boss: entity.name,
|
||||
serverEntry,
|
||||
playerLevel,
|
||||
enemyLevel,
|
||||
windowSeconds: windowMs / 1_000,
|
||||
attackDamage,
|
||||
partyDamage,
|
||||
partyDamagePerSecond: partyDamage / Math.max(1, windowMs / 1_000),
|
||||
tankDamage: sum("primary"),
|
||||
randomTargetDamage: sum("random-party"),
|
||||
groupwideDamage: sum("nearby-party"),
|
||||
attacks,
|
||||
};
|
||||
}
|
||||
|
||||
export function rankBossPartyPressure(
|
||||
definitions: readonly DungeonDefinition[],
|
||||
playerLevel = 80,
|
||||
windowMs = BOSS_PRESSURE_WINDOW_MS,
|
||||
partySize = BOSS_PRESSURE_PARTY_SIZE,
|
||||
): readonly BossPartyPressureEstimate[] {
|
||||
return definitions.flatMap((dungeon) => Object.entries(dungeon.entities)
|
||||
.filter(([, entity]) => entity.kind === "boss")
|
||||
.map(([entityId]) => estimateBossPartyPressure(dungeon, entityId, playerLevel, windowMs, partySize)))
|
||||
.sort((left, right) => right.partyDamage - left.partyDamage
|
||||
|| left.boss.localeCompare(right.boss)
|
||||
|| left.dungeonId.localeCompare(right.dungeonId));
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { buildBossTestingCatalog, BOSS_TESTING_CATALOG, createBossTestingRoom, randomTestingBoss } from "./bossTesting";
|
||||
import { anotherRandomTestingBoss, buildBossTestingCatalog, BOSS_TESTING_CATALOG, createBossTestingRoom, randomTestingBoss } from "./bossTesting";
|
||||
import { clearBossTestingDungeons, DUNGEON_DEFINITIONS, registerBossTestingDungeon, requireDungeonDefinition } from "./dungeonRegistry";
|
||||
import { activateDungeonSession, restartActiveDungeonSession } from "./dungeonSession";
|
||||
import { useGameStore } from "./store";
|
||||
@@ -138,5 +138,7 @@ describe("Boss Testing", () => {
|
||||
expect(randomTestingBoss(candidates, () => 0)).toBe(candidates[0]);
|
||||
expect(randomTestingBoss(candidates, () => 1)).toBe(candidates[1]);
|
||||
expect(randomTestingBoss(candidates, () => Number.NaN)).toBe(candidates[0]);
|
||||
expect(anotherRandomTestingBoss(candidates, candidates[0].key, () => 0)).toBe(candidates[1]);
|
||||
expect(anotherRandomTestingBoss([candidates[0]], candidates[0].key)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -42,6 +42,14 @@ export function randomTestingBoss(bosses: readonly TestableBoss[], random: () =>
|
||||
return bosses[index];
|
||||
}
|
||||
|
||||
export function anotherRandomTestingBoss(
|
||||
bosses: readonly TestableBoss[],
|
||||
currentKey: string,
|
||||
random: () => number = Math.random,
|
||||
): TestableBoss | null {
|
||||
return randomTestingBoss(bosses.filter((boss) => boss.key !== currentKey), random);
|
||||
}
|
||||
|
||||
function references(value: unknown, ids: ReadonlySet<string>): boolean {
|
||||
if (typeof value === "string") return ids.has(value);
|
||||
if (Array.isArray(value)) return value.some((entry) => references(entry, ids));
|
||||
|
||||
+10
-5
@@ -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) {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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),
|
||||
}));
|
||||
@@ -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
|
||||
)));
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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));
|
||||
|
||||
@@ -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)]),
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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} />
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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; }
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user