Compare commits

...
2 Commits
Author SHA1 Message Date
phenom ed0d2fea3d Release Healer Man 0.1.39 2026-09-13 11:07:56 -04:00
phenom b417b7c3c3 Release Healer Man 0.1.38 2026-09-12 23:55:24 -04:00
89 changed files with 19787 additions and 133 deletions

No files matched your search

+321
View File
@@ -2558,3 +2558,324 @@ Vite's existing large-chunk advisory remains informational.
checks passed. Browser error logs were empty. Physical Thor hardware and a checks passed. Browser error logs were empty. Physical Thor hardware and a
multiplayer session were not exercised; the new per-party totals consume multiplayer session were not exercised; the new per-party totals consume
this client's combat stream. No hosted release was published. this client's combat stream. No hosted release was published.
## Player-only revival during ongoing party combat — September 12, 2026
- In normal offline dungeons, a defeated player with living companions and an
engaged, living enemy can revive at the entrance or an unlocked boss checkpoint
without resetting the pull or relocating/restoring companions. The player-only
path advances the relocation revision, not the party or encounter reset signals.
Recall remains an explicit, optional command. Full-wipe recovery is unchanged;
Manastorm's shared-charge resurrection remains separate.
- Player recovery restores health/resources, clears corpse animation, casts,
control, shields, harmful player auras, and player-targeted periodic effects.
It preserves NPC/enemy actor snapshots, positions, health, targets, casts,
periodic effects on other actors, and ongoing cooldowns. The existing checkpoint
chooser is retained; its defeat-prompt copy explains that companions keep fighting.
- Nine dedicated regressions cover trash/boss combat, selected checkpoints,
unchanged companion/enemy snapshots and Recall revisions, continued simulation,
periodic-effect isolation, full-wipe fallback, canonical actor health, and invalid
requests. A prompt-rendering regression checks the optional-Recall explanation.
Final Vitest validation passed 239 files and 1,511 tests, with six existing skips.
Test fixtures use a fixed combat clock and respect surviving crowd-control timers.
- Localhost Stockades smoke testing at 1280x720 showed the rendered dungeon,
party frames, unchanged Recall control, and an active party fight with increasing
damage and declining tank health. Session screenshots captured the entrance and
combat HUD. Startup navigation initially timed out and recovered after reload;
browser error logs were empty after recovery. A live player-death/mid-fight
checkpoint-revival replay was not achieved, so spatial proof of that exact
sequence is not claimed. Multiplayer and physical Thor hardware were not tested.
- Production TypeScript/Vite compilation, static compression, release-bundle
verification, and prebuild texture/performance audits passed. Existing chunk
advisories remain. No hosted release was published. Character rendering,
camera/input, dungeon geometry, lighting, and unrelated in-progress party damage
changes were not edited for this request.
## RoM dungeon entry and level-up crash — September 12, 2026
- Reproduced in the local PC browser with an offline RoM Human Priest. Stockades
loaded at level 1, then the first level-up unmounted the entire app. The console
recorded `Unsupported party specialization: rom-champion:Primary` from
`partyStore.syncLevel` through `PartyBridge`. Initial party creation already
skipped WoW talents for RoM classes; level synchronization did not. The same
call also runs during entry when the saved character level replaces the
previous combat level.
- Level synchronization now retains empty, frozen talent ranks for RoM
companions, matching party creation. Other classes still receive their
existing talent plans. Two regressions failed with the original exception
before the fix, then passed. They cover all ten RoM companion classes, all
three player roles, levels 1/2/10/70 and downward synchronization, stable
identities, fallen companions, and living health/resource ratios.
- After reload, the same saved level 2 Priest entered Stockades successfully,
fought with the RoM party, and reached level 3 without losing the scene. The
same character then entered Forsaken Abbey and progressed from level 3 to 6.
At the 74-second check after Attack, the scene and HUD remained present,
combat continued, and the renderer reported about 60 FPS. No new browser
errors or context-loss messages appeared. Rapier initialization and multiple
active KTX2-loader warnings occurred during these development sessions.
- Screenshots and timestamped browser evidence are in
`artifacts/rom-dungeon-level-sync/`. Screenshot capture retained the browser
backend's existing scaling issue; DOM progression and renderer counters
supplemented the rendered dungeon/body/equipment checks. This was an offline
PC smoke test, not full dungeon completion, multiplayer, or physical Android
validation. The temporary desktop viewport was restored and test tab closed.
- All 54 focused party, talent-plan, and RoM combat tests passed. The production
TypeScript/Vite build, prebuild texture/performance audits, compression, and
release-bundle verification passed with existing chunk advisories. The final
release-candidate check includes untracked changes. No hosted release was
published. Existing combat, death/respawn, AI, and HUD work was preserved.
## NPC party damage — September 12, 2026
- Reproduced Garrick, Arms, and Fury dealing zero damage in level 5/20
sustained fights: Rage starts at zero, companion Rage regeneration is
disabled, and the ability loop had no regular weapon swings. Priests,
hunters, and warlocks also dealt zero because they repeatedly refreshed
their DoTs before the first tick could fire.
- Added independent weapon swings for melee companions and hunters, with
Rage earned from successful hits. Swings respect range, visibility, line
of sight, facing, control, casting, rejoining, and party orders. Maintained
DoTs and debuffs now allow other abilities to run between applications;
utility taunts/interrupts/control require a useful combat context.
- Self-centered area attacks now validate their radius and hit around the
caster. Companion direct and periodic amounts use the existing spell
scaling calculation, including spell base damage and equipment bonuses.
- The new audit runs 30-second fights for all 26 WoW party templates at
levels 1, 3, 5, 20, and 80. Every template produces positive damage from
actual abilities, beyond weapon swings, with resources within bounds.
Separate regressions cover zero-Rage short pulls, swings during the GCD,
Stop, Thunder Clap targeting, and zero-coefficient spell base damage.
- In an offline PC Stockades run, the saved level 3 Priest commanded Attack
without player attacks. Garrick dealt 6 damage in the first three seconds
and 80 damage by approximately 20 seconds; Kael dealt 147, Korra 220, and
Ash 146 at that check. The party defeated enemies and the player reached
level 4. The saved party's Garrick is a Tauren; the class/role fix also
applies to Dwarf Garrick. This was a PC combat smoke test, not physical
Thor, multiplayer, or a full dungeon completion test.
- `artifacts/npc-damage/stockades-warrior-damage.png` shows the rendered
dungeon, player body/equipment, and active DPS rows for both warriors.
A paused accessibility snapshot records totals. No browser errors were
reported. The temporary browser tab was closed after verification.
- All 310 focused combat, party, death/respawn, and DPS tests passed. The
production build, texture/performance audits, compression, bundle
verification, and release candidate check passed. Existing concurrent
party-level synchronization, death/respawn, catalog, and server changes
were preserved.
## Dungeon release stages and issue reports — September 12, 2026
- The complete 84-dungeon browser now sorts Full Release, Beta, then Alpha,
alphabetically within each stage. Stormwind Stockade is the only Beta;
the other 83 entries are Alpha. WoW/RoM category tabs remain available.
- Dungeon pause menus can submit a nonempty issue comment of up to 500
characters to the shared SQLite report database. Connected guest players
can submit reports too. New reports are stamped Submitted by the server;
comments remain in the form after a failed network request.
- View Reports on the main menu shows all reports, including Closed, by
default. Status and Dungeon filters combine, and Clear filters restores
the complete list. GM accounts can select Submitted, In-Progress,
Testing Fixes, or Closed, use Close report, and reopen closed reports.
The server rejects non-GM updates and stale revisions.
- Dungeon rows with Submitted, In-Progress, or Testing Fixes reports have
an orange border and a top-right open-report count. Closing the final
open report removes both treatments. Counts refresh when opening the
browser, returning to the app, and every 30 seconds while visible.
- A headless Edge browser exercised an actual Stockades entry, its pause
report form, a 500-character submission, whitespace rejection, failed
submission/retry, the shared player report list, all GM status changes,
combined filters, and badge removal. Keyboard Back and controller filter
navigation/GM status changes passed. No browser page errors occurred.
- Visually inspected the form, orange dungeon badge, player report list,
and GM report screens at 1280×800 and 390×844. Long comments wrap and the
mobile report screen has no horizontal overflow. Evidence and the test
result are in `playtest-artifacts/dungeon-reports/`. This validates the
reporting flow; it is not an exhaustive dungeon completion or physical
Android/Thor playtest.
- All 43 focused report, catalog, store, shell, and display tests passed
(five existing shelved-mode tests skipped), along with all 61 server tests.
The 32 party AI/store/sustained-damage checks also passed after preserving
immutable cast metadata across companion channel updates to resolve three
concurrent combat type-check errors. A separate, in-progress expanded
class-damage audit failed its level-1 CoA player case for Witch Doctor
and Sun Cleric; those combat behaviors are outside the reporting change.
- The final production TypeScript/Vite build, prebuild texture/performance
audits, compression, and release-bundle verification passed with existing
chunk advisories. `npm run release:check` passed after a transient Windows
Git crash, including untracked files. No hosted release was published.
## CoA and RoM class damage audit — September 12, 2026
- Ran 342 sustained, 30-second combat scenarios: all 21 CoA classes as player
characters and synthetic NPC slots at levels 1, 2, 4, 9, 20, and 80; all 10
RoM classes in both runtimes at levels 1, 20, and 70; and the actual shipped
RoM companion templates with their assigned roles and starting resources at
those three levels. All scenarios passed. CoA expeditions currently use WoW
companion templates, so the synthetic CoA slots exercise class execution
without implying that a CoA companion roster has been added.
- Found and fixed companion spacing based only on WoW class names. RoM and
CoA casters now use their class metadata and learned offensive spell ranges;
short-range attacks remain reachable. A thrown summon such as Caltrops does
not force an early Witch Hunter away from its melee attacks. Hunter aliases
also use ranged weapon stats and projectile presentations for ranged swings.
- Fixed Dawn Blade's melee classification: a five-yard hunter-archetype attack
previously counted as ranged and could not initiate the player's weapon
swings needed to generate Rage. The companion AI now rejects unlearned CoA
tree entries and resolves the rank actually learned.
- Fixed channel execution in both runtimes. Normal amounts are distributed
across five pulses, while guaranteed periodic trigger payloads fire per
pulse. Radiant Flame's imported periodic damage now happens during the
channel. Focused tests verify five damaging pulses and no duplicate amount
on completion. Conditional proc payloads are not treated as guaranteed hits.
- Corrected the RoM vocation import to use the player's `FixArgTableID = 1`
record, as selected in the source client's level-up calculation. The previous
adjacent table made Champion and Warlock stats grow exponentially; a level-20
Champion weapon hit reached a raw 704 billion damage in the initial audit.
The corrected Champion instead sustained attacks and damaging abilities for
the full test. Source record sentinels and all ten classes' growth bounds are
covered by regression checks; this is a source-table correction, not a balance
multiplier adjustment.
- Corrected tooltip fallback damage on retaliation auras. Drakkari Barrier
stays friendly and deals its authored damage through its damage-taken proc.
The imported first offensive unlocks are Witch Doctor 9, Sun Cleric 4, and
Primalist 2. The player audit requires offensive ability damage once unlocked;
these classes have no learned offensive cast at level 1. Their NPC weapon
attacks deal damage at level 1. This supersedes the preliminary player
failures mentioned in the reporting notes above.
- Live PC check: created the isolated offline test character Romaudit, entered
Forsaken Abbey as a healer, and issued Attack. At 26 seconds the meter showed
Ilyen (Mage) 492 damage, Morgha (Warlock) 370, Veya (Rogue) 217, and Bronn
(Champion tank) 148. All four NPCs dealt damage; visible enemy effects also
confirmed Heavy Bash, Electrocution, Weakening Weave Curse, and Shadowstab.
No player healing was applied during this bounded damage check, and the tank
eventually died. This was not a dungeon-completion test.
- Visually verified the loaded roster body/staff and in-dungeon player
body/staff. The DPS meter remained readable at the PC bottom-left default.
The first attempt used the downloadable shelved preview without its content
server; the final captures use the full bundled development asset set and
recorded no browser errors. No physical Ayn Thor device was tested here.
- Evidence: `artifacts/class-damage-audit/report.json`,
`level-20-damage.csv`, `rom-roster.png`, `rom-dungeon-start.png`,
`rom-party-damage.png`, and `rom-browser-errors.json`. Training-target totals
demonstrate execution and sustained damage; they are not class-balance
rankings or exhaustive coverage of every talent build and dual-class pair.
- All 434 focused combat, party, CoA, RoM, DPS-meter, stat, and avatar tests
passed, along with the native vocation-import regression. The production
TypeScript/Vite build and release-bundle verification passed. The final
release candidate check includes the new tests and generated catalogs.
## RoM dungeon enemy durability — September 13, 2026
- Reproduced Barren Caves' low health with a RoM Priest and the four shipped
companions. Tested 108 combinations: 12 party seeds at levels 1, 2, 4, 8, 9,
20, 40, 70, and 80. The old fallback had 94 health at level 1 and 1,832 at
level 80; kills took 0–1.9 seconds from the first damaging NPC hit.
- Normal RoM dungeons now use a party-health baseline of
`1000 + 100 * enemyLevel + 8 * enemyLevel * enemyLevel`, with authored
creature/boss multipliers applied separately. This is explicit Healer Man
balancing because native RoM creature health tables are not packaged.
Barren Caves' Anglo retains his 7.5 multiplier and one-level advantage.
- Loaded RoM creatures receive the new health when the character levels up.
Re-registration preserves wounds as a health percentage and keeps corpses
dead. Enemy attack damage also rebases on a level change instead of retaining
its original low-level value. Regression checks cover these transitions.
- With the new health, the 108 isolated party-damage pulls lasted 3–17.5
seconds. A separate pull exercised actual enemy attacks and Priest healing
before the enemy died, with all companions surviving. These are opening
pulls with baseline equipment, rather than an audit of every geared build.
- Live PC check at 1280×720: created the offline Human Priest Barrenaudit and
entered Barren Caves as Healer. A level-1 Cave Sand Bug had 1,108 health.
At 10 seconds it still had 374 health, with 734 party damage on the meter.
The completed first fight showed 14 seconds and 1,108 total damage:
Morgha 370, Ilyen 369, Maerwen 289, and Cael 80. Urgent Heal then restored
Maerwen to 407/407 while the next pull was active. No browser errors were
captured during that bounded combat check.
- Concurrent navigation edits triggered development hot reloads around the
playtest; the captures above were taken during uninterrupted combat. This
check did not complete the dungeon or use physical Ayn Thor hardware.
Restored the original Hudtest roster selection, closed the test tab, and
stopped the temporary development servers.
- Evidence: `artifacts/rom-dungeon-health/report.json`,
`barren-caves-health.png`, `barren-caves-combat.png`, `barren-caves-healed.png`,
`live-combat-dom.txt`, and `browser-errors.json`.
- Validation: all 407 focused combat, party, CoA, RoM, DPS-meter, stat, and
dungeon-scaling tests passed across 52 files. The final production
TypeScript/Vite build and release-bundle verification passed.
## 2026-09-13 - WoW dungeon lighting and stair fleet pass
- Applied environment-specific readable lighting to all 54 WoW dungeon entries,
using the reviewed Stockades ambient/hemisphere fill as the baseline. Stone,
cavern, fire, arcane, crypt, and outdoor profiles preserve distinct coloration.
Stockades' existing presentation/three ramps and RuneWaker lighting are unchanged.
- Scanned every shipped WoW collision package, including authored Wailing
Caverns' actual source assets. Added 347 verified walking proxies across 22
other dungeons and eight filled navigation portal bridges in Uldaman. Rendered
steps and source visual/collision/navigation GLBs remain unchanged.
- Added package-identity gates and a compiler-independent shared runtime adapter.
Collision preparation spatially indexes flights; rejected candidates cannot
supply geometry needed by accepted proxies. Existing navigation validation,
content, encounter, and availability gates were not promoted to green.
- Shipping-asset regressions independently pass all 347 new proxies through
prepared collision and the live NPC segment validator uphill/downhill at the
center and two interior lateral lanes. Exact portal matching and all existing
Stockades player/NPC/wall tests pass.
- The game-playtest visual viewer uses shipped meshes and runtime lighting and
material policies without touching saved character/party state. At 1280x720,
Uldaman's old/new-fill comparison showed clearer stonework; Wailing Caverns
retained green, readable rock; Mechanar retained cool arcane interiors;
Scholomance's visible entrance steps/masonry remained intact and readable.
Blood Furnace's dark source textures remained visible at its wall-facing
entrance viewpoint. Durnholde's visual load completed without console errors.
- Zul'Farrak entrance/boss views and Durnholde's exterior boss view showed
geometry/terrain/viewpoint gaps, recorded for separate investigation. This is
not exhaustive staircase certification: curved/short/heavily-decimated flights,
rejected candidates, outer stair edges/landing lips, complete dungeon routes,
full clears, and physical Android QA still require individual review.
- Evidence/coverage: `dungeon-pipeline/WOW_ENVIRONMENT_REVIEW.md`,
`src/game/generated/wowDungeonStairs.generated.json`, and screenshots in
`artifacts/wow-dungeon-environment/`. No hosted release was published.
- Validation: 247 Vitest files passed, 1,599 tests passed, six existing tests
skipped. Production TypeScript/Vite build, texture/performance audits, bundle
compression/verification, and release candidate check passed. Existing physics
initialization and bundle-size/import advisories remain.
## 2026-09-13 - Dungeon completion results and run leaderboards
- Fixed missing RuneWaker completion bookkeeping for the 21 dungeons with
configured bosses. Barren Caves now awards Anglo's objective on his actual
death and announces the completed run. Bossless placeholder entries do not
fabricate a completion.
- Run timing starts when the dungeon is playable, survives wipes, pauses, and
saved resumes, and freezes at the final-boss objective. Older checkpoints
without a recorded start time still announce completion but remain unranked.
- The results dialog shows the final-boss time, final-clear and full-clear
placement, party classes/levels, and defeated bosses. Stay and loot, reopening
results, and Return to main menu were verified through the live UI. Returning
clears the completed checkpoint.
- Replaced the dungeon picker's minimap with persistent completion-time
leaderboards. Final Boss clears includes all qualifying runs; Full dungeon
clears requires every available boss for the selected difficulty. Expanded
entries show each party member and boss status. Normal/heroic rankings are
separate; disconnected results use an explicitly labeled device leaderboard.
- An actual combat integration check fought Barren Caves' level-2 Anglo with
9,240 health using NPC ability selection, enemy attacks, and Priest healing.
NPCs dealt 9,239 damage after a one-damage player tag; healing totaled 441.
The final death awarded completion at 84.1 seconds with all companions alive.
- Manual browser combat confirmed NPC damage and healing on Barren Caves
trash. The live results check restored the integration fight's completed
checkpoint through an isolated local QA account: 01:24.1 ranked #1 of three
seeded local-server entries in both filters. This was not a manual browser
full clear and no production leaderboard entries were published.
- PC results and expanded final/full leaderboard layouts were visually
reviewed at 1280x720. The built-in Thor preview displayed the leaderboard,
filters, and role-selection control within its 960x540 main surface. Compact
480x360 DOM bounds kept the dialog footer inside the viewport; the in-app
browser's viewport-override screenshot scaled incorrectly, so that capture
does not establish compact visual quality. Physical Thor hardware was not used.
- Evidence: `artifacts/dungeon-runs/combat-completion.json`, `results-world.png`,
`leaderboard-final-expanded.png`, `leaderboard-full-expanded.png`, and
`leaderboard-thor.png`. The bounded final browser check captured no errors.
- Validation: 70 completion, checkpoint, encounter, catalog, client, and UI
tests plus the real-combat integration test passed. The 432-test focused
combat/party/class/health/input regression suite and 18 server tests passed.
Production build, final TypeScript check, and release candidate check passed.
+119
View File
@@ -0,0 +1,119 @@
# WoW dungeon lighting and stair review - 2026-09-13
## Implemented coverage
All 54 campaign WoW dungeons now receive environment-specific lighting using
Stockades' reviewed ambient/hemisphere fill as the baseline. Stone, cavern,
fire, arcane, crypt, and outdoor profiles preserve atmosphere without applying
the old dark expansion palettes. Stockades' existing presentation and three
reviewed stair proxies remain unchanged. RuneWaker environments are excluded.
The fleet scan produced 347 additional collision-verified walking proxies in
22 dungeons, and eight filled Recast boundary bridges in Uldaman. This makes
350 walking proxies including the three existing Stockades flights. Visual
stairs remain steps: source visual/collision/navigation GLBs were not modified.
The shared prepared collision is used by players, companions, and enemies.
| Dungeon | Additional walking proxies | Additional navigation bridges |
| --- | ---: | ---: |
| Shadowfang Keep | 41 | 0 |
| Blackfathom Deeps | 5 | 0 |
| Uldaman | 5 | 8 |
| Gnomeregan | 4 | 0 |
| Sunken Temple | 2 | 0 |
| Scarlet Monastery | 37 | 0 |
| Zul'Farrak | 1 | 0 |
| Scholomance | 36 | 0 |
| Stratholme | 12 | 0 |
| Blood Furnace | 1 | 0 |
| Shadow Labyrinth | 1 | 0 |
| Mana-Tombs | 3 | 0 |
| Auchenai Crypts | 5 | 0 |
| Magister's Terrace | 10 | 0 |
| Escape From Durnholde | 120 | 0 |
| Utgarde Keep | 8 | 0 |
| Utgarde Pinnacle | 32 | 0 |
| Culling of Stratholme | 11 | 0 |
| Halls of Stone | 2 | 0 |
| Halls of Lightning | 1 | 0 |
| Pit of Saron | 1 | 0 |
| Halls of Reflection | 9 | 0 |
The machine-readable `src/game/generated/wowDungeonStairs.generated.json`
records a scan for every WoW dungeon, including entries with no accepted new
proxy, collision package identities, candidate/rejection counts, and exact
navigation portals. Authored Wailing Caverns is scanned against its actual
source assets, rather than the campaign's separate canonical package.
## Safety and verification
- Require repeated short risers with consistent rise/run and overlapping width.
- Merge compatible continuous flights; independently verify short fallbacks
when a merged flight is unsafe.
- Rebuild after rejected proxies are removed, so accepted paths do not depend
on rejected geometry edits.
- Check real terrain support and capsule sweeps uphill/downhill along the center
and two interior lateral lanes at approximately 0.35m intervals.
- Fill only separate existing boundary portals wholly within a verified flight.
Do not invent off-mesh links across unrelated floors or walls.
- Gate generated coordinates by collision package URL/checksum identity, and
navigation identity where exact portal bridges are present.
- Keep existing validation/availability gates unchanged. Stair checks do not
certify boss routes, encounters, resets, checkpoints, or complete runs.
- Spatially index ramp footprints during preparation to avoid checking every
ramp against every vertex in large, multi-chunk environments.
The shipping-asset regression tests independently exercise all 347 new proxies
through `prepareCollision` and the live NPC direct-segment validator in both
directions and all three tested lanes. All generated portals match the actual
shipping navigation vertices. Existing Stockades player/NPC/wall regressions pass.
Full validation: 247 Vitest files passed, 1,599 tests passed, six existing tests
skipped. Production TypeScript/Vite build, texture/performance audits, bundle
compression/verification, and release candidate checking passed. Existing
physics initialization and bundle-size/import advisories remain.
## Visual evidence and remaining QA
The local visual viewer uses the runtime definitions/material policy, shipped
GLBs, Meshopt/KTX2 decoding, ACES tone mapping, and 1280x720 screenshots. It does
not modify saved characters or party state. Evidence is under
`artifacts/wow-dungeon-environment/` (ignored QA artifacts).
Uldaman's new/old-fill comparison shows clearer stone walls, columns, and floor
tiles. Wailing Caverns retains green coloration with readable rock formations.
Mechanar retains cool purple/blue interiors and unchanged visible steps.
Scholomance's entrance stairs and masonry remain visible with the crypt profile.
Blood Furnace's dark source textures remain atmospheric; the entrance viewpoint
faces a nearby wall, so this is not a full-room traversal review.
Zul'Farrak showed a geometry/viewpoint gap in the standalone entrance and boss
views. Do not treat its lighting profile or passing isolated proxy sweep as an
end-to-end visual/playability pass. Durnholde's large package completed loading
without console errors; its cavern entrance and outdoor fort masonry/trees were
readable, but the boss viewpoint also showed missing terrain/viewpoint coverage.
That environment needs a separate terrain/spawn review and live traversal check.
This is not an exhaustive certification that every staircase is repaired.
Conservative repeated-riser detection can miss curved flights, one/two-step
transitions, and heavily decimated source geometry. A rejected candidate may be
decorative, lack headroom/ground, or require an individually reviewed footprint.
An entry with no new proxy is not proof that it contains no stairs. Full-dungeon
actor traversal, entrance/exit lips, outer stair edges, manual review of rejected
or undetected flights, full clears, and physical Android QA remain open.
## Repeating the checks
Requires the project's Node 24 runtime for native TypeScript pipeline execution.
```text
npm run dungeon:wow:stairs:audit
npm run dungeon:wow:stairs:refresh
npm run dungeon:wow:stairs:audit -- scholomance --diagnostics
node scripts/dungeon-pipeline/audit-wow-stairs.ts wailing-caverns --update
npm test -- --run src/game/wowDungeonStairAssets.test.ts
```
The audit is read-only by default. Refresh writes the full generated catalog;
`--update` replaces only the named entry while retaining the other entries.
Campaign regeneration does not erase the runtime lighting/stair adapter.
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "healer-man", "name": "healer-man",
"version": "0.1.37", "version": "0.1.39",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "healer-man", "name": "healer-man",
"version": "0.1.37", "version": "0.1.39",
"dependencies": { "dependencies": {
"@capacitor/android": "8.4.1", "@capacitor/android": "8.4.1",
"@capacitor/core": "8.4.1", "@capacitor/core": "8.4.1",
+3 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "healer-man", "name": "healer-man",
"private": true, "private": true,
"version": "0.1.37", "version": "0.1.39",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite --host 0.0.0.0", "dev": "vite --host 0.0.0.0",
@@ -56,6 +56,8 @@
"dungeon:five-player:verify": "node scripts/dungeon-pipeline/verify-five-player-dungeon-imports.mjs", "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:wow:audit": "vite-node scripts/dungeon-pipeline/audit-wow-dungeon-content.mjs",
"dungeon:wow:functionality:audit": "vite-node scripts/dungeon-pipeline/audit-wow-dungeon-functionality.ts", "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",
"dungeon:wow:test": "vitest run src/game/wowDungeonContent.test.ts", "dungeon:wow:test": "vitest run src/game/wowDungeonContent.test.ts",
"dungeon:wow:export-tool:test": "node --test scripts/dungeon-pipeline/wow-export-tool.test.mjs", "dungeon:wow:export-tool:test": "node --test scripts/dungeon-pipeline/wow-export-tool.test.mjs",
"dungeon:ascension:recovery:validate": "node scripts/dungeon-pipeline/validate-ascension-custom-dungeon-recovery.mjs --skip-dbc", "dungeon:ascension:recovery:validate": "node scripts/dungeon-pipeline/validate-ascension-custom-dungeon-recovery.mjs --skip-dbc",
+2 -2
View File
@@ -1,4 +1,4 @@
{ {
"versionName": "0.1.37", "versionName": "0.1.39",
"versionCode": 1037 "versionCode": 1039
} }
@@ -0,0 +1,142 @@
import { readFileSync, writeFileSync } from "node:fs";
import { createHash } from "node:crypto";
import path from "node:path";
import { fileURLToPath } from "node:url";
import type { DungeonDefinition } from "../../src/game/dungeonTypes";
import { detectDungeonStairRamps, dungeonStairNavigationBridges, dungeonStairRisers, readDungeonGeometry } from "./wow-stair-geometry.ts";
import RAPIER from "@dimforge/rapier3d-compat";
import { dungeonRampHeight, smoothDungeonStairGeometry } from "../../src/game/dungeonTraversalRamps.ts";
import type { DungeonTraversalRamp } from "../../src/game/dungeonTypes";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const catalog = JSON.parse(readFileSync(path.join(root, "src/game/generated/dungeonCampaignCatalog.json"), "utf8"));
const outputFile = path.join(root, "src/game/generated/wowDungeonStairs.generated.json");
const output: Record<string, unknown> = process.argv.includes("--update")
? JSON.parse(readFileSync(outputFile, "utf8")).dungeons : {};
const requested = process.argv.slice(2).find((arg) => !arg.startsWith("--"));
if (requested && !catalog.definitions.some((dungeon: DungeonDefinition) => dungeon.id === requested)) {
throw new Error(`Unknown WoW dungeon: ${requested}`);
}
await RAPIER.init();
function rampTraversable(world: RAPIER.World, ramp: DungeonTraversalRamp): boolean {
const rejected = (reason: string, detail: unknown) => {
if (process.argv.includes("--diagnostics")) console.log(ramp.id, reason, JSON.stringify(detail));
return false;
};
const dx = ramp.end[0] - ramp.start[0];
const dz = ramp.end[2] - ramp.start[2];
const length = Math.hypot(dx, dz);
const shape = new RAPIER.Capsule(0.32, 0.34);
const rotation = { x: 0, y: 0, z: 0, w: 1 };
// Test both directions across the center and two capsule-safe lateral lanes.
const lateral = Math.max(0, Math.min(ramp.width / 4, ramp.width / 2 - 0.45));
for (const lane of [0, -lateral, lateral]) {
const count = Math.max(3, Math.ceil(length / 0.35) + 1);
const points = Array.from({ length: count }, (_, index) => {
const t = index / (count - 1);
return {
x: ramp.start[0] + dx * t - dz / length * lane,
y: ramp.start[1] + (ramp.end[1] - ramp.start[1]) * t,
z: ramp.start[2] + dz * t + dx / length * lane,
};
});
for (const point of points) {
const floor = world.castRayAndGetNormal(new RAPIER.Ray({ ...point, y: point.y + 1.25 }, { x: 0, y: -1, z: 0 }), 2, true);
if (!floor || floor.normal.y < 0.65 || Math.abs(1.25 - floor.timeOfImpact) > 0.55) return rejected("floor", { lane, point, normal: floor?.normal, heightDelta: floor ? 1.25 - floor.timeOfImpact : null });
}
for (let index = 1; index < points.length; index += 1) {
for (const [start, end] of [[points[index - 1], points[index]], [points[index], points[index - 1]]]) {
const delta = { x: end.x - start.x, y: end.y - start.y, z: end.z - start.z };
const distance = Math.hypot(delta.x, delta.y, delta.z);
const hit = world.castShape({ ...start, y: start.y + 1.02 }, rotation, delta, shape, 0.025, 1, false);
if (hit && hit.time_of_impact < 1 - 0.04 / distance) return rejected("sweep", { lane, start, end, hit: hit.time_of_impact });
}
}
}
return true;
}
for (const dungeon of catalog.definitions as DungeonDefinition[]) {
if (requested && dungeon.id !== requested) continue;
if (dungeon.id === "stormwind-stockade") {
output[dungeon.id] = { collision: dungeon.assets.collision, ramps: [], reviewedOverride: true };
console.log(`${dungeon.id}: preserving three reviewed ramps`);
continue;
}
const risers = [];
const geometries = [];
// Wailing Caverns retains its authored environment rather than the campaign's
// canonical Manastorm package. Audit the assets actually used by that entry.
const collisionAssets = dungeon.id === "wailing-caverns" ? [{
fileName: "wailing-caverns-collision.glb", url: undefined,
checksum: createHash("sha256").update(readFileSync(path.join(root, "src/assets/game/dungeons/wailing-caverns/wailing-caverns-collision.glb"))).digest("hex"),
}] : dungeon.assets.collision;
const assetFile = (asset: { url?: string; fileName: string }) => asset.url
? path.join(root, "public", asset.url)
: path.join(root, "src/assets/game/dungeons", dungeon.id, asset.fileName);
const navigationAsset = dungeon.id === "wailing-caverns" ? {
fileName: "wailing-caverns-navigation.glb", url: undefined,
checksum: createHash("sha256").update(readFileSync(path.join(root, "src/assets/game/dungeons/wailing-caverns/wailing-caverns-navigation.glb"))).digest("hex"),
} : dungeon.assets.navigation;
for (const asset of collisionAssets) {
for (const geometry of await readDungeonGeometry(assetFile(asset))) {
risers.push(...dungeonStairRisers(geometry));
geometries.push(geometry);
}
}
const candidates = detectDungeonStairRamps(risers);
const verify = (input: DungeonTraversalRamp[]): DungeonTraversalRamp[] => {
let ramps = input;
// Rebuild after rejection: accepted ramps must not depend on rejected geometry changes.
while (ramps.length) {
const world = new RAPIER.World({ x: 0, y: -18, z: 0 });
try {
for (const source of geometries) {
const geometry = source.clone();
smoothDungeonStairGeometry(geometry, ramps);
world.createCollider(RAPIER.ColliderDesc.trimesh(
Float32Array.from(geometry.getAttribute("position").array),
Uint32Array.from(geometry.index?.array ?? Array.from({ length: geometry.getAttribute("position").count }, (_, index) => index)),
));
geometry.dispose();
}
world.step();
const accepted = ramps.filter((ramp) => rampTraversable(world, ramp));
if (accepted.length === ramps.length) break;
ramps = accepted;
} finally { world.free(); }
}
return ramps;
};
let ramps = verify(candidates);
// If a long merged flight was unsafe, retain independently verified shorter
// flights instead. Do not let fallback fragments reshape an accepted plane.
const fallback = detectDungeonStairRamps(risers, false).filter((ramp) => !ramps.some((accepted) => {
const within = (a: DungeonTraversalRamp, b: DungeonTraversalRamp) => [b.start, b.end,
b.start.map((v, i) => (v + b.end[i]) / 2)].some((point) => {
const height = dungeonRampHeight(a, point[0], point[2]);
return height !== null && Math.abs(point[1] - height) <= 0.85;
});
return within(accepted, ramp) || within(ramp, accepted);
})).map((ramp, index) => ({ ...ramp, id: `stair-fallback-${index + 1}` }));
ramps = verify([...ramps, ...fallback]);
output[dungeon.id] = {
collision: collisionAssets.map(({ fileName, url, checksum }) => ({ fileName, url, checksum })),
navigation: navigationAsset,
ramps, candidateCount: candidates.length + fallback.length,
rejectedCount: candidates.length + fallback.length - ramps.length,
};
if (ramps.length && navigationAsset) {
const navigation = await readDungeonGeometry(assetFile(navigationAsset));
const largest = navigation.sort((a, b) => b.getAttribute("position").count - a.getAttribute("position").count)[0];
(output[dungeon.id] as { ramps: DungeonTraversalRamp[] }).ramps = dungeonStairNavigationBridges(largest, ramps);
navigation.forEach((geometry) => geometry.dispose());
}
console.log(`${dungeon.id}: ${ramps.length}/${candidates.length + fallback.length} collision-verified stair proxies`);
geometries.forEach((geometry) => geometry.dispose());
}
if (process.argv.includes("--write") || process.argv.includes("--update")) {
if (requested && !process.argv.includes("--update")) throw new Error("Refusing to replace the fleet catalog with a single-dungeon scan.");
writeFileSync(outputFile, `${JSON.stringify({ schemaVersion: 1, dungeons: output }, null, 2)}\n`);
}
@@ -0,0 +1,249 @@
import { NodeIO } from "@gltf-transform/core";
import { ALL_EXTENSIONS } from "@gltf-transform/extensions";
import { MeshoptDecoder } from "meshoptimizer";
import { BufferAttribute, BufferGeometry, Float32BufferAttribute, Matrix4 } from "three";
import type { DungeonTraversalRamp } from "../../src/game/dungeonTypes";
import { dungeonRampHeight } from "../../src/game/dungeonTraversalRamps.ts";
const io = new NodeIO().registerExtensions(ALL_EXTENSIONS)
.registerDependencies({ "meshopt.decoder": MeshoptDecoder });
/** Decode exactly as GLTFLoader does, expanding quantized coordinates before baking transforms. */
export async function readDungeonGeometry(file: string): Promise<BufferGeometry[]> {
await MeshoptDecoder.ready;
const document = await io.read(file);
const geometries: BufferGeometry[] = [];
for (const node of document.getRoot().listNodes()) {
for (const primitive of node.getMesh()?.listPrimitives() ?? []) {
const accessor = primitive.getAttribute("POSITION");
if (!accessor?.getArray()) continue;
const source = new BufferAttribute(accessor.getArray()!, 3, accessor.getNormalized());
const positions = new Float32Array(source.count * 3);
for (let index = 0; index < source.count; index += 1) {
positions.set([source.getX(index), source.getY(index), source.getZ(index)], index * 3);
}
const geometry = new BufferGeometry();
geometry.setAttribute("position", new Float32BufferAttribute(positions, 3));
const indices = primitive.getIndices()?.getArray();
if (indices) geometry.setIndex(Array.from(indices));
geometry.applyMatrix4(new Matrix4().fromArray(node.getWorldMatrix()));
geometries.push(geometry);
}
}
return geometries;
}
interface Riser {
direction: number;
along: number;
low: number;
high: number;
left: number;
right: number;
}
/** Repeated short vertical faces, not arbitrary slopes or nearby disconnected floors. */
export function dungeonStairRisers(geometry: BufferGeometry): Riser[] {
const p = geometry.getAttribute("position");
const indices = geometry.index?.array ?? Array.from({ length: p.count }, (_, i) => i);
const faces = new Map<string, Riser>();
for (let offset = 0; offset + 2 < indices.length; offset += 3) {
const vertices = [indices[offset], indices[offset + 1], indices[offset + 2]];
const points = vertices.map((i) => [p.getX(i), p.getY(i), p.getZ(i)]);
const [a, b, c] = points;
const ab = b.map((v, i) => v - a[i]);
const ac = c.map((v, i) => v - a[i]);
const nx = ab[1] * ac[2] - ab[2] * ac[1];
const ny = ab[2] * ac[0] - ab[0] * ac[2];
const nz = ab[0] * ac[1] - ab[1] * ac[0];
if (Math.hypot(nx, nz) < 0.1 || Math.abs(ny) / Math.hypot(nx, ny, nz) > 0.025) continue;
const low = Math.min(...points.map((v) => v[1]));
const high = Math.max(...points.map((v) => v[1]));
if (high - low < 0.08 || high - low > 0.82) continue;
// Opposite winding still describes the same plane. Quantization permits ~1 degree noise.
const direction = ((Math.round(Math.atan2(nz, nx) / Math.PI * 180) % 180) + 180) % 180;
const angle = direction * Math.PI / 180;
const along = points.reduce((sum, v) => sum + v[0] * Math.cos(angle) + v[2] * Math.sin(angle), 0) / 3;
const across = points.map((v) => -v[0] * Math.sin(angle) + v[2] * Math.cos(angle));
const left = Math.min(...across);
const right = Math.max(...across);
if (right - left < 0.9) continue;
const key = [direction, Math.round(along / 0.04), Math.round(low / 0.04), Math.round(high / 0.04)].join(":");
const previous = faces.get(key);
if (previous && Math.min(previous.right, right) - Math.max(previous.left, left) >= -0.04) {
previous.left = Math.min(previous.left, left);
previous.right = Math.max(previous.right, right);
} else faces.set(`${key}${previous ? `:${Math.round(left / 0.04)}` : ""}`, { direction, along, low, high, left, right });
}
return [...faces.values()];
}
export function detectDungeonStairRamps(risers: Riser[], merge = true): DungeonTraversalRamp[] {
const ramps: DungeonTraversalRamp[] = [];
const used = new Set<Riser>();
const sorted = [...risers].sort((a, b) => a.direction - b.direction || a.along - b.along || a.low - b.low);
for (const first of sorted) {
if (used.has(first)) continue;
const neighbors = sorted.filter((r) => Math.min(Math.abs(r.direction - first.direction), 180 - Math.abs(r.direction - first.direction)) <= 2)
.map((source) => {
const difference = (source.direction - first.direction) * Math.PI / 180;
const across = (source.left + source.right) / 2;
const halfWidth = (source.right - source.left) / 2 * Math.abs(Math.cos(difference));
const center = source.along * Math.sin(difference) + across * Math.cos(difference);
return { source, ...source, along: source.along * Math.cos(difference) - across * Math.sin(difference), left: center - halfWidth, right: center + halfWidth };
}).sort((a, b) => a.along - b.along || a.low - b.low);
const chain = [first];
const chainSources = [first];
let sign = 0;
while (true) {
const current = chain[chain.length - 1];
const next = neighbors.find((candidate) => {
if (used.has(candidate.source)) return false;
const run = candidate.along - current.along;
const rise = candidate.low - current.low;
const overlap = Math.min(...chain.map((r) => r.right), candidate.right)
- Math.max(...chain.map((r) => r.left), candidate.left);
return run > 0.12 && run < 2.5 && Math.abs(rise) >= 0.08 && Math.abs(rise) <= 0.82
&& Math.abs(rise) / run < 0.85 && overlap >= 1
&& (!sign || Math.sign(rise) === sign)
&& Math.min(Math.abs(candidate.low - current.high), Math.abs(candidate.high - current.low)) < 0.22;
});
if (!next) break;
if (chain.length > 1) {
const run = chain[1].along - first.along;
const rise = chain[1].low - first.low;
if (Math.abs(next.low - (first.low + rise / run * (next.along - first.along))) > 0.1) break;
}
chain.push(next);
chainSources.push(next.source);
sign = Math.sign(next.low - current.low);
}
if (chain.length < 3) continue;
const last = chain[chain.length - 1];
const run = (last.along - first.along) / (chain.length - 1);
const rise = (last.low - first.low) / (chain.length - 1);
const left = Math.max(...chain.map((r) => r.left));
const right = Math.min(...chain.map((r) => r.right));
const across = (left + right) / 2;
const angle = first.direction * Math.PI / 180;
const point = (along: number, y: number): [number, number, number] => [
along * Math.cos(angle) - across * Math.sin(angle), y,
along * Math.sin(angle) + across * Math.cos(angle),
];
// Meet the lower/upper landings, including the first/last riser, without a lip.
ramps.push({
id: `stair-flight-${ramps.length + 1}`,
start: point(first.along - (sign < 0 ? run : 0.04), sign < 0 ? first.high : first.low),
end: point(last.along + (sign > 0 ? run : 0.04), sign > 0 ? last.high : last.low),
width: right - left + 0.02,
});
chainSources.forEach((r) => used.add(r));
}
return merge ? mergeDungeonStairRamps(ramps) : ramps;
}
/** Keep one plane per continuous flight; independent overlapping fragments introduce new lips. */
export function mergeDungeonStairRamps(input: DungeonTraversalRamp[]): DungeonTraversalRamp[] {
const ramps = [...input].sort((a, b) => Math.hypot(b.end[0] - b.start[0], b.end[2] - b.start[2])
- Math.hypot(a.end[0] - a.start[0], a.end[2] - a.start[2]));
const result: DungeonTraversalRamp[] = [];
for (const ramp of ramps) {
let combined = false;
for (let index = 0; index < result.length; index += 1) {
const previous = result[index];
const dx = previous.end[0] - previous.start[0]; const dz = previous.end[2] - previous.start[2];
const length = Math.hypot(dx, dz);
const rdX = ramp.end[0] - ramp.start[0]; const rdZ = ramp.end[2] - ramp.start[2];
const rLength = Math.hypot(rdX, rdZ);
if ((dx * rdX + dz * rdZ) / length / rLength < 0.999) continue;
const along = (p: readonly number[]) => ((p[0] - previous.start[0]) * dx + (p[2] - previous.start[2]) * dz) / length;
const across = (p: readonly number[]) => (-(p[0] - previous.start[0]) * dz + (p[2] - previous.start[2]) * dx) / length;
const start = along(ramp.start); const end = along(ramp.end);
const acrossOffset = (across(ramp.start) + across(ramp.end)) / 2;
const left = Math.max(-previous.width / 2, acrossOffset - ramp.width / 2);
const right = Math.min(previous.width / 2, acrossOffset + ramp.width / 2);
if (right - left < 1 || start > length + 1.5 || end < -1.5) continue;
const slope = (previous.end[1] - previous.start[1]) / length;
const rSlope = (ramp.end[1] - ramp.start[1]) / rLength;
if (Math.abs(slope - rSlope) > 0.12
|| Math.abs(ramp.start[1] - (previous.start[1] + start * slope)) > 0.32
|| Math.abs(ramp.end[1] - (previous.start[1] + end * slope)) > 0.32) continue;
const low = Math.min(0, start); const high = Math.max(length, end);
const center = (left + right) / 2;
const point = (t: number, y: number): [number, number, number] => [
previous.start[0] + dx / length * t - dz / length * center, y,
previous.start[2] + dz / length * t + dx / length * center,
];
result[index] = {
id: previous.id,
start: point(low, start < 0 ? ramp.start[1] : previous.start[1]),
end: point(high, end > length ? ramp.end[1] : previous.end[1]),
width: right - left,
};
combined = true;
break;
}
if (!combined) result.push(ramp);
}
return result.map((ramp, index) => ({ ...ramp, id: `stair-flight-${index + 1}` }));
}
/** Fill only separate Recast boundary portals wholly inside a verified stair footprint. */
export function dungeonStairNavigationBridges(geometry: BufferGeometry, ramps: DungeonTraversalRamp[]): DungeonTraversalRamp[] {
const p = geometry.getAttribute("position");
const indices = geometry.index?.array ?? Array.from({ length: p.count }, (_, i) => i);
const parents = Array.from({ length: p.count }, (_, i) => i);
const root = (i: number): number => {
while (parents[i] !== i) { parents[i] = parents[parents[i]]; i = parents[i]; }
return i;
};
const weld = new Map<string, number>();
const point = (i: number): [number, number, number] => [p.getX(i), p.getY(i), p.getZ(i)];
for (let i = 0; i < p.count; i += 1) {
const key = point(i).map((v) => Math.round(v / 0.002)).join(":");
const previous = weld.get(key);
if (previous !== undefined) parents[i] = previous;
else weld.set(key, i);
}
const edges = new Map<string, { a: number; b: number; count: number }>();
for (let offset = 0; offset + 2 < indices.length; offset += 3) {
const triangle = [indices[offset], indices[offset + 1], indices[offset + 2]];
for (let side = 0; side < 3; side += 1) {
const a = triangle[side]; const b = triangle[(side + 1) % 3];
const wa = weld.get(point(a).map((v) => Math.round(v / 0.002)).join(":"))!;
const wb = weld.get(point(b).map((v) => Math.round(v / 0.002)).join(":"))!;
parents[root(a)] = root(b);
const key = wa < wb ? `${wa}:${wb}` : `${wb}:${wa}`;
const edge = edges.get(key);
if (edge) edge.count += 1;
else edges.set(key, { a, b, count: 1 });
}
}
const boundary = [...edges.values()].filter((e) => e.count === 1);
return ramps.map((ramp) => {
const dx = ramp.end[0] - ramp.start[0]; const dz = ramp.end[2] - ramp.start[2];
const length = Math.hypot(dx, dz);
const along = (v: number[]) => ((v[0] - ramp.start[0]) * dx + (v[2] - ramp.start[2]) * dz) / length;
const across = (v: number[]) => (-(v[0] - ramp.start[0]) * dz + (v[2] - ramp.start[2]) * dx) / length;
const portals = boundary.flatMap((e) => {
const points = [point(e.a), point(e.b)].sort((a, b) => across(a) - across(b));
if (points.some((v) => {
const y = dungeonRampHeight(ramp, v[0], v[2]);
return y === null || Math.abs(v[1] - y) > 0.8;
})) return [];
if (Math.abs(along(points[0]) - along(points[1])) > 0.15 || across(points[1]) - across(points[0]) < 0.7) return [];
return [{ ...e, points: points as [[number, number, number], [number, number, number]], along: (along(points[0]) + along(points[1])) / 2 }];
}).sort((a, b) => a.along - b.along);
const bridges = [];
for (let index = 1; index < portals.length; index += 1) {
const a = portals[index - 1]; const b = portals[index];
const gap = b.along - a.along;
if (gap < 0.15 || gap > 2.5 || root(a.a) === root(b.a)) continue;
const overlap = Math.min(across(a.points[1]), across(b.points[1])) - Math.max(across(a.points[0]), across(b.points[0]));
if (overlap < 0.7) continue;
bridges.push({ linkId: `${ramp.id}-seam-${bridges.length + 1}`, startPortal: a.points, endPortal: b.points });
parents[root(a.a)] = root(b.a);
}
return bridges.length ? { ...ramp, navigationBridges: bridges } : ramp;
});
}
@@ -6,6 +6,7 @@ import path from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { inflateSync } from "node:zlib"; import { inflateSync } from "node:zlib";
import sharp from "sharp"; import sharp from "sharp";
import { vocationGrowth } from "./vocation-growth.mjs";
const scriptDirectory = path.dirname(fileURLToPath(import.meta.url)); const scriptDirectory = path.dirname(fileURLToPath(import.meta.url));
const projectRoot = path.resolve(scriptDirectory, "../.."); const projectRoot = path.resolve(scriptDirectory, "../..");
@@ -38,17 +39,6 @@ const RACE_MATRIX = {
"rom-dwarf": [1, 3, 4, 5, 9, 10], "rom-dwarf": [1, 3, 4, 5, 9, 10],
}; };
function vocationGrowth(classIndex) {
const recordSize = 816;
const offset = DATA_START + classIndex * recordSize + 8;
const values = Array.from({ length: 21 }, (_, index) => cleanFloat(float32(buffers.vocationTable, offset + index * 4)));
const keys = ["strength", "stamina", "intellect", "wisdom", "agility", "health", "mana"];
return Object.fromEntries(keys.map((key, index) => [key, {
levelOne: values[index],
flatGrowth: values[index + 7],
percentGrowth: values[index + 14],
}]));
}
const DATA_START = 140; const DATA_START = 140;
const LEARN_RECORD_SIZE = 12184; const LEARN_RECORD_SIZE = 12184;
const LEARN_ENTRY_SIZE = 24; const LEARN_ENTRY_SIZE = 24;
@@ -578,7 +568,7 @@ const catalog = {
schemaVersion: 1, schemaVersion: 1,
source: { rootHint: "RuneWaker source snapshot", hashes: sourceHashes }, source: { rootHint: "RuneWaker source snapshot", hashes: sourceHashes },
canonicalClasses: CLASS_IDS.map((id, index) => ({ id, sourceClassId: index + 1, name: CLASS_NAMES[index], resource: RESOURCES[index] })), canonicalClasses: CLASS_IDS.map((id, index) => ({ id, sourceClassId: index + 1, name: CLASS_NAMES[index], resource: RESOURCES[index] })),
baseStatGrowth: Object.fromEntries(CLASS_IDS.map((id, index) => [id, vocationGrowth(index + 1)])), baseStatGrowth: Object.fromEntries(CLASS_IDS.map((id, index) => [id, vocationGrowth(buffers.vocationTable, index + 1)])),
raceMatrix: Object.fromEntries(Object.entries(RACE_MATRIX).map(([raceId, classes]) => [raceId, classes.map((index) => CLASS_IDS[index - 1])])), raceMatrix: Object.fromEntries(Object.entries(RACE_MATRIX).map(([raceId, classes]) => [raceId, classes.map((index) => CLASS_IDS[index - 1])])),
eliteLevels: ELITE_LEVELS, eliteLevels: ELITE_LEVELS,
skills, skills,
@@ -0,0 +1,23 @@
const DATA_START = 140;
const RECORD_SIZE = 408;
const STAT_KEYS = ["strength", "stamina", "intellect", "wisdom", "agility", "health", "mana"];
/** Players use FixArgTableID = 1; each vocation has two adjacent source records. */
export function vocationGrowth(buffer, classIndex) {
const recordId = 301000 + classIndex * 2 + 1;
const offset = DATA_START + (classIndex * 2 + 1) * RECORD_SIZE;
if (offset + RECORD_SIZE > buffer.length
|| buffer.readInt32LE(offset) !== recordId || buffer.readInt32LE(offset + 4) !== recordId) {
throw new Error(`Player vocation ${classIndex} record sentinel drifted.`);
}
const values = Array.from({ length: 21 }, (_, index) => {
const value = buffer.readFloatLE(offset + 8 + index * 4);
if (!Number.isFinite(value)) throw new Error(`Player vocation ${classIndex} has invalid growth.`);
return Number(value.toFixed(5));
});
return Object.fromEntries(STAT_KEYS.map((key, index) => [key, {
levelOne: values[index],
flatGrowth: values[index + 7],
percentGrowth: values[index + 14],
}]));
}
@@ -0,0 +1,16 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { vocationGrowth } from "./vocation-growth.mjs";
test("reads the player table instead of the adjacent alternate growth table", () => {
const buffer = Buffer.alloc(140 + 22 * 408);
const offset = 140 + 21 * 408;
buffer.writeInt32LE(301021, offset);
buffer.writeInt32LE(301021, offset + 4);
const values = [14, 13, 5, 6, 12, 60, 20, 5, 4.5, 1.9, 2.1, 4.25, 2, 2, ...Array(7).fill(0.02)];
values.forEach((value, index) => buffer.writeFloatLE(value, offset + 8 + index * 4));
buffer.writeFloatLE(2.8, offset - 408 + 8 + 14 * 4);
assert.deepEqual(vocationGrowth(buffer, 10).strength, { levelOne: 14, flatGrowth: 5, percentGrowth: 0.02 });
buffer.writeInt32LE(301020, offset);
assert.throws(() => vocationGrowth(buffer, 10), /sentinel drifted/);
});
+4
View File
@@ -10,6 +10,8 @@ import {
} from "./manastorm-config.mjs"; } from "./manastorm-config.mjs";
import { arenaRatingBand, calculateArenaRating, clampArenaRating } from "./arena-rules.mjs"; import { arenaRatingBand, calculateArenaRating, clampArenaRating } from "./arena-rules.mjs";
import { arenaRoleForSpecialization } from "./arena-specializations.mjs"; import { arenaRoleForSpecialization } from "./arena-specializations.mjs";
import { createDungeonReportRepository } from "./dungeon-reports.mjs";
import { createDungeonLeaderboardRepository } from "./dungeon-leaderboards.mjs";
const scrypt = promisify(scryptCallback); const scrypt = promisify(scryptCallback);
@@ -895,6 +897,8 @@ export function openGameDatabase(options = {}) {
return { return {
databasePath, databasePath,
...createDungeonReportRepository(database, { isGameMaster, ErrorType: GameDatabaseError }),
...createDungeonLeaderboardRepository(database, { ErrorType: GameDatabaseError }),
register, register,
login, login,
authenticate, authenticate,
+112
View File
@@ -0,0 +1,112 @@
import catalog from "../src/game/generated/dungeonLeaderboardCatalog.generated.json" with { type: "json" };
const dungeons = new Map(catalog.dungeons.map((entry) => [entry.id, entry]));
const classes = new Set(catalog.classes.map((entry) => entry.id));
export function createDungeonLeaderboardRepository(database, { ErrorType }) {
database.exec(`
CREATE TABLE IF NOT EXISTS dungeon_runs (
id TEXT PRIMARY KEY,
dungeon_id TEXT NOT NULL,
difficulty_id TEXT NOT NULL,
duration_ms INTEGER NOT NULL CHECK (duration_ms > 0),
full_clear INTEGER NOT NULL CHECK (full_clear IN (0, 1)),
finished_at INTEGER NOT NULL,
party_json TEXT NOT NULL,
bosses_json TEXT NOT NULL,
submitted_by_account_id TEXT REFERENCES accounts(id) ON DELETE SET NULL,
received_at INTEGER NOT NULL
) STRICT;
CREATE INDEX IF NOT EXISTS dungeon_runs_times_idx
ON dungeon_runs(dungeon_id, difficulty_id, duration_ms, finished_at, id);
CREATE INDEX IF NOT EXISTS dungeon_runs_full_times_idx
ON dungeon_runs(dungeon_id, difficulty_id, full_clear, duration_ms);
`);
const byId = database.prepare("SELECT * FROM dungeon_runs WHERE id = ?");
const insert = database.prepare(`INSERT INTO dungeon_runs
(id, dungeon_id, difficulty_id, duration_ms, full_clear, finished_at, party_json, bosses_json, submitted_by_account_id, received_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
const fail = (message) => { throw new ErrorType(message, 400, "invalid_dungeon_run"); };
const record = (row) => ({ id: row.id, dungeonId: row.dungeon_id, difficultyId: row.difficulty_id,
durationMs: Number(row.duration_ms), finishedAt: Number(row.finished_at), fullClear: row.full_clear === 1,
party: JSON.parse(row.party_json), killedBossIds: JSON.parse(row.bosses_json) });
function selection(input) {
const dungeon = dungeons.get(input.dungeonId);
if (!dungeon) fail("Choose a known dungeon.");
const difficultyId = input.difficultyId || dungeon.defaultDifficultyId;
if (!dungeon.difficulties.some((entry) => entry.id === difficultyId)) fail("Choose a supported dungeon difficulty.");
const difficulty = dungeon.difficulties.find((entry) => entry.id === difficultyId);
return { dungeon, difficultyId, difficulty };
}
function placement(run) {
const counts = database.prepare(`SELECT COUNT(*) AS total,
COALESCE(SUM(CASE WHEN duration_ms < ? THEN 1 ELSE 0 END), 0) AS faster,
COALESCE(SUM(full_clear), 0) AS full_total,
COALESCE(SUM(CASE WHEN full_clear = 1 AND duration_ms < ? THEN 1 ELSE 0 END), 0) AS full_faster
FROM dungeon_runs WHERE dungeon_id = ? AND difficulty_id = ?`)
.get(run.durationMs, run.durationMs, run.dungeonId, run.difficultyId);
return { finalRank: Number(counts.faster) + 1, finalTotal: Number(counts.total),
fullRank: run.fullClear ? Number(counts.full_faster) + 1 : null, fullTotal: Number(counts.full_total) };
}
return {
submitDungeonRun(account, input) {
if (!input || typeof input !== "object" || Array.isArray(input)) fail("Provide a completed dungeon run.");
const { dungeon, difficultyId, difficulty } = selection(input);
if (!difficulty.finalBossId) fail("This dungeon does not have a configured final boss yet.");
if (typeof input.id !== "string" || !/^[A-Za-z0-9_-]{8,100}$/.test(input.id)) fail("Provide a valid run id.");
if (!Number.isSafeInteger(input.durationMs) || input.durationMs < 1 || input.durationMs > 7 * 24 * 60 * 60 * 1000)
fail("Provide a valid completion time.");
if (!Number.isSafeInteger(input.finishedAt) || input.finishedAt < 0 || input.finishedAt > Date.now() + 60_000)
fail("Provide a valid finish timestamp.");
if (!Array.isArray(input.killedBossIds) || input.killedBossIds.length > 100
|| input.killedBossIds.some((id) => !difficulty.bossIds.includes(id))) fail("Provide bosses from this dungeon and difficulty.");
const killedBossIds = [...new Set(input.killedBossIds)].sort();
if (!killedBossIds.includes(difficulty.finalBossId)) fail("The final boss must be defeated before recording a run.");
if (!Array.isArray(input.party) || input.party.length < 1 || input.party.length > 5) fail("Provide the run's party.");
const party = input.party.map((member) => {
if (!member || typeof member !== "object" || typeof member.name !== "string" || !member.name.trim()
|| member.name.length > 40 || !classes.has(member.classId)
|| !Number.isInteger(member.level) || member.level < 1 || member.level > 80
|| !["tank", "healer", "damage"].includes(member.role) || typeof member.npc !== "boolean"
|| (member.secondaryClassId !== undefined && !classes.has(member.secondaryClassId))) fail("Provide valid party classes and levels.");
return { name: member.name.trim(), classId: member.classId, level: member.level,
role: member.role, npc: member.npc,
...(member.secondaryClassId ? { secondaryClassId: member.secondaryClassId } : {}) };
});
const fullClear = difficulty.bossIds.every((id) => killedBossIds.includes(id));
const run = { id: input.id, dungeonId: dungeon.id, difficultyId, durationMs: input.durationMs,
finishedAt: input.finishedAt, killedBossIds, party, fullClear };
const existing = byId.get(run.id);
if (existing) {
if (JSON.stringify(record(existing)) !== JSON.stringify({ id: run.id, dungeonId: run.dungeonId,
difficultyId: run.difficultyId, durationMs: run.durationMs, finishedAt: run.finishedAt,
fullClear: run.fullClear, party: run.party, killedBossIds: run.killedBossIds }))
throw new ErrorType("That run id already belongs to a different result.", 409, "run_conflict");
} else insert.run(run.id, run.dungeonId, run.difficultyId, run.durationMs, fullClear ? 1 : 0,
run.finishedAt, JSON.stringify(party), JSON.stringify(killedBossIds), account?.id ?? null, Date.now());
return { run, placement: placement(run) };
},
getDungeonRun(id) {
const row = byId.get(id);
if (!row) throw new ErrorType("That dungeon run is not recorded yet.", 404, "run_not_found");
const run = record(row);
return { run, placement: placement(run) };
},
listDungeonLeaderboard(input) {
const { dungeon, difficultyId } = selection(input);
const filter = input.clear || "final";
if (!["full", "final"].includes(filter)) fail("Choose full dungeon clears or final boss clears.");
const offset = Number(input.offset ?? 0);
const limit = Number(input.limit ?? 20);
if (!Number.isSafeInteger(offset) || offset < 0 || offset > 1_000_000
|| !Number.isSafeInteger(limit) || limit < 1 || limit > 100) fail("Choose a valid leaderboard page.");
const condition = filter === "full" ? "AND full_clear = 1" : "";
const rows = database.prepare(`SELECT *, RANK() OVER (ORDER BY duration_ms) AS place FROM dungeon_runs
WHERE dungeon_id = ? AND difficulty_id = ? ${condition}
ORDER BY duration_ms, finished_at, id LIMIT ? OFFSET ?`).all(dungeon.id, difficultyId, limit, offset);
const total = database.prepare(`SELECT COUNT(*) AS total FROM dungeon_runs
WHERE dungeon_id = ? AND difficulty_id = ? ${condition}`).get(dungeon.id, difficultyId).total;
return { entries: rows.map((row) => ({ ...record(row), rank: Number(row.place) })), total: Number(total) };
},
};
}
+89
View File
@@ -0,0 +1,89 @@
import assert from "node:assert/strict";
import { mkdtempSync, rmSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import { openGameDatabase } from "./database.mjs";
import { createGameServer } from "./server.mjs";
import catalog from "../src/game/generated/dungeonLeaderboardCatalog.generated.json" with { type: "json" };
function run(id, durationMs, fullClear = true, dungeonId = "deadmines", difficultyId) {
const dungeon = catalog.dungeons.find((entry) => entry.id === dungeonId);
return { id, dungeonId, difficultyId: difficultyId ?? dungeon.defaultDifficultyId, durationMs,
finishedAt: 1700000000000, killedBossIds: fullClear ? dungeon.bosses.map((boss) => boss.id) : [dungeon.finalBossId],
party: [{ name: "Tester", classId: "priest", level: 20, role: "healer", npc: false },
{ name: "Garrick", classId: "warrior", level: 20, role: "tank", npc: true }] };
}
async function withDatabase(callback) {
const directory = mkdtempSync(path.join(os.tmpdir(), "healer-man-leaderboard-test-"));
let database = openGameDatabase({ dataDir: directory });
try { await callback(database, () => { database.close(); database = openGameDatabase({ dataDir: directory }); return database; }); }
finally { database.close(); rmSync(directory, { recursive: true, force: true }); }
}
test("persists ranked party/boss records, derives all-boss eligibility, handles ties, and retries without duplicates", () => withDatabase((database, reopen) => {
database.submitDungeonRun(null, run("run-fastest", 1000, false));
database.submitDungeonRun(null, run("run-full-one", 2000));
database.submitDungeonRun(null, run("run-full-tie", 2000));
const last = run("run-slowest", 3000);
const result = database.submitDungeonRun(null, last);
assert.deepEqual(result.placement, { finalRank: 4, finalTotal: 4, fullRank: 3, fullTotal: 3 });
assert.deepEqual(database.submitDungeonRun(null, last), result);
assert.throws(() => database.submitDungeonRun(null, { ...last, durationMs: 5 }), { status: 409 });
const final = database.listDungeonLeaderboard({ dungeonId: "deadmines", clear: "final" });
assert.deepEqual(final.entries.map((entry) => entry.rank), [1, 2, 2, 4]);
const full = database.listDungeonLeaderboard({ dungeonId: "deadmines", clear: "full" });
assert.deepEqual(full.entries.map((entry) => entry.rank), [1, 1, 3]);
assert.deepEqual(full.entries[0].party, last.party);
assert.equal(full.entries[0].killedBossIds.length, last.killedBossIds.length);
const page = database.listDungeonLeaderboard({ dungeonId: "deadmines", clear: "final", limit: 2, offset: 2 });
assert.equal(page.total, 4); assert.deepEqual(page.entries.map((entry) => entry.rank), [2, 4]);
database = reopen();
assert.deepEqual(database.getDungeonRun(last.id), result);
}));
test("validates final boss, dungeon/difficulty, duration and party without trusting client full-clear flags", () => withDatabase((database) => {
const valid = run("valid-run-id", 1000, false);
for (const invalid of [{ ...valid, durationMs: 0 }, { ...valid, durationMs: Infinity },
{ ...valid, killedBossIds: [] }, { ...valid, killedBossIds: ["fake-boss"] },
{ ...valid, dungeonId: "unknown" }, { ...valid, difficultyId: "fake" },
{ ...valid, party: [] }, { ...valid, party: [{ ...valid.party[0], classId: "fake" }] },
{ ...valid, party: [{ ...valid.party[0], level: 81 }] }])
assert.throws(() => database.submitDungeonRun(null, invalid), { code: "invalid_dungeon_run" });
assert.equal(database.submitDungeonRun(null, { ...valid, fullClear: true }).run.fullClear, false);
const caves = run("barren-caves-test", 2000, true, "barren-caves");
caves.party[0].classId = "rom-priest";
assert.equal(database.submitDungeonRun(null, caves).placement.fullRank, 1);
assert.equal(database.listDungeonLeaderboard({ dungeonId: "deadmines" }).total, 1);
assert.throws(() => database.listDungeonLeaderboard({ dungeonId: "deadmines", clear: "bad" }), { status: 400 });
assert.throws(() => database.listDungeonLeaderboard({ dungeonId: "deadmines", offset: -1 }), { status: 400 });
}));
test("public HTTP leaderboards accept completion records and return results with rank while rejecting unfinished runs", async () => {
const directory = mkdtempSync(path.join(os.tmpdir(), "healer-man-leaderboard-http-"));
const { server } = createGameServer({ dataDir: directory, enableShelvedModes: false });
try {
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
const base = `http://127.0.0.1:${server.address().port}`;
const payload = run("http-caves-clear", 15555, true, "barren-caves");
const response = await fetch(`${base}/api/dungeon-runs`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) });
assert.equal(response.status, 201);
const result = await response.json(); assert.equal(result.placement.finalRank, 1);
const leaderboard = await fetch(`${base}/api/dungeon-leaderboards?dungeon=barren-caves&clear=full`);
assert.equal(leaderboard.status, 200); assert.equal((await leaderboard.json()).entries[0].durationMs, 15555);
assert.deepEqual(await (await fetch(`${base}/api/dungeon-runs/${payload.id}`)).json(), result);
const bad = await fetch(`${base}/api/dungeon-runs`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ...payload, id: "unfinished-run", killedBossIds: [] }) });
assert.equal(bad.status, 400);
const unauthorized = await fetch(`${base}/api/dungeon-runs`, { method: "POST", headers: { Authorization: "Bearer invalid", "Content-Type": "application/json" }, body: JSON.stringify(payload) });
assert.equal(unauthorized.status, 401);
} finally { await new Promise((resolve) => server.close(resolve)); rmSync(directory, { recursive: true, force: true }); }
});
test("separates normal and heroic times and counts only bosses that exist on each difficulty", () => withDatabase((database) => {
const dungeon = catalog.dungeons.find((entry) => entry.difficulties.length > 1 && entry.difficulties.every((difficulty) => difficulty.finalBossId));
for (const difficulty of dungeon.difficulties) {
const payload = run(`difficulty-${difficulty.id}-test`, 1000, true, dungeon.id, difficulty.id);
payload.killedBossIds = difficulty.bossIds;
const result = database.submitDungeonRun(null, payload);
assert.equal(result.run.fullClear, true);
assert.equal(result.placement.finalRank, 1); assert.equal(result.placement.finalTotal, 1);
assert.equal(database.listDungeonLeaderboard({ dungeonId: dungeon.id, difficultyId: difficulty.id, clear: "full" }).total, 1);
}
}));
+94
View File
@@ -0,0 +1,94 @@
import { randomUUID } from "node:crypto";
import availability from "../src/game/generated/dungeonAvailability.json" with { type: "json" };
import policy from "../shared/dungeon-report-policy.json" with { type: "json" };
export const REPORT_DUNGEON_IDS = new Set(["forsaken-abbey", ...Object.keys(availability.enabled)]);
export function createDungeonReportRepository(database, { isGameMaster, ErrorType }) {
database.exec(`
CREATE TABLE IF NOT EXISTS dungeon_reports (
id TEXT PRIMARY KEY,
dungeon_id TEXT NOT NULL,
comment TEXT NOT NULL CHECK (length(comment) BETWEEN 1 AND 500),
status TEXT NOT NULL CHECK (status IN ('Submitted', 'In-Progress', 'Testing Fixes', 'Closed')),
submitted_by_account_id TEXT REFERENCES accounts(id) ON DELETE SET NULL,
submitted_by_username TEXT NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
updated_by_username TEXT,
revision INTEGER NOT NULL DEFAULT 0
) STRICT;
CREATE INDEX IF NOT EXISTS dungeon_reports_dungeon_status_idx ON dungeon_reports(dungeon_id, status);
`);
const insert = database.prepare(`
INSERT INTO dungeon_reports (
id, dungeon_id, comment, status, submitted_by_account_id, submitted_by_username, created_at, updated_at
) VALUES (?, ?, ?, 'Submitted', ?, ?, ?, ?)
`);
const byId = database.prepare("SELECT * FROM dungeon_reports WHERE id = ?");
const list = database.prepare(`
SELECT * FROM dungeon_reports
WHERE (? IS NULL OR status = ?) AND (? IS NULL OR dungeon_id = ?)
ORDER BY created_at DESC, id ASC
`);
const counts = database.prepare(`
SELECT dungeon_id, COUNT(*) AS count FROM dungeon_reports WHERE status != 'Closed' GROUP BY dungeon_id
`);
const update = database.prepare(`
UPDATE dungeon_reports SET status = ?, updated_at = ?, updated_by_username = ?, revision = revision + 1
WHERE id = ? AND revision = ? RETURNING *
`);
function validateDungeon(id) {
if (!REPORT_DUNGEON_IDS.has(id)) throw new ErrorType("Choose a valid dungeon.", 400, "invalid_dungeon");
}
function validateStatus(status) {
if (!policy.statuses.includes(status)) throw new ErrorType("Choose a valid report status.", 400, "invalid_report_status");
}
function publicReport(row) {
return {
id: row.id,
dungeonId: row.dungeon_id,
comment: row.comment,
status: row.status,
submittedBy: row.submitted_by_username,
createdAt: Number(row.created_at),
updatedAt: Number(row.updated_at),
updatedBy: row.updated_by_username,
revision: Number(row.revision),
};
}
return {
listDungeonReports({ status = null, dungeonId = null } = {}) {
if (status !== null) validateStatus(status);
if (dungeonId !== null) validateDungeon(dungeonId);
return {
reports: list.all(status, status, dungeonId, dungeonId).map(publicReport),
openCounts: Object.fromEntries(counts.all().map((row) => [row.dungeon_id, Number(row.count)])),
};
},
submitDungeonReport(account, input) {
validateDungeon(input?.dungeonId);
if (typeof input?.comment !== "string" || !input.comment.trim() || input.comment.length > policy.commentMaxLength) {
throw new ErrorType("Describe the issue in 1-500 characters.", 400, "invalid_report_comment");
}
const id = randomUUID();
const now = Date.now();
insert.run(id, input.dungeonId, input.comment.replace(/\r\n?/g, "\n").trim(), account?.id ?? null,
account?.username ?? "Guest player", now, now);
return publicReport(byId.get(id));
},
updateDungeonReport(account, id, input) {
if (!isGameMaster(account)) throw new ErrorType("Game Master access is required.", 403, "gm_required");
validateStatus(input?.status);
if (!Number.isInteger(input?.expectedRevision) || input.expectedRevision < 0) {
throw new ErrorType("A valid expected revision is required.", 400, "invalid_revision");
}
if (!byId.get(id)) throw new ErrorType("Report not found.", 404, "report_not_found");
const row = update.get(input.status, Date.now(), account.username, id, input.expectedRevision);
if (!row) throw new ErrorType("This report has changed. Refresh reports and try again.", 409, "report_conflict");
return publicReport(row);
},
};
}
+104
View File
@@ -0,0 +1,104 @@
import assert from "node:assert/strict";
import { mkdtempSync, rmSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import { openGameDatabase } from "./database.mjs";
import { createGameServer } from "./server.mjs";
async function withDatabase(run) {
const directory = mkdtempSync(path.join(os.tmpdir(), "healer-man-reports-test-"));
const database = openGameDatabase({ dataDir: directory });
try { await run(database); } finally { database.close(); rmSync(directory, { recursive: true, force: true }); }
}
test("validates comments and dungeon IDs and stamps new reports as Submitted", () => withDatabase(async (database) => {
assert.deepEqual(database.listDungeonReports(), { reports: [], openCounts: {} });
for (const comment of ["", " \n ", "x".repeat(501), null, 123]) {
assert.throws(() => database.submitDungeonReport(null, { dungeonId: "stormwind-stockade", comment }), { code: "invalid_report_comment" });
}
assert.throws(() => database.submitDungeonReport(null, { dungeonId: "fake", comment: "Broken" }), { code: "invalid_dungeon" });
const account = (await database.register("Reporter", "report-pass")).account;
const report = database.submitDungeonReport(account, {
dungeonId: "stormwind-stockade", comment: "x".repeat(500), status: "Closed", submittedBy: "Phenom",
});
assert.equal(report.comment.length, 500);
assert.equal(report.status, "Submitted");
assert.equal(report.submittedBy, "Reporter");
assert.equal(database.submitDungeonReport(null, { dungeonId: "forsaken-abbey", comment: " Door is stuck\r\nAfter a wipe " }).comment, "Door is stuck\nAfter a wipe");
assert.deepEqual(database.listDungeonReports().openCounts, { "forsaken-abbey": 1, "stormwind-stockade": 1 });
}));
test("enforces GM updates, counts all open statuses, supports reopening, and prevents stale changes", () => withDatabase(async (database) => {
const gm = (await database.register("Phenom", "report-admin")).account;
const player = (await database.register("Tester", "report-player")).account;
let report = database.submitDungeonReport(player, { dungeonId: "stormwind-stockade", comment: "Gate is stuck" });
database.submitDungeonReport(player, { dungeonId: "wailing-caverns", comment: "Boss stuck" });
assert.throws(() => database.updateDungeonReport({ ...player, roles: ["gm"] }, report.id, { status: "Closed", expectedRevision: 0 }), { status: 403 });
assert.throws(() => database.updateDungeonReport(gm, report.id, { status: "Other", expectedRevision: 0 }), { code: "invalid_report_status" });
assert.throws(() => database.updateDungeonReport(gm, report.id, { status: "Closed" }), { code: "invalid_revision" });
assert.throws(() => database.updateDungeonReport(gm, "missing", { status: "Closed", expectedRevision: 0 }), { status: 404 });
for (const status of ["In-Progress", "Testing Fixes"]) {
report = database.updateDungeonReport(gm, report.id, { status, expectedRevision: report.revision });
assert.equal(report.updatedBy, "Phenom");
assert.equal(database.listDungeonReports().openCounts[report.dungeonId], 1);
}
assert.throws(() => database.updateDungeonReport(gm, report.id, { status: "Closed", expectedRevision: 0 }), { status: 409 });
report = database.updateDungeonReport(gm, report.id, { status: "Closed", expectedRevision: report.revision });
assert.deepEqual(database.listDungeonReports().openCounts, { "wailing-caverns": 1 });
assert.equal(database.listDungeonReports().reports.length, 2);
const filtered = database.listDungeonReports({ status: "Closed", dungeonId: report.dungeonId });
assert.equal(filtered.reports.length, 1);
assert.deepEqual(filtered.openCounts, { "wailing-caverns": 1 });
report = database.updateDungeonReport(gm, report.id, { status: "Submitted", expectedRevision: report.revision });
assert.equal(database.listDungeonReports().openCounts[report.dungeonId], 1);
}));
test("persists reports and status changes across database restarts", async () => {
const directory = mkdtempSync(path.join(os.tmpdir(), "healer-man-report-restart-"));
let database;
try {
database = openGameDatabase({ dataDir: directory });
const gm = (await database.register("Phenom", "report-admin")).account;
const report = database.submitDungeonReport(null, { dungeonId: "stormwind-stockade", comment: "Entrance issue" });
const updated = database.updateDungeonReport(gm, report.id, { status: "Testing Fixes", expectedRevision: 0 });
database.close();
database = openGameDatabase({ dataDir: directory });
assert.deepEqual(database.listDungeonReports().reports, [updated]);
assert.equal(database.listDungeonReports().openCounts[report.dungeonId], 1);
} finally { database?.close(); rmSync(directory, { recursive: true, force: true }); }
});
test("serves public reports and GM-only status API with shelved modes disabled", async () => {
const directory = mkdtempSync(path.join(os.tmpdir(), "healer-man-report-api-"));
const runtime = createGameServer({ dataDir: directory, enableShelvedModes: false });
await new Promise((resolve) => runtime.server.listen(0, "127.0.0.1", resolve));
const origin = `http://127.0.0.1:${runtime.server.address().port}`;
const request = (route, method = "GET", body, token) => fetch(`${origin}/api/dungeon-reports${route}`, {
method, headers: { "Content-Type": "application/json", ...(token ? { Authorization: `Bearer ${token}` } : {}) },
...(body === undefined ? {} : { body: JSON.stringify(body) }),
});
try {
const player = await runtime.database.register("ApiPlayer", "report-player");
const gm = await runtime.database.register("Phenom", "report-admin");
const created = await request("", "POST", { dungeonId: "stormwind-stockade", comment: "Issue via API" }, player.token);
assert.equal(created.status, 201);
const { report } = await created.json();
assert.equal((await request("", "POST", { dungeonId: "wailing-caverns", comment: "Guest issue" })).status, 201);
assert.equal((await request(`/${report.id}`, "PATCH", { status: "Closed", expectedRevision: 0 })).status, 401);
assert.equal((await request(`/${report.id}`, "PATCH", { status: "Closed", expectedRevision: 0 }, player.token)).status, 403);
assert.equal((await request("", "POST", { dungeonId: "stormwind-stockade", comment: "x".repeat(501) })).status, 400);
const closed = await request(`/${report.id}`, "PATCH", { status: "Closed", expectedRevision: 0 }, gm.token);
assert.equal(closed.status, 200);
assert.equal((await closed.json()).report.status, "Closed");
const all = await (await request("")).json();
assert.equal(all.reports.length, 2);
assert.deepEqual(all.openCounts, { "wailing-caverns": 1 });
assert.equal((await (await request("?status=Closed&dungeon=stormwind-stockade")).json()).reports.length, 1);
assert.equal((await request("?status=Bad")).status, 400);
assert.equal((await request("?dungeon=Bad")).status, 400);
} finally {
await new Promise((resolve) => runtime.server.close(resolve));
rmSync(directory, { recursive: true, force: true });
}
});
+45
View File
@@ -181,6 +181,8 @@ export function createGameServer(options = {}) {
const onlineSessions = createOnlineSessionManager(); const onlineSessions = createOnlineSessionManager();
const arenaService = createArenaService({ database: gameDatabase, autoPump: enableShelvedModes }); const arenaService = createArenaService({ database: gameDatabase, autoPump: enableShelvedModes });
const allowAuthRequest = createAuthLimiter(); const allowAuthRequest = createAuthLimiter();
const allowReportRequest = createAuthLimiter();
const allowDungeonRunRequest = createAuthLimiter();
gameDatabase.pruneExpiredSessions(); gameDatabase.pruneExpiredSessions();
const server = createServer(async (request, response) => { const server = createServer(async (request, response) => {
@@ -239,6 +241,49 @@ export function createGameServer(options = {}) {
json(response, 200, { account: gameDatabase.authenticate(bearerToken(request)) }); json(response, 200, { account: gameDatabase.authenticate(bearerToken(request)) });
return; return;
} }
if (url.pathname === "/api/dungeon-leaderboards" && request.method === "GET") {
json(response, 200, gameDatabase.listDungeonLeaderboard({ dungeonId: url.searchParams.get("dungeon"),
difficultyId: url.searchParams.get("difficulty"), clear: url.searchParams.get("clear"),
offset: url.searchParams.get("offset"), limit: url.searchParams.get("limit") }));
return;
}
if (url.pathname === "/api/dungeon-runs" && request.method === "POST") {
const token = bearerToken(request);
const account = token ? gameDatabase.authenticate(token) : null;
if (!allowDungeonRunRequest(account?.id ?? request.socket.remoteAddress))
throw new GameDatabaseError("Too many run submissions. Please try again shortly.", 429, "rate_limited");
json(response, 201, gameDatabase.submitDungeonRun(account, await readJson(request, 32 * 1024)));
return;
}
const dungeonRunMatch = /^\/api\/dungeon-runs\/([A-Za-z0-9_-]{8,100})$/.exec(url.pathname);
if (dungeonRunMatch && request.method === "GET") {
json(response, 200, gameDatabase.getDungeonRun(dungeonRunMatch[1]));
return;
}
if (url.pathname === "/api/dungeon-reports" && request.method === "GET") {
json(response, 200, gameDatabase.listDungeonReports({
status: url.searchParams.get("status"),
dungeonId: url.searchParams.get("dungeon"),
}));
return;
}
if (url.pathname === "/api/dungeon-reports" && request.method === "POST") {
const token = bearerToken(request);
const account = token ? gameDatabase.authenticate(token) : null;
if (!allowReportRequest(account?.id ?? request.socket.remoteAddress)) {
throw new GameDatabaseError("Too many reports. Please wait a minute and try again.", 429, "rate_limited");
}
const body = await readJson(request, 8 * 1024);
json(response, 201, { report: gameDatabase.submitDungeonReport(account, body) });
return;
}
const reportMatch = /^\/api\/dungeon-reports\/([^/]+)$/.exec(url.pathname);
if (reportMatch && request.method === "PATCH") {
const account = gameDatabase.authenticate(bearerToken(request));
const body = await readJson(request, 8 * 1024);
json(response, 200, { report: gameDatabase.updateDungeonReport(account, reportMatch[1], body) });
return;
}
if (url.pathname === "/api/cloud-save" && request.method === "GET") { if (url.pathname === "/api/cloud-save" && request.method === "GET") {
const account = gameDatabase.authenticate(bearerToken(request)); const account = gameDatabase.authenticate(bearerToken(request));
json(response, 200, gameDatabase.getCloudSave(account.id)); json(response, 200, gameDatabase.getCloudSave(account.id));
+4
View File
@@ -0,0 +1,4 @@
{
"commentMaxLength": 500,
"statuses": ["Submitted", "In-Progress", "Testing Fixes", "Closed"]
}
+5 -1
View File
@@ -20,6 +20,8 @@ import { PauseMenu } from "./ui/PauseMenu";
import { SceneErrorBoundary } from "./ui/SceneErrorBoundary"; import { SceneErrorBoundary } from "./ui/SceneErrorBoundary";
import { OnlineSessionBridge } from "./game/OnlineSessionBridge"; import { OnlineSessionBridge } from "./game/OnlineSessionBridge";
import { DungeonRunCheckpointBridge } from "./game/DungeonRunCheckpointBridge"; import { DungeonRunCheckpointBridge } from "./game/DungeonRunCheckpointBridge";
import { DungeonRunBridge } from "./game/DungeonRunBridge";
import { DungeonRunResults } from "./ui/DungeonRunResults";
import { DungeonEncounterEffectBridge } from "./game/DungeonEncounterEffectBridge"; import { DungeonEncounterEffectBridge } from "./game/DungeonEncounterEffectBridge";
import { interactWithActiveDungeonObject } from "./game/dungeonWorldInteraction"; import { interactWithActiveDungeonObject } from "./game/dungeonWorldInteraction";
import { DungeonInteractionPrompt } from "./ui/DungeonInteractionPrompt"; import { DungeonInteractionPrompt } from "./ui/DungeonInteractionPrompt";
@@ -37,7 +39,7 @@ function InputBridge() {
useEffect(() => installInput({ useEffect(() => installInput({
desktopActionBarsActive: () => currentUiDevice() === "pc" && activeUiLayout().wowBars, desktopActionBarsActive: () => currentUiDevice() === "pc" && activeUiLayout().wowBars,
inputSuspended: () => ["ui-layout", "keybinds"].includes(useGameStore.getState().overlay ?? ""), inputSuspended: () => ["ui-layout", "keybinds", "dungeon-results"].includes(useGameStore.getState().overlay ?? ""),
getBindings: (device) => { getBindings: (device) => {
const preferences = useUiLayoutStore.getState().preferences; const preferences = useUiLayoutStore.getState().preferences;
const bindings = profileInputBindings(preferences, device); const bindings = profileInputBindings(preferences, device);
@@ -115,6 +117,7 @@ export default function GameRuntime() {
<DungeonRunCheckpointBridge /> <DungeonRunCheckpointBridge />
<CombatBridge /> <CombatBridge />
<PartyBridge /> <PartyBridge />
<DungeonRunBridge />
<ShelvedGameBridges /> <ShelvedGameBridges />
<SceneErrorBoundary><GameScene /></SceneErrorBoundary> <SceneErrorBoundary><GameScene /></SceneErrorBoundary>
<Hud /> <Hud />
@@ -122,6 +125,7 @@ export default function GameRuntime() {
<MapPanel /> <MapPanel />
<GameplayPanels /> <GameplayPanels />
<PauseMenu /> <PauseMenu />
<DungeonRunResults />
<UiLayoutEditor /> <UiLayoutEditor />
<KeybindsPanel /> <KeybindsPanel />
</main> </main>
+44
View File
@@ -0,0 +1,44 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { localDungeonLeaderboard, localDungeonRunPlacement, saveLocalDungeonRun, submitDungeonRun } from "./dungeonLeaderboardClient";
import type { DungeonRunRecord } from "../game/dungeonRunTypes";
import catalog from "../game/generated/dungeonLeaderboardCatalog.generated.json";
const dungeon = catalog.dungeons.find((entry) => entry.id === "deadmines")!;
const record = (id: string, durationMs: number, fullClear = true): DungeonRunRecord => ({
id, dungeonId: dungeon.id, difficultyId: dungeon.defaultDifficultyId, durationMs, finishedAt: 100000,
party: [{ name: "Tester", classId: "priest", level: 20, role: "healer", npc: false }],
killedBossIds: fullClear ? dungeon.bosses.map((boss) => boss.id) : [dungeon.finalBossId], fullClear,
});
beforeEach(() => {
const values = new Map<string, string>();
vi.stubGlobal("localStorage", { getItem: (key: string) => values.get(key) ?? null,
setItem: (key: string, value: string) => values.set(key, value) });
});
afterEach(() => vi.unstubAllGlobals());
describe("offline dungeon leaderboard", () => {
it("persists full/final filters, ties, pages, and the placement of a run outside the first page", () => {
for (let index = 0; index < 25; index += 1) saveLocalDungeonRun(record(`offline-${index}`, 1000 + index * 100));
const fastest = record("offline-fastest", 500, false);
saveLocalDungeonRun(fastest); saveLocalDungeonRun(fastest);
saveLocalDungeonRun(record("offline-tie", 1000));
const all = localDungeonLeaderboard(dungeon.id, dungeon.defaultDifficultyId, "final");
expect(all.total).toBe(27); expect(all.entries[0].id).toBe(fastest.id);
expect(all.entries.slice(0, 3).map((entry) => entry.rank)).toEqual([1, 2, 2]);
expect(localDungeonLeaderboard(dungeon.id, dungeon.defaultDifficultyId, "full").total).toBe(26);
const page = localDungeonLeaderboard(dungeon.id, dungeon.defaultDifficultyId, "final", 20);
expect(page.entries).toHaveLength(7);
expect(localDungeonRunPlacement(record("offline-24", 3400))).toEqual({ finalRank: 27, finalTotal: 27, fullRank: 26, fullTotal: 26 });
});
it("retains the local time after a failed upload and uses the same run id when retried", async () => {
const run = record("offline-retry-test", 4321);
saveLocalDungeonRun(run);
const fetch = vi.fn().mockRejectedValueOnce(new Error("offline"))
.mockResolvedValueOnce(new Response(JSON.stringify({ placement: { finalRank: 2, finalTotal: 3, fullRank: 1, fullTotal: 2 } }), { status: 201 }));
vi.stubGlobal("fetch", fetch);
await expect(submitDungeonRun(run, null)).rejects.toThrow();
expect(localDungeonLeaderboard(dungeon.id, dungeon.defaultDifficultyId, "final").entries[0].durationMs).toBe(4321);
await expect(submitDungeonRun(run, null)).resolves.toMatchObject({ finalRank: 2 });
expect(fetch.mock.calls.map((call) => JSON.parse(call[1].body).id)).toEqual([run.id, run.id]);
});
});
+102
View File
@@ -0,0 +1,102 @@
import { requestOnlineJson } from "./onlineAccountClient";
import type { PlayerSession } from "./types";
import catalog from "../game/generated/dungeonLeaderboardCatalog.generated.json";
import { normalizeDungeonRunProgress, type DungeonClearFilter, type DungeonLeaderboard,
type DungeonRunPlacement, type DungeonRunRecord } from "../game/dungeonRunTypes";
const STORAGE_KEY = "healer-man:dungeon-runs:v1";
const listeners = new Set<() => void>();
let memory: DungeonRunRecord[] = [];
export const DUNGEON_LEADERBOARD_PAGE_SIZE = 20;
export type LeaderboardScope = "world" | "device";
export function subscribeDungeonLeaderboard(listener: () => void) {
listeners.add(listener);
return () => { listeners.delete(listener); };
}
function localRuns(): DungeonRunRecord[] {
try {
const input: unknown = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? "[]");
if (Array.isArray(input)) memory = input.flatMap((value: DungeonRunRecord) => {
if (!value || typeof value !== "object") return [];
const progress = normalizeDungeonRunProgress({ ...value, startedAt: value.finishedAt - value.durationMs, timed: true });
const dungeon = catalog.dungeons.find((entry) => entry.id === progress?.dungeonId);
const difficulty = dungeon?.difficulties.find((entry) => entry.id === progress?.difficultyId);
if (!progress || progress.finishedAt === null || progress.durationMs === null || !dungeon
|| !difficulty || !progress.killedBossIds.includes(difficulty.finalBossId)
|| progress.killedBossIds.some((id) => !difficulty.bossIds.includes(id))) return [];
return [{ ...value, ...progress, durationMs: progress.durationMs, finishedAt: progress.finishedAt,
fullClear: difficulty.bossIds.every((id) => progress.killedBossIds.includes(id)) }];
});
} catch { /* In-memory results remain available if storage is disabled. */ }
return memory;
}
export function saveLocalDungeonRun(record: DungeonRunRecord): void {
memory = [record, ...localRuns().filter((entry) => entry.id !== record.id)]
.sort((a, b) => b.finishedAt - a.finishedAt).slice(0, 1000);
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(memory)); } catch { /* Keep the current session's results. */ }
listeners.forEach((listener) => listener());
}
export function localDungeonLeaderboard(dungeonId: string, difficultyId: string,
filter: DungeonClearFilter, offset = 0): DungeonLeaderboard {
const runs = localRuns().filter((entry) => entry.dungeonId === dungeonId && entry.difficultyId === difficultyId
&& (filter === "final" || entry.fullClear)).sort((a, b) => a.durationMs - b.durationMs
|| a.finishedAt - b.finishedAt || a.id.localeCompare(b.id));
return { total: runs.length, entries: runs.slice(offset, offset + DUNGEON_LEADERBOARD_PAGE_SIZE)
.map((entry) => ({ ...entry, rank: 1 + runs.filter((other) => other.durationMs < entry.durationMs).length })) };
}
export function localDungeonRunPlacement(record: DungeonRunRecord): DungeonRunPlacement {
const final = localDungeonLeaderboard(record.dungeonId, record.difficultyId, "final");
const full = localDungeonLeaderboard(record.dungeonId, record.difficultyId, "full");
const runs = localRuns().filter((entry) => entry.dungeonId === record.dungeonId && entry.difficultyId === record.difficultyId);
return { finalRank: 1 + runs.filter((entry) => entry.durationMs < record.durationMs).length,
finalTotal: final.total, fullTotal: full.total,
fullRank: record.fullClear ? 1 + runs.filter((entry) => entry.fullClear && entry.durationMs < record.durationMs).length : null };
}
export async function fetchDungeonLeaderboard(dungeonId: string, difficultyId: string,
filter: DungeonClearFilter, offset: number, signal?: AbortSignal): Promise<DungeonLeaderboard> {
const query = new URLSearchParams({ dungeon: dungeonId, difficulty: difficultyId, clear: filter,
offset: String(offset), limit: String(DUNGEON_LEADERBOARD_PAGE_SIZE) });
return requestOnlineJson(`/api/dungeon-leaderboards?${query}`, { signal });
}
const submissions = new Map<string, Promise<DungeonRunPlacement>>();
export function submitDungeonRun(record: DungeonRunRecord, session: PlayerSession | null): Promise<DungeonRunPlacement> {
const existing = submissions.get(record.id);
if (existing) return existing;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 8000);
const submission = requestOnlineJson<{ placement: DungeonRunPlacement }>("/api/dungeon-runs", {
method: "POST", body: JSON.stringify(record), signal: controller.signal,
}, session?.accessToken).then((result) => {
listeners.forEach((listener) => listener());
return result.placement;
}).catch((error: unknown) => { submissions.delete(record.id); throw error; })
.finally(() => clearTimeout(timeout));
submissions.set(record.id, submission);
return submission;
}
/** Group followers read the leader's single shared run instead of submitting duplicate entries. */
export async function awaitSharedDungeonRunPlacement(id: string): Promise<DungeonRunPlacement> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 8000);
try {
for (let attempt = 0; attempt < 8; attempt += 1) {
try {
const response = await requestOnlineJson<{ placement: DungeonRunPlacement }>(`/api/dungeon-runs/${encodeURIComponent(id)}`,
{ signal: controller.signal });
return response.placement;
} catch (error) {
if (attempt === 7 || controller.signal.aborted) throw error;
await new Promise((resolve) => setTimeout(resolve, 1000));
}
}
throw new Error("The shared run is not recorded yet.");
} finally { clearTimeout(timeout); }
}
+58
View File
@@ -0,0 +1,58 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
DUNGEON_REPORT_STATUSES, fetchDungeonReports, filterDungeonReports, sessionCanManageDungeonReports,
submitDungeonReport, updateDungeonReport, type DungeonReport,
} from "./dungeonReports";
import { useGameStore } from "../game/store";
function report(id: string, dungeonId: string, status: DungeonReport["status"]): DungeonReport {
return { id, dungeonId, status, comment: "Door does not open", submittedBy: "Player", createdAt: 1, updatedAt: 1, updatedBy: null, revision: 0 };
}
afterEach(() => { vi.unstubAllGlobals(); useGameStore.getState().closeOverlay(); });
describe("dungeon reports", () => {
it("shows all statuses by default, including Closed, and combines status and dungeon filters", () => {
const reports = DUNGEON_REPORT_STATUSES.flatMap((status, index) => [
report(`stock-${index}`, "stormwind-stockade", status), report(`cave-${index}`, "wailing-caverns", status),
]);
expect(filterDungeonReports(reports, "all", "all")).toEqual(reports);
expect(filterDungeonReports(reports, "Closed", "all")).toHaveLength(2);
expect(filterDungeonReports(reports, "all", "wailing-caverns")).toHaveLength(4);
expect(filterDungeonReports(reports, "Testing Fixes", "stormwind-stockade").map((item) => item.id)).toEqual(["stock-2"]);
expect(filterDungeonReports(reports, "Submitted", "missing")).toEqual([]);
});
it("allows GM report management in the ordinary release and rejects offline or local-only authority", () => {
const gm = { kind: "account" as const, ownerId: "gm", displayName: "Phenom", accessToken: "token", roles: ["gm" as const] };
expect(sessionCanManageDungeonReports(gm)).toBe(true);
expect(sessionCanManageDungeonReports({ ...gm, roles: [] })).toBe(false);
expect(sessionCanManageDungeonReports({ ...gm, kind: "offline" })).toBe(false);
expect(sessionCanManageDungeonReports({ ...gm, accessToken: undefined })).toBe(false);
expect(sessionCanManageDungeonReports(null)).toBe(false);
});
it("sends the active dungeon and preserves the expected report revision for GM updates", async () => {
const item = report("report-id", "stormwind-stockade", "Submitted");
const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ report: item }) });
vi.stubGlobal("fetch", fetchMock);
const gm = { kind: "account" as const, ownerId: "gm", displayName: "Phenom", accessToken: "token", roles: ["gm" as const] };
await submitDungeonReport(gm, item.dungeonId, "Door does not open");
expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toEqual({ dungeonId: item.dungeonId, comment: "Door does not open" });
expect(fetchMock.mock.calls[0][1].headers.get("Authorization")).toBe("Bearer token");
await updateDungeonReport(gm, { ...item, revision: 3 }, "Closed");
expect(fetchMock.mock.calls[1][1].method).toBe("PATCH");
expect(JSON.parse(fetchMock.mock.calls[1][1].body)).toEqual({ status: "Closed", expectedRevision: 3 });
await fetchDungeonReports();
expect(fetchMock.mock.calls[2][0]).toBe("/api/dungeon-reports");
});
it("keeps the game paused and camera look stopped while reporting and returning to pause", () => {
const game = useGameStore.getState();
game.setCameraLookActive(true);
game.openOverlay("report-issue");
expect(useGameStore.getState()).toMatchObject({ paused: true, overlay: "report-issue", cameraLookActive: false });
game.openOverlay("pause");
expect(useGameStore.getState()).toMatchObject({ paused: true, overlay: "pause", cameraLookActive: false });
});
});
+61
View File
@@ -0,0 +1,61 @@
import type { PlayerSession } from "./types";
import { requestOnlineJson } from "./onlineAccountClient";
import policy from "../../shared/dungeon-report-policy.json";
export type DungeonReportStatus = "Submitted" | "In-Progress" | "Testing Fixes" | "Closed";
export const DUNGEON_REPORT_STATUSES = policy.statuses as readonly DungeonReportStatus[];
export const DUNGEON_REPORT_COMMENT_MAX_LENGTH = policy.commentMaxLength;
export interface DungeonReport {
readonly id: string;
readonly dungeonId: string;
readonly comment: string;
readonly status: DungeonReportStatus;
readonly submittedBy: string;
readonly createdAt: number;
readonly updatedAt: number;
readonly updatedBy: string | null;
readonly revision: number;
}
export interface DungeonReportSnapshot {
readonly reports: readonly DungeonReport[];
readonly openCounts: Readonly<Record<string, number>>;
}
export function sessionCanManageDungeonReports(session: PlayerSession | null): boolean {
return Boolean(session?.kind === "account" && session.accessToken && session.roles?.includes("gm"));
}
export function filterDungeonReports(
reports: readonly DungeonReport[],
status: DungeonReportStatus | "all",
dungeonId: string,
): readonly DungeonReport[] {
return reports.filter((report) => (status === "all" || report.status === status)
&& (dungeonId === "all" || report.dungeonId === dungeonId));
}
export function fetchDungeonReports(): Promise<DungeonReportSnapshot> {
return requestOnlineJson<DungeonReportSnapshot>("/api/dungeon-reports");
}
export async function submitDungeonReport(session: PlayerSession | null, dungeonId: string, comment: string): Promise<DungeonReport> {
const response = await requestOnlineJson<{ report: DungeonReport }>("/api/dungeon-reports", {
method: "POST",
body: JSON.stringify({ dungeonId, comment }),
}, session?.kind === "account" ? session.accessToken : undefined);
return response.report;
}
export async function updateDungeonReport(
session: PlayerSession,
report: DungeonReport,
status: DungeonReportStatus,
): Promise<DungeonReport> {
const response = await requestOnlineJson<{ report: DungeonReport }>(`/api/dungeon-reports/${encodeURIComponent(report.id)}`, {
method: "PATCH",
body: JSON.stringify({ status, expectedRevision: report.revision }),
}, session.accessToken);
return response.report;
}
+2
View File
@@ -61,6 +61,8 @@ function enterDungeon(dungeonId: string, requestedRole?: PartyRole): boolean {
if (!activateDungeonSession(dungeonId, resume?.difficultyId, { if (!activateDungeonSession(dungeonId, resume?.difficultyId, {
spawn: resume?.spawn, spawn: resume?.spawn,
encounterSnapshots: resume?.encounters, encounterSnapshots: resume?.encounters,
runProgress: resume?.run,
legacyResume: Boolean(resume && !resume.run),
})) { })) {
useShellStore.setState({ useShellStore.setState({
notice: dungeon.availabilityReason ?? "That dungeon failed its latest validation gate.", notice: dungeon.availabilityReason ?? "That dungeon failed its latest validation gate.",
+66
View File
@@ -0,0 +1,66 @@
import { useEffect } from "react";
import { useShellStore } from "../app/shellStore";
import { useOnlineSessionStore } from "../app/onlineSessionStore";
import { useOnlineGroupStore } from "../app/onlineGroupStore";
import { activeDungeonCompletedObjectiveIds, subscribeDungeonEncounterSnapshots } from "./dungeonEncounterRuntime";
import { useCombatStore } from "./combatStore";
import { usePartyStore } from "./partyStore";
import { useGameStore } from "./store";
import { useDungeonRunStore } from "./dungeonRunStore";
import type { DungeonRunMember } from "./dungeonRunTypes";
function currentRunParty(): readonly DungeonRunMember[] {
const shell = useShellStore.getState();
const character = shell.activeCharacter;
if (!character) return [];
const members: DungeonRunMember[] = [{ name: character.name, classId: character.classId,
level: useCombatStore.getState().level, role: shell.activeDungeonRole, npc: false,
...(character.secondaryClassId ? { secondaryClassId: character.secondaryClassId } : {}) }];
const online = useOnlineSessionStore.getState().snapshot;
for (const player of online?.players ?? []) {
if (player.accountId === shell.session?.ownerId || !player.character) continue;
members.push({ name: player.character.name, classId: player.character.classId,
level: player.character.level, role: player.role ?? "damage", npc: false });
}
for (const member of usePartyStore.getState().members) {
members.push({ name: member.name, classId: member.classId, level: member.level, role: member.role, npc: true });
}
return members.slice(0, 5);
}
/** Wall-clock run timing survives wipes, pauses, checkpoints, and renderer retries. */
export function DungeonRunBridge() {
useEffect(() => {
let announcedId: string | null = null;
const advance = () => {
const game = useGameStore.getState();
const shell = useShellStore.getState();
if (shell.phase !== "game" || game.gameMode !== "dungeon" || game.gmTestMode
|| !["ready", "proxy"].includes(game.assetStatus)) return;
const run = useDungeonRunStore.getState();
if (run.progress?.dungeonId !== game.activeDungeonId) return;
const party = currentRunParty();
const activity = useOnlineGroupStore.getState().activeActivity;
if (activity?.type === "dungeon" && activity.contentId === game.activeDungeonId
&& run.progress.finishedAt === null && run.progress.id !== activity.id) {
useDungeonRunStore.setState({ progress: { ...run.progress, id: activity.id,
startedAt: activity.startedAt, party } });
}
run.start(Date.now(), party);
run.advance(activeDungeonCompletedObjectiveIds(game.activeDungeonId),
Object.values(useCombatStore.getState().mobs).filter((mob) => mob.dead && !mob.despawned).map((mob) => mob.id),
Date.now(), party);
const progress = useDungeonRunStore.getState().progress;
if (progress?.finishedAt !== null && progress && announcedId !== progress.id) {
announcedId = progress.id;
useGameStore.getState().openOverlay("dungeon-results");
}
};
const unsubscribeEncounters = subscribeDungeonEncounterSnapshots(advance);
const unsubscribeGame = useGameStore.subscribe(advance);
const interval = setInterval(advance, 250);
advance();
return () => { unsubscribeEncounters(); unsubscribeGame(); clearInterval(interval); };
}, []);
return null;
}
+6 -1
View File
@@ -6,6 +6,7 @@ import {
} from "./dungeonEncounterRuntime"; } from "./dungeonEncounterRuntime";
import { DUNGEON_RUN_CHECKPOINT_VERSION } from "./dungeonRunCheckpoint"; import { DUNGEON_RUN_CHECKPOINT_VERSION } from "./dungeonRunCheckpoint";
import { useGameStore } from "./store"; import { useGameStore } from "./store";
import { useDungeonRunStore } from "./dungeonRunStore";
function checkpointSignature(dungeonId: string): string { function checkpointSignature(dungeonId: string): string {
const game = useGameStore.getState(); const game = useGameStore.getState();
@@ -15,6 +16,7 @@ function checkpointSignature(dungeonId: string): string {
difficultyId: game.activeDifficultyId, difficultyId: game.activeDifficultyId,
spawn: game.activeSpawn, spawn: game.activeSpawn,
encounters, encounters,
run: useDungeonRunStore.getState().progress,
}); });
} }
@@ -41,10 +43,13 @@ export function DungeonRunCheckpointBridge() {
spawn: game.activeSpawn, spawn: game.activeSpawn,
encounters, encounters,
savedAt: Date.now(), savedAt: Date.now(),
run: useDungeonRunStore.getState().progress ?? undefined,
}); });
}; };
save(); save();
return subscribeDungeonEncounterSnapshots(save); const unsubscribeEncounters = subscribeDungeonEncounterSnapshots(save);
const unsubscribeRun = useDungeonRunStore.subscribe(save);
return () => { unsubscribeEncounters(); unsubscribeRun(); };
}, [activeCharacterId, phase]); }, [activeCharacterId, phase]);
return null; return null;
+10
View File
@@ -14,6 +14,16 @@ import {
} from "./coaLiveCatalog"; } from "./coaLiveCatalog";
const abilities = Object.values(COA_ABILITIES_BY_CLASS).flat(); const abilities = Object.values(COA_ABILITIES_BY_CLASS).flat();
it("keeps Drakkari Barrier friendly and its retaliation in the damage-taken proc", () => {
const barrier = COA_ABILITIES_BY_CLASS["witch-doctor"].find((ability) => ability.name === "Drakkari Barrier")!;
expect(barrier.target).toBe("friendly");
for (const rank of barrier.ranks ?? []) {
expect(rank.effects.some((effect) => effect.kind === "damage")).toBe(false);
expect(rank.effects.some((effect) => effect.kind === "apply-aura"
&& effect.aura.procs?.some((proc) => proc.triggers === "damage-taken" && proc.action.kind === "damage"))).toBe(true);
}
});
const ranksFor = (ability: AbilityDefinition): readonly AbilityRankDefinition[] => ability.ranks ?? [{ const ranksFor = (ability: AbilityDefinition): readonly AbilityRankDefinition[] => ability.ranks ?? [{
rank: 1, rank: 1,
spellId: ability.dbcSpellId, spellId: ability.dbcSpellId,
+2 -1
View File
@@ -987,7 +987,8 @@ function effectsForSpell(
} }
const hasHealing = effects.some((effect) => ["heal", "hot"].includes(effect.kind)); const hasHealing = effects.some((effect) => ["heal", "hot"].includes(effect.kind));
const hasDamage = effects.some((effect) => ["damage", "dot"].includes(effect.kind)); const hasDamage = effects.some((effect) => ["damage", "dot"].includes(effect.kind)
|| (effect.kind === "apply-aura" && effect.aura.procs?.some((proc) => proc.action.kind === "damage")));
const describesHealing = /\b(?:heal(?:s|ing|ed)?|restores? .*health|mends?|rejuvenat\w*)\b/i.test(description); const describesHealing = /\b(?:heal(?:s|ing|ed)?|restores? .*health|mends?|rejuvenat\w*)\b/i.test(description);
const describesDamage = /\b(?:deal(?:s|ing)?|inflict(?:s|ing)?|caus(?:e|es|ing)|damag(?:e|es|ing))\b[^.]*\bdamage\b/i.test(description); const describesDamage = /\b(?:deal(?:s|ing)?|inflict(?:s|ing)?|caus(?:e|es|ing)|damag(?:e|es|ing))\b[^.]*\bdamage\b/i.test(description);
if (!hasHealing && describesHealing) effects.unshift({ kind: "heal", coefficient: 1 }); if (!hasHealing && describesHealing) effects.unshift({ kind: "heal", coefficient: 1 });
+1
View File
@@ -33,6 +33,7 @@ export interface CombatActorCastState {
/** Simulation-space origin used for movement cancellation and completion validation. */ /** Simulation-space origin used for movement cancellation and completion validation. */
readonly origin?: CombatActorPosition; readonly origin?: CombatActorPosition;
readonly targetId?: string | null; readonly targetId?: string | null;
readonly channelTicksCompleted?: number;
} }
/** /**
+25
View File
@@ -0,0 +1,25 @@
import { describe, expect, it } from "vitest";
import { abilitiesForClass } from "./abilityCatalog";
import { channelAmountEffects } from "./combatChannelAmounts";
describe("channel amount expansion", () => {
it("spreads a normal channel's full amount across its pulses", () => {
const ability = abilitiesForClass("mage")[0];
const effects = channelAmountEffects({ ...ability, effects: [
{ kind: "damage", coefficient: 1, basePoints: 100 },
{ kind: "dot", coefficient: 1, ticks: 5, intervalMs: 1_000, tag: "channel" },
] }, 5);
expect(effects.map(({ multiplier }) => multiplier)).toEqual([0.2, 1]);
});
it("uses the authored periodic payload for Radiant Flame", () => {
const ability = abilitiesForClass("sun-cleric").find((candidate) => candidate.name === "Radiant Flame")!;
const amounts = channelAmountEffects(ability, 5);
expect(amounts).toHaveLength(1);
expect(amounts[0]).toMatchObject({ effect: { kind: "damage", basePoints: 1 }, multiplier: 1 });
const conditional = { ...ability, effects: ability.effects.map((effect) => effect.kind === "apply-aura"
? { ...effect, aura: { ...effect.aura, procs: effect.aura.procs?.map((proc) => ({ ...proc, chance: 0.5 })) } }
: effect) };
expect(channelAmountEffects(conditional, 5)).toEqual([]);
});
});
+33
View File
@@ -0,0 +1,33 @@
import { triggeredAbilityBySpellId, type AbilityDefinition, type AbilityEffect } from "./abilityCatalog";
type ChannelAmountEffect = Extract<AbilityEffect, { kind: "damage" | "dot" | "heal" | "hot" }>;
/** Direct channel amounts span the cast; periodic trigger payloads fire each pulse. */
export function channelAmountEffects(ability: AbilityDefinition, totalTicks: number): readonly {
effect: ChannelAmountEffect;
multiplier: number;
}[] {
const ticks = Math.max(1, totalTicks);
const amounts: { effect: ChannelAmountEffect; multiplier: number }[] = [];
for (const effect of ability.effects) {
if (effect.kind === "damage" || effect.kind === "heal" || effect.kind === "dot" || effect.kind === "hot") {
amounts.push({ effect, multiplier: (effect.kind === "dot" || effect.kind === "hot" ? effect.ticks : 1) / ticks });
} else if (effect.kind === "apply-aura") {
for (const proc of effect.aura.procs ?? []) {
const triggers = Array.isArray(proc.triggers) ? proc.triggers : [proc.triggers];
const action = proc.action;
if (!triggers.includes("periodic") || proc.chance !== 1 || proc.internalCooldownMs || proc.charges !== undefined
|| action.kind !== "custom"
|| !["wow335-trigger-spell", "rom-trigger-skill"].includes(action.id)
|| typeof action.data?.spellId !== "number") continue;
const payload = triggeredAbilityBySpellId(action.data.spellId);
for (const payloadEffect of payload?.effects ?? []) {
if (payloadEffect.kind === "damage" || payloadEffect.kind === "heal") {
amounts.push({ effect: payloadEffect, multiplier: 1 });
}
}
}
}
}
return amounts;
}
+65 -19
View File
@@ -129,6 +129,7 @@ import {
} from "./combatAuras"; } from "./combatAuras";
import { rollBossLoot } from "./lootCatalog"; import { rollBossLoot } from "./lootCatalog";
import { adaptRunewakerEnemyDamage } from "./runewakerCombatFormula"; import { adaptRunewakerEnemyDamage } from "./runewakerCombatFormula";
import { channelAmountEffects } from "./combatChannelAmounts";
import { normalizeInventory, type InventoryItem } from "./lootTypes"; import { normalizeInventory, type InventoryItem } from "./lootTypes";
import { import {
PLAYER_EQUIPMENT_OWNER_ID, PLAYER_EQUIPMENT_OWNER_ID,
@@ -721,6 +722,8 @@ export interface CombatState {
damageSummon: (id: string, amount: number) => number; damageSummon: (id: string, amount: number) => number;
healPlayer: (amount: number) => number; healPlayer: (amount: number) => number;
revivePlayer: (percentMaxHealth?: number) => number; revivePlayer: (percentMaxHealth?: number) => number;
/** Checkpoint recovery of the player only; keeps the party and current pull intact. */
respawnPlayerOnly: (position: CombatPosition, now?: number) => boolean;
cancelCast: (message?: string) => boolean; cancelCast: (message?: string) => boolean;
engageNearbyMobs: (now?: number, playerPosition?: CombatPosition) => number; engageNearbyMobs: (now?: number, playerPosition?: CombatPosition) => number;
advanceMobCombat: (now?: number, playerPosition?: CombatPosition) => number; advanceMobCombat: (now?: number, playerPosition?: CombatPosition) => number;
@@ -1183,7 +1186,7 @@ function abilityAttackKind(classId: ClassId, ability: AbilityDefinition): WotlkA
const physicalKeyword = /\b(?:strike|slash|stab|shot|arrow|mangle|claw|bite|weapon|melee)\b/.test(searchable); const physicalKeyword = /\b(?:strike|slash|stab|shot|arrow|mangle|claw|bite|weapon|melee)\b/.test(searchable);
const rangedPhysicalKeyword = /\b(?:shot|arrow|bow|gun|bullet|javelin)\b/.test(searchable); const rangedPhysicalKeyword = /\b(?:shot|arrow|bow|gun|bullet|javelin)\b/.test(searchable);
if (healing) return "spell"; if (healing) return "spell";
if (ability.range.max > 4 && (rangedPhysicalKeyword || baseClassFor(classId) === "hunter")) return "ranged"; if (ability.range.max > 5 && (rangedPhysicalKeyword || baseClassFor(classId) === "hunter")) return "ranged";
if (["warrior", "rogue", "death-knight"].includes(baseClassFor(classId)) || physicalKeyword) return "melee"; if (["warrior", "rogue", "death-knight"].includes(baseClassFor(classId)) || physicalKeyword) return "melee";
return "spell"; return "spell";
} }
@@ -1202,7 +1205,7 @@ export function abilityDamageSchool(classId: ClassId, ability: AbilityDefinition
return "arcane"; return "arcane";
} }
function equipmentAdjustedCombatAmount( export function equipmentAdjustedCombatAmount(
scaling: number | (AbilityAmountScaling & { readonly coefficient: number }), scaling: number | (AbilityAmountScaling & { readonly coefficient: number }),
level: number, level: number,
gearStats: ItemStats, gearStats: ItemStats,
@@ -3586,11 +3589,11 @@ export const useCombatStore = create<CombatState>((set, get) => ({
0, 0,
registration.moveSpeed ?? existing?.moveSpeed ?? (boss ? 3.15 : 3.5), registration.moveSpeed ?? existing?.moveSpeed ?? (boss ? 3.15 : 3.5),
); );
const attackDamage = existing && registration.damageMultiplier !== undefined const attackDamage = existing && level === existing.level && registration.damageMultiplier !== undefined
? Math.max(1, Math.round( ? Math.max(1, Math.round(
existing.attackDamage * (damageMultiplier / Math.max(0.01, existing.damageMultiplier)), existing.attackDamage * (damageMultiplier / Math.max(0.01, existing.damageMultiplier)),
)) ))
: existing?.attackDamage ?? Math.max(1, Math.round( : (existing && level === existing.level ? existing.attackDamage : undefined) ?? Math.max(1, Math.round(
defaults.attackDamage * damageMultiplier, defaults.attackDamage * damageMultiplier,
)); ));
const mob: MobCombatState = existing const mob: MobCombatState = existing
@@ -3618,7 +3621,9 @@ export const useCombatStore = create<CombatState>((set, get) => ({
leashRange, leashRange,
moveSpeed, moveSpeed,
attackDamage, attackDamage,
health: Math.min(existing.health, maxHealth), health: existing.dead || existing.health <= 0
? 0
: clamp(Math.round(existing.health / Math.max(1, existing.maxHealth) * maxHealth), 1, maxHealth),
xpReward, xpReward,
hasLoot, hasLoot,
} }
@@ -6106,12 +6111,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
const sourceActor = state.actors[sourceActorId]; const sourceActor = state.actors[sourceActorId];
const sourceMember = usePartyStore.getState().members.find((member) => member.id === sourceActorId); const sourceMember = usePartyStore.getState().members.find((member) => member.id === sourceActorId);
const amount = sourceMember const amount = sourceMember
? Math.max(1, Math.round((effect.basePoints ?? 0) + Math.max(0.1, effect.coefficient) * ( ? equipmentAdjustedCombatAmount(effect, sourceMember.level, sourceMember.gearStats, sourceMember.classId, ability)
sourceMember.level * 4
+ sourceMember.gearStats.spellPower
+ sourceMember.gearStats.attackPower * 0.35
+ sourceMember.gearStats.rangedAttackPower * 0.35
)))
: Math.max(1, Math.round((effect.basePoints ?? 0) + Math.max(0.1, effect.coefficient) * sourceActor.level * 4)); : Math.max(1, Math.round((effect.basePoints ?? 0) + Math.max(0.1, effect.coefficient) * sourceActor.level * 4));
const next = state.effects.filter((entry) => !( const next = state.effects.filter((entry) => !(
entry.kind === effect.kind entry.kind === effect.kind
@@ -6417,6 +6417,51 @@ export const useCombatStore = create<CombatState>((set, get) => ({
return restored; return restored;
}, },
respawnPlayerOnly: (position, now = combatNow()) => {
const state = get();
if (state.health > 0 || state.maxHealth <= 0 || !Number.isFinite(now)
|| position.length !== 3 || !position.every(Number.isFinite)) return false;
const auras = syncPassiveAuras(state.auras.filter((aura) => (
aura.targetId !== PLAYER_AURA_ENTITY_ID || aura.definition.disposition === "buff"
)), state.abilities, state.level, state.talentRanks, now);
const maxHealth = auraAdjustedPlayerMaxHealth(state.classId, state.level, state.talentModifiers,
state.gearStats, state.raceId, state.secondaryClassId, auras);
const patch: Partial<CombatState> = {
health: maxHealth,
maxHealth,
resource: initialResourceForMaximum(state.classId, state.maxResource),
resourcePools: Object.fromEntries(Object.entries(state.maxResourcePools).map(([type, maximum]) => [
type, type === resourceProfileForClass(state.classId).type
? initialResourceForMaximum(state.classId, maximum ?? state.maxResource) : 0,
])),
controlledUntil: 0,
controlMechanic: null,
schoolLockedUntil: {},
shield: 0,
shieldExpiresAt: 0,
activeCast: null,
pendingGroundTarget: null,
autoAttackTargetId: null,
nextAutoAttackAt: 0,
comboPoints: 0,
comboTargetId: null,
playerPosition: [...position],
playerAnimationEvent: nextAnimationEvent("ability-cancel"),
effects: state.effects.filter((effect) => effect.targetId !== null && effect.targetId !== PLAYER_AURA_ENTITY_ID),
auras,
combatEvents: recordCombatEvent(state.combatEvents, {
occurredAt: now, kind: "resurrection", sourceActorId: PLAYER_AURA_ENTITY_ID,
targetActorId: PLAYER_AURA_ENTITY_ID, abilityId: "player-checkpoint-resurrection",
rawAmount: maxHealth, effectiveAmount: maxHealth, overheal: 0, absorbed: 0, critical: false,
}),
feedback: nextFeedback("success", "You have revived. Your party is still fighting; use Recall only if you want to regroup."),
};
// Publish only the player snapshot; never replace or mirror companion/enemy actors.
const player = combatActorSnapshots({ ...state, ...patch })[PLAYER_AURA_ENTITY_ID];
set({ ...patch, actors: { ...state.actors, [PLAYER_AURA_ENTITY_ID]: player } });
return true;
},
cancelCast: (message = "Casting interrupted.") => { cancelCast: (message = "Casting interrupted.") => {
const cast = get().activeCast; const cast = get().activeCast;
if (!cast) return false; if (!cast) return false;
@@ -8151,30 +8196,31 @@ export const useCombatStore = create<CombatState>((set, get) => ({
: "Channel interrupted: target unavailable."); : "Channel interrupted: target unavailable.");
return; return;
} }
const damageAmount = ability.effects.reduce( const channelEffects = channelAmountEffects(ability, cast.totalTicks);
(total, effect) => total + (effect.kind === "damage" || effect.kind === "dot" const damageAmount = channelEffects.reduce(
(total, { effect, multiplier }) => total + (effect.kind === "damage" || effect.kind === "dot"
? equipmentAdjustedCombatAmount( ? equipmentAdjustedCombatAmount(
effect, effect,
get().level, get().level,
get().gearStats, get().gearStats,
get().classId, get().classId,
ability, ability,
) * (effect.kind === "dot" ? effect.ticks : 1) ) * multiplier
: 0), : 0),
0, 0,
) / cast.totalTicks; );
const healingAmount = ability.effects.reduce( const healingAmount = channelEffects.reduce(
(total, effect) => total + (effect.kind === "heal" || effect.kind === "hot" (total, { effect, multiplier }) => total + (effect.kind === "heal" || effect.kind === "hot"
? equipmentAdjustedCombatAmount( ? equipmentAdjustedCombatAmount(
effect, effect,
get().level, get().level,
get().gearStats, get().gearStats,
get().classId, get().classId,
ability, ability,
) * (effect.kind === "hot" ? effect.ticks : 1) ) * multiplier
: 0), : 0),
0, 0,
) / cast.totalTicks; );
if (damageAmount > 0) { if (damageAmount > 0) {
const channelCenter = channelHostile ? targetPosition ?? cast.origin : get().playerPosition; const channelCenter = channelHostile ? targetPosition ?? cast.origin : get().playerPosition;
const channelTargets = ability.radius === undefined const channelTargets = ability.radius === undefined
+24 -6
View File
@@ -1,23 +1,41 @@
import { useCombatStore } from "./combatStore"; import { useCombatStore, type MobCombatState } from "./combatStore";
import type { CombatActorState } from "./combatActors";
import { requireDungeonDefinition } from "./dungeonRegistry"; import { requireDungeonDefinition } from "./dungeonRegistry";
import { useGameStore } from "./store"; import { useGameStore } from "./store";
import { resolveResurrectionCheckpoint } from "./resurrectionCheckpoints"; import { resolveResurrectionCheckpoint } from "./resurrectionCheckpoints";
import { usePartyStore, type PartyMember } from "./partyStore";
import { import {
activeDungeonCompletedObjectiveIds, activeDungeonCompletedObjectiveIds,
resetDungeonEncounters, resetDungeonEncounters,
} from "./dungeonEncounterRuntime"; } from "./dungeonEncounterRuntime";
/** export function partyFightStillActive(
* Atomically returns a defeated player to the saved run spawn and resets the members: readonly Pick<PartyMember, "id" | "health">[],
* current pull. Defeated enemies and persisted dungeon progress remain clear, mobs: Readonly<Record<string, MobCombatState>>,
* matching a wipe instead of silently starting a brand-new dungeon. actors: Readonly<Record<string, Pick<CombatActorState, "health">>> = {},
*/ ): boolean {
return members.some((member) => (actors[member.id]?.health ?? member.health) > 0)
&& Object.values(mobs).some((mob) => mob.engaged && !mob.dead && !mob.despawned);
}
/** Recover only the player during an ongoing party fight; otherwise retain wipe recovery. */
export function respawnDefeatedPlayer(checkpointId?: string): boolean { export function respawnDefeatedPlayer(checkpointId?: string): boolean {
const combat = useCombatStore.getState(); const combat = useCombatStore.getState();
if (combat.health > 0) return false; if (combat.health > 0) return false;
const game = useGameStore.getState();
if (game.gameMode !== "dungeon") return false;
const checkpoint = checkpointId === undefined ? null : resolveResurrectionCheckpoint(checkpointId); const checkpoint = checkpointId === undefined ? null : resolveResurrectionCheckpoint(checkpointId);
if (checkpointId !== undefined && !checkpoint) return false; if (checkpointId !== undefined && !checkpoint) return false;
if (partyFightStillActive(usePartyStore.getState().members, combat.mobs, combat.actors)) {
const spawn = checkpoint?.spawn ?? game.activeSpawn;
if (!combat.respawnPlayerOnly(spawn.footPosition)) return false;
// Relocation does not fire the resetRevision watched by PartyBridge/PartyPopulation.
useGameStore.getState().relocatePlayer(spawn, true);
useGameStore.getState().closeOverlay();
return true;
}
const dungeonId = useGameStore.getState().activeDungeonId; const dungeonId = useGameStore.getState().activeDungeonId;
const completedObjectiveIds = new Set(activeDungeonCompletedObjectiveIds(dungeonId)); const completedObjectiveIds = new Set(activeDungeonCompletedObjectiveIds(dungeonId));
const retrySpawnIds = new Set( const retrySpawnIds = new Set(
+38 -6
View File
@@ -7,16 +7,22 @@ import {
} from "./dungeonCatalog"; } from "./dungeonCatalog";
import { DUNGEON_MANIFEST } from "./dungeonManifest"; import { DUNGEON_MANIFEST } from "./dungeonManifest";
import { RUNEWAKER_DUNGEON_DEFINITIONS } from "./generated/runewakerDungeonDefinitions.generated"; import { RUNEWAKER_DUNGEON_DEFINITIONS } from "./generated/runewakerDungeonDefinitions.generated";
import { compareDungeonReleaseOrder } from "./dungeonReleaseStage";
import availability from "./generated/dungeonAvailability.json";
describe("dungeon catalog", () => { describe("dungeon catalog", () => {
it("lists the complete generated campaign with authored Wailing Caverns first", () => { it("lists the complete generated campaign with Stormwind Stockades in Beta first", () => {
expect(DUNGEON_CATALOG).toHaveLength(84); expect(DUNGEON_CATALOG).toHaveLength(84);
expect(DUNGEON_CATALOG[0]).toMatchObject({ expect(DUNGEON_CATALOG[0]).toMatchObject({
id: DUNGEON_MANIFEST.id, id: "stormwind-stockade",
releaseStage: "beta",
available: true,
});
expect(dungeonById(DUNGEON_MANIFEST.id)).toMatchObject({
title: DUNGEON_MANIFEST.title, title: DUNGEON_MANIFEST.title,
mapId: DUNGEON_MANIFEST.mapId, mapId: DUNGEON_MANIFEST.mapId,
entranceName: DUNGEON_MANIFEST.entrance.name, entranceName: DUNGEON_MANIFEST.entrance.name,
available: true, releaseStage: "alpha",
}); });
expect(DUNGEON_CATALOG.find((dungeon) => dungeon.id === "deadmines")).toMatchObject({ expect(DUNGEON_CATALOG.find((dungeon) => dungeon.id === "deadmines")).toMatchObject({
id: "deadmines", id: "deadmines",
@@ -33,10 +39,10 @@ describe("dungeon catalog", () => {
it("separates WoW and RoM dungeons with all RuneWaker imports under RoM", () => { it("separates WoW and RoM dungeons with all RuneWaker imports under RoM", () => {
expect(DUNGEON_CATEGORIES.map((category) => category.id)).toEqual(["wow", "rom"]); expect(DUNGEON_CATEGORIES.map((category) => category.id)).toEqual(["wow", "rom"]);
expect(dungeonsByCategory("rom").map((dungeon) => dungeon.id)).toEqual([ expect(dungeonsByCategory("rom").map((dungeon) => dungeon.id).sort()).toEqual([
"forsaken-abbey", "forsaken-abbey",
...RUNEWAKER_DUNGEON_DEFINITIONS.map((definition) => definition.id), ...RUNEWAKER_DUNGEON_DEFINITIONS.map((definition) => definition.id),
]); ].sort());
expect(dungeonsByCategory("wow")).not.toContainEqual( expect(dungeonsByCategory("wow")).not.toContainEqual(
expect.objectContaining({ id: "forsaken-abbey" }), expect.objectContaining({ id: "forsaken-abbey" }),
); );
@@ -46,7 +52,7 @@ describe("dungeon catalog", () => {
it("keeps stable unique ids and resolves only catalogued dungeons", () => { it("keeps stable unique ids and resolves only catalogued dungeons", () => {
const ids = DUNGEON_CATALOG.map((dungeon) => dungeon.id); const ids = DUNGEON_CATALOG.map((dungeon) => dungeon.id);
expect(new Set(ids).size).toBe(ids.length); expect(new Set(ids).size).toBe(ids.length);
expect(dungeonById(DUNGEON_MANIFEST.id)).toBe(DUNGEON_CATALOG[0]); expect(dungeonById(DUNGEON_MANIFEST.id)).toBe(DUNGEON_CATALOG.find((dungeon) => dungeon.id === DUNGEON_MANIFEST.id));
expect(dungeonById("missing-dungeon")).toBeNull(); expect(dungeonById("missing-dungeon")).toBeNull();
}); });
@@ -57,4 +63,30 @@ describe("dungeon catalog", () => {
expect(dungeon.summary.trim()).not.toBe(""); expect(dungeon.summary.trim()).not.toBe("");
} }
}); });
it("keeps only Stockades in Beta and alphabetizes each release group", () => {
expect(DUNGEON_CATALOG.filter((dungeon) => dungeon.releaseStage === "beta").map((dungeon) => dungeon.id))
.toEqual(["stormwind-stockade"]);
expect(DUNGEON_CATALOG.filter((dungeon) => dungeon.id !== "stormwind-stockade").every((dungeon) => dungeon.releaseStage === "alpha"))
.toBe(true);
for (const category of DUNGEON_CATEGORIES) {
const dungeons = dungeonsByCategory(category.id);
expect(dungeons).toEqual([...dungeons].sort(compareDungeonReleaseOrder));
}
const stages = [
{ title: "Zeta", releaseStage: "alpha" as const },
{ title: "Beta Z", releaseStage: "beta" as const },
{ title: "Released Z", releaseStage: "full-release" as const },
{ title: "Alpha", releaseStage: "alpha" as const },
{ title: "Beta A", releaseStage: "beta" as const },
{ title: "Released A", releaseStage: "full-release" as const },
];
expect(stages.sort(compareDungeonReleaseOrder).map((entry) => entry.title))
.toEqual(["Released A", "Released Z", "Beta A", "Beta Z", "Alpha", "Zeta"]);
});
it("shares the complete dungeon identity list used to validate reports on the server", () => {
expect(DUNGEON_CATALOG.map((dungeon) => dungeon.id).sort())
.toEqual(["forsaken-abbey", ...Object.keys(availability.enabled)].sort());
});
}); });
+5 -2
View File
@@ -1,5 +1,6 @@
import { DUNGEON_DEFINITIONS, dungeonCanEnter, type DungeonId } from "./dungeonRegistry"; import { DUNGEON_DEFINITIONS, dungeonCanEnter, type DungeonId } from "./dungeonRegistry";
import { contentCategoryById, type ContentCategoryId } from "../app/contentCategories"; import { contentCategoryById, type ContentCategoryId } from "../app/contentCategories";
import { compareDungeonReleaseOrder, dungeonReleaseStage, type DungeonReleaseStage } from "./dungeonReleaseStage";
const DUNGEON_CATEGORY_IDS = ["wow", "rom"] as const satisfies readonly ContentCategoryId[]; const DUNGEON_CATEGORY_IDS = ["wow", "rom"] as const satisfies readonly ContentCategoryId[];
export type DungeonCategoryId = (typeof DUNGEON_CATEGORY_IDS)[number]; export type DungeonCategoryId = (typeof DUNGEON_CATEGORY_IDS)[number];
@@ -27,10 +28,11 @@ export interface DungeonCatalogEntry {
readonly theme: string; readonly theme: string;
readonly summary: string; readonly summary: string;
readonly available: boolean; readonly available: boolean;
readonly releaseStage: DungeonReleaseStage;
readonly availabilityReason?: string; readonly availabilityReason?: string;
} }
/** Stable browser ordering is the registry order; identity is never duplicated. */ /** Release readiness first, then alphabetical title within each release stage. */
export const DUNGEON_CATALOG: readonly DungeonCatalogEntry[] = Object.freeze( export const DUNGEON_CATALOG: readonly DungeonCatalogEntry[] = Object.freeze(
DUNGEON_DEFINITIONS.map<DungeonCatalogEntry>((definition) => ({ DUNGEON_DEFINITIONS.map<DungeonCatalogEntry>((definition) => ({
id: definition.id as DungeonId, id: definition.id as DungeonId,
@@ -42,8 +44,9 @@ export const DUNGEON_CATALOG: readonly DungeonCatalogEntry[] = Object.freeze(
theme: definition.presentation.theme, theme: definition.presentation.theme,
summary: definition.presentation.summary, summary: definition.presentation.summary,
available: dungeonCanEnter(definition.id), available: dungeonCanEnter(definition.id),
releaseStage: dungeonReleaseStage(definition.id),
availabilityReason: "availabilityReason" in definition ? definition.availabilityReason : undefined, availabilityReason: "availabilityReason" in definition ? definition.availabilityReason : undefined,
})), })).sort(compareDungeonReleaseOrder),
); );
export type { DungeonId } from "./dungeonRegistry"; export type { DungeonId } from "./dungeonRegistry";
@@ -0,0 +1,26 @@
import { readFileSync, writeFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import { CLASSES } from "../app/characterCatalog";
import { DUNGEON_DEFINITIONS } from "./dungeonRegistry";
import { dungeonBossesForDifficulty, finalDungeonBoss } from "./dungeonRunStore";
describe("dungeon leaderboard catalog", () => {
it("keeps the server's final bosses, boss lists, difficulties, and classes aligned with the game", () => {
const catalog = {
dungeons: DUNGEON_DEFINITIONS.map((dungeon) => ({ id: dungeon.id, title: dungeon.title,
finalBossId: finalDungeonBoss(dungeon)?.id ?? "",
bosses: dungeon.bosses.map((boss) => ({ id: boss.id, name: boss.name })),
difficulties: dungeon.difficultyVariants.map((difficulty) => ({ id: difficulty.id, name: difficulty.difficulty,
bossIds: dungeonBossesForDifficulty(dungeon, difficulty.id).map((boss) => boss.id),
finalBossId: finalDungeonBoss(dungeon, difficulty.id)?.id ?? "" })),
defaultDifficultyId: dungeon.defaultDifficultyId,
})).sort((a, b) => a.id.localeCompare(b.id)),
classes: CLASSES.map((entry) => ({ id: entry.id, name: entry.name })),
};
expect(catalog.dungeons).toHaveLength(84);
expect(catalog.dungeons.filter((dungeon) => dungeon.bosses.length > 0 && !dungeon.finalBossId)).toEqual([]);
const path = "src/game/generated/dungeonLeaderboardCatalog.generated.json";
if (process.env.WRITE_DUNGEON_LEADERBOARD_CATALOG === "1") writeFileSync(path, `${JSON.stringify(catalog, null, 2)}\n`);
expect(JSON.parse(readFileSync(path, "utf8"))).toEqual(catalog);
});
});
+16 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { scaledDungeonEnemyLevel } from "./dungeonLevelScaling"; import { dungeonEnemyHealthBaseline, scaledDungeonEnemyLevel } from "./dungeonLevelScaling";
describe("normal dungeon level scaling", () => { describe("normal dungeon level scaling", () => {
it("scales opening-tier trash to the character and gives bosses a one-level edge", () => { it("scales opening-tier trash to the character and gives bosses a one-level edge", () => {
@@ -13,4 +13,19 @@ describe("normal dungeon level scaling", () => {
expect(scaledDungeonEnemyLevel(80, false)).toBe(80); expect(scaledDungeonEnemyLevel(80, false)).toBe(80);
expect(scaledDungeonEnemyLevel(80, true)).toBe(80); expect(scaledDungeonEnemyLevel(80, true)).toBe(80);
}); });
it("grows RoM health with ability ranks and clamps invalid levels", () => {
expect(dungeonEnemyHealthBaseline(1, "runewaker")).toBeGreaterThan(1_000);
expect(dungeonEnemyHealthBaseline(20, "runewaker")).toBeGreaterThan(dungeonEnemyHealthBaseline(8, "runewaker")!);
expect(dungeonEnemyHealthBaseline(70, "runewaker")).toBeGreaterThan(dungeonEnemyHealthBaseline(20, "runewaker")! * 5);
expect(dungeonEnemyHealthBaseline(0, "runewaker")).toBe(dungeonEnemyHealthBaseline(1, "runewaker"));
expect(dungeonEnemyHealthBaseline(100, "runewaker")).toBe(dungeonEnemyHealthBaseline(80, "runewaker"));
expect(dungeonEnemyHealthBaseline(Number.NaN, "runewaker")).toBe(dungeonEnemyHealthBaseline(1, "runewaker"));
});
it("retains native WoW creature health across its three expansions", () => {
for (const expansion of ["classic", "burning-crusade", "wrath"] as const) {
expect(dungeonEnemyHealthBaseline(20, expansion)).toBeUndefined();
}
});
}); });
+13
View File
@@ -1,4 +1,5 @@
import { clampPlayerLevel } from "./progression"; import { clampPlayerLevel } from "./progression";
import type { DungeonExpansion } from "./dungeonTypes";
/** /**
* Normal campaign dungeons scale to the character instead of retaining the * Normal campaign dungeons scale to the character instead of retaining the
@@ -8,3 +9,15 @@ import { clampPlayerLevel } from "./progression";
export function scaledDungeonEnemyLevel(characterLevel: number, boss: boolean): number { export function scaledDungeonEnemyLevel(characterLevel: number, boss: boolean): number {
return clampPlayerLevel(characterLevel + (boss ? 1 : 0)); return clampPlayerLevel(characterLevel + (boss ? 1 : 0));
} }
/**
* RoM creatures do not have packaged native health tables. Give their dungeon
* baseline enough growth for a full party's rank-scaled abilities; the old
* 72 + level * 22 solo-target fallback was overwhelmed by the opening volley.
* Authored creature and boss health multipliers are applied separately.
*/
export function dungeonEnemyHealthBaseline(level: number, expansion: DungeonExpansion): number | undefined {
if (expansion !== "runewaker") return undefined;
const safeLevel = clampPlayerLevel(level);
return 1_000 + safeLevel * 100 + safeLevel * safeLevel * 8;
}
+4
View File
@@ -7,6 +7,7 @@ import compressedCampaign from "./generated/dungeonCampaignCatalog.compressed";
import { gunzipSync, strFromU8 } from "fflate"; import { gunzipSync, strFromU8 } from "fflate";
import { DEFAULT_DUNGEON_ID as DEFAULT_DUNGEON_KEY } from "./dungeonDefaults"; import { DEFAULT_DUNGEON_ID as DEFAULT_DUNGEON_KEY } from "./dungeonDefaults";
import { withRunewakerEncounterContent } from "./runewakerEncounterCatalog"; import { withRunewakerEncounterContent } from "./runewakerEncounterCatalog";
import { withRunewakerCompletion } from "./runewakerCompletion";
import { withWowEncounterContent } from "./wowEncounterContentCatalog"; import { withWowEncounterContent } from "./wowEncounterContentCatalog";
import { withWowProgressionObjectContent } from "./wowProgressionObjectContentCatalog"; import { withWowProgressionObjectContent } from "./wowProgressionObjectContentCatalog";
import { withAscensionCustomEncounterContent } from "./ascensionCustomEncounterCatalog"; import { withAscensionCustomEncounterContent } from "./ascensionCustomEncounterCatalog";
@@ -19,6 +20,7 @@ import { withWowVerifiedNoOpInstanceContent } from "./wowVerifiedNoOpInstanceCat
import { withWowClassicEncounterBatchTwo } from "./wowClassicEncounterBatchTwoCatalog"; import { withWowClassicEncounterBatchTwo } from "./wowClassicEncounterBatchTwoCatalog";
import { withWowClassicEncounterBatchThree } from "./wowClassicEncounterBatchThreeCatalog"; import { withWowClassicEncounterBatchThree } from "./wowClassicEncounterBatchThreeCatalog";
import { withWowTbcEncounterBatchTwoContent } from "./wowTbcEncounterBatchTwoCatalog"; import { withWowTbcEncounterBatchTwoContent } from "./wowTbcEncounterBatchTwoCatalog";
import { withWowDungeonEnvironment } from "./wowDungeonEnvironment";
export { DEFAULT_DUNGEON_ID } from "./dungeonDefaults"; export { DEFAULT_DUNGEON_ID } from "./dungeonDefaults";
@@ -151,6 +153,7 @@ if (authoredWailingCaverns.id !== DEFAULT_DUNGEON_KEY) {
const DUNGEON_CONTENT_ADAPTERS: readonly ( const DUNGEON_CONTENT_ADAPTERS: readonly (
(definition: DungeonDefinition) => DungeonDefinition (definition: DungeonDefinition) => DungeonDefinition
)[] = Object.freeze([ )[] = Object.freeze([
withWowDungeonEnvironment,
withWowEncounterContent, withWowEncounterContent,
withWowClassicEncounterContent, withWowClassicEncounterContent,
withWowClassicEncounterBatchTwo, withWowClassicEncounterBatchTwo,
@@ -164,6 +167,7 @@ const DUNGEON_CONTENT_ADAPTERS: readonly (
withWowTbcEncounterBatchTwoContent, withWowTbcEncounterBatchTwoContent,
withAscensionCustomEncounterContent, withAscensionCustomEncounterContent,
withRunewakerEncounterContent, withRunewakerEncounterContent,
withRunewakerCompletion,
]); ]);
function applyDungeonContentAdapters(definition: DungeonDefinition): DungeonDefinition { function applyDungeonContentAdapters(definition: DungeonDefinition): DungeonDefinition {
+20
View File
@@ -0,0 +1,20 @@
export const DUNGEON_RELEASE_STAGES = ["full-release", "beta", "alpha"] as const;
export type DungeonReleaseStage = (typeof DUNGEON_RELEASE_STAGES)[number];
export const DUNGEON_RELEASE_STAGE_LABELS: Readonly<Record<DungeonReleaseStage, string>> = {
"full-release": "Full Release",
beta: "Beta",
alpha: "Alpha",
};
export function dungeonReleaseStage(id: string): DungeonReleaseStage {
return id === "stormwind-stockade" ? "beta" : "alpha";
}
export function compareDungeonReleaseOrder(
left: { readonly title: string; readonly releaseStage: DungeonReleaseStage },
right: { readonly title: string; readonly releaseStage: DungeonReleaseStage },
): number {
return DUNGEON_RELEASE_STAGES.indexOf(left.releaseStage) - DUNGEON_RELEASE_STAGES.indexOf(right.releaseStage)
|| left.title.localeCompare(right.title, "en", { sensitivity: "base" });
}
+8
View File
@@ -14,6 +14,14 @@ const definition: DungeonEncounterDefinition = {
}; };
describe("dungeon run checkpoints", () => { describe("dungeon run checkpoints", () => {
it("round-trips a saved timer and rejects a timer belonging to another dungeon", () => {
const input = { version: DUNGEON_RUN_CHECKPOINT_VERSION, dungeonId: "test", difficultyId: "normal",
checkpointId: "entrance", spawn: { footPosition: [1, 2, 3], yaw: 0.5 }, encounters: [], savedAt: 123,
run: { id: "checkpoint-timer", dungeonId: "test", difficultyId: "normal", startedAt: 100,
finishedAt: null, durationMs: null, timed: true, killedBossIds: [], party: [] } };
expect(normalizeDungeonRunCheckpoint(JSON.parse(JSON.stringify(input)))?.run).toEqual(input.run);
expect(normalizeDungeonRunCheckpoint({ ...input, run: { ...input.run, dungeonId: "other" } })).toBeNull();
});
it("normalizes JSON-safe encounter progress and world spawn", () => { it("normalizes JSON-safe encounter progress and world spawn", () => {
const result = normalizeDungeonRunCheckpoint({ const result = normalizeDungeonRunCheckpoint({
version: DUNGEON_RUN_CHECKPOINT_VERSION, version: DUNGEON_RUN_CHECKPOINT_VERSION,
+5
View File
@@ -1,6 +1,7 @@
import type { DungeonEncounterSnapshot } from "./dungeonEncounterTypes"; import type { DungeonEncounterSnapshot } from "./dungeonEncounterTypes";
import { DUNGEON_ENCOUNTER_SNAPSHOT_SCHEMA_VERSION } from "./dungeonEncounterTypes"; import { DUNGEON_ENCOUNTER_SNAPSHOT_SCHEMA_VERSION } from "./dungeonEncounterTypes";
import type { Vector3Tuple, WorldSpawn } from "./dungeonTypes"; import type { Vector3Tuple, WorldSpawn } from "./dungeonTypes";
import { normalizeDungeonRunProgress, type DungeonRunProgress } from "./dungeonRunTypes";
// Version 1 snapshots sorted consumed action ids and cannot be migrated // Version 1 snapshots sorted consumed action ids and cannot be migrated
// reliably when multiple durable actions write the same state slot. Failing // reliably when multiple durable actions write the same state slot. Failing
@@ -15,6 +16,7 @@ export interface DungeonRunCheckpoint {
readonly spawn: WorldSpawn; readonly spawn: WorldSpawn;
readonly encounters: readonly DungeonEncounterSnapshot[]; readonly encounters: readonly DungeonEncounterSnapshot[];
readonly savedAt: number; readonly savedAt: number;
readonly run?: DungeonRunProgress;
} }
function finiteVector(value: unknown): Vector3Tuple | null { function finiteVector(value: unknown): Vector3Tuple | null {
@@ -115,6 +117,8 @@ export function normalizeDungeonRunCheckpoint(value: unknown): DungeonRunCheckpo
) return null; ) return null;
const encounters = input.encounters.map(normalizeSnapshot); const encounters = input.encounters.map(normalizeSnapshot);
if (encounters.some((entry) => entry === null)) return null; if (encounters.some((entry) => entry === null)) return null;
const run = input.run === undefined ? undefined : normalizeDungeonRunProgress(input.run);
if (run === null || (run && (run.dungeonId !== input.dungeonId || run.difficultyId !== input.difficultyId))) return null;
return Object.freeze({ return Object.freeze({
version: DUNGEON_RUN_CHECKPOINT_VERSION, version: DUNGEON_RUN_CHECKPOINT_VERSION,
dungeonId: input.dungeonId, dungeonId: input.dungeonId,
@@ -123,5 +127,6 @@ export function normalizeDungeonRunCheckpoint(value: unknown): DungeonRunCheckpo
spawn: { footPosition: position, yaw }, spawn: { footPosition: position, yaw },
encounters: Object.freeze(encounters as DungeonEncounterSnapshot[]), encounters: Object.freeze(encounters as DungeonEncounterSnapshot[]),
savedAt: input.savedAt, savedAt: input.savedAt,
...(run ? { run } : {}),
}); });
} }
@@ -0,0 +1,95 @@
import { mkdirSync, writeFileSync } from "node:fs";
import { expect, it } from "vitest";
import { createCharacter } from "../app/accountRepository";
import { randomAppearance } from "../app/characterCatalog";
import { abilitiesForClass } from "./abilityCatalog";
import { useCombatStore } from "./combatStore";
import { subscribeCombatEvents } from "./combatEvents";
import { deriveDungeonEncounterCombatEvents, type DungeonEncounterMobCursor } from "./dungeonEncounterCombatEvents";
import { activateDungeonEncounterDefinitions, activeDungeonCompletedObjectiveIds, activeDungeonRunIsComplete,
advanceDungeonEncounters, dispatchActiveDungeonEntityEvent, exportDungeonEncounterCheckpointSnapshots, resetDungeonEncounters } from "./dungeonEncounterRuntime";
import { requireDungeonDefinition } from "./dungeonRegistry";
import { dungeonEnemyHealthBaseline, scaledDungeonEnemyLevel } from "./dungeonLevelScaling";
import { completedDungeonRun, finalDungeonBoss, useDungeonRunStore } from "./dungeonRunStore";
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
import { usePartyStore } from "./partyStore";
import { advanceDungeonPartyCombat, partyCombatRangeBand, resetPartyRuntimeClock } from "./partyRuntime";
import { clearPartyRuntimeRegistry, registerPartyRuntimePosition, updatePartyRuntimeTargetVisibility } from "./partyRuntimeRegistry";
import { useGameStore } from "./store";
it("turns an actual RoM party's lethal damage to Anglo into one timed completion and a resumable result", () => {
const dungeon = requireDungeonDefinition("barren-caves");
const boss = finalDungeonBoss(dungeon)!;
const spawn = dungeon.staticSpawns.find((entry) => entry.id === boss.id)!;
const entity = dungeon.entities[spawn.entityId];
const bossCombat = entity.combat;
if (!bossCombat) throw new Error("Anglo must have a combat definition");
const healthMultiplier = bossCombat.healthMultiplier ?? 1;
clearMobRuntimeRegistry(); clearPartyRuntimeRegistry(); resetPartyRuntimeClock();
useGameStore.setState({ activeDungeonId: dungeon.id, activeDifficultyId: "normal", gameMode: "dungeon", gmTestMode: false });
activateDungeonEncounterDefinitions(dungeon.id, dungeon.encounters ?? [], "normal");
resetDungeonEncounters("dungeon", dungeon.id);
const combat = useCombatStore.getState();
combat.initializeCharacter({ classId: "rom-priest", raceId: "rom-human", level: 1 });
combat.setPlayerPosition([-18, 0, 0]);
usePartyStore.getState().initializeParty("completion-run", 1, useCombatStore.getState().health, "healer", 5, "rom");
combat.synchronizeActors();
const bossLevel = scaledDungeonEnemyLevel(1, true);
combat.registerMob(boss.id, { name: entity.name, boss: true, level: bossLevel, xpReward: 0,
maxHealth: dungeonEnemyHealthBaseline(bossLevel, "runewaker")! * healthMultiplier,
healthMultiplier, attacks: bossCombat.attacks });
setMobPosition(boss.id, [0, 0, 0]);
for (const member of usePartyStore.getState().members) {
registerPartyRuntimePosition(member.id, [-partyCombatRangeBand(member).preferred, 0, 0]);
updatePartyRuntimeTargetVisibility(member.id, boss.id, true);
}
const party = () => [{ name: "Runview", classId: "rom-priest", level: 1, role: "healer", npc: false },
...usePartyStore.getState().members.map((member) => ({ name: member.name, classId: member.classId,
level: member.level, role: member.role, npc: true }))];
const epoch = Date.now() - 181000;
const run = useDungeonRunStore.getState(); run.begin(dungeon.id, "normal"); run.start(epoch, party());
combat.damageMob(boss.id, 1, epoch);
const heal = abilitiesForClass("rom-priest").find((ability) => ability.name === "Urgent Heal")!;
let cursor: DungeonEncounterMobCursor = {};
let npcDamage = 0; let healing = 0; let deaths = 0;
const stop = subscribeCombatEvents((event) => {
if (event.kind === "damage" && event.targetActorId === boss.id && event.sourceActorId !== "player") npcDamage += event.effectiveAmount;
if (event.kind === "healing") healing += event.effectiveAmount;
});
try {
for (let now = epoch + 100; now <= epoch + 181000; now += 100) {
combat.tick(.1, now); advanceDungeonPartyCombat(now); combat.advanceMobCombat(now);
const tank = usePartyStore.getState().members.find((member) => member.role === "tank")!;
if (tank.health < tank.maxHealth * .65 && !useCombatStore.getState().activeCast) {
usePartyStore.getState().selectMember(tank.id); combat.castAbility(heal.id, undefined, now);
}
const mobs = useCombatStore.getState().mobs;
const events = deriveDungeonEncounterCombatEvents(cursor, mobs, now); cursor = events.cursor;
for (const event of events.events) { dispatchActiveDungeonEntityEvent(event); if (event.kind === "entity-death") deaths += 1; }
const dead = Object.values(mobs).filter((mob) => mob.dead).map((mob) => mob.id);
advanceDungeonEncounters(dungeon.id, { kind: "tick", now, facts: { registeredEntityIds: Object.keys(mobs), deadEntityIds: dead } });
run.advance(activeDungeonCompletedObjectiveIds(dungeon.id), dead, now, party());
if (useDungeonRunStore.getState().progress?.finishedAt !== null) break;
}
const progress = useDungeonRunStore.getState().progress!;
expect(useCombatStore.getState().mobs[boss.id].dead).toBe(true);
expect(activeDungeonRunIsComplete(dungeon.id)).toBe(true);
expect(npcDamage).toBeGreaterThan(0); expect(healing).toBeGreaterThan(0); expect(deaths).toBe(1);
expect(completedDungeonRun(progress)).toMatchObject({ fullClear: true, killedBossIds: [boss.id] });
expect(progress.durationMs).toBeGreaterThan(20000);
expect(usePartyStore.getState().members.every((member) => member.health > 0)).toBe(true);
const snapshots = exportDungeonEncounterCheckpointSnapshots(dungeon.id, 0);
if (process.env.DUNGEON_RUN_REPORT === "1") {
const values = new Map<string, string>();
const character = createCharacter({ ownerId: "qa-owner", name: "Runview", raceId: "rom-human", classId: "rom-priest",
categoryId: "rom", gender: "female", appearance: randomAppearance("rom-human", "female", () => 0) },
{ getItem: (key) => values.get(key) ?? null, setItem: (key, value) => { values.set(key, value); },
removeItem: (key) => { values.delete(key); } }).character!;
mkdirSync("artifacts/dungeon-runs", { recursive: true });
writeFileSync("artifacts/dungeon-runs/combat-completion.json", `${JSON.stringify({ npcDamage, healing, deaths, progress,
character: { ...character, dungeonRunCheckpoint: { version: 2, dungeonId: dungeon.id, difficultyId: "normal",
checkpointId: "entrance", spawn: { footPosition: dungeon.entrance.footPosition, yaw: dungeon.entrance.yaw },
encounters: snapshots, savedAt: Date.now(), run: progress } } }, null, 2)}\n`);
}
} finally { stop(); clearMobRuntimeRegistry(); clearPartyRuntimeRegistry(); }
});
+96
View File
@@ -0,0 +1,96 @@
import { beforeEach, describe, expect, it } from "vitest";
import { DUNGEON_DEFINITIONS, requireDungeonDefinition } from "./dungeonRegistry";
import { completedDungeonRun, finalDungeonBoss, useDungeonRunStore } from "./dungeonRunStore";
import { normalizeDungeonRunProgress, formatDungeonDuration, type DungeonRunMember } from "./dungeonRunTypes";
import { activateDungeonEncounterDefinitions, activeDungeonCompletedObjectiveIds, activeDungeonRunIsComplete,
advanceDungeonEncounters, createDungeonEncounterSnapshot, exportDungeonEncounterCheckpointSnapshots,
importDungeonEncounterSnapshots, reduceDungeonEncounter, resetDungeonEncounters } from "./dungeonEncounterRuntime";
const party: readonly DungeonRunMember[] = [{ name: "Tester", classId: "rom-priest", level: 1, role: "healer", npc: false }];
beforeEach(() => { useDungeonRunStore.setState({ progress: null }); });
describe("dungeon completion and run timing", () => {
it("completes Barren Caves when Anglo dies, records its real spawn id, and survives checkpoint hydration", () => {
const dungeon = requireDungeonDefinition("barren-caves");
activateDungeonEncounterDefinitions(dungeon.id, dungeon.encounters ?? [], "normal");
resetDungeonEncounters("dungeon", dungeon.id);
const run = useDungeonRunStore.getState();
run.begin(dungeon.id, "normal"); run.start(1000, party);
advanceDungeonEncounters(dungeon.id, { kind: "tick", now: 1000, facts: { registeredEntityIds: [], deadEntityIds: [] } });
expect(activeDungeonRunIsComplete(dungeon.id)).toBe(false);
const boss = finalDungeonBoss(dungeon)!;
expect(boss.name).toBe("Anglo");
advanceDungeonEncounters(dungeon.id, { kind: "tick", now: 12456,
facts: { registeredEntityIds: [boss.id], deadEntityIds: [boss.id] } });
run.advance(activeDungeonCompletedObjectiveIds(dungeon.id), [boss.id], 12456, party);
expect(activeDungeonRunIsComplete(dungeon.id)).toBe(true);
expect(completedDungeonRun(useDungeonRunStore.getState().progress!)).toMatchObject({
durationMs: 11456, fullClear: true, killedBossIds: [boss.id], party,
});
const snapshots = exportDungeonEncounterCheckpointSnapshots(dungeon.id, 0);
resetDungeonEncounters("dungeon", dungeon.id);
importDungeonEncounterSnapshots(dungeon.id, snapshots, { authoritative: true });
expect(activeDungeonRunIsComplete(dungeon.id)).toBe(true);
const result = useDungeonRunStore.getState().progress;
run.advance([boss.id], [boss.id], 99999, []); run.start(99999, []);
expect(useDungeonRunStore.getState().progress).toBe(result);
});
it("adds durable completion bookkeeping to every RoM dungeon with configured bosses, without awarding missing bosses", () => {
for (const dungeon of DUNGEON_DEFINITIONS.filter((entry) => entry.expansion === "runewaker" && entry.bosses.length > 0)) {
const baseline = dungeon.encounters!.find((entry) => entry.id === `${dungeon.id}:completion-baseline`)!;
expect(baseline, dungeon.id).toBeDefined();
let snapshot = createDungeonEncounterSnapshot(baseline, 0);
snapshot = reduceDungeonEncounter(baseline, snapshot, { kind: "tick", now: 10,
facts: { registeredEntityIds: [], deadEntityIds: [] } }).snapshot;
expect(snapshot.flags["dungeon-complete"], dungeon.id).toBe(false);
const ordinary = [...new Set([...dungeon.objectiveOrder, ...dungeon.bosses]
.filter((boss) => !boss.eventOnly).map((boss) => boss.id))];
snapshot = reduceDungeonEncounter(baseline, snapshot, { kind: "tick", now: 20,
facts: { registeredEntityIds: ordinary, deadEntityIds: ordinary } }).snapshot;
expect(snapshot.completedObjectiveIds, dungeon.id).toEqual(expect.arrayContaining(ordinary));
if (!dungeon.objectiveOrder.some((boss) => !boss.optional && boss.eventOnly)) {
expect(snapshot.flags["dungeon-complete"], dungeon.id).toBe(true);
}
}
});
it("finishes a final boss clear even when earlier bosses were skipped, but keeps it out of full clears", () => {
const dungeon = requireDungeonDefinition("deadmines");
const final = finalDungeonBoss(dungeon)!;
const run = useDungeonRunStore.getState();
run.begin(dungeon.id, dungeon.defaultDifficultyId); run.start(1000, party);
run.advance([], [final.id], 2000, party);
expect(useDungeonRunStore.getState().progress?.finishedAt).toBeNull();
run.advance([final.id], [final.id], 3000, party);
expect(completedDungeonRun(useDungeonRunStore.getState().progress!)).toMatchObject({ durationMs: 2000, fullClear: false });
});
it("keeps the original wall-clock timer and boss history through a wipe and a saved resume", () => {
const dungeon = requireDungeonDefinition("deadmines");
const run = useDungeonRunStore.getState();
run.begin(dungeon.id, "normal"); run.start(1000, party);
run.advance([], [dungeon.bosses[0].id], 2000, party);
const saved = normalizeDungeonRunProgress(JSON.parse(JSON.stringify(useDungeonRunStore.getState().progress)))!;
run.begin(dungeon.id, "normal", saved); run.start(5000, party);
run.advance([finalDungeonBoss(dungeon)!.id], [], 9000, party);
expect(useDungeonRunStore.getState().progress).toMatchObject({ id: saved.id, startedAt: 1000,
durationMs: 8000, killedBossIds: expect.arrayContaining([dungeon.bosses[0].id]) });
});
it("announces legacy resumed runs without inventing a leaderboard time", () => {
const run = useDungeonRunStore.getState(); run.begin("barren-caves", "normal", undefined, true);
run.start(1000, party); run.advance([finalDungeonBoss(requireDungeonDefinition("barren-caves"))!.id], [], 2000, party);
expect(useDungeonRunStore.getState().progress?.finishedAt).toBe(2000);
expect(completedDungeonRun(useDungeonRunStore.getState().progress!)).toBeNull();
});
it("rejects corrupted saved timers and impossible party levels", () => {
useDungeonRunStore.getState().begin("barren-caves", "normal");
const progress = useDungeonRunStore.getState().progress!;
expect(normalizeDungeonRunProgress({ ...progress, startedAt: NaN })).toBeNull();
expect(normalizeDungeonRunProgress({ ...progress, startedAt: 100, finishedAt: 50, durationMs: 10 })).toBeNull();
expect(normalizeDungeonRunProgress({ ...progress, party: [{ ...party[0], level: 81 }] })).toBeNull();
expect(formatDungeonDuration(123456)).toBe("02:03.4");
});
});
+67
View File
@@ -0,0 +1,67 @@
import { create } from "zustand";
import { dungeonDefinitionById } from "./dungeonRegistry";
import type { DungeonDefinition } from "./dungeonTypes";
import type { DungeonRunMember, DungeonRunProgress, DungeonRunRecord } from "./dungeonRunTypes";
export function dungeonBossesForDifficulty(definition: DungeonDefinition, difficultyId = definition.defaultDifficultyId) {
const difficulty = definition.difficultyVariants.find((variant) => variant.id === difficultyId);
return definition.bosses.filter((boss) => {
const spawn = definition.staticSpawns.find((entry) => entry.id === boss.id);
return !spawn || (((spawn.spawnMask ?? 1) & (difficulty?.spawnMask ?? 1)) !== 0);
});
}
export function finalDungeonBoss(definition: DungeonDefinition, difficultyId = definition.defaultDifficultyId) {
const bosses = dungeonBossesForDifficulty(definition, difficultyId);
const bossIds = new Set(bosses.map((boss) => boss.id));
return definition.objectiveOrder.filter((objective) => !objective.optional && bossIds.has(objective.id)).at(-1)
?? bosses.filter((boss) => !boss.optional).at(-1);
}
export function completedDungeonRun(progress: DungeonRunProgress): DungeonRunRecord | null {
const definition = dungeonDefinitionById(progress.dungeonId);
if (!definition || !progress.timed || progress.finishedAt === null || progress.durationMs === null) return null;
return { id: progress.id, dungeonId: progress.dungeonId, difficultyId: progress.difficultyId,
finishedAt: progress.finishedAt, durationMs: progress.durationMs, killedBossIds: progress.killedBossIds,
party: progress.party, fullClear: dungeonBossesForDifficulty(definition, progress.difficultyId)
.every((boss) => progress.killedBossIds.includes(boss.id)) };
}
interface DungeonRunState {
readonly progress: DungeonRunProgress | null;
begin: (dungeonId: string, difficultyId: string, resume?: DungeonRunProgress, legacyResume?: boolean) => void;
start: (now: number, party: readonly DungeonRunMember[]) => void;
advance: (completedObjectiveIds: readonly string[], deadBossIds: readonly string[], now: number,
party: readonly DungeonRunMember[]) => void;
}
export const useDungeonRunStore = create<DungeonRunState>((set, get) => ({
progress: null,
begin: (dungeonId, difficultyId, resume, legacyResume = false) => set({
progress: resume?.dungeonId === dungeonId && resume.difficultyId === difficultyId ? resume : {
id: globalThis.crypto.randomUUID(), dungeonId, difficultyId, startedAt: null,
finishedAt: null, durationMs: null, killedBossIds: [], party: [], timed: !legacyResume,
},
}),
start: (now, party) => {
const progress = get().progress;
if (!progress || progress.startedAt !== null || party.length === 0) return;
set({ progress: { ...progress, startedAt: now, party: [...party] } });
},
advance: (completedObjectiveIds, deadBossIds, now, party) => {
const progress = get().progress;
if (!progress || progress.startedAt === null || progress.finishedAt !== null) return;
const definition = dungeonDefinitionById(progress.dungeonId);
if (!definition) return;
const bossIds = new Set(definition.bosses.map((boss) => boss.id));
const killedBossIds = [...new Set([...progress.killedBossIds, ...completedObjectiveIds, ...deadBossIds])]
.filter((id) => bossIds.has(id));
const finalBoss = finalDungeonBoss(definition, progress.difficultyId);
// A scripted final boss must award its objective; despawns and raw corpse states cannot complete it.
const finished = finalBoss !== undefined && completedObjectiveIds.includes(finalBoss.id);
if (!finished && killedBossIds.length === progress.killedBossIds.length) return;
set({ progress: { ...progress, killedBossIds,
...(finished ? { finishedAt: Math.max(now, progress.startedAt), durationMs: Math.max(1, Math.round(now - progress.startedAt)),
party: [...party] } : {}) } });
},
}));
+78
View File
@@ -0,0 +1,78 @@
export interface DungeonRunMember {
readonly name: string;
readonly classId: string;
readonly level: number;
readonly secondaryClassId?: string;
readonly role: string;
readonly npc: boolean;
}
export interface DungeonRunProgress {
readonly id: string;
readonly dungeonId: string;
readonly difficultyId: string;
readonly startedAt: number | null;
readonly finishedAt: number | null;
readonly durationMs: number | null;
readonly timed: boolean;
readonly killedBossIds: readonly string[];
readonly party: readonly DungeonRunMember[];
}
export interface DungeonRunRecord {
readonly id: string;
readonly dungeonId: string;
readonly difficultyId: string;
readonly durationMs: number;
readonly finishedAt: number;
readonly killedBossIds: readonly string[];
readonly party: readonly DungeonRunMember[];
readonly fullClear: boolean;
}
export type DungeonClearFilter = "full" | "final";
export interface RankedDungeonRun extends DungeonRunRecord { readonly rank: number }
export interface DungeonLeaderboard {
readonly entries: readonly RankedDungeonRun[];
readonly total: number;
}
export interface DungeonRunPlacement {
readonly finalRank: number;
readonly finalTotal: number;
readonly fullRank: number | null;
readonly fullTotal: number;
}
export function formatDungeonDuration(durationMs: number): string {
const tenths = Math.floor(Math.max(0, durationMs) / 100);
const seconds = Math.floor(tenths / 10);
const minutes = Math.floor(seconds / 60);
return `${String(minutes).padStart(2, "0")}:${String(seconds % 60).padStart(2, "0")}.${tenths % 10}`;
}
/** Untrusted checkpoint/local-storage data must never reset or invent a ranked timer. */
export function normalizeDungeonRunProgress(value: unknown): DungeonRunProgress | null {
if (!value || typeof value !== "object") return null;
const input = value as DungeonRunProgress;
const text = (entry: unknown) => typeof entry === "string" && entry.length > 0 && entry.length <= 100;
const time = (entry: unknown) => entry === null || (typeof entry === "number" && Number.isFinite(entry) && entry >= 0);
if (!text(input.id) || !text(input.dungeonId) || !text(input.difficultyId)
|| !time(input.startedAt) || !time(input.finishedAt) || !time(input.durationMs)
|| typeof input.timed !== "boolean" || !Array.isArray(input.killedBossIds)
|| input.killedBossIds.length > 100 || !input.killedBossIds.every(text)
|| !Array.isArray(input.party) || input.party.length > 5
|| !input.party.every((member) => member && text(member.name) && text(member.classId)
&& Number.isInteger(member.level) && member.level >= 1 && member.level <= 80
&& ["tank", "healer", "damage"].includes(member.role) && typeof member.npc === "boolean"
&& (member.secondaryClassId === undefined || text(member.secondaryClassId)))
|| (input.finishedAt !== null && (input.startedAt === null || input.finishedAt < input.startedAt))
|| (input.durationMs !== null && (input.finishedAt === null || input.durationMs < 1))
|| (input.finishedAt !== null && (input.durationMs === null || input.party.length === 0
|| input.durationMs !== Math.max(1, Math.round(input.finishedAt - input.startedAt!))))) return null;
return { id: input.id, dungeonId: input.dungeonId, difficultyId: input.difficultyId,
startedAt: input.startedAt, finishedAt: input.finishedAt, durationMs: input.durationMs,
timed: input.timed, killedBossIds: [...new Set(input.killedBossIds)],
party: input.party.map((member) => ({ name: member.name, classId: member.classId,
level: member.level, role: member.role, npc: member.npc,
...(member.secondaryClassId ? { secondaryClassId: member.secondaryClassId } : {}) })) };
}
+8
View File
@@ -18,6 +18,8 @@ import {
} from "./dungeonEncounterRuntime"; } from "./dungeonEncounterRuntime";
import type { DungeonEncounterSnapshot } from "./dungeonEncounterTypes"; import type { DungeonEncounterSnapshot } from "./dungeonEncounterTypes";
import { dungeonContentIsUsable } from "../content/contentManager"; import { dungeonContentIsUsable } from "../content/contentManager";
import { useDungeonRunStore } from "./dungeonRunStore";
import type { DungeonRunProgress } from "./dungeonRunTypes";
export interface DungeonSessionOptions { export interface DungeonSessionOptions {
readonly gameMode?: GameMode; readonly gameMode?: GameMode;
@@ -28,6 +30,8 @@ export interface DungeonSessionOptions {
readonly preserveCharacter?: boolean; readonly preserveCharacter?: boolean;
/** Hydrates save-safe state after definitions have been activated and reset. */ /** Hydrates save-safe state after definitions have been activated and reset. */
readonly encounterSnapshots?: readonly DungeonEncounterSnapshot[]; readonly encounterSnapshots?: readonly DungeonEncounterSnapshot[];
readonly runProgress?: DungeonRunProgress;
readonly legacyResume?: boolean;
} }
/** /**
@@ -47,6 +51,10 @@ export function activateDungeonSession(
const previousDefinition = dungeonDefinitionById(useGameStore.getState().activeDungeonId); const previousDefinition = dungeonDefinitionById(useGameStore.getState().activeDungeonId);
if (previousDefinition) releaseDungeonGltfCache(previousDefinition); if (previousDefinition) releaseDungeonGltfCache(previousDefinition);
if (!options.preserveCharacter) {
useDungeonRunStore.getState().begin(dungeonId, difficultyId ?? definition.defaultDifficultyId,
options.runProgress, options.legacyResume);
}
activateDungeonEncounterDefinitions( activateDungeonEncounterDefinitions(
dungeonId, dungeonId,
definition.encounters ?? [], definition.encounters ?? [],
+2 -2
View File
@@ -37,8 +37,8 @@ describe("reviewed dungeon stair proxies", () => {
it("bridges exactly the existing reviewed portal vertices and rejects stale asset coordinates", () => { it("bridges exactly the existing reviewed portal vertices and rejects stale asset coordinates", () => {
const geometry = new BufferGeometry(); const geometry = new BufferGeometry();
geometry.setAttribute("position", new Float32BufferAttribute([ geometry.setAttribute("position", new Float32BufferAttribute([
...RAMP.navigationBridge.startPortal.flat(), ...RAMP.navigationBridge!.startPortal.flat(),
...RAMP.navigationBridge.endPortal.flat(), ...RAMP.navigationBridge!.endPortal.flat(),
], 3)); ], 3));
geometry.setIndex([]); geometry.setIndex([]);
smoothDungeonStairGeometry(geometry, [RAMP], 0.05); smoothDungeonStairGeometry(geometry, [RAMP], 0.05);
+42 -8
View File
@@ -6,7 +6,7 @@ import type { DungeonTraversalRamp, Vector3Tuple } from "./dungeonTypes";
const MAX_STAIR_SURFACE_DELTA = 0.85; const MAX_STAIR_SURFACE_DELTA = 0.85;
const PORTAL_VERTEX_TOLERANCE = 0.002; const PORTAL_VERTEX_TOLERANCE = 0.002;
function rampHeight( export function dungeonRampHeight(
ramp: DungeonTraversalRamp, ramp: DungeonTraversalRamp,
x: number, x: number,
z: number, z: number,
@@ -36,10 +36,38 @@ export function smoothDungeonStairGeometry(
surfaceClearance = 0, surfaceClearance = 0,
): void { ): void {
const position = geometry.getAttribute("position"); const position = geometry.getAttribute("position");
// Fleet packages can contain many short flights and hundreds of thousands of
// vertices. Index their footprints once rather than checking every flight
// (and validating/recomputing its direction) for every collision vertex.
const cellSize = 16;
const cells = new Map<string, { ramp: DungeonTraversalRamp; dx: number; dz: number; length: number }[]>();
for (const ramp of ramps) {
dungeonRampHeight(ramp, ramp.start[0], ramp.start[2]);
const dx = ramp.end[0] - ramp.start[0];
const dz = ramp.end[2] - ramp.start[2];
const length = Math.hypot(dx, dz);
const xPadding = Math.abs(dz) / length * ramp.width / 2;
const zPadding = Math.abs(dx) / length * ramp.width / 2;
for (let x = Math.floor((Math.min(ramp.start[0], ramp.end[0]) - xPadding) / cellSize);
x <= Math.floor((Math.max(ramp.start[0], ramp.end[0]) + xPadding) / cellSize); x += 1) {
for (let z = Math.floor((Math.min(ramp.start[2], ramp.end[2]) - zPadding) / cellSize);
z <= Math.floor((Math.max(ramp.start[2], ramp.end[2]) + zPadding) / cellSize); z += 1) {
const key = `${x}:${z}`;
const bucket = cells.get(key) ?? [];
bucket.push({ ramp, dx, dz, length });
cells.set(key, bucket);
}
}
}
for (let vertex = 0; vertex < position.count; vertex += 1) { for (let vertex = 0; vertex < position.count; vertex += 1) {
for (const ramp of ramps) { const x = position.getX(vertex); const z = position.getZ(vertex);
const height = rampHeight(ramp, position.getX(vertex), position.getZ(vertex)); for (const { ramp, dx, dz, length } of cells.get(`${Math.floor(x / cellSize)}:${Math.floor(z / cellSize)}`) ?? []) {
if (height === null || Math.abs(position.getY(vertex) - height) > MAX_STAIR_SURFACE_DELTA) continue; const offsetX = x - ramp.start[0]; const offsetZ = z - ramp.start[2];
const along = (offsetX * dx + offsetZ * dz) / length;
const across = (offsetX * dz - offsetZ * dx) / length;
if (along < 0 || along > length || Math.abs(across) > ramp.width / 2) continue;
const height = ramp.start[1] + (ramp.end[1] - ramp.start[1]) * along / length;
if (Math.abs(position.getY(vertex) - height) > MAX_STAIR_SURFACE_DELTA) continue;
position.setY(vertex, height + surfaceClearance); position.setY(vertex, height + surfaceClearance);
break; break;
} }
@@ -57,7 +85,7 @@ export function bridgeDungeonStairNavigation(
? Array.from(geometry.index.array) ? Array.from(geometry.index.array)
: Array.from({ length: position.count }, (_, vertex) => vertex); : Array.from({ length: position.count }, (_, vertex) => vertex);
const portalVertex = (ramp: DungeonTraversalRamp, point: Vector3Tuple): number => { const portalVertex = (ramp: DungeonTraversalRamp, point: Vector3Tuple): number => {
const height = rampHeight(ramp, point[0], point[2]); const height = dungeonRampHeight(ramp, point[0], point[2]);
if (height === null) throw new Error(`${ramp.id}: navigation portal is outside the stair ramp.`); if (height === null) throw new Error(`${ramp.id}: navigation portal is outside the stair ramp.`);
for (let vertex = 0; vertex < position.count; vertex += 1) { for (let vertex = 0; vertex < position.count; vertex += 1) {
if (Math.hypot( if (Math.hypot(
@@ -69,9 +97,15 @@ export function bridgeDungeonStairNavigation(
throw new Error(`${ramp.id}: reviewed navigation portal no longer matches the source GLB.`); throw new Error(`${ramp.id}: reviewed navigation portal no longer matches the source GLB.`);
}; };
for (const ramp of ramps) { for (const ramp of ramps) {
const [a, b] = ramp.navigationBridge.startPortal.map((point) => portalVertex(ramp, point)); for (const bridge of dungeonRampNavigationBridges(ramp)) {
const [c, d] = ramp.navigationBridge.endPortal.map((point) => portalVertex(ramp, point)); const [a, b] = bridge.startPortal.map((point) => portalVertex(ramp, point));
indices.push(a, b, d, a, d, c); const [c, d] = bridge.endPortal.map((point) => portalVertex(ramp, point));
indices.push(a, b, d, a, d, c);
}
} }
geometry.setIndex(indices); geometry.setIndex(indices);
} }
export function dungeonRampNavigationBridges(ramp: DungeonTraversalRamp) {
return [...(ramp.navigationBridge ? [ramp.navigationBridge] : []), ...(ramp.navigationBridges ?? [])];
}
+6 -1
View File
@@ -162,11 +162,16 @@ export interface DungeonTraversalRamp {
readonly start: Vector3Tuple; readonly start: Vector3Tuple;
readonly end: Vector3Tuple; readonly end: Vector3Tuple;
readonly width: number; readonly width: number;
readonly navigationBridge: { readonly navigationBridge?: {
readonly linkId: string; readonly linkId: string;
readonly startPortal: readonly [Vector3Tuple, Vector3Tuple]; readonly startPortal: readonly [Vector3Tuple, Vector3Tuple];
readonly endPortal: readonly [Vector3Tuple, Vector3Tuple]; readonly endPortal: readonly [Vector3Tuple, Vector3Tuple];
}; };
readonly navigationBridges?: readonly {
readonly linkId: string;
readonly startPortal: readonly [Vector3Tuple, Vector3Tuple];
readonly endPortal: readonly [Vector3Tuple, Vector3Tuple];
}[];
} }
export interface DungeonEncounterSpawnGroupDefinition { export interface DungeonEncounterSpawnGroupDefinition {
+225
View File
@@ -0,0 +1,225 @@
import { afterAll, describe, expect, it } from "vitest";
import { mkdirSync, writeFileSync } from "node:fs";
import { resolve } from "node:path";
import { COA_CLASS_IDS, ROM_CLASS_IDS, PLAYABLE_RACES, classesForRace, type ClassId } from "../app/characterCatalog";
import { createDungeonParty, usePartyStore, type PartyMember } from "./partyStore";
import { useCombatStore } from "./combatStore";
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
import { clearPartyRuntimeRegistry, registerPartyRuntimePosition, updatePartyRuntimeTargetVisibility } from "./partyRuntimeRegistry";
import { advanceDungeonPartyCombat, partyCombatRangeBand, resetPartyRuntimeClock } from "./partyRuntime";
import { subscribeCombatPresentation } from "./combatPresentation";
import { subscribeCombatEvents } from "./combatEvents";
import { abilitiesForClass, resourceProfileForClass } from "./abilityCatalog";
import { choosePartyAbility } from "./partyAbilityAi";
const auditReport: unknown[] = [];
afterAll(() => {
if (process.env.CLASS_DAMAGE_AUDIT_REPORT !== "1") return;
const directory = resolve("artifacts/class-damage-audit");
mkdirSync(directory, { recursive: true });
writeFileSync(resolve(directory, "report.json"), `${JSON.stringify({ durationSeconds: 30, results: auditReport }, null, 2)}\n`);
});
function initializeAudit(classId: ClassId, level: number, template?: PartyMember): PartyMember {
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
resetPartyRuntimeClock();
const raceId = PLAYABLE_RACES.find((race) => classesForRace(race.id).some((entry) => entry.id === classId))!.id;
useCombatStore.getState().initializeCharacter({ classId, raceId, level });
const combat = useCombatStore.getState();
const profile = resourceProfileForClass(classId);
const base = createDungeonParty("expanded-damage-audit", level)[0];
const member: PartyMember = template ?? { ...base, id: `audit-${classId}`, name: classId, classId, raceId, level,
role: "damage", specialization: "Primary", specializationId: null, talentTreeId: "", talentRanks: {},
health: combat.maxHealth, maxHealth: combat.maxHealth, gearStats: combat.gearStats,
resourceType: profile.type, resourceName: combat.resourceName, resource: combat.resource,
maxResource: combat.maxResource, resourcePools: combat.resourcePools, runes: combat.deathKnightRunes };
usePartyStore.getState().initializeParty("expanded-damage-audit", level, combat.health);
usePartyStore.setState({ members: [member] });
useCombatStore.getState().synchronizeActors();
registerPartyRuntimePosition(member.id, [2 - partyCombatRangeBand(member).preferred, 0, 0]);
useCombatStore.getState().registerMob("audit", { name: "Training target", maxHealth: 10_000_000, xpReward: 0 });
setMobPosition("audit", [2, 0, 0]);
useCombatStore.getState().damageMob("audit", 1, 100);
updatePartyRuntimeTargetVisibility(member.id, "audit", true);
return member;
}
describe("expanded class damage audit", () => {
for (const control of ["NPC", "player"] as const) {
it(`${control} Radiant Flame deals five pulses during the channel without repeating them on completion`, () => {
const member = initializeAudit("sun-cleric", 20);
const ability = abilitiesForClass(member.classId).find((candidate) => candidate.name === "Radiant Flame")!;
const damageEvents: number[] = [];
if (control === "NPC") {
usePartyStore.setState({ members: [{ ...member, cooldowns: Object.fromEntries(abilitiesForClass(member.classId)
.filter((candidate) => candidate.id !== ability.id).map((candidate) => [candidate.id, 60_000])) }] });
useCombatStore.getState().commitCompanionActors(usePartyStore.getState().members);
} else {
usePartyStore.setState({ members: [], selectedMemberId: null, selfSelected: false });
useCombatStore.getState().synchronizeActors();
useCombatStore.getState().selectMob("audit");
}
const stop = subscribeCombatEvents((event) => {
if (event.kind === "damage" && event.targetActorId === "audit" && event.abilityId === ability.id) damageEvents.push(event.effectiveAmount);
});
const advance = (now: number) => {
useCombatStore.getState().tick(0.1, now);
if (control === "NPC") advanceDungeonPartyCombat(now);
};
if (control === "player") expect(useCombatStore.getState().castAbility(ability.id, undefined, 1_000).ok).toBe(true);
advance(1_000);
advance(1_700);
expect(damageEvents).toHaveLength(0);
advance(1_800);
expect(damageEvents).toHaveLength(1);
expect(damageEvents[0]).toBeGreaterThan(0);
for (const now of [2_600, 3_400, 4_200, 5_000]) advance(now);
expect(damageEvents).toHaveLength(5);
usePartyStore.getState().setCommand("stop");
advance(5_100);
advance(6_000);
expect(damageEvents).toHaveLength(5);
stop();
});
}
for (const [mode, classes, levels] of [["CoA", COA_CLASS_IDS, [1, 2, 4, 9, 20, 80]], ["RoM", ROM_CLASS_IDS, [1, 20, 70]]] as const) {
for (const level of levels) {
it(`${mode} NPCs execute damaging abilities at level ${level}`, () => {
const report: unknown[] = [];
const failures: string[] = [];
for (const classId of classes) {
const member = initializeAudit(classId, level);
const releases = new Set<string>();
let damage = 0;
let abilityDamage = 0;
const stopEvents = subscribeCombatEvents((event) => {
const actor = useCombatStore.getState().actors[event.sourceActorId];
if (event.kind === "damage" && event.targetActorId === "audit"
&& (event.sourceActorId === member.id || actor?.ownerId === member.id)) {
damage += event.effectiveAmount;
if (event.abilityId !== "party-auto-attack") abilityDamage += event.effectiveAmount;
}
});
const stopPresentation = subscribeCombatPresentation((message) => {
if (message.kind === "event" && message.event.sourceActorId === member.id && message.event.phase === "release") releases.add(message.event.name);
});
for (let now = 1_000; now <= 31_000; now += 100) {
useCombatStore.getState().tick(0.1, now);
advanceDungeonPartyCombat(now);
}
stopEvents();
stopPresentation();
expect(useCombatStore.getState().mobs.audit.dead, `${classId} level ${level} must sustain the full audit`).toBe(false);
report.push({ classId, damage, abilityDamage, releases: [...releases], resource: usePartyStore.getState().members[0].resource });
auditReport.push({ mode, control: "NPC", classId, level, damage, abilityDamage, abilities: [...releases] });
const hasDamageAbility = abilitiesForClass(classId).some((ability) => !ability.passive
&& ability.talentEntryId === undefined && ability.unlockLevel <= level
&& ability.effects.some((effect) => ["damage", "dot", "finisher-damage", "execute", "summon"].includes(effect.kind)));
if (damage <= 1 || (hasDamageAbility && abilityDamage <= 0)) failures.push(classId);
}
expect(failures, JSON.stringify(report)).toEqual([]);
});
}
for (const level of levels) {
it(`${mode} players execute damaging abilities at level ${level}`, () => {
const failures: unknown[] = [];
for (const classId of classes) {
const member = initializeAudit(classId, level);
usePartyStore.setState({ members: [], selectedMemberId: null, selfSelected: false });
useCombatStore.getState().synchronizeActors();
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
setMobPosition("audit", [partyCombatRangeBand(member).preferred, 0, 0]);
useCombatStore.getState().selectMob("audit");
const abilities = abilitiesForClass(classId);
const harmful = (ability: (typeof abilities)[number]) => !ability.passive && ability.talentEntryId === undefined
&& ability.effects.some((effect) => ["damage", "dot", "finisher-damage", "execute", "summon"].includes(effect.kind));
const firstAttack = abilities.filter(harmful).sort((left, right) => left.unlockLevel - right.unlockLevel)[0];
let damage = 0;
let abilityDamage = 0;
const casts = new Set<string>();
const reasons = new Set<string>();
const stop = subscribeCombatEvents((event) => {
if (event.kind === "damage" && event.targetActorId === "audit") {
damage += event.effectiveAmount;
if (event.abilityId && !event.abilityId.includes("auto-attack")) abilityDamage += event.effectiveAmount;
}
});
const kickoff = firstAttack ? useCombatStore.getState().castAbility(firstAttack.id, undefined, 1_000) : null;
if (kickoff?.ok && firstAttack) casts.add(firstAttack.name);
for (let now = 1_000; now <= 31_000; now += 100) {
useCombatStore.getState().tick(0.1, now);
const combat = useCombatStore.getState();
if (combat.activeCast) continue;
const rejected = new Set<string>();
const playerMember: PartyMember = { ...member, resource: combat.resource, resourcePools: combat.resourcePools,
cooldowns: combat.cooldowns, globalCooldownEndsAt: combat.globalCooldownEndsAt, runes: combat.deathKnightRunes };
for (let attempts = 0; attempts < abilities.length; attempts += 1) {
const ability = choosePartyAbility(playerMember, { now, target: combat.mobs.audit,
canUseAbility: (candidate) => harmful(candidate) && !rejected.has(candidate.id)
&& !combat.effects.some((effect) => effect.sourceAbilityId === candidate.id && effect.remainingTicks > 0)
&& !candidate.effects.some((effect) => effect.kind === "apply-aura"
&& combat.auras.some((aura) => aura.definition.id === effect.aura.id && aura.targetId === "audit")) });
if (!ability) break;
const result = useCombatStore.getState().castAbility(ability.id, undefined, now);
if (result.ok) { casts.add(ability.name); break; }
rejected.add(ability.id);
if (result.reason) reasons.add(result.reason);
}
}
stop();
expect(useCombatStore.getState().mobs.audit.dead, `${classId} level ${level} must sustain the full audit`).toBe(false);
const hasAttack = abilities.some((ability) => harmful(ability) && ability.unlockLevel <= level);
auditReport.push({ mode, control: "player", classId, level, damage, abilityDamage, abilities: [...casts],
firstOffensiveAbilityLevel: firstAttack?.unlockLevel ?? null, failureReasons: [...reasons] });
if (hasAttack && abilityDamage <= 0) failures.push({ classId, damage, abilityDamage, casts: [...casts], reasons: [...reasons], kickoff,
firstAttack: firstAttack?.name, autoAttackTargetId: useCombatStore.getState().autoAttackTargetId });
}
expect(failures).toEqual([]);
});
}
}
for (const level of [1, 20, 70]) {
it(`actual RoM companion templates deal ability damage at level ${level}`, () => {
const templates = new Map<string, PartyMember>();
for (let seed = 0; seed < 100; seed += 1) {
for (const role of ["healer", "damage"] as const) {
for (const member of createDungeonParty(`rom-audit-${seed}`, level, role, 5, "rom")) {
templates.set(`${member.classId}:${member.role}`, member);
}
}
}
expect(new Set([...templates.values()].map((member) => member.classId))).toEqual(new Set(ROM_CLASS_IDS));
for (const template of templates.values()) {
const member = initializeAudit(template.classId, level, template);
let damage = 0;
let abilityDamage = 0;
const abilities = new Set<string>();
const stop = subscribeCombatEvents((event) => {
const actor = useCombatStore.getState().actors[event.sourceActorId];
if (event.kind !== "damage" || event.targetActorId !== "audit"
|| (event.sourceActorId !== member.id && actor?.ownerId !== member.id)) return;
damage += event.effectiveAmount;
if (event.abilityId !== "party-auto-attack") {
abilityDamage += event.effectiveAmount;
if (event.abilityId) abilities.add(event.abilityId);
}
});
for (let now = 1_000; now <= 31_000; now += 100) {
useCombatStore.getState().tick(0.1, now);
advanceDungeonPartyCombat(now);
}
stop();
auditReport.push({ mode: "RoM", control: "shipped NPC", classId: member.classId, role: member.role,
level, damage, abilityDamage, abilities: [...abilities] });
expect(abilityDamage, `${member.classId} ${member.role} level ${level}`).toBeGreaterThan(0);
const finalMember = usePartyStore.getState().members[0];
expect(finalMember.resource).toBeGreaterThanOrEqual(0);
expect(finalMember.resource).toBeLessThanOrEqual(finalMember.maxResource);
}
});
}
});
@@ -1 +1 @@
{"schemaVersion":1,"classId":"necromancer","abilities":[{"id":"coa-tree-30600","classId":"necromancer","dbcSpellId":805428,"name":"Animate: Frost Wyrm","unlockLevel":1,"icon":"/assets/ui/spells/5_dragoncoldbreath_border.png","sigil":"AF","description":"Animate a Frost Wyrm that flies behind the Necromancer that blasts nearby enemies every 2 sec for 30 seconds, dealing 1500 + 14.88% frost SP + 14.88% Intellect Frost damage.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":120000,"gcdMs":1500,"cost":{"resource":"mana","amount":31,"percentage":true},"effects":[{"kind":"summon","creatureId":50177,"coefficient":0.08,"durationMs":30000}],"passive":false,"source":"coa-tree","ranks":[{"rank":1,"spellId":805428,"level":1,"description":"Animate a Frost Wyrm that flies behind the Necromancer that blasts nearby enemies every 2 sec for 30 seconds, dealing 1500 + 14.88% frost SP + 14.88% Intellect Frost damage.","castMode":"instant","castTimeMs":0,"cooldownMs":120000,"gcdMs":1500,"cost":{"resource":"mana","amount":31,"percentage":true},"effects":[{"kind":"summon","creatureId":50177,"coefficient":0.08,"durationMs":30000}]}],"talentEntryId":30600},{"id":"coa-23-animate-zombies-84-504315","classId":"necromancer","dbcSpellId":504315,"name":"Animate: Zombies","unlockLevel":1,"icon":"/assets/ui/spells/zombiewc3.png","sigil":"AZ","description":"Animate: ZombiesRank 121% of base mana 3 sec cast15 sec cooldownAnimate 2 Zombies to aid you in combat for 15 sec. Every 3 sec, Zombies emit Zombie Plague, dealing 79+1.0 Points Per Level+SP*0.1 Plague Damage to nearby enemies.","target":"hostile","range":{"min":0,"max":0},"radius":3,"castMode":"cast","castTimeMs":3000,"cooldownMs":15000,"gcdMs":1000,"cost":{"resource":"mana","amount":21,"percentage":true},"effects":[{"kind":"trigger-spell","spellId":525379,"withValue":2},{"kind":"damage","coefficient":1},{"kind":"summon","creatureId":503031,"coefficient":0.08,"durationMs":15000},{"kind":"utility","action":"class-script","effectType":3,"auraType":0,"miscValue":524287,"triggerSpellId":500991,"durationMs":100}],"passive":false,"source":"coa-progression","ranks":[{"rank":1,"spellId":504315,"level":1,"description":"Animate: ZombiesRank 121% of base mana 3 sec cast15 sec cooldownAnimate 2 Zombies to aid you in combat for 15 sec. Every 3 sec, Zombies emit Zombie Plague, dealing 79+1.0 Points Per Level+SP*0.1 Plague Damage to nearby enemies.","castMode":"cast","castTimeMs":3000,"cooldownMs":15000,"gcdMs":1000,"cost":{"resource":"mana","amount":21,"percentage":true},"effects":[{"kind":"trigger-spell","spellId":525379,"withValue":2},{"kind":"damage","coefficient":1},{"kind":"summon","creatureId":503031,"coefficient":0.08,"durationMs":15000},{"kind":"utility","action":"class-script","effectType":3,"auraType":0,"miscValue":524287,"triggerSpellId":500991,"durationMs":100}]},{"rank":2,"spellId":525378,"level":30,"description":"Animate: ZombiesRank 223% of base mana 3 sec cast15 sec cooldownAnimate 3 Zombies to aid you in combat for 15 sec. Every 3 sec, Zombies emit Zombie Plague, dealing 79+1.0 Points Per Level+SP*0.1 Plague Damage to nearby enemies.","castMode":"cast","castTimeMs":3000,"cooldownMs":15000,"gcdMs":1000,"cost":{"resource":"mana","amount":23,"percentage":true},"effects":[{"kind":"trigger-spell","spellId":525379,"withValue":3},{"kind":"damage","coefficient":1},{"kind":"summon","creatureId":503031,"coefficient":0.08,"durationMs":15000},{"kind":"utility","action":"class-script","effectType":3,"auraType":0,"miscValue":524287,"triggerSpellId":500991,"durationMs":100}]},{"rank":3,"spellId":525381,"level":51,"description":"Animate: ZombiesRank 324% of base mana 3 sec cast15 sec cooldownAnimate 4 Zombies to aid you in combat for 10 sec. Every 3 sec, Zombies emit Zombie Plague, dealing 79+1.0 Points Per Level+SP*0.1 Plague Damage to nearby enemies.","castMode":"cast","castTimeMs":3000,"cooldownMs":15000,"gcdMs":1000,"cost":{"resource":"mana","amount":24,"percentage":true},"effects":[{"kind":"trigger-spell","spellId":525379,"withValue":4},{"kind":"damage","coefficient":1},{"kind":"summon","creatureId":503031,"coefficient":0.08,"durationMs":15000},{"kind":"utility","action":"class-script","effectType":3,"auraType":0,"miscValue":524287,"triggerSpellId":500991,"durationMs":100}]}]},{"id":"coa-tree-29774","classId":"necromancer","dbcSpellId":802121,"name":"Bone Tithe","unlockLevel":1,"icon":"/assets/ui/spells/ossuary_bond.png","sigil":"BT","description":"Siphon life from your undead minions, sacrificing 40% of their health to grant yourself an absorb shield equal to half of the health stolen for 20 seconds. Usable while stunned.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":120000,"gcdMs":1500,"cost":{"resource":"mana","amount":25,"percentage":true},"effects":[{"kind":"shield","coefficient":1,"durationMs":15000},{"kind":"utility","action":"class-script","effectType":77,"auraType":0,"miscValue":0,"triggerSpellId"Line truncated {"schemaVersion":1,"classId":"necromancer","abilities":[{"id":"coa-tree-30600","classId":"necromancer","dbcSpellId":805428,"name":"Animate: Frost Wyrm","unlockLevel":1,"icon":"/assets/ui/spells/5_dragoncoldbreath_border.png","sigil":"AF","description":"Animate a Frost Wyrm that flies behind the Necromancer that blasts nearby enemies every 2 sec for 30 seconds, dealing 1500 + 14.88% frost SP + 14.88% Intellect Frost damage.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":120000,"gcdMs":1500,"cost":{"resource":"mana","amount":31,"percentage":true},"effects":[{"kind":"summon","creatureId":50177,"coefficient":0.08,"durationMs":30000}],"passive":false,"source":"coa-tree","ranks":[{"rank":1,"spellId":805428,"level":1,"description":"Animate a Frost Wyrm that flies behind the Necromancer that blasts nearby enemies every 2 sec for 30 seconds, dealing 1500 + 14.88% frost SP + 14.88% Intellect Frost damage.","castMode":"instant","castTimeMs":0,"cooldownMs":120000,"gcdMs":1500,"cost":{"resource":"mana","amount":31,"percentage":true},"effects":[{"kind":"summon","creatureId":50177,"coefficient":0.08,"durationMs":30000}]}],"talentEntryId":30600},{"id":"coa-23-animate-zombies-84-504315","classId":"necromancer","dbcSpellId":504315,"name":"Animate: Zombies","unlockLevel":1,"icon":"/assets/ui/spells/zombiewc3.png","sigil":"AZ","description":"Animate: ZombiesRank 121% of base mana 3 sec cast15 sec cooldownAnimate 2 Zombies to aid you in combat for 15 sec. Every 3 sec, Zombies emit Zombie Plague, dealing 79+1.0 Points Per Level+SP*0.1 Plague Damage to nearby enemies.","target":"hostile","range":{"min":0,"max":0},"radius":3,"castMode":"cast","castTimeMs":3000,"cooldownMs":15000,"gcdMs":1000,"cost":{"resource":"mana","amount":21,"percentage":true},"effects":[{"kind":"trigger-spell","spellId":525379,"withValue":2},{"kind":"damage","coefficient":1},{"kind":"summon","creatureId":503031,"coefficient":0.08,"durationMs":15000},{"kind":"utility","action":"class-script","effectType":3,"auraType":0,"miscValue":524287,"triggerSpellId":500991,"durationMs":100}],"passive":false,"source":"coa-progression","ranks":[{"rank":1,"spellId":504315,"level":1,"description":"Animate: ZombiesRank 121% of base mana 3 sec cast15 sec cooldownAnimate 2 Zombies to aid you in combat for 15 sec. Every 3 sec, Zombies emit Zombie Plague, dealing 79+1.0 Points Per Level+SP*0.1 Plague Damage to nearby enemies.","castMode":"cast","castTimeMs":3000,"cooldownMs":15000,"gcdMs":1000,"cost":{"resource":"mana","amount":21,"percentage":true},"effects":[{"kind":"trigger-spell","spellId":525379,"withValue":2},{"kind":"damage","coefficient":1},{"kind":"summon","creatureId":503031,"coefficient":0.08,"durationMs":15000},{"kind":"utility","action":"class-script","effectType":3,"auraType":0,"miscValue":524287,"triggerSpellId":500991,"durationMs":100}]},{"rank":2,"spellId":525378,"level":30,"description":"Animate: ZombiesRank 223% of base mana 3 sec cast15 sec cooldownAnimate 3 Zombies to aid you in combat for 15 sec. Every 3 sec, Zombies emit Zombie Plague, dealing 79+1.0 Points Per Level+SP*0.1 Plague Damage to nearby enemies.","castMode":"cast","castTimeMs":3000,"cooldownMs":15000,"gcdMs":1000,"cost":{"resource":"mana","amount":23,"percentage":true},"effects":[{"kind":"trigger-spell","spellId":525379,"withValue":3},{"kind":"damage","coefficient":1},{"kind":"summon","creatureId":503031,"coefficient":0.08,"durationMs":15000},{"kind":"utility","action":"class-script","effectType":3,"auraType":0,"miscValue":524287,"triggerSpellId":500991,"durationMs":100}]},{"rank":3,"spellId":525381,"level":51,"description":"Animate: ZombiesRank 324% of base mana 3 sec cast15 sec cooldownAnimate 4 Zombies to aid you in combat for 10 sec. Every 3 sec, Zombies emit Zombie Plague, dealing 79+1.0 Points Per Level+SP*0.1 Plague Damage to nearby enemies.","castMode":"cast","castTimeMs":3000,"cooldownMs":15000,"gcdMs":1000,"cost":{"resource":"mana","amount":24,"percentage":true},"effects":[{"kind":"trigger-spell","spellId":525379,"withValue":4},{"kind":"damage","coefficient":1},{"kind":"summon","creatureId":503031,"coefficient":0.08,"durationMs":15000},{"kind":"utility","action":"class-script","effectType":3,"auraType":0,"miscValue":524287,"triggerSpellId":500991,"durationMs":100}]}]},{"id":"coa-tree-29774","classId":"necromancer","dbcSpellId":802121,"name":"Bone Tithe","unlockLevel":1,"icon":"/assets/ui/spells/ossuary_bond.png","sigil":"BT","description":"Siphon life from your undead minions, sacrificing 40% of their health to grant yourself an absorb shield equal to half of the health stolen for 20 seconds. Usable while stunned.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":120000,"gcdMs":1500,"cost":{"resource":"mana","amount":25,"percentage":true},"effects":[{"kind":"shield","coefficient":1,"durationMs":15000},{"kind":"utility","action":"class-script","effectType":77,"auraType":0,"miscValue":0,"triggerSpellId"Line truncated
@@ -1 +1 @@
{"schemaVersion":1,"classId":"witch-doctor","abilities":[{"id":"coa-tree-30147","classId":"witch-doctor","dbcSpellId":705921,"name":"Alchemical Enhancement","unlockLevel":1,"icon":"/assets/ui/spells/inv_alchemy_71_potion4.png","sigil":"AE","description":"Increases your haste by 5%.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"energy","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:705921:effect:0:aura:216","name":"Alchemical Enhancement (Aura 216)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"haste","operation":"percent","value":0.05}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":216,"miscValue":0,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:705921:effect:1:aura:192","name":"Alchemical Enhancement (Aura 192)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"haste","operation":"percent","value":0.05}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":192,"miscValue":127,"triggerSpellId":0}],"passive":true,"source":"coa-tree","ranks":[{"rank":1,"spellId":705921,"level":1,"description":"Increases your haste by 5%.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"energy","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:705921:effect:0:aura:216","name":"Alchemical Enhancement (Aura 216)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"haste","operation":"percent","value":0.05}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":216,"miscValue":0,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:705921:effect:1:aura:192","name":"Alchemical Enhancement (Aura 192)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"haste","operation":"percent","value":0.05}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":192,"miscValue":127,"triggerSpellId":0}]}],"talentEntryId":30147},{"id":"coa-tree-11323","classId":"witch-doctor","dbcSpellId":301297,"name":"Blatant Curse","unlockLevel":1,"icon":"/assets/ui/spells/spell_nature_removecurse.png","sigil":"BC","description":"Reduces the mana cost of your Jinxes by -25%.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:301297:effect:0:aura:108","name":"Blatant Curse (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"curse","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":0}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":8,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:301297:effect:1:aura:108","name":"Blatant Curse (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"curse","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":-0.25}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":14,"triggerSpellId":0}],"passive":true,"source":"coa-tree","ranks":[{"rank":1,"spellId":301297,"level":1,"description":"Reduces the mana cost of your Jinxes by -25%.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:301297:effect:0:aura:108","name":"Blatant Curse (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"curse","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":0}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":8,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:301297:effect:1:aura:108","name":"Blatant Curse (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"curse","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":-0.25}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":14,"triggerSpellId":0}]}],"talentEntryId":11323},{"id":"coa-tree-12048","classId":"witch-doctor","dbcSpellId":572870,"name":"Blessing of Hir'eek","unlockLevel":1,"icon":"/assets/ui/spells/trade_archaeology_trollbatidol.png","sigil":"BO","description":"Your Hexbreak now removes an additional curse effect.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:572870:effect:0:aura:107","name":"Blessing of Hir'eek (Aura 107)","disposition":"buff","durationMs":null,"maxStacks":1,"sLine truncated {"schemaVersion":1,"classId":"witch-doctor","abilities":[{"id":"coa-tree-30147","classId":"witch-doctor","dbcSpellId":705921,"name":"Alchemical Enhancement","unlockLevel":1,"icon":"/assets/ui/spells/inv_alchemy_71_potion4.png","sigil":"AE","description":"Increases your haste by 5%.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"energy","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:705921:effect:0:aura:216","name":"Alchemical Enhancement (Aura 216)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"haste","operation":"percent","value":0.05}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":216,"miscValue":0,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:705921:effect:1:aura:192","name":"Alchemical Enhancement (Aura 192)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"haste","operation":"percent","value":0.05}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":192,"miscValue":127,"triggerSpellId":0}],"passive":true,"source":"coa-tree","ranks":[{"rank":1,"spellId":705921,"level":1,"description":"Increases your haste by 5%.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"energy","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:705921:effect:0:aura:216","name":"Alchemical Enhancement (Aura 216)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"haste","operation":"percent","value":0.05}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":216,"miscValue":0,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:705921:effect:1:aura:192","name":"Alchemical Enhancement (Aura 192)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"haste","operation":"percent","value":0.05}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":192,"miscValue":127,"triggerSpellId":0}]}],"talentEntryId":30147},{"id":"coa-tree-11323","classId":"witch-doctor","dbcSpellId":301297,"name":"Blatant Curse","unlockLevel":1,"icon":"/assets/ui/spells/spell_nature_removecurse.png","sigil":"BC","description":"Reduces the mana cost of your Jinxes by -25%.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:301297:effect:0:aura:108","name":"Blatant Curse (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"curse","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":0}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":8,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:301297:effect:1:aura:108","name":"Blatant Curse (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"curse","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":-0.25}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":14,"triggerSpellId":0}],"passive":true,"source":"coa-tree","ranks":[{"rank":1,"spellId":301297,"level":1,"description":"Reduces the mana cost of your Jinxes by -25%.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:301297:effect:0:aura:108","name":"Blatant Curse (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"curse","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":0}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":8,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:301297:effect:1:aura:108","name":"Blatant Curse (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"curse","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":-0.25}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":14,"triggerSpellId":0}]}],"talentEntryId":11323},{"id":"coa-tree-12048","classId":"witch-doctor","dbcSpellId":572870,"name":"Blessing of Hir'eek","unlockLevel":1,"icon":"/assets/ui/spells/trade_archaeology_trollbatidol.png","sigil":"BO","description":"Your Hexbreak now removes an additional curse effect.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:572870:effect:0:aura:107","name":"Blessing of Hir'eek (Aura 107)","disposition":"buff","durationMs":null,"maxStacks":1,"sLine truncated
@@ -1 +1 @@
{"schemaVersion":1,"abilitiesByClass":{"barbarian":[{"id":"coa-tree-30762","classId":"barbarian","dbcSpellId":500918,"name":"Ancestral Roar","unlockLevel":1,"icon":"/assets/ui/spells/_d3threateningshout.png","sigil":"AR","description":"Ancestral RoarRank 1Instant cast25 sec cooldownRequires Enraged Roar with ancestral fervor, healing you for 100+0+AP*0.5.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":25000,"gcdMs":1500,"cost":{"resource":"energy","amount":0},"effects":[{"kind":"heal","coefficient":1.6580645243326824,"basePoints":100,"dieSides":7,"pointsPerLevel":3.200000047683716,"bonusPowerCoefficient":0,"sourceLevel":16,"maxScalingLevel":22},{"kind":"utility","action":"class-script","effectType":3,"auraType":0,"miscValue":3,"triggerSpellId":0,"durationMs":0}],"passive":false,"source":"coa-tree","ranks":[{"rank":1,"spellId":500918,"level":1,"description":"Ancestral RoarRank 1Instant cast25 sec cooldownRequires Enraged Roar with ancestral fervor, healing you for 100+0+AP*0.5.","castMode":"instant","castTimeMs":0,"cooldownMs":25000,"gcdMs":1500,"cost":{"resource":"energy","amount":0},"effects":[{"kind":"heal","coefficient":1.6580645243326824,"basePoints":100,"dieSides":7,"pointsPerLevel":3.200000047683716,"bonusPowerCoefficient":0,"sourceLevel":16,"maxScalingLevel":22},{"kind":"utility","action":"class-script","effectType":3,"auraType":0,"miscValue":3,"triggerSpellId":0,"durationMs":0}]}],"talentEntryId":30762},{"id":"coa-12-ancestral-spirit-47-705153","classId":"barbarian","dbcSpellId":705153,"name":"Ancestral Spirit","unlockLevel":1,"icon":"/assets/ui/spells/inv_misc_questionmark.png","sigil":"AS","description":"Ancestral SpiritRank 1Reduces the Energy cost of your Spirit spells and increases the duration of your Shouts by 50%.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"energy","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:705153:effect:0:aura:108","name":"Ancestral Spirit (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":-0.5}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":14,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:705153:effect:1:aura:108","name":"Ancestral Spirit (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":0.5}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":1,"triggerSpellId":0}],"passive":true,"source":"coa-progression","ranks":[{"rank":1,"spellId":705153,"level":1,"description":"Ancestral SpiritRank 1Reduces the Energy cost of your Spirit spells and increases the duration of your Shouts by 50%.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"energy","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:705153:effect:0:aura:108","name":"Ancestral Spirit (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":-0.5}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":14,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:705153:effect:1:aura:108","name":"Ancestral Spirit (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":0.5}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":1,"triggerSpellId":0}]},{"rank":2,"spellId":705154,"level":1,"description":"Ancestral SpiritRank 2Reduces the Energy cost of your Spirit spells and increases the duration of your Shouts by 100%.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"energy","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:705154:effect:0:aura:108","name":"Ancestral Spirit (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":-1}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":14,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:705154:effect:1:aura:108","name":"Ancestral Spirit (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":1}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":1,"triggerSpellId":0}]}]},{"id":"coa-tree-34279","classId":"barbarian","dbcSpellId":560456,"name":"Athleticism","unlockLevel":1,"icon":"/assets/ui/spells/5_run_border.png","sigil":"AT","descriptionLine truncated {"schemaVersion":1,"abilitiesByClass":{"barbarian":[{"id":"coa-tree-30762","classId":"barbarian","dbcSpellId":500918,"name":"Ancestral Roar","unlockLevel":1,"icon":"/assets/ui/spells/_d3threateningshout.png","sigil":"AR","description":"Ancestral RoarRank 1Instant cast25 sec cooldownRequires Enraged Roar with ancestral fervor, healing you for 100+0+AP*0.5.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":25000,"gcdMs":1500,"cost":{"resource":"energy","amount":0},"effects":[{"kind":"heal","coefficient":1.6580645243326824,"basePoints":100,"dieSides":7,"pointsPerLevel":3.200000047683716,"bonusPowerCoefficient":0,"sourceLevel":16,"maxScalingLevel":22},{"kind":"utility","action":"class-script","effectType":3,"auraType":0,"miscValue":3,"triggerSpellId":0,"durationMs":0}],"passive":false,"source":"coa-tree","ranks":[{"rank":1,"spellId":500918,"level":1,"description":"Ancestral RoarRank 1Instant cast25 sec cooldownRequires Enraged Roar with ancestral fervor, healing you for 100+0+AP*0.5.","castMode":"instant","castTimeMs":0,"cooldownMs":25000,"gcdMs":1500,"cost":{"resource":"energy","amount":0},"effects":[{"kind":"heal","coefficient":1.6580645243326824,"basePoints":100,"dieSides":7,"pointsPerLevel":3.200000047683716,"bonusPowerCoefficient":0,"sourceLevel":16,"maxScalingLevel":22},{"kind":"utility","action":"class-script","effectType":3,"auraType":0,"miscValue":3,"triggerSpellId":0,"durationMs":0}]}],"talentEntryId":30762},{"id":"coa-12-ancestral-spirit-47-705153","classId":"barbarian","dbcSpellId":705153,"name":"Ancestral Spirit","unlockLevel":1,"icon":"/assets/ui/spells/inv_misc_questionmark.png","sigil":"AS","description":"Ancestral SpiritRank 1Reduces the Energy cost of your Spirit spells and increases the duration of your Shouts by 50%.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"energy","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:705153:effect:0:aura:108","name":"Ancestral Spirit (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":-0.5}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":14,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:705153:effect:1:aura:108","name":"Ancestral Spirit (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":0.5}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":1,"triggerSpellId":0}],"passive":true,"source":"coa-progression","ranks":[{"rank":1,"spellId":705153,"level":1,"description":"Ancestral SpiritRank 1Reduces the Energy cost of your Spirit spells and increases the duration of your Shouts by 50%.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"energy","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:705153:effect:0:aura:108","name":"Ancestral Spirit (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":-0.5}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":14,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:705153:effect:1:aura:108","name":"Ancestral Spirit (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":0.5}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":1,"triggerSpellId":0}]},{"rank":2,"spellId":705154,"level":1,"description":"Ancestral SpiritRank 2Reduces the Energy cost of your Spirit spells and increases the duration of your Shouts by 100%.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"energy","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:705154:effect:0:aura:108","name":"Ancestral Spirit (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":-1}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":14,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:705154:effect:1:aura:108","name":"Ancestral Spirit (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":1}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":1,"triggerSpellId":0}]}]},{"id":"coa-tree-34279","classId":"barbarian","dbcSpellId":560456,"name":"Athleticism","unlockLevel":1,"icon":"/assets/ui/spells/5_run_border.png","sigil":"AT","descriptionLine truncated
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+16 -1
View File
@@ -3,7 +3,7 @@ import { abilitiesForClass, abilityAtLevel, resourceProfileForClass } from "./ab
import { aiHintsForAbility } from "./abilityRules"; import { aiHintsForAbility } from "./abilityRules";
import { useCombatStore } from "./combatStore"; import { useCombatStore } from "./combatStore";
import { EMPTY_ITEM_STATS } from "./itemStats"; import { EMPTY_ITEM_STATS } from "./itemStats";
import { commitPartyAbilityUse, choosePartyAbility, partyAbilityIsReady } from "./partyAbilityAi"; import { commitPartyAbilityUse, choosePartyAbility, partyAbilityIsReady, partyAbilityAmount } from "./partyAbilityAi";
import { usePartyStore, type PartyMember } from "./partyStore"; import { usePartyStore, type PartyMember } from "./partyStore";
import { deriveCharacterStats } from "./wotlkStats"; import { deriveCharacterStats } from "./wotlkStats";
@@ -87,4 +87,19 @@ describe("catalog-driven party rotations", () => {
.find((ability) => aiHintsForAbility(ability).categories.includes("heal") && ability.cost.amount > 0)!; .find((ability) => aiHintsForAbility(ability).categories.includes("heal") && ability.cost.amount > 0)!;
expect(partyAbilityIsReady(member, heal, 2_000)).toBe(false); expect(partyAbilityIsReady(member, heal, 2_000)).toBe(false);
}); });
it("preserves spell base damage when its bonus-power coefficient is zero", () => {
const member = { ...memberFor("warrior", "tank"), level: 5, gearStats: EMPTY_ITEM_STATS };
const ability = abilitiesForClass("warrior").find((candidate) => candidate.id === "warrior-heroic-strike")!;
expect(partyAbilityAmount(member, ability, {
kind: "damage", coefficient: 0, basePoints: 17, dieSides: 1, sourceLevel: 5,
})).toBe(17);
});
it("requires a learned CoA tree entry before the NPC can cast it", () => {
const member = { ...memberFor("pyromancer", "damage"), talentRanks: {} };
const ability = abilitiesForClass(member.classId).find((candidate) => candidate.talentEntryId !== undefined && !candidate.passive)!;
expect(partyAbilityIsReady(member, ability, 2_000)).toBe(false);
expect(partyAbilityIsReady({ ...member, talentRanks: { [`coa-entry-${ability.talentEntryId}`]: 1 } }, ability, 2_000)).toBe(true);
});
}); });
+19 -8
View File
@@ -8,7 +8,7 @@ import {
} from "./abilityCatalog"; } from "./abilityCatalog";
import { aiHintsForAbility, runeCostForAbility } from "./abilityRules"; import { aiHintsForAbility, runeCostForAbility } from "./abilityRules";
import { spendDeathKnightRunes } from "./combatResources"; import { spendDeathKnightRunes } from "./combatResources";
import type { MobCombatState } from "./combatStore"; import { equipmentAdjustedCombatAmount, type MobCombatState } from "./combatStore";
import type { PartyMember } from "./partyStore"; import type { PartyMember } from "./partyStore";
export interface PartyAbilityContext { export interface PartyAbilityContext {
@@ -20,6 +20,7 @@ export interface PartyAbilityContext {
readonly fallenAlly?: boolean; readonly fallenAlly?: boolean;
readonly injuredAllies?: number; readonly injuredAllies?: number;
readonly lowestHealthFraction?: number; readonly lowestHealthFraction?: number;
readonly canUseAbility?: (ability: AbilityDefinition) => boolean;
} }
function currentResource(member: PartyMember, type: ResourceType): number { function currentResource(member: PartyMember, type: ResourceType): number {
@@ -35,6 +36,7 @@ function abilityCosts(ability: AbilityDefinition): readonly { resource: Resource
export function partyAbilityIsReady(member: PartyMember, ability: AbilityDefinition, now: number): boolean { export function partyAbilityIsReady(member: PartyMember, ability: AbilityDefinition, now: number): boolean {
if (ability.passive || !isAbilityUnlocked(ability, member.level)) return false; if (ability.passive || !isAbilityUnlocked(ability, member.level)) return false;
if (ability.talentEntryId !== undefined && !(member.talentRanks[`coa-entry-${ability.talentEntryId}`] ?? 0)) return false;
if (member.globalCooldownEndsAt > now || (member.cooldowns[ability.id] ?? 0) > now) return false; if (member.globalCooldownEndsAt > now || (member.cooldowns[ability.id] ?? 0) > now) return false;
if ((member.schoolLockedUntil[ability.castRules?.school ?? "physical"] ?? 0) > now) return false; if ((member.schoolLockedUntil[ability.castRules?.school ?? "physical"] ?? 0) > now) return false;
if (aiHintsForAbility(ability).categories.includes("taunt") && member.tauntReadyAt > now) return false; if (aiHintsForAbility(ability).categories.includes("taunt") && member.tauntReadyAt > now) return false;
@@ -50,12 +52,18 @@ export function partyAbilityIsReady(member: PartyMember, ability: AbilityDefinit
function candidateAbilities(member: PartyMember, now: number): readonly AbilityDefinition[] { function candidateAbilities(member: PartyMember, now: number): readonly AbilityDefinition[] {
return abilitiesForClass(member.classId) return abilitiesForClass(member.classId)
.filter((ability) => partyAbilityIsReady(member, abilityAtLevel(ability, member.level), now)) .map((ability) => abilityAtLevel(ability, member.level, ability.talentEntryId === undefined
.map((ability) => abilityAtLevel(ability, member.level)); ? undefined : member.talentRanks[`coa-entry-${ability.talentEntryId}`] ?? 0))
.filter((ability) => partyAbilityIsReady(member, ability, now));
} }
function scoreAbility(member: PartyMember, ability: AbilityDefinition, context: PartyAbilityContext): number { function scoreAbility(member: PartyMember, ability: AbilityDefinition, context: PartyAbilityContext): number {
const categories = new Set(aiHintsForAbility(ability).categories); const categories = new Set(aiHintsForAbility(ability).categories);
const damaging = categories.has("damage") || categories.has("execute");
if (categories.has("taunt") && (member.role !== "tank" || !context.targetIsLoose)) return 0;
if (!damaging && categories.has("interrupt") && !context.target?.activeCast) return 0;
const control = categories.has("control") || ability.effects.some((effect) => effect.kind === "apply-aura" && effect.control);
if (!damaging && control && !context.requiredControl) return 0;
let score = ability.ai?.priority ?? 0; let score = ability.ai?.priority ?? 0;
if (member.role === "tank") { if (member.role === "tank") {
if (context.target?.activeCast && categories.has("interrupt")) score += 1_000; if (context.target?.activeCast && categories.has("interrupt")) score += 1_000;
@@ -89,6 +97,7 @@ export function choosePartyAbility(
context: PartyAbilityContext, context: PartyAbilityContext,
): AbilityDefinition | null { ): AbilityDefinition | null {
return candidateAbilities(member, context.now) return candidateAbilities(member, context.now)
.filter((ability) => context.canUseAbility?.(ability) ?? true)
.map((ability) => ({ ability, score: scoreAbility(member, ability, context) })) .map((ability) => ({ ability, score: scoreAbility(member, ability, context) }))
.filter((candidate) => candidate.score > 0) .filter((candidate) => candidate.score > 0)
.sort((left, right) => right.score - left.score .sort((left, right) => right.score - left.score
@@ -142,9 +151,11 @@ export function partyAbilityAmount(
: effect.kind === "finisher-damage" : effect.kind === "finisher-damage"
? effect.baseCoefficient + effect.perComboCoefficient * 5 ? effect.baseCoefficient + effect.perComboCoefficient * 5
: 0; : 0;
const power = member.level * 4 return equipmentAdjustedCombatAmount(
+ member.gearStats.spellPower "coefficient" in effect ? effect : coefficient,
+ member.gearStats.attackPower * 0.35 member.level,
+ member.gearStats.rangedAttackPower * 0.35; member.gearStats,
return Math.max(1, Math.round(Math.max(0.1, coefficient) * Math.max(8, power))); member.classId,
ability,
);
} }
+120
View File
@@ -0,0 +1,120 @@
import { describe, expect, it } from "vitest";
import { createDungeonParty, usePartyStore, type PartyMember } from "./partyStore";
import { useCombatStore } from "./combatStore";
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
import { clearPartyRuntimeRegistry, registerPartyRuntimePosition, updatePartyRuntimeTargetVisibility } from "./partyRuntimeRegistry";
import { advanceDungeonPartyCombat, partyUsesRangedAttacks, resetPartyRuntimeClock } from "./partyRuntime";
import { subscribeCombatPresentation } from "./combatPresentation";
import { abilitiesForClass } from "./abilityCatalog";
function partyTemplates(level: number): PartyMember[] {
const templates = new Map<string, PartyMember>();
for (let seed = 0; seed < 100; seed += 1) {
for (const role of ["healer", "damage"] as const) {
for (const member of createDungeonParty(`damage-audit-${seed}`, level, role)) {
templates.set(`${member.classId}:${member.specialization}`, member);
}
}
}
return [...templates.values()];
}
describe("NPC sustained damage audit", () => {
it("lets Garrick swing immediately with no Rage, and keeps swinging during the GCD", () => {
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
resetPartyRuntimeClock();
useCombatStore.getState().initializeCharacter({ classId: "priest", level: 3 });
const garrick = partyTemplates(3).find((member) => member.name === "Garrick")!;
usePartyStore.getState().initializeParty("short-pull", 3, useCombatStore.getState().health);
usePartyStore.setState({ members: [{ ...garrick, globalCooldownEndsAt: 10_000 }] });
useCombatStore.getState().synchronizeActors();
registerPartyRuntimePosition(garrick.id, [0, 0, 0]);
useCombatStore.getState().registerMob("short-pull", { name: "Training target", maxHealth: 1_000, xpReward: 0 });
setMobPosition("short-pull", [2, 0, 0]);
useCombatStore.getState().damageMob("short-pull", 1, 100);
updatePartyRuntimeTargetVisibility(garrick.id, "short-pull", true);
advanceDungeonPartyCombat(1_000);
const swings = () => useCombatStore.getState().combatEvents.filter((event) => event.sourceActorId === garrick.id && event.kind === "damage");
expect(swings()).toHaveLength(1);
expect(swings()[0]).toMatchObject({ abilityId: "party-auto-attack" });
expect(swings()[0].effectiveAmount).toBeGreaterThan(1);
expect(usePartyStore.getState().members[0].resource).toBe(6);
advanceDungeonPartyCombat(1_100);
expect(swings()).toHaveLength(1);
advanceDungeonPartyCombat(2_800);
expect(swings()).toHaveLength(2);
expect(usePartyStore.getState().members[0].resource).toBe(12);
usePartyStore.getState().setCommand("stop");
advanceDungeonPartyCombat(5_000);
expect(swings()).toHaveLength(2);
});
it("casts Thunder Clap around the tank rather than rejecting its self range or centering it on the enemy", () => {
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
resetPartyRuntimeClock();
useCombatStore.getState().initializeCharacter({ classId: "priest", level: 5 });
const garrick = partyTemplates(5).find((member) => member.name === "Garrick")!;
usePartyStore.getState().initializeParty("area-pull", 5, useCombatStore.getState().health);
usePartyStore.setState({ members: [{ ...garrick, resource: 100, resourcePools: { rage: 100 },
cooldowns: Object.fromEntries(abilitiesForClass("warrior").filter((ability) => ability.id !== "warrior-thunder-clap")
.map((ability) => [ability.id, 60_000])) }] });
useCombatStore.getState().synchronizeActors();
registerPartyRuntimePosition(garrick.id, [0, 0, 0]);
for (const [id, x] of [["area:a", 7], ["area:b", -7], ["area:c", 10]] as const) {
useCombatStore.getState().registerMob(id, { name: id, maxHealth: 1_000, xpReward: 0 });
setMobPosition(id, [x, 0, 0]);
updatePartyRuntimeTargetVisibility(garrick.id, id, true);
}
useCombatStore.getState().damageMob("area:a", 1, 100);
updatePartyRuntimeTargetVisibility(garrick.id, "area:a", true);
advanceDungeonPartyCombat(1_000);
const damage = useCombatStore.getState().combatEvents.filter((event) => event.sourceActorId === garrick.id && event.kind === "damage");
expect(damage.map((event) => event.targetActorId).sort()).toEqual(["area:a", "area:b"]);
expect(damage.every((event) => event.abilityId === "warrior-thunder-clap" && event.effectiveAmount > 0)).toBe(true);
expect(useCombatStore.getState().mobs["area:c"].health).toBe(1_000);
});
for (const level of [1, 3, 5, 20, 80]) {
it(`executes damaging abilities for every party template at level ${level}`, () => {
const templates = partyTemplates(level);
expect(templates).toHaveLength(26);
for (const member of templates) {
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
resetPartyRuntimeClock();
useCombatStore.getState().initializeCharacter({ classId: "priest", level });
usePartyStore.getState().initializeParty("damage-audit", level, useCombatStore.getState().health);
usePartyStore.setState({ members: [member] });
useCombatStore.getState().synchronizeActors();
registerPartyRuntimePosition(member.id, partyUsesRangedAttacks(member) ? [-12, 0, 0] : [0, 0, 0]);
useCombatStore.getState().registerMob("audit", { name: "Training target", maxHealth: 100_000, xpReward: 0 });
setMobPosition("audit", [2, 0, 0]);
useCombatStore.getState().damageMob("audit", 1, 100);
updatePartyRuntimeTargetVisibility(member.id, "audit", true);
const releases: string[] = [];
const unsubscribe = subscribeCombatPresentation((message) => {
if (message.kind === "event" && message.event.sourceActorId === member.id && message.event.phase === "release") {
releases.push(message.event.name);
}
});
for (let now = 1_000; now <= 31_000; now += 100) {
useCombatStore.getState().tick(0.1, now);
advanceDungeonPartyCombat(now);
}
unsubscribe();
const damageEvents = useCombatStore.getState().combatEvents.filter((event) => event.kind === "damage" && event.sourceActorId === member.id);
const damage = damageEvents
.reduce((total, event) => total + event.effectiveAmount, 0);
const description = `${member.name}, ${member.classId}:${member.specialization}, level ${level}`;
expect(damage, description).toBeGreaterThan(1);
expect(releases.filter((name) => name !== "Auto Attack").length, description).toBeGreaterThan(0);
expect(damageEvents.some((event) => event.abilityId !== "party-auto-attack" && event.effectiveAmount > 0), description).toBe(true);
const finalMember = usePartyStore.getState().members[0];
expect(finalMember.resource, description).toBeGreaterThanOrEqual(0);
expect(finalMember.resource, description).toBeLessThanOrEqual(finalMember.maxResource);
}
});
}
});
+2 -2
View File
@@ -1,6 +1,6 @@
import { BufferGeometry, Float32BufferAttribute, type Group, type Mesh } from "three"; import { BufferGeometry, Float32BufferAttribute, type Group, type Mesh } from "three";
import type { DungeonNavigationLink, DungeonTraversalRamp } from "./dungeonTypes"; import type { DungeonNavigationLink, DungeonTraversalRamp } from "./dungeonTypes";
import { bridgeDungeonStairNavigation, smoothDungeonStairGeometry } from "./dungeonTraversalRamps"; import { bridgeDungeonStairNavigation, dungeonRampNavigationBridges, smoothDungeonStairGeometry } from "./dungeonTraversalRamps";
import { import {
buildPartyNavigationGraph, buildPartyNavigationGraph,
type PartyNavigationGraph, type PartyNavigationGraph,
@@ -77,7 +77,7 @@ export function preparePartyNavigationGraph(
smoothDungeonStairGeometry(geometry, ramps, 0.05); smoothDungeonStairGeometry(geometry, ramps, 0.05);
bridgeDungeonStairNavigation(geometry, ramps, 0.05); bridgeDungeonStairNavigation(geometry, ramps, 0.05);
} }
const bridgedLinks = new Set(ramps.map((ramp) => ramp.navigationBridge.linkId)); const bridgedLinks = new Set(ramps.flatMap((ramp) => dungeonRampNavigationBridges(ramp).map((bridge) => bridge.linkId)));
graph = buildPartyNavigationGraph(geometry, { graph = buildPartyNavigationGraph(geometry, {
maxSourceTriangles: MAX_SYNCHRONOUS_PARTY_NAVIGATION_TRIANGLES, maxSourceTriangles: MAX_SYNCHRONOUS_PARTY_NAVIGATION_TRIANGLES,
offMeshLinks: offMeshLinks.filter((link) => !bridgedLinks.has(link.id)), offMeshLinks: offMeshLinks.filter((link) => !bridgedLinks.has(link.id)),
+114 -15
View File
@@ -28,6 +28,7 @@ import {
updatePartyRuntimePosition, updatePartyRuntimePosition,
} from "./partyRuntimeRegistry"; } from "./partyRuntimeRegistry";
import { usePartyStore, type PartyCommand, type PartyMember } from "./partyStore"; import { usePartyStore, type PartyCommand, type PartyMember } from "./partyStore";
import { baseClassFor, classById } from "../app/characterCatalog";
import { useGameStore } from "./store"; import { useGameStore } from "./store";
import { useManastormStore } from "./manastormStore"; import { useManastormStore } from "./manastormStore";
import { activeDungeonCompletedObjectiveIds } from "./dungeonEncounterRuntime"; import { activeDungeonCompletedObjectiveIds } from "./dungeonEncounterRuntime";
@@ -54,6 +55,7 @@ import {
} from "./partyAbilityAi"; } from "./partyAbilityAi";
import { normalizeActorResourcePool } from "./combatActors"; import { normalizeActorResourcePool } from "./combatActors";
import { advanceCombatResourcePool } from "./combatResources"; import { advanceCombatResourcePool } from "./combatResources";
import { channelAmountEffects } from "./combatChannelAmounts";
import { import {
combatHasLineOfSight, combatHasLineOfSight,
combatSpatialActor, combatSpatialActor,
@@ -69,6 +71,7 @@ const TANK_TAUNT_COOLDOWN_MS = 8_000;
const TANK_TAUNT_DURATION_MS = 3_000; const TANK_TAUNT_DURATION_MS = 3_000;
let lastAdvanceAt = 0; let lastAdvanceAt = 0;
let nextRegenAt = 0; let nextRegenAt = 0;
const nextWeaponAttackAt = new Map<string, number>();
export interface PartyPresentationIdentity { export interface PartyPresentationIdentity {
readonly spellId: number; readonly spellId: number;
@@ -141,6 +144,7 @@ export function activePartyBossObjectives(): readonly PartyBossObjective[] {
export function resetPartyRuntimeClock(): void { export function resetPartyRuntimeClock(): void {
lastAdvanceAt = 0; lastAdvanceAt = 0;
nextRegenAt = 0; nextRegenAt = 0;
nextWeaponAttackAt.clear();
} }
export function activePartyTargetId( export function activePartyTargetId(
@@ -167,7 +171,7 @@ export function partyAttackDamage(
specialization: member.specialization ?? "", specialization: member.specialization ?? "",
role: member.role, role: member.role,
}) })
? classId === "hunter" ? "ranged" : "spell" ? baseClassFor(classId) === "hunter" ? "ranged" : "spell"
: "melee"; : "melee";
const weapon = kind === "ranged" ? gear.rangedWeapon : kind === "melee" ? gear.meleeWeapon : null; const weapon = kind === "ranged" ? gear.rangedWeapon : kind === "melee" ? gear.meleeWeapon : null;
const equipmentPower = kind === "spell" const equipmentPower = kind === "spell"
@@ -193,15 +197,30 @@ export function partyAttackDamage(
} }
export function partyUsesRangedAttacks( export function partyUsesRangedAttacks(
member: Pick<PartyMember, "classId" | "specialization" | "role">, member: Pick<PartyMember, "classId" | "specialization" | "role"> & Partial<Pick<PartyMember, "level" | "talentRanks">>,
): boolean { ): boolean {
if (member.role === "tank") return false; if (member.role === "tank") return false;
if (member.role === "healer") return true; if (member.role === "healer") return true;
const definition = classById(member.classId);
if (definition.mode !== "classic") {
return (baseClassFor(member.classId) === "hunter"
|| ["intellect", "wisdom", "spirit"].includes(definition.primaryStat))
&& partyCatalogAttackRange(member) > 5;
}
return ["hunter", "mage", "priest", "warlock"].includes(member.classId) return ["hunter", "mage", "priest", "warlock"].includes(member.classId)
|| member.specialization === "Elemental" || member.specialization === "Elemental"
|| member.specialization === "Balance"; || member.specialization === "Balance";
} }
function partyCatalogAttackRange(member: Pick<PartyMember, "classId"> & Partial<Pick<PartyMember, "level" | "talentRanks">>): number {
return Math.max(0, ...abilitiesForClass(member.classId)
.filter((ability) => !ability.passive && ability.unlockLevel <= (member.level ?? 80)
&& (ability.talentEntryId === undefined || (member.talentRanks?.[`coa-entry-${ability.talentEntryId}`] ?? 0) > 0))
.map((ability) => abilityAtLevel(ability, member.level ?? 80))
.filter((ability) => ability.effects.some((effect) => ["damage", "dot", "finisher-damage", "execute"].includes(effect.kind)))
.map((ability) => ability.range.max));
}
export function partyAttackRange(member: Pick<PartyMember, "classId" | "specialization" | "role">): number { export function partyAttackRange(member: Pick<PartyMember, "classId" | "specialization" | "role">): number {
if (member.role === "tank") return 3.8; if (member.role === "tank") return 3.8;
if (partyUsesRangedAttacks(member)) return 19; if (partyUsesRangedAttacks(member)) return 19;
@@ -236,9 +255,18 @@ const MELEE_DAMAGE_COMBAT_RANGE_BAND: PartyCombatRangeBand = Object.freeze({
* target instead of treating the tank's position as their own. * target instead of treating the tank's position as their own.
*/ */
export function partyCombatRangeBand( export function partyCombatRangeBand(
member: Pick<PartyMember, "classId" | "specialization" | "role">, member: Pick<PartyMember, "classId" | "specialization" | "role"> & Partial<Pick<PartyMember, "level" | "talentRanks">>,
): PartyCombatRangeBand { ): PartyCombatRangeBand {
if (partyUsesRangedAttacks(member)) return RANGED_COMBAT_RANGE_BAND; if (partyUsesRangedAttacks(member)) {
if (classById(member.classId).mode !== "classic") {
const attackRange = partyCatalogAttackRange(member);
if (attackRange <= 5) return RANGED_COMBAT_RANGE_BAND;
const maximum = Math.min(RANGED_COMBAT_RANGE_BAND.maximum, attackRange - 0.1);
const preferred = Math.min(RANGED_COMBAT_RANGE_BAND.preferred, maximum - 0.5);
return { minimum: Math.min(RANGED_COMBAT_RANGE_BAND.minimum, preferred - 0.5), preferred, maximum };
}
return RANGED_COMBAT_RANGE_BAND;
}
return member.role === "tank" ? TANK_COMBAT_RANGE_BAND : MELEE_DAMAGE_COMBAT_RANGE_BAND; return member.role === "tank" ? TANK_COMBAT_RANGE_BAND : MELEE_DAMAGE_COMBAT_RANGE_BAND;
} }
@@ -599,7 +627,7 @@ function memberSwingMs(member: PartyMember): number {
ratingPercent( ratingPercent(
member.gearStats.hasteRating, member.gearStats.hasteRating,
partyUsesRangedAttacks(member) partyUsesRangedAttacks(member)
? member.classId === "hunter" ? "rangedHaste" : "spellHaste" ? baseClassFor(member.classId) === "hunter" ? "rangedHaste" : "spellHaste"
: "meleeHaste", : "meleeHaste",
member.level, member.level,
member.classId, member.classId,
@@ -639,6 +667,33 @@ function emitPartyAbility(
}); });
} }
/** Weapon swings continue during the GCD and supply Rage for paid attacks. */
function advancePartyWeaponAttack(member: PartyMember, targetId: string, now: number): PartyMember {
if (partyUsesRangedAttacks(member) && baseClassFor(member.classId) !== "hunter") return member;
const origin = getPartyRuntimePosition(member.id) ?? useCombatStore.getState().playerPosition;
const targetPosition = getMobPosition(targetId);
const spatial = combatSpatialActor(member.id);
if (!targetPosition || !partyRuntimeTargetVisible(member.id, targetId)
|| distanceSquared(origin, targetPosition) > partyAttackRange(member) ** 2
|| Math.abs(origin[1] - targetPosition[1]) > 8
|| !combatHasLineOfSight(origin, targetPosition)
|| (spatial && !isPositionInFacingArc(spatial, targetPosition, Math.PI))
|| now < (nextWeaponAttackAt.get(member.id) ?? 0)) return member;
nextWeaponAttackAt.set(member.id, now + memberSwingMs(member));
const abilityId = "party-auto-attack";
const result = useCombatStore.getState().damageMob(targetId, partyAttackDamage(member), now,
{ actorId: member.id, role: member.role, abilityId }, "physical");
const identity = partyAttackPresentation(member);
for (const phase of ["release", "impact"] as const) {
emitCombatPresentation({ occurredAt: now, phase, actorGroup: "party", sourceActorId: member.id,
targetActorId: targetId, abilityId, name: "Auto Attack", source: "fallback", school: "physical",
delivery: baseClassFor(member.classId) === "hunter" ? "projectile" : identity.delivery, origin, targetPosition, instantCast: true });
}
if (member.resourceType !== "rage" || result.amount <= 0) return member;
const resource = Math.min(member.maxResource, member.resource + 6);
return { ...member, resource, resourcePools: { ...member.resourcePools, rage: resource } };
}
function partyFriendlyPosition(actorId: string): MobVector3 | null { function partyFriendlyPosition(actorId: string): MobVector3 | null {
if (actorId === PLAYER_AGGRO_ID) return useCombatStore.getState().playerPosition; if (actorId === PLAYER_AGGRO_ID) return useCombatStore.getState().playerPosition;
return getPartyRuntimePosition(actorId); return getPartyRuntimePosition(actorId);
@@ -661,8 +716,11 @@ function partyAbilityTargetIsValid(
if (!hostile && (!targetActor?.alive || targetActor.health <= 0) if (!hostile && (!targetActor?.alive || targetActor.health <= 0)
&& !ability.effects.some((effect) => effect.kind === "resurrection")) return false; && !ability.effects.some((effect) => effect.kind === "resurrection")) return false;
const distance = Math.sqrt(distanceSquared(origin, targetPosition)); const distance = Math.sqrt(distanceSquared(origin, targetPosition));
if (distance < ability.range.min || distance > ability.range.max || Math.abs(origin[1] - targetPosition[1]) > 8) return false;
const targeting = targetingRulesForAbility(ability); const targeting = targetingRulesForAbility(ability);
const maximumRange = hostile && ability.target === "self"
? targeting.radius ?? ability.radius ?? ability.range.max
: ability.range.max;
if (distance < ability.range.min || distance > maximumRange || Math.abs(origin[1] - targetPosition[1]) > 8) return false;
if (targeting.requiresLineOfSight && !combatHasLineOfSight(origin, targetPosition)) return false; if (targeting.requiresLineOfSight && !combatHasLineOfSight(origin, targetPosition)) return false;
if (hostile && targeting.requiresFacing) { if (hostile && targeting.requiresFacing) {
const sourceSpatial = combatSpatialActor(member.id); const sourceSpatial = combatSpatialActor(member.id);
@@ -706,7 +764,7 @@ function partyHostileTargets(
.filter((mob) => { .filter((mob) => {
const position = getMobPosition(mob.id); const position = getMobPosition(mob.id);
return !mob.dead && Boolean(position return !mob.dead && Boolean(position
&& distanceSquared(origin, position) <= ability.range.max ** 2 && distanceSquared(origin, position) <= (targeting.radius ?? ability.radius ?? ability.range.max) ** 2
&& combatHasLineOfSight(origin, position) && combatHasLineOfSight(origin, position)
&& (!memberSpatial || isPositionInFacingArc(memberSpatial, position, targeting.attackArcRadians ?? Math.PI / 2))); && (!memberSpatial || isPositionInFacingArc(memberSpatial, position, targeting.attackArcRadians ?? Math.PI / 2)));
}) })
@@ -715,12 +773,13 @@ function partyHostileTargets(
} }
if (targeting.shape === "circle" || targeting.shape === "ground" || ability.radius !== undefined) { if (targeting.shape === "circle" || targeting.shape === "ground" || ability.radius !== undefined) {
const radius = targeting.radius ?? ability.radius ?? 0; const radius = targeting.radius ?? ability.radius ?? 0;
const center = ability.target === "self" ? origin : primaryPosition;
return Object.values(combat.mobs) return Object.values(combat.mobs)
.filter((mob) => { .filter((mob) => {
const position = getMobPosition(mob.id); const position = getMobPosition(mob.id);
return !mob.dead && Boolean(position return !mob.dead && Boolean(position
&& distanceSquared(primaryPosition, position) <= radius * radius && distanceSquared(center, position) <= radius * radius
&& combatHasLineOfSight(primaryPosition, position)); && combatHasLineOfSight(center, position));
}) })
.map((mob) => mob.id) .map((mob) => mob.id)
.sort(); .sort();
@@ -856,7 +915,7 @@ function executePartyAbility(
? Boolean(useCombatStore.getState().createActorAreaEffect( ? Boolean(useCombatStore.getState().createActorAreaEffect(
member.id, member.id,
ability, ability,
getMobPosition(targetId) ?? partyFriendlyPosition(targetId) ?? origin, ability.target === "self" ? origin : getMobPosition(targetId) ?? partyFriendlyPosition(targetId) ?? origin,
now, now,
)) ))
: false; : false;
@@ -1143,6 +1202,7 @@ export function advanceDungeonPartyCombat(now = Date.now()): void {
member = activeMember; member = activeMember;
const currentCombat = useCombatStore.getState(); const currentCombat = useCombatStore.getState();
if (member.activeCast) { if (member.activeCast) {
const activeCast = member.activeCast;
const castingAbility = abilityById(member.activeCast.abilityId); const castingAbility = abilityById(member.activeCast.abilityId);
const castTargetId = member.activeCastTargetId; const castTargetId = member.activeCastTargetId;
if (!castingAbility || !castTargetId) { if (!castingAbility || !castTargetId) {
@@ -1163,7 +1223,31 @@ export function advanceDungeonPartyCombat(now = Date.now()): void {
status: "advancing", nextActionAt: now + 250 }; status: "advancing", nextActionAt: now + 250 };
continue; continue;
} }
if (now < member.activeCast.completesAt) { if (member.activeCast.mode === "channel") {
const cast = member.activeCast;
if (!partyAbilityTargetIsValid(member, castingAbility, castTargetId, cast.origin)) {
emitPartyAbility(member, castingAbility, castTargetId, "cast-cancel", now);
nextMembers[index] = { ...member, activeCast: null, activeCastTargetId: null, status: "advancing", nextActionAt: now + 250 };
continue;
}
const interval = Math.max(1, cast.originalDurationMs / 5);
let completed = cast.channelTicksCompleted ?? 0;
while (completed < 5 && cast.startedAt + (completed + 1) * interval <= now) {
const effects: AbilityEffect[] = channelAmountEffects(castingAbility, 5).map(({ effect, multiplier }) => ({
kind: effect.kind === "heal" || effect.kind === "hot" ? "heal" : "damage",
coefficient: 0, basePoints: Math.round(partyAbilityAmount(member, castingAbility, effect) * multiplier),
bonusPowerCoefficient: 0, sourceLevel: member.level,
...(effect.kind === "heal" && effect.percentMaxHealth !== undefined
? { percentMaxHealth: effect.percentMaxHealth * multiplier } : {}),
}));
const execution = executePartyAbility(member, { ...castingAbility, effects }, castTargetId,
cast.startedAt + (completed + 1) * interval, nextMembers, 0, true);
member = execution.member;
completed += 1;
}
member = { ...member, activeCast: { ...cast, channelTicksCompleted: completed } };
}
if (now < activeCast.completesAt) {
nextMembers[index] = member.status === desiredStatus ? member : { ...member, status: desiredStatus }; nextMembers[index] = member.status === desiredStatus ? member : { ...member, status: desiredStatus };
if (hostileCast) memberTargetIds[member.id] = castTargetId; if (hostileCast) memberTargetIds[member.id] = castTargetId;
continue; continue;
@@ -1173,12 +1257,14 @@ export function advanceDungeonPartyCombat(now = Date.now()): void {
completedMember, completedMember,
castingAbility, castingAbility,
castTargetId, castTargetId,
member.activeCast.origin, activeCast.origin,
); );
if (valid) { if (valid) {
const execution = executePartyAbility( const execution = executePartyAbility(
completedMember, completedMember,
castingAbility, activeCast.mode === "channel"
? { ...castingAbility, effects: castingAbility.effects.filter((effect) => !["damage", "dot", "heal", "hot"].includes(effect.kind)) }
: castingAbility,
castTargetId, castTargetId,
now, now,
nextMembers, nextMembers,
@@ -1284,7 +1370,11 @@ export function advanceDungeonPartyCombat(now = Date.now()): void {
const targetVisible = Boolean( const targetVisible = Boolean(
targetPosition && partyRuntimeTargetVisible(member.id, memberTargetId) targetPosition && partyRuntimeTargetVisible(member.id, memberTargetId)
); );
let runtimeMember = member; let runtimeMember = targetVisible ? advancePartyWeaponAttack(member, memberTargetId, now) : member;
if (useCombatStore.getState().mobs[memberTargetId]?.dead) {
nextMembers[index] = { ...runtimeMember, status: desiredStatus };
continue;
}
const targetMob = currentCombat.mobs[memberTargetId]; const targetMob = currentCombat.mobs[memberTargetId];
if (now < member.nextActionAt) { if (now < member.nextActionAt) {
if (runtimeMember.status !== desiredStatus) { if (runtimeMember.status !== desiredStatus) {
@@ -1297,9 +1387,18 @@ export function advanceDungeonPartyCombat(now = Date.now()): void {
now, now,
target: targetMob ?? null, target: targetMob ?? null,
targetIsLoose: member.role === "tank" && targetMob?.targetActorId !== member.id, targetIsLoose: member.role === "tank" && targetMob?.targetActorId !== member.id,
canUseAbility: (ability) => partyAbilityTargetIsValid(runtimeMember, ability, memberTargetId)
// Let maintained effects tick instead of resetting their first tick on every GCD.
&& !currentCombat.effects.some((effect) => effect.sourceActorId === member.id
&& effect.sourceAbilityId === ability.id && effect.targetId === memberTargetId && effect.remainingTicks > 0)
&& !ability.effects.some((effect) => effect.kind === "apply-aura"
&& currentCombat.auras.some((aura) => aura.sourceId === member.id
&& aura.definition.id === effect.aura.id && aura.targetId === memberTargetId)),
}); });
if (!selectedAbility) { if (!selectedAbility) {
nextMembers[index] = { ...runtimeMember, status: desiredStatus, nextActionAt: now + 250 }; const inAttackRange = targetVisible && targetPosition
&& distanceSquared(memberPosition, targetPosition) <= partyAttackRange(member) ** 2;
nextMembers[index] = { ...runtimeMember, status: inAttackRange ? desiredStatus : "advancing", nextActionAt: now + 250 };
continue; continue;
} }
const inRange = targetVisible && partyAbilityTargetIsValid(runtimeMember, selectedAbility, memberTargetId); const inRange = targetVisible && partyAbilityTargetIsValid(runtimeMember, selectedAbility, memberTargetId);
+46
View File
@@ -1,6 +1,7 @@
import { beforeEach, describe, expect, it } from "vitest"; import { beforeEach, describe, expect, it } from "vitest";
import { import {
APPEARANCE_KEYS, APPEARANCE_KEYS,
ROM_CLASS_IDS,
classesForRace, classesForRace,
raceById, raceById,
supportedGenders, supportedGenders,
@@ -80,6 +81,51 @@ describe("dungeon party composition", () => {
expect(first.every((member) => member.level === 18 && member.health === member.maxHealth)).toBe(true); expect(first.every((member) => member.level === 18 && member.health === member.maxHealth)).toBe(true);
}); });
it("syncs every RoM companion class through entry and level changes without WoW talents", () => {
const seenClasses = new Set<string>();
for (const role of ["tank", "healer", "damage"] as const) {
for (let seed = 0; seed < 20; seed += 1) {
usePartyStore.getState().initializeParty(`rom-level-${seed}`, 1, 100, role, 5, "rom");
const identities = usePartyStore.getState().members.map(({ id, classId, raceId, gender, appearance }) => ({
id, classId, raceId, gender, appearance,
}));
for (const level of [2, 10, 70, 1]) {
usePartyStore.getState().syncLevel(level);
const members = usePartyStore.getState().members;
expect(members.map(({ id, classId, raceId, gender, appearance }) => ({
id, classId, raceId, gender, appearance,
}))).toEqual(identities);
for (const member of members) {
seenClasses.add(member.classId);
expect(member.level).toBe(level);
expect(member.talentRanks).toEqual({});
expect(Object.isFrozen(member.talentRanks)).toBe(true);
expect(member.health).toBe(member.maxHealth);
expect(member.resourcePools[member.resourceType]).toBe(member.resource);
}
}
}
}
expect([...seenClasses].sort()).toEqual([...ROM_CLASS_IDS].sort());
});
it("preserves fallen RoM companions and living health/resource ratios on level sync", () => {
usePartyStore.getState().initializeParty("rom-level-ratios", 10, 100, "damage", 5, "rom");
const [fallen, healer] = usePartyStore.getState().members;
usePartyStore.getState().updateMemberCombat(fallen.id, { health: 0, status: "down" });
usePartyStore.getState().updateMemberCombat(healer.id, {
health: Math.round(healer.maxHealth * 0.5),
resource: Math.round(healer.maxResource * 0.25),
});
const before = usePartyStore.getState().members[1];
usePartyStore.getState().syncLevel(11);
const [syncedFallen, syncedHealer] = usePartyStore.getState().members;
expect(syncedFallen).toMatchObject({ health: 0, status: "down", level: 11, talentRanks: {} });
expect(syncedHealer.health).toBe(Math.round(before.health / before.maxHealth * syncedHealer.maxHealth));
expect(syncedHealer.resource).toBe(Math.round(before.resource / before.maxResource * syncedHealer.maxResource));
expect(syncedHealer.resourcePools.mana).toBe(syncedHealer.resource);
});
it("supports solo through five-person Manastorm party sizes without changing the dungeon default", () => { it("supports solo through five-person Manastorm party sizes without changing the dungeon default", () => {
expect(createDungeonParty("sizes", 18)).toHaveLength(4); expect(createDungeonParty("sizes", 18)).toHaveLength(4);
for (const size of [1, 2, 3, 4, 5] as const) { for (const size of [1, 2, 3, 4, 5] as const) {
+3 -1
View File
@@ -496,7 +496,9 @@ export const usePartyStore = create<PartyState>((set, get) => ({
resource, resource,
maxResource, maxResource,
resourcePools: Object.freeze({ ...member.resourcePools, [member.resourceType]: resource }), resourcePools: Object.freeze({ ...member.resourcePools, [member.resourceType]: resource }),
talentRanks: runtimePartyTalentRanksForLevel(member.classId, member.specialization, safeLevel), talentRanks: isRomClassId(member.classId)
? Object.freeze({})
: runtimePartyTalentRanksForLevel(member.classId, member.specialization, safeLevel),
}; };
}), }),
}; };
+200
View File
@@ -0,0 +1,200 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { installCombatClock } from "./combatClock";
import { PLAYER_AURA_ENTITY_ID, useCombatStore } from "./combatStore";
import { partyFightStillActive, respawnDefeatedPlayer } from "./deathRespawn";
import { activateDungeonEncounterDefinitions, advanceDungeonEncounters, resetDungeonEncounters } from "./dungeonEncounterRuntime";
import { requireDungeonDefinition } from "./dungeonRegistry";
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
import { advanceDungeonPartyCombat, resetPartyRuntimeClock } from "./partyRuntime";
import { clearPartyRuntimeRegistry, getPartyRuntimePosition, registerPartyRuntimePosition, updatePartyRuntimeTargetVisibility } from "./partyRuntimeRegistry";
import { usePartyStore } from "./partyStore";
import { useGameStore } from "./store";
const dungeon = requireDungeonDefinition("stormwind-stockade");
const fightPosition = [2, 0, 0] as const;
function startFight(boss = false): string {
const id = boss ? dungeon.bosses[1].id : "ongoing-pack";
useCombatStore.getState().registerMob(id, { name: id, maxHealth: 100_000, boss, xpReward: 0 });
setMobPosition(id, fightPosition);
const tank = usePartyStore.getState().members[0];
useCombatStore.getState().damageMob(id, 1, 100, { actorId: tank.id, role: "tank" });
for (const member of usePartyStore.getState().members) {
updatePartyRuntimeTargetVisibility(member.id, id, true);
}
return id;
}
function killPlayer(): void {
useCombatStore.getState().damagePlayer(useCombatStore.getState().maxHealth * 10);
useCombatStore.getState().synchronizeActors();
expect(useCombatStore.getState().health).toBe(0);
}
describe("personal checkpoint resurrection during an ongoing party fight", () => {
let restoreClock: () => void;
beforeEach(() => {
restoreClock = installCombatClock(() => 1_000);
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
resetPartyRuntimeClock();
useGameStore.getState().activateDungeon(dungeon.id);
activateDungeonEncounterDefinitions(dungeon.id, dungeon.encounters ?? []);
resetDungeonEncounters("dungeon", dungeon.id);
useCombatStore.getState().initializeCharacter({ classId: "priest", level: 16 });
usePartyStore.getState().initializeParty("personal-respawn", 16, useCombatStore.getState().health);
usePartyStore.getState().selectMember(null);
for (const member of usePartyStore.getState().members) {
registerPartyRuntimePosition(member.id, [0, 0, 0]);
}
useCombatStore.getState().synchronizeActors();
});
afterEach(() => restoreClock());
it("revives only the player without restoring companions, resetting the pull, or triggering Recall", () => {
const id = startFight();
advanceDungeonPartyCombat(1_000);
useCombatStore.getState().advanceMobCombat(1_000);
const members = usePartyStore.getState().members;
useCombatStore.getState().commitCompanionActors([
{ ...members[0], health: Math.round(members[0].maxHealth / 2) },
{ ...members[1], health: 0, status: "down" },
{ ...members[2], activeCast: {
abilityId: "ongoing-party-cast", school: "fire", mode: "cast", startedAt: 1_000,
completesAt: 5_000, originalDurationMs: 4_000, pushbackHits: 0, targetId: id,
}, activeCastTargetId: id },
]);
killPlayer();
const combatBefore = useCombatStore.getState();
const partyBefore = usePartyStore.getState();
const gameBefore = useGameStore.getState();
const positions = partyBefore.members.map((member) => getPartyRuntimePosition(member.id));
expect(respawnDefeatedPlayer("entrance")).toBe(true);
const after = useCombatStore.getState();
expect(after.health).toBe(after.maxHealth);
expect(after.resource).toBe(after.maxResource);
expect(after.playerPosition).toEqual(dungeon.entrance.footPosition);
expect(after.actors[PLAYER_AURA_ENTITY_ID]).toMatchObject({ alive: true, health: after.maxHealth });
expect(after.playerAnimationEvent?.kind).toBe("ability-cancel");
expect(after.mobs).toBe(combatBefore.mobs);
expect(usePartyStore.getState()).toBe(partyBefore);
for (const [index, member] of partyBefore.members.entries()) {
expect(after.actors[member.id]).toBe(combatBefore.actors[member.id]);
expect(getPartyRuntimePosition(member.id)).toEqual(positions[index]);
}
expect(useGameStore.getState()).toMatchObject({
resetRevision: gameBefore.resetRevision,
encounterResetRevision: gameBefore.encounterResetRevision,
relocationRevision: gameBefore.relocationRevision + 1,
playerPosition: dungeon.entrance.footPosition,
});
expect(usePartyStore.getState().recallRevision).toBe(partyBefore.recallRevision);
});
it("allows personal resurrection during a boss fight at an already unlocked boss checkpoint", () => {
const completed = dungeon.bosses[0];
advanceDungeonEncounters(dungeon.id, {
kind: "tick", now: 100,
facts: { deadEntityIds: [completed.id], registeredEntityIds: [completed.id] },
});
const currentBoss = startFight(true);
advanceDungeonPartyCombat(1_000);
killPlayer();
const before = useCombatStore.getState().mobs[currentBoss];
const revision = useGameStore.getState().resetRevision;
expect(respawnDefeatedPlayer(`boss:${completed.id}`)).toBe(true);
expect(useGameStore.getState().playerPosition).toEqual(completed.position);
expect(useCombatStore.getState().mobs[currentBoss]).toBe(before);
expect(before.engaged).toBe(true);
expect(useGameStore.getState().resetRevision).toBe(revision);
});
it("continues the actual party and enemy combat ticks after the player leaves for the entrance", () => {
const id = startFight();
advanceDungeonPartyCombat(1_000);
useCombatStore.getState().advanceMobCombat(1_000);
killPlayer();
expect(respawnDefeatedPlayer("entrance")).toBe(true);
const beforeMobHealth = useCombatStore.getState().mobs[id].health;
const tank = usePartyStore.getState().members.find((member) => member.role === "tank")!;
const beforeAttackRevision = useCombatStore.getState().mobs[id].attackRevision;
advanceDungeonPartyCombat(3_000);
const nextAttackTime = Math.max(3_000, ...useCombatStore.getState().mobs[id].statuses.map((status) => status.endsAt)) + 1_000;
useCombatStore.getState().advanceMobCombat(nextAttackTime, dungeon.entrance.footPosition);
expect(useCombatStore.getState().mobs[id].engaged).toBe(true);
expect(useCombatStore.getState().mobs[id].health).toBeLessThan(beforeMobHealth);
expect(useCombatStore.getState().mobs[id].attackRevision).toBeGreaterThan(beforeAttackRevision);
expect(useCombatStore.getState().mobs[id].targetActorId).toBe(tank.id);
expect(useCombatStore.getState().health).toBe(useCombatStore.getState().maxHealth);
});
it("keeps companions' periodic healing and enemies' ongoing damage while removing old player HoTs", () => {
const id = startFight();
const renew = useCombatStore.getState().abilities.find((ability) => ability.name === "Renew")!;
expect(useCombatStore.getState().castAbility(renew.id, undefined, 1_000).ok).toBe(true);
const selfHot = useCombatStore.getState().effects.find((effect) => effect.kind === "hot")!;
const allyHot = { ...selfHot, id: "preserved-ally-hot", targetId: usePartyStore.getState().members[0].id };
const enemyDot = { ...selfHot, id: "preserved-enemy-dot", kind: "dot" as const, targetId: id };
const explicitPlayerHot = { ...selfHot, id: "removed-player-hot", targetId: PLAYER_AURA_ENTITY_ID };
useCombatStore.setState({ effects: [selfHot, allyHot, enemyDot, explicitPlayerHot] });
killPlayer();
useCombatStore.setState({ cooldowns: { "ongoing-player-cooldown": 100_000 } });
const cooldowns = useCombatStore.getState().cooldowns;
expect(respawnDefeatedPlayer("entrance")).toBe(true);
expect(useCombatStore.getState().effects).toEqual([allyHot, enemyDot]);
expect(useCombatStore.getState().cooldowns).toBe(cooldowns);
});
it.each(["party wiped", "fight ended"])("retains normal reset recovery when the %s", (reason) => {
const id = startFight();
if (reason === "party wiped") {
useCombatStore.getState().commitCompanionActors(usePartyStore.getState().members.map((member) => ({
...member, health: 0, status: "down" as const,
})));
} else {
useCombatStore.getState().damageMob(id, 100_000, 200);
}
killPlayer();
const before = useGameStore.getState().resetRevision;
expect(respawnDefeatedPlayer("entrance")).toBe(true);
expect(useGameStore.getState().resetRevision).toBe(before + 1);
expect(useCombatStore.getState().mobs[id].engaged).toBe(false);
});
it("uses canonical companion health and ignores defeated or despawned enemies", () => {
const id = startFight();
const combat = useCombatStore.getState();
const members = usePartyStore.getState().members;
expect(partyFightStillActive(members, combat.mobs, combat.actors)).toBe(true);
const actors = Object.fromEntries(members.map((member) => [member.id, { health: 0 }]));
expect(partyFightStillActive(members, combat.mobs, actors)).toBe(false);
expect(partyFightStillActive(members, { [id]: { ...combat.mobs[id], despawned: true } })).toBe(false);
expect(partyFightStillActive(members, { [id]: { ...combat.mobs[id], dead: true } })).toBe(false);
});
it("rejects locked checkpoints and leaves Manastorm's shared-charge resurrection separate", () => {
startFight(true);
killPlayer();
const before = useGameStore.getState();
expect(respawnDefeatedPlayer(`boss:${dungeon.bosses[0].id}`)).toBe(false);
expect(useGameStore.getState()).toBe(before);
expect(useCombatStore.getState().health).toBe(0);
useGameStore.setState({ gameMode: "manastorm" });
expect(respawnDefeatedPlayer("entrance")).toBe(false);
expect(useCombatStore.getState().health).toBe(0);
});
it("cannot use player-only recovery as a living-player teleport or with invalid coordinates", () => {
const before = useCombatStore.getState();
expect(before.respawnPlayerOnly([0, 0, 0], 1_000)).toBe(false);
expect(useCombatStore.getState()).toBe(before);
killPlayer();
const dead = useCombatStore.getState();
expect(dead.respawnPlayerOnly([NaN, 0, 0], 1_000)).toBe(false);
expect(dead.respawnPlayerOnly([0, 0, 0], NaN)).toBe(false);
expect(useCombatStore.getState()).toBe(dead);
});
});
+151
View File
@@ -0,0 +1,151 @@
import { afterAll, describe, expect, it } from "vitest";
import { mkdirSync, writeFileSync } from "node:fs";
import { resolve } from "node:path";
import { useCombatStore } from "./combatStore";
import { usePartyStore } from "./partyStore";
import { dungeonEnemyHealthBaseline, scaledDungeonEnemyLevel } from "./dungeonLevelScaling";
import { BARREN_CAVES_ENTITIES } from "./generated/barrenCavesPopulation.generated";
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
import { advanceDungeonPartyCombat, partyCombatRangeBand, resetPartyRuntimeClock } from "./partyRuntime";
import { clearPartyRuntimeRegistry, registerPartyRuntimePosition, updatePartyRuntimeTargetVisibility } from "./partyRuntimeRegistry";
import { subscribeCombatEvents } from "./combatEvents";
import { abilitiesForClass } from "./abilityCatalog";
const report: unknown[] = [];
const trash = BARREN_CAVES_ENTITIES["rw-barren-caves-101456"];
const boss = BARREN_CAVES_ENTITIES["rw-barren-caves-101462"];
afterAll(() => {
if (process.env.ROM_DUNGEON_HEALTH_REPORT !== "1") return;
const directory = resolve("artifacts/rom-dungeon-health");
mkdirSync(directory, { recursive: true });
writeFileSync(resolve(directory, "report.json"), `${JSON.stringify({ results: report }, null, 2)}\n`);
});
function initializePull(level: number, seed: string, maxHealth: number, isBoss = false): void {
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
resetPartyRuntimeClock();
useCombatStore.getState().initializeCharacter({ classId: "rom-priest", raceId: "rom-human", level });
useCombatStore.getState().setPlayerPosition([-18, 0, 0]);
usePartyStore.getState().initializeParty(seed, level, useCombatStore.getState().health, "healer", 5, "rom");
useCombatStore.getState().synchronizeActors();
const definition = isBoss ? boss : trash;
useCombatStore.getState().registerMob("pull", {
name: definition.name,
boss: isBoss,
level: scaledDungeonEnemyLevel(level, isBoss),
maxHealth,
healthMultiplier: definition.combat.healthMultiplier,
attacks: definition.combat.attacks,
xpReward: 0,
});
setMobPosition("pull", [0, 0, 0]);
for (const member of usePartyStore.getState().members) {
registerPartyRuntimePosition(member.id, [-partyCombatRangeBand(member).preferred, 0, 0]);
updatePartyRuntimeTargetVisibility(member.id, "pull", true);
}
useCombatStore.getState().damageMob("pull", 1, 100);
}
function measurePull(level: number, seed: string, maxHealth: number, isBoss = false) {
initializePull(level, seed, maxHealth, isBoss);
let firstDamageAt: number | null = null;
let diedAt: number | null = null;
let openingHealth = maxHealth;
const stop = subscribeCombatEvents((event) => {
if (event.targetActorId !== "pull") return;
if (event.kind === "damage" && event.effectiveAmount > 0) firstDamageAt ??= event.occurredAt;
});
try {
for (let now = 1_000; now <= 121_000; now += 100) {
useCombatStore.getState().tick(0.1, now);
advanceDungeonPartyCombat(now);
const mob = useCombatStore.getState().mobs.pull;
if (firstDamageAt !== null && now <= firstDamageAt + 1_000) openingHealth = mob.health;
if (mob.dead) {
diedAt = mob.diedAt ?? now;
break;
}
}
return { maxHealth, seconds: diedAt === null || firstDamageAt === null ? null : (diedAt - firstDamageAt) / 1_000,
openingHealth, classes: usePartyStore.getState().members.map((member) => member.classId) };
} finally {
stop();
}
}
describe("RoM dungeon enemy durability", () => {
for (const level of [1, 2, 4, 8, 9, 20, 40, 70, 80]) {
it(`Barren Caves trash survives a full RoM party's opening volley at level ${level}`, () => {
for (let seed = 0; seed < 12; seed += 1) {
const partySeed = `rom-health-${seed}`;
const before = measurePull(level, partySeed, 72 + level * 22);
const after = measurePull(level, partySeed, dungeonEnemyHealthBaseline(level, "runewaker")!);
report.push({ level, seed: partySeed, before, after });
expect(after.openingHealth, JSON.stringify({ level, before, after })).toBeGreaterThan(0);
expect(after.seconds, JSON.stringify({ level, before, after })).not.toBeNull();
expect(after.seconds!, JSON.stringify({ level, before, after })).toBeGreaterThanOrEqual(3);
expect(after.seconds!, JSON.stringify({ level, before, after })).toBeLessThanOrEqual(30);
}
});
}
it("retains Barren Caves' authored boss health multiplier", () => {
const level = 8;
const maxHealth = dungeonEnemyHealthBaseline(scaledDungeonEnemyLevel(level, true), "runewaker")!
* boss.combat.healthMultiplier;
const result = measurePull(level, "rom-health-0", maxHealth, true);
report.push({ level, boss: boss.name, ...result });
expect(result.seconds).not.toBeNull();
expect(result.seconds!).toBeGreaterThanOrEqual(20);
expect(result.seconds!).toBeLessThanOrEqual(120);
});
it("allows enemy attacks and Priest healing before an opening pull ends", () => {
const level = 1;
initializePull(level, "rom-health-0", dungeonEnemyHealthBaseline(level, "runewaker")!);
const heal = abilitiesForClass("rom-priest").find((ability) => ability.name === "Urgent Heal")!;
let incomingDamage = 0;
let healing = 0;
const stop = subscribeCombatEvents((event) => {
if (event.sourceActorId === "pull" && event.kind === "damage") incomingDamage += event.effectiveAmount;
if (event.kind === "healing") healing += event.effectiveAmount;
});
try {
for (let now = 1_000; now <= 31_000; now += 100) {
useCombatStore.getState().tick(0.1, now);
advanceDungeonPartyCombat(now);
useCombatStore.getState().advanceMobCombat(now);
const tank = usePartyStore.getState().members.find((member) => member.role === "tank")!;
if (tank.health < tank.maxHealth && !useCombatStore.getState().activeCast) {
usePartyStore.getState().selectMember(tank.id);
useCombatStore.getState().castAbility(heal.id, undefined, now);
}
if (useCombatStore.getState().mobs.pull.dead) break;
}
expect(useCombatStore.getState().mobs.pull.dead).toBe(true);
expect(incomingDamage).toBeGreaterThan(0);
expect(healing).toBeGreaterThan(0);
expect(usePartyStore.getState().members.every((member) => member.health > 0)).toBe(true);
} finally {
stop();
}
});
it("updates loaded enemies on level-up while retaining wounds and fallen enemies", () => {
const initial = dungeonEnemyHealthBaseline(1, "runewaker")!;
const scaled = dungeonEnemyHealthBaseline(8, "runewaker")!;
initializePull(1, "rom-health-level-up", initial);
useCombatStore.getState().damageMob("pull", initial / 2 - 1, 1_000);
useCombatStore.getState().registerMob("pull", { name: trash.name, level: 8, maxHealth: scaled });
expect(useCombatStore.getState().mobs.pull).toMatchObject({ level: 8, maxHealth: scaled, health: scaled / 2,
attackDamage: 20 });
useCombatStore.getState().registerMob("pull", { name: trash.name, level: 8, maxHealth: scaled });
expect(useCombatStore.getState().mobs.pull.health).toBe(scaled / 2);
useCombatStore.getState().damageMob("pull", scaled, 2_000);
useCombatStore.getState().registerMob("pull", { name: trash.name, level: 20,
maxHealth: dungeonEnemyHealthBaseline(20, "runewaker") });
expect(useCombatStore.getState().mobs.pull).toMatchObject({ dead: true, health: 0 });
});
});
+1 -1
View File
@@ -1 +1 @@
{"schemaVersion":1,"source":{"rootHint":"RuneWaker source snapshot","hashes":{"learnMagic":"6148199bcd1484ba1a122a6db5e2b3c2f2a161402bfe67fc67eb53f55fc6cfa3","magicCollect":"4aae19865cb07b30f99945aa7319a0096db244ff5e5b4b5d902fc6889474f501","magicObject":"52af13a2d8cfa9a66d4caa2d3c38832edc232f058995a64ef8135a6b82e43691","imageObject":"dd5ffcfd74b253dcecaa2f41a931a634dd45ef5f7c13e849d539ddc47fdee1f9","vocationTable":"f06cdaf60c54415dcbea47e9fa0e77b7527035a2aec73d3289370dc39751e76c","strings":"0c20d43660cb2ee042a78f7a8c18b4e104a263d54dd8c4bc98a54155e8adb3b0","eliteLua":"ee9e337548abd7fc555d85bf2f96813dfd77416c3e65d4cadf74a80838da1486","roleAttr":"8143a90ce826bf3152551a4508d212f186012cbc054e505e426551cb62369c1f","objectStruct":"708f2d087f2654b97d149ab7124d9cdffc915cf8bf18108feff3f1a45f124c20","interfaceFdb":"51bbb5a9d6a19a254b302000d3dd6e3a796f4f759015f3352f576a1055496e30"}},"canonicalClasses":[{"id":"rom-warrior","sourceClassId":1,"name":"Warrior","resource":"rage"},{"id":"rom-scout","sourceClassId":2,"name":"Scout","resource":"focus"},{"id":"rom-rogue","sourceClassId":3,"name":"Rogue","resource":"energy"},{"id":"rom-mage","sourceClassId":4,"name":"Mage","resource":"mana"},{"id":"rom-priest","sourceClassId":5,"name":"Priest","resource":"mana"},{"id":"rom-knight","sourceClassId":6,"name":"Knight","resource":"mana"},{"id":"rom-warden","sourceClassId":7,"name":"Warden","resource":"mana"},{"id":"rom-druid","sourceClassId":8,"name":"Druid","resource":"mana"},{"id":"rom-warlock","sourceClassId":9,"name":"Warlock","resource":"focus"},{"id":"rom-champion","sourceClassId":10,"name":"Champion","resource":"rage"}],"baseStatGrowth":{"rom-warrior":{"strength":{"levelOne":14,"flatGrowth":5,"percentGrowth":0.02},"stamina":{"levelOne":13,"flatGrowth":4.5,"percentGrowth":0.02},"intellect":{"levelOne":5,"flatGrowth":1.9,"percentGrowth":0.02},"wisdom":{"levelOne":14,"flatGrowth":4,"percentGrowth":0.02},"agility":{"levelOne":12,"flatGrowth":4.25,"percentGrowth":0.02},"health":{"levelOne":60,"flatGrowth":2,"percentGrowth":0.02},"mana":{"levelOne":20,"flatGrowth":2,"percentGrowth":0.02}},"rom-scout":{"strength":{"levelOne":11,"flatGrowth":3.85,"percentGrowth":0.02},"stamina":{"levelOne":10,"flatGrowth":3.65,"percentGrowth":0.02},"intellect":{"levelOne":10,"flatGrowth":3.6,"percentGrowth":0.02},"wisdom":{"levelOne":14,"flatGrowth":4,"percentGrowth":0.02},"agility":{"levelOne":13,"flatGrowth":4.5,"percentGrowth":0.02},"health":{"levelOne":60,"flatGrowth":2,"percentGrowth":0.02},"mana":{"levelOne":20,"flatGrowth":2,"percentGrowth":0.02}},"rom-rogue":{"strength":{"levelOne":12,"flatGrowth":4.15,"percentGrowth":0.02},"stamina":{"levelOne":11,"flatGrowth":3.9,"percentGrowth":0.02},"intellect":{"levelOne":7,"flatGrowth":2.6,"percentGrowth":0.02},"wisdom":{"levelOne":14,"flatGrowth":4,"percentGrowth":0.02},"agility":{"levelOne":14,"flatGrowth":5,"percentGrowth":0.02},"health":{"levelOne":60,"flatGrowth":2,"percentGrowth":0.02},"mana":{"levelOne":20,"flatGrowth":2,"percentGrowth":0.02}},"rom-mage":{"strength":{"levelOne":5,"flatGrowth":1.9,"percentGrowth":0.02},"stamina":{"levelOne":9,"flatGrowth":3.4,"percentGrowth":0.02},"intellect":{"levelOne":14,"flatGrowth":5,"percentGrowth":0.02},"wisdom":{"levelOne":14,"flatGrowth":5,"percentGrowth":0.02},"agility":{"levelOne":10,"flatGrowth":3.5,"percentGrowth":0.02},"health":{"levelOne":60,"flatGrowth":2,"percentGrowth":0.02},"mana":{"levelOne":20,"flatGrowth":2,"percentGrowth":0.02}},"rom-priest":{"strength":{"levelOne":7,"flatGrowth":2.5,"percentGrowth":0.02},"stamina":{"levelOne":10,"flatGrowth":3.6,"percentGrowth":0.02},"intellect":{"levelOne":13,"flatGrowth":4.5,"percentGrowth":0.02},"wisdom":{"levelOne":14,"flatGrowth":5,"percentGrowth":0.02},"agility":{"levelOne":10,"flatGrowth":3.5,"percentGrowth":0.02},"health":{"levelOne":60,"flatGrowth":2,"percentGrowth":0.02},"mana":{"levelOne":20,"flatGrowth":2,"percentGrowth":0.02}},"rom-knight":{"strength":{"levelOne":13,"flatGrowth":4.5,"percentGrowth":0.02},"stamina":{"levelOne":14,"flatGrowth":5,"percentGrowth":0.02},"intellect":{"levelOne":11,"flatGrowth":3.75,"percentGrowth":0.02},"wisdom":{"levelOne":14,"flatGrowth":4,"percentGrowth":0.02},"agility":{"levelOne":11,"flatGrowth":3.9,"percentGrowth":0.02},"health":{"levelOne":60,"flatGrowth":2,"percentGrowth":0.02},"mana":{"levelOne":20,"flatGrowth":2,"percentGrowth":0.02}},"rom-warden":{"strength":{"levelOne":9,"flatGrowth":3.25,"percentGrowth":0.02},"stamina":{"levelOne":9,"flatGrowth":3.4,"percentGrowth":0.02},"intellect":{"levelOne":12,"flatGrowth":4.25,"percentGrowth":0.02},"wisdom":{"levelOne":13,"flatGrowth":4.5,"percentGrowth":0.02},"agility":{"levelOne":10,"flatGrowth":3.5,"percentGrowth":0.02},"health":{"levelOne":60,"flatGrowth":2,"percentGrowth":0.02},"mana":{"levelOne":20,"flatGrowth":2,"percentGrowth":0.02}},"rom-druid":{"strength":{"levelOne":12,"flatGrowth":4.35,"percentGrowth":0.02},"stamina":{"levelOne":12,"flatGrowth":4.15,"percentGrowth":0.02},"intellect":{"levelOne":11,"flatGLine truncated {"schemaVersion":1,"source":{"rootHint":"RuneWaker source snapshot","hashes":{"learnMagic":"6148199bcd1484ba1a122a6db5e2b3c2f2a161402bfe67fc67eb53f55fc6cfa3","magicCollect":"4aae19865cb07b30f99945aa7319a0096db244ff5e5b4b5d902fc6889474f501","magicObject":"52af13a2d8cfa9a66d4caa2d3c38832edc232f058995a64ef8135a6b82e43691","imageObject":"dd5ffcfd74b253dcecaa2f41a931a634dd45ef5f7c13e849d539ddc47fdee1f9","vocationTable":"f06cdaf60c54415dcbea47e9fa0e77b7527035a2aec73d3289370dc39751e76c","strings":"0c20d43660cb2ee042a78f7a8c18b4e104a263d54dd8c4bc98a54155e8adb3b0","eliteLua":"ee9e337548abd7fc555d85bf2f96813dfd77416c3e65d4cadf74a80838da1486","roleAttr":"8143a90ce826bf3152551a4508d212f186012cbc054e505e426551cb62369c1f","objectStruct":"708f2d087f2654b97d149ab7124d9cdffc915cf8bf18108feff3f1a45f124c20","interfaceFdb":"51bbb5a9d6a19a254b302000d3dd6e3a796f4f759015f3352f576a1055496e30"}},"canonicalClasses":[{"id":"rom-warrior","sourceClassId":1,"name":"Warrior","resource":"rage"},{"id":"rom-scout","sourceClassId":2,"name":"Scout","resource":"focus"},{"id":"rom-rogue","sourceClassId":3,"name":"Rogue","resource":"energy"},{"id":"rom-mage","sourceClassId":4,"name":"Mage","resource":"mana"},{"id":"rom-priest","sourceClassId":5,"name":"Priest","resource":"mana"},{"id":"rom-knight","sourceClassId":6,"name":"Knight","resource":"mana"},{"id":"rom-warden","sourceClassId":7,"name":"Warden","resource":"mana"},{"id":"rom-druid","sourceClassId":8,"name":"Druid","resource":"mana"},{"id":"rom-warlock","sourceClassId":9,"name":"Warlock","resource":"focus"},{"id":"rom-champion","sourceClassId":10,"name":"Champion","resource":"rage"}],"baseStatGrowth":{"rom-warrior":{"strength":{"levelOne":14,"flatGrowth":5,"percentGrowth":0.02},"stamina":{"levelOne":13,"flatGrowth":4.5,"percentGrowth":0.02},"intellect":{"levelOne":5,"flatGrowth":1.9,"percentGrowth":0.02},"wisdom":{"levelOne":6,"flatGrowth":2.1,"percentGrowth":0.02},"agility":{"levelOne":12,"flatGrowth":4.25,"percentGrowth":0.02},"health":{"levelOne":60,"flatGrowth":2,"percentGrowth":0.02},"mana":{"levelOne":20,"flatGrowth":2,"percentGrowth":0.02}},"rom-scout":{"strength":{"levelOne":11,"flatGrowth":3.85,"percentGrowth":0.02},"stamina":{"levelOne":10,"flatGrowth":3.65,"percentGrowth":0.02},"intellect":{"levelOne":10,"flatGrowth":3.6,"percentGrowth":0.02},"wisdom":{"levelOne":9,"flatGrowth":3.4,"percentGrowth":0.02},"agility":{"levelOne":13,"flatGrowth":4.5,"percentGrowth":0.02},"health":{"levelOne":60,"flatGrowth":2,"percentGrowth":0.02},"mana":{"levelOne":20,"flatGrowth":2,"percentGrowth":0.02}},"rom-rogue":{"strength":{"levelOne":12,"flatGrowth":4.15,"percentGrowth":0.02},"stamina":{"levelOne":11,"flatGrowth":3.9,"percentGrowth":0.02},"intellect":{"levelOne":7,"flatGrowth":2.6,"percentGrowth":0.02},"wisdom":{"levelOne":7,"flatGrowth":2.4,"percentGrowth":0.02},"agility":{"levelOne":14,"flatGrowth":5,"percentGrowth":0.02},"health":{"levelOne":60,"flatGrowth":2,"percentGrowth":0.02},"mana":{"levelOne":20,"flatGrowth":2,"percentGrowth":0.02}},"rom-mage":{"strength":{"levelOne":5,"flatGrowth":1.9,"percentGrowth":0.02},"stamina":{"levelOne":9,"flatGrowth":3.4,"percentGrowth":0.02},"intellect":{"levelOne":14,"flatGrowth":5,"percentGrowth":0.02},"wisdom":{"levelOne":12,"flatGrowth":4.25,"percentGrowth":0.02},"agility":{"levelOne":10,"flatGrowth":3.5,"percentGrowth":0.02},"health":{"levelOne":60,"flatGrowth":2,"percentGrowth":0.02},"mana":{"levelOne":20,"flatGrowth":2,"percentGrowth":0.02}},"rom-priest":{"strength":{"levelOne":7,"flatGrowth":2.5,"percentGrowth":0.02},"stamina":{"levelOne":10,"flatGrowth":3.6,"percentGrowth":0.02},"intellect":{"levelOne":13,"flatGrowth":4.5,"percentGrowth":0.02},"wisdom":{"levelOne":14,"flatGrowth":5,"percentGrowth":0.02},"agility":{"levelOne":10,"flatGrowth":3.5,"percentGrowth":0.02},"health":{"levelOne":60,"flatGrowth":2,"percentGrowth":0.02},"mana":{"levelOne":20,"flatGrowth":2,"percentGrowth":0.02}},"rom-knight":{"strength":{"levelOne":13,"flatGrowth":4.5,"percentGrowth":0.02},"stamina":{"levelOne":14,"flatGrowth":5,"percentGrowth":0.02},"intellect":{"levelOne":11,"flatGrowth":3.75,"percentGrowth":0.02},"wisdom":{"levelOne":11,"flatGrowth":4.1,"percentGrowth":0.02},"agility":{"levelOne":11,"flatGrowth":3.9,"percentGrowth":0.02},"health":{"levelOne":60,"flatGrowth":2,"percentGrowth":0.02},"mana":{"levelOne":20,"flatGrowth":2,"percentGrowth":0.02}},"rom-warden":{"strength":{"levelOne":14,"flatGrowth":5,"percentGrowth":0.02},"stamina":{"levelOne":12,"flatGrowth":4.2,"percentGrowth":0.02},"intellect":{"levelOne":10,"flatGrowth":3.8,"percentGrowth":0.02},"wisdom":{"levelOne":10,"flatGrowth":4,"percentGrowth":0.02},"agility":{"levelOne":10,"flatGrowth":3.5,"percentGrowth":0.02},"health":{"levelOne":60,"flatGrowth":2,"percentGrowth":0.02},"mana":{"levelOne":20,"flatGrowth":2,"percentGrowth":0.02}},"rom-druid":{"strength":{"levelOne":7,"flatGrowth":2.5,"percentGrowth":0.02},"stamina":{"levelOne":11,"flatGrowth":3.7,"percentGrowth":0.02},"intellect":{"levelOne":13,"flatLine truncated
+14 -1
View File
@@ -122,7 +122,7 @@ describe("generated RuneWaker player catalog", () => {
103 * MANA_POOL_MULTIPLIER, 103 * MANA_POOL_MULTIPLIER,
); );
expect(deriveCharacterStats("rom-druid", 1, EMPTY_ITEM_STATS, "rom-elf").mana).toBe( expect(deriveCharacterStats("rom-druid", 1, EMPTY_ITEM_STATS, "rom-elf").mana).toBe(
86 * MANA_POOL_MULTIPLIER, 98 * MANA_POOL_MULTIPLIER,
); );
expect(generated.skills.find((ability) => ability.id === "rom-priest-general-1")?.costs).toEqual([ expect(generated.skills.find((ability) => ability.id === "rom-priest-general-1")?.costs).toEqual([
{ resource: "mana", amount: 12, percentage: false }, { resource: "mana", amount: 12, percentage: false },
@@ -131,4 +131,17 @@ describe("generated RuneWaker player catalog", () => {
{ resource: "mana", amount: 12, percentage: false }, { resource: "mana", amount: 12, percentage: false },
]); ]);
}); });
it("uses player growth tables for every released RoM class without exponential stat explosions", () => {
for (const entry of generated.canonicalClasses) {
const classId = entry.id as keyof typeof generated.baseStatGrowth;
const growth = generated.baseStatGrowth[classId];
expect(growth.strength.percentGrowth, classId).toBe(0.02);
const stats = romNativeClassBaseStats(classId, 70);
expect(stats.health, classId).toBeLessThan(10_000);
expect(stats.strength, classId).toBeLessThan(2_000);
}
expect(generated.baseStatGrowth["rom-champion"].strength).toEqual({ levelOne: 14, flatGrowth: 5, percentGrowth: 0.02 });
expect(generated.baseStatGrowth["rom-warlock"].intellect).toEqual({ levelOne: 14, flatGrowth: 5, percentGrowth: 0.02 });
});
}); });
+38
View File
@@ -0,0 +1,38 @@
import type { DungeonDefinition } from "./dungeonTypes";
import type { DungeonEncounterDefinition } from "./dungeonEncounterTypes";
/** Authored completion bookkeeping, independent of reviewed RoM encounter scripts. */
export function withRunewakerCompletion(definition: DungeonDefinition): DungeonDefinition {
if (definition.expansion !== "runewaker" || definition.bosses.length === 0) return definition;
const objectives = [...new Map([...definition.objectiveOrder, ...definition.bosses]
.map((objective) => [objective.id, objective] as const)).values()];
const encounter: DungeonEncounterDefinition = {
id: `${definition.id}:completion-baseline`,
dungeonId: definition.id,
initialPhase: "running",
initialState: { flags: { "dungeon-complete": false } },
source: {
fidelity: "healer-man-authored", scope: "instance-orchestration",
files: ["src/game/runewakerCompletion.ts"],
reviewedBehavior: "Records defeated dungeon bosses and completes the required objectives.",
adaptations: ["Completion bookkeeping uses runtime spawn ids and persists across checkpoints."],
remainingGaps: [],
},
rules: [
...objectives.filter((objective) => !objective.eventOnly).map((objective) => ({
id: `defeated:${objective.id}`,
trigger: { kind: "entities-dead" as const, entityIds: [objective.id] },
actions: [{ id: `record:${objective.id}`, kind: "complete-objective" as const,
objectiveId: objective.id, persistence: "run" as const }],
})),
{
id: "complete", trigger: { kind: "tick" },
guards: objectives.filter((objective) => !objective.optional).map((objective) => ({
kind: "objective-complete", objectiveId: objective.id, equals: true,
})),
actions: [{ id: "complete", kind: "set-flag", flagId: "dungeon-complete", value: true, persistence: "run" }],
},
],
};
return { ...definition, encounters: [...definition.encounters ?? [], encounter] };
}
+1 -1
View File
@@ -8,7 +8,7 @@ import type { GmReviewTarget } from "./gmContentReview";
export type AssetStatus = "checking" | "loading" | "ready" | "proxy" | "error"; export type AssetStatus = "checking" | "loading" | "ready" | "proxy" | "error";
export type InputMode = "keyboard" | "gamepad"; export type InputMode = "keyboard" | "gamepad";
export type GameMode = "dungeon" | "manastorm"; export type GameMode = "dungeon" | "manastorm";
export type GameOverlay = "pause" | "party" | "inventory" | "map" | "boss-guide" | "spellbook" | "talents" | "bindings" | "options" | "ui-layout" | "keybinds" | "gm" | null; export type GameOverlay = "pause" | "report-issue" | "party" | "inventory" | "map" | "boss-guide" | "spellbook" | "talents" | "bindings" | "options" | "ui-layout" | "keybinds" | "gm" | "dungeon-results" | null;
interface PlayerSnapshot { interface PlayerSnapshot {
position: Vector3Tuple; position: Vector3Tuple;
+52
View File
@@ -0,0 +1,52 @@
import { describe, expect, it } from "vitest";
import { DUNGEON_DEFINITIONS, requireDungeonDefinition } from "./dungeonRegistry";
import { FORSAKEN_ABBEY_DEFINITION } from "./dungeons/forsakenAbbey";
import { WOW_DUNGEON_LIGHTING, withWowDungeonEnvironment, wowDungeonLightingProfile } from "./wowDungeonEnvironment";
import stairs from "./generated/wowDungeonStairs.generated.json";
import stockadesOverride from "../../dungeon-pipeline/runtime-overrides/stormwind-stockade.json";
describe("fleet-wide WoW dungeon environment policy", () => {
const wowDungeons = DUNGEON_DEFINITIONS.filter((dungeon) => dungeon.expansion !== "runewaker");
it("gives every WoW dungeon readable ambient and hemisphere fill without changing fog draw distance or material roles", () => {
expect(wowDungeons).toHaveLength(54);
for (const dungeon of wowDungeons) {
const profile = WOW_DUNGEON_LIGHTING[wowDungeonLightingProfile(dungeon.id)];
expect(dungeon.presentation.ambientLight).toEqual(profile.ambientLight);
expect(dungeon.presentation.hemisphereLight).toEqual(profile.hemisphereLight);
expect(dungeon.presentation.fog.far).toBeGreaterThan(dungeon.presentation.fog.near);
const updated = withWowDungeonEnvironment(dungeon);
expect(updated.presentation.materials).toBe(dungeon.presentation.materials);
expect(updated.assets).toBe(dungeon.assets);
expect(updated.validation).toBe(dungeon.validation);
}
});
it("keeps Stockades' reviewed lighting, ramps and navigation links intact", () => {
const stockades = requireDungeonDefinition("stormwind-stockade");
// The existing campaign compiler stores package vectors at four decimals.
const compiled = (value: unknown) => JSON.parse(JSON.stringify(value, (_key, item) => typeof item === "number" ? Number(item.toFixed(4)) : item));
expect(stockades.traversalRamps).toEqual(compiled(stockadesOverride.traversalRamps));
expect(stockades.navigationLinks).toEqual(compiled(stockadesOverride.navigationLinks));
expect(stockades.presentation.directionalLight).toEqual(stockadesOverride.presentation.directionalLight);
});
it("does not apply WoW changes to RuneWaker environments", () => {
expect(withWowDungeonEnvironment(FORSAKEN_ABBEY_DEFINITION)).toBe(FORSAKEN_ABBEY_DEFINITION);
});
it("records a scan for every shipped WoW dungeon and installs only package-matched proxies", () => {
expect(Object.keys(stairs.dungeons).sort()).toEqual(wowDungeons.map((dungeon) => dungeon.id).sort());
for (const dungeon of wowDungeons) {
const record = (stairs.dungeons as Record<string, { ramps: unknown[] }>)[dungeon.id];
if (record.ramps.length) expect(dungeon.traversalRamps).toEqual(record.ramps);
}
});
it("rejects stale ramp coordinates when collision content is replaced", () => {
const dungeon = wowDungeons.find((entry) => entry.id !== "stormwind-stockade" && entry.traversalRamps?.length)!;
expect(dungeon).toBeDefined();
const replaced = { ...dungeon, traversalRamps: undefined, assets: { ...dungeon.assets, collision: dungeon.assets.collision.map((asset) => ({ ...asset, checksum: "replacement" })) } };
expect(withWowDungeonEnvironment(replaced).traversalRamps).toBeUndefined();
});
});
+85
View File
@@ -0,0 +1,85 @@
import type { DungeonDefinition, DungeonPresentation, DungeonTraversalRamp } from "./dungeonTypes";
import stairCatalog from "./generated/wowDungeonStairs.generated.json";
type LightingProfile = Pick<DungeonPresentation, "background" | "ambientLight" | "hemisphereLight" | "directionalLight"> & {
readonly fog: Pick<DungeonPresentation["fog"], "color" | "near">;
};
// Imported WoW environments do not carry the original client's baked lighting.
// Stockades' reviewed fill levels are the baseline; color remains environment-specific.
export const WOW_DUNGEON_LIGHTING: Readonly<Record<string, LightingProfile>> = {
stone: {
background: "#171815", fog: { color: "#1b1c18", near: 44 },
ambientLight: { color: "#d8c9aa", intensity: 0.58 },
hemisphereLight: { skyColor: "#cfc3a5", groundColor: "#292820", intensity: 0.86 },
directionalLight: { color: "#ffe0a8", intensity: 1.1, offset: [10, 18, -5] },
},
cavern: {
background: "#121914", fog: { color: "#172019", near: 44 },
ambientLight: { color: "#bcccaf", intensity: 0.58 },
hemisphereLight: { skyColor: "#b2c6b5", groundColor: "#242b22", intensity: 0.86 },
directionalLight: { color: "#dce5bc", intensity: 1.1, offset: [10, 18, -5] },
},
fire: {
background: "#211612", fog: { color: "#291c17", near: 44 },
ambientLight: { color: "#d3bca9", intensity: 0.58 },
hemisphereLight: { skyColor: "#d1bca6", groundColor: "#30251f", intensity: 0.86 },
directionalLight: { color: "#ffd3a1", intensity: 1.1, offset: [10, 18, -5] },
},
arcane: {
background: "#161827", fog: { color: "#1c2030", near: 44 },
ambientLight: { color: "#c4c8de", intensity: 0.58 },
hemisphereLight: { skyColor: "#bdc9e6", groundColor: "#25283a", intensity: 0.86 },
directionalLight: { color: "#e0e9ff", intensity: 1.1, offset: [10, 18, -5] },
},
crypt: {
background: "#171721", fog: { color: "#1d1d28", near: 44 },
ambientLight: { color: "#c8c3d1", intensity: 0.58 },
hemisphereLight: { skyColor: "#c0c2d3", groundColor: "#282530", intensity: 0.86 },
directionalLight: { color: "#e7ddcf", intensity: 1.1, offset: [10, 18, -5] },
},
outdoor: {
background: "#293330", fog: { color: "#35423e", near: 80 },
ambientLight: { color: "#d4dccb", intensity: 0.58 },
hemisphereLight: { skyColor: "#c9d9e5", groundColor: "#30382b", intensity: 0.86 },
directionalLight: { color: "#fff0cd", intensity: 1.1, offset: [10, 18, -5] },
},
};
const PROFILE_DUNGEONS: Readonly<Record<string, readonly string[]>> = {
cavern: ["wailing-caverns", "razorfen-kraul", "razorfen-downs", "blackfathom-deeps", "maraudon", "underbog", "slave-pens", "steamvault"],
fire: ["ragefire-chasm", "blackrock-depths", "blackrock-spire", "hellfire-ramparts", "blood-furnace", "shattered-halls"],
arcane: ["gnomeregan", "arcatraz", "botanica", "mechanar", "magisters-terrace", "the-nexus", "the-oculus", "violet-hold", "tinkertech-showdown"],
crypt: ["shadowfang-keep", "scholomance", "stratholme", "sunken-temple", "auchenai-crypts", "mana-tombs", "sethekk-halls", "shadow-labyrinth", "azjol-nerub", "ahnkahet-old-kingdom", "forge-of-souls", "halls-of-reflection"],
outdoor: ["zulfarrak", "black-morass", "escape-from-durnholde", "culling-of-stratholme", "trial-of-the-champion", "pit-of-saron", "frozen-reach"],
};
export function wowDungeonLightingProfile(id: string): string {
return Object.entries(PROFILE_DUNGEONS).find(([, ids]) => ids.includes(id))?.[0] ?? "stone";
}
/** Shared adapter also covers authored Wailing Caverns and survives campaign regeneration. */
export function withWowDungeonEnvironment(definition: DungeonDefinition): DungeonDefinition {
if (definition.expansion === "runewaker") return definition;
const profile = WOW_DUNGEON_LIGHTING[wowDungeonLightingProfile(definition.id)];
const stairs = (stairCatalog.dungeons as unknown as Readonly<Record<string, {
readonly collision: readonly { readonly url: string; readonly checksum: string }[];
readonly navigation?: { readonly url: string; readonly checksum: string };
readonly ramps: readonly DungeonTraversalRamp[];
}>>)[definition.id];
// Never apply package-space coordinates to a newly replaced environment.
const matchingAssets = stairs && definition.assets.collision.length === stairs.collision.length
&& definition.assets.collision.every((asset, index) => asset.url === stairs.collision[index].url
&& asset.checksum === stairs.collision[index].checksum);
const matchingNavigation = !stairs?.ramps.some((ramp) => ramp.navigationBridges?.length)
|| (definition.assets.navigation?.url === stairs?.navigation?.url
&& definition.assets.navigation?.checksum === stairs?.navigation?.checksum);
return {
...definition,
presentation: definition.id === "stormwind-stockade" ? definition.presentation : {
...definition.presentation, ...profile,
fog: { ...definition.presentation.fog, ...profile.fog },
},
...(matchingAssets && matchingNavigation && stairs.ramps.length ? { traversalRamps: stairs.ramps } : {}),
};
}
+81
View File
@@ -0,0 +1,81 @@
import path from "node:path";
import RAPIER from "@dimforge/rapier3d-compat";
import { BufferGeometry, Group, Mesh } from "three";
import { beforeAll, describe, expect, it } from "vitest";
import type { DungeonTraversalRamp } from "./dungeonTypes";
import stairs from "./generated/wowDungeonStairs.generated.json";
import { requireDungeonDefinition } from "./dungeonRegistry";
import { bridgeDungeonStairNavigation, smoothDungeonStairGeometry } from "./dungeonTraversalRamps";
import { collisionTrimeshes, prepareCollision } from "../scene/productionDungeonCollision";
import { DUNGEON_PHYSICAL_COLLISION_GROUPS } from "../scene/dungeonLineOfSight";
import { createPartyDirectSegmentValidator } from "../scene/partyTraversal";
const pipelineModule = "../../scripts/dungeon-pipeline/wow-stair-geometry.ts";
const { readDungeonGeometry } = await import(pipelineModule) as {
readDungeonGeometry(file: string): Promise<BufferGeometry[]>;
};
const packages = Object.entries(stairs.dungeons).filter(([, entry]) => entry.ramps.length);
beforeAll(async () => { await RAPIER.init(); });
describe("shipping WoW dungeon stair proxies", () => {
it.each(packages)("%s supports live NPC capsule sweeps uphill/downhill in three lanes", async (id) => {
const definition = requireDungeonDefinition(id);
const ramps = definition.traversalRamps!;
const source = new Group();
for (const asset of definition.assets.collision) {
for (const geometry of await readDungeonGeometry(path.join(process.cwd(), "public", asset.url!))) {
source.add(new Mesh(geometry));
}
}
const prepared = prepareCollision(source, ramps);
const world = new RAPIER.World({ x: 0, y: -18, z: 0 });
try {
for (const mesh of collisionTrimeshes(prepared)) {
world.createCollider(RAPIER.ColliderDesc.trimesh(Float32Array.from(mesh.vertices), Uint32Array.from(mesh.indices))
.setCollisionGroups(DUNGEON_PHYSICAL_COLLISION_GROUPS));
}
world.step();
const allowed = createPartyDirectSegmentValidator(RAPIER, world);
for (const ramp of ramps) {
const dx = ramp.end[0] - ramp.start[0]; const dz = ramp.end[2] - ramp.start[2];
const length = Math.hypot(dx, dz);
const count = Math.max(3, Math.ceil(length / 0.35) + 1);
const lateral = Math.max(0, Math.min(ramp.width / 4, ramp.width / 2 - 0.45));
for (const lane of [0, -lateral, lateral]) {
const points = Array.from({ length: count }, (_, index): [number, number, number] => {
const t = index / (count - 1);
return [ramp.start[0] + dx * t - dz / length * lane,
ramp.start[1] + (ramp.end[1] - ramp.start[1]) * t,
ramp.start[2] + dz * t + dx / length * lane];
});
for (let index = 1; index < points.length; index += 1) {
const detail = `${id}/${ramp.id}, lane ${lane}, sample ${index}`;
expect(allowed(points[index - 1], points[index]), detail).toBe(true);
expect(allowed(points[index], points[index - 1]), detail).toBe(true);
}
}
}
} finally {
world.free();
for (const group of [source, prepared]) group.traverse((node) => { if ((node as Mesh).isMesh) (node as Mesh).geometry.dispose(); });
}
}, 120000);
it("every generated seam matches actual shipping navigation vertices", async () => {
for (const [id] of packages) {
const definition = requireDungeonDefinition(id);
const ramps = definition.traversalRamps as readonly DungeonTraversalRamp[];
const bridges = ramps.reduce((count, ramp) => count + (ramp.navigationBridges?.length ?? 0), 0);
if (!bridges) continue;
const geometries = await readDungeonGeometry(path.join(process.cwd(), "public", definition.assets.navigation!.url!));
const geometry = geometries.sort((a, b) => b.getAttribute("position").count - a.getAttribute("position").count)[0];
try {
const originalCount = geometry.index!.count;
smoothDungeonStairGeometry(geometry, ramps, 0.05);
expect(() => bridgeDungeonStairNavigation(geometry, ramps, 0.05)).not.toThrow();
expect(geometry.index!.count).toBe(originalCount + bridges * 6);
} finally { geometries.forEach((entry) => entry.dispose()); }
}
}, 120000);
});
+59
View File
@@ -0,0 +1,59 @@
import { BufferGeometry, Float32BufferAttribute } from "three";
import { describe, expect, it } from "vitest";
import { bridgeDungeonStairNavigation, smoothDungeonStairGeometry } from "./dungeonTraversalRamps";
// The offline Node 24 pipeline uses native .ts specifiers; load it at runtime,
// without making those tool-only modules part of the browser TypeScript build.
const pipelineModule = "../../scripts/dungeon-pipeline/wow-stair-geometry.ts";
const { detectDungeonStairRamps, dungeonStairNavigationBridges, dungeonStairRisers } = await import(pipelineModule);
function staircase(steps: number, rotated = false) {
const vertices: number[] = [];
for (let step = 0; step < steps; step += 1) {
const x = step;
const y = step * 0.5;
const points = [[x, y, -2], [x, y + 0.5, -2], [x, y + 0.5, 2], [x, y, -2], [x, y + 0.5, 2], [x, y, 2]];
for (const point of points) vertices.push(...(rotated ? [-point[2], point[1], point[0]] : point));
}
const geometry = new BufferGeometry();
geometry.setAttribute("position", new Float32BufferAttribute(vertices, 3));
return geometry;
}
describe("WoW shipped stair detection", () => {
it.each([false, true])("detects repeated risers, including rotated stairs (%s)", (rotated) => {
const geometry = staircase(7, rotated);
const ramps = detectDungeonStairRamps(dungeonStairRisers(geometry));
expect(ramps).toHaveLength(1);
expect(ramps[0].width).toBeCloseTo(4.02);
expect(ramps[0].end[1] - ramps[0].start[1]).toBeCloseTo(3.5);
geometry.dispose();
});
it("does not interpret a single ledge or tall wall as a staircase", () => {
const ledge = staircase(1);
expect(detectDungeonStairRamps(dungeonStairRisers(ledge))).toEqual([]);
const wall = staircase(4);
const p = wall.getAttribute("position");
for (let index = 0; index < p.count; index += 1) p.setY(index, p.getY(index) * 10);
expect(detectDungeonStairRamps(dungeonStairRisers(wall))).toEqual([]);
ledge.dispose(); wall.dispose();
});
it("fills separate stair portals using existing source vertices, without bridging an unrelated floor", () => {
const geometry = new BufferGeometry();
geometry.setAttribute("position", new Float32BufferAttribute([
-1, 0, 0, 1, 0, 0, -1, 0.5, 2, 1, 0.5, 2,
-1, 0.75, 3, 1, 0.75, 3, -1, 1.5, 6, 1, 1.5, 6,
-1, 8, 3, 1, 8, 3, -1, 8, 4,
], 3));
geometry.setIndex([0, 1, 3, 0, 3, 2, 4, 5, 7, 4, 7, 6, 8, 9, 10]);
const ramps = dungeonStairNavigationBridges(geometry, [{ id: "flight", start: [0, 0, -0.01], end: [0, 1.5, 6.01], width: 3 }]);
expect(ramps[0].navigationBridges).toHaveLength(1);
smoothDungeonStairGeometry(geometry, ramps, 0.05);
expect(() => bridgeDungeonStairNavigation(geometry, ramps, 0.05)).not.toThrow();
expect(geometry.index!.count).toBe(21);
expect(geometry.getAttribute("position").getY(8)).toBe(8);
geometry.dispose();
});
});
+1
View File
@@ -6,6 +6,7 @@ import { startControllerInput } from "./input/controller";
import { currentDisplayLayout } from "./platform/displayLayout"; import { currentDisplayLayout } from "./platform/displayLayout";
import { initializeContentSystem } from "./content/contentManager"; import { initializeContentSystem } from "./content/contentManager";
import "./styles.css"; import "./styles.css";
import "./ui/dungeonReports.css";
const BottomDisplayApp = lazy(() => import("./platform/BottomDisplayApp").then((module) => ({ default: module.BottomDisplayApp }))); const BottomDisplayApp = lazy(() => import("./platform/BottomDisplayApp").then((module) => ({ default: module.BottomDisplayApp })));
+14 -5
View File
@@ -45,7 +45,8 @@ import {
} from "../game/mobRuntimeRegistry"; } from "../game/mobRuntimeRegistry";
import { combatHasLineOfSight } from "../game/combatSpatial"; import { combatHasLineOfSight } from "../game/combatSpatial";
import { registerDungeonRuntimeReset } from "../game/dungeonRuntimeReset"; import { registerDungeonRuntimeReset } from "../game/dungeonRuntimeReset";
import { scaledDungeonEnemyLevel } from "../game/dungeonLevelScaling"; import { dungeonEnemyHealthBaseline, scaledDungeonEnemyLevel } from "../game/dungeonLevelScaling";
import { requireDungeonDefinition } from "../game/dungeonRegistry";
import { stepFearedMobAway } from "../game/mobMotion"; import { stepFearedMobAway } from "../game/mobMotion";
import { import {
clusterStaticMobSpawns, clusterStaticMobSpawns,
@@ -884,6 +885,7 @@ function ProxyMob({
const selected = useCombatStore((state) => state.selectedTargetId === instanceId); const selected = useCombatStore((state) => state.selectedTargetId === instanceId);
const showAggroRanges = useCombatStore((state) => state.settings.showMobAggroRanges); const showAggroRanges = useCombatStore((state) => state.settings.showMobAggroRanges);
const gameMode = useGameStore((state) => state.gameMode); const gameMode = useGameStore((state) => state.gameMode);
const dungeonExpansion = useGameStore((state) => requireDungeonDefinition(state.activeDungeonId).expansion);
const characterLevel = useCombatStore((state) => state.level); const characterLevel = useCombatStore((state) => state.level);
const manastormLevel = useManastormStore((state) => state.level); const manastormLevel = useManastormStore((state) => state.level);
const manastormPartySize = useManastormStore((state) => state.partySize); const manastormPartySize = useManastormStore((state) => state.partySize);
@@ -923,16 +925,22 @@ function ProxyMob({
useEffect(() => { useEffect(() => {
const baseHealthMultiplier = definition.combat?.healthMultiplier ?? 1; const baseHealthMultiplier = definition.combat?.healthMultiplier ?? 1;
const baseDamageMultiplier = definition.combat?.damageMultiplier ?? 1; const baseDamageMultiplier = definition.combat?.damageMultiplier ?? 1;
const level = gameMode === "manastorm"
? definition.combat?.level
: scaledDungeonEnemyLevel(characterLevel, boss);
const healthMultiplier = baseHealthMultiplier * (manastormModifiers?.enemyStatMultiplier ?? 1);
const healthBaseline = gameMode === "dungeon"
? dungeonEnemyHealthBaseline(level ?? characterLevel, dungeonExpansion)
: undefined;
useCombatStore.getState().registerMob(instanceId, { useCombatStore.getState().registerMob(instanceId, {
name: definition.name, name: definition.name,
serverEntry: definition.combat?.serverEntry, serverEntry: definition.combat?.serverEntry,
boss, boss,
level: gameMode === "manastorm" level,
? definition.combat?.level maxHealth: healthBaseline === undefined ? undefined : healthBaseline * healthMultiplier,
: scaledDungeonEnemyLevel(characterLevel, boss),
hasLoot: Boolean((boss && bossHasLoot(definition.id)) || definition.hasLoot), hasLoot: Boolean((boss && bossHasLoot(definition.id)) || definition.hasLoot),
lootSourceId: boss ? definition.id : undefined, lootSourceId: boss ? definition.id : undefined,
healthMultiplier: baseHealthMultiplier * (manastormModifiers?.enemyStatMultiplier ?? 1), healthMultiplier,
damageMultiplier: baseDamageMultiplier * (manastormModifiers?.enemyStatMultiplier ?? 1), damageMultiplier: baseDamageMultiplier * (manastormModifiers?.enemyStatMultiplier ?? 1),
bonusLootChance: manastormModifiers?.bonusLootChance, bonusLootChance: manastormModifiers?.bonusLootChance,
moveSpeed: definition.combat?.moveSpeed, moveSpeed: definition.combat?.moveSpeed,
@@ -959,6 +967,7 @@ function ProxyMob({
definition.combat, definition.combat,
instanceId, instanceId,
gameMode, gameMode,
dungeonExpansion,
manastormLevel, manastormLevel,
manastormMapRewardMultiplier, manastormMapRewardMultiplier,
manastormModeId, manastormModeId,
+69
View File
@@ -0,0 +1,69 @@
import { useMemo, useRef, useState, type FormEvent } from "react";
import { useShellStore } from "../app/shellStore";
import { DUNGEON_REPORT_COMMENT_MAX_LENGTH, submitDungeonReport } from "../app/dungeonReports";
import { dungeonById } from "../game/dungeonCatalog";
import { useGameStore } from "../game/store";
import { useMenuController, type MenuAction } from "../input/useMenuController";
import { ControllerButton } from "./ControllerButton";
export function DungeonIssueReportPanel() {
const dungeonId = useGameStore((state) => state.activeDungeonId);
const openOverlay = useGameStore((state) => state.openOverlay);
const session = useShellStore((state) => state.session);
const [comment, setComment] = useState("");
const [submitting, setSubmitting] = useState(false);
const [submitted, setSubmitted] = useState(false);
const [error, setError] = useState("");
const busy = useRef(false);
const form = useRef<HTMLFormElement>(null);
const back = () => { if (!busy.current) openOverlay("pause"); };
const canSubmit = Boolean(comment.trim()) && comment.length <= DUNGEON_REPORT_COMMENT_MAX_LENGTH && !submitting;
const actions = useMemo<MenuAction[]>(() => [
{ id: "report-back", run: back, enabled: !submitting },
{ id: "report-submit", run: () => form.current?.requestSubmit(), enabled: canSubmit && !submitted },
], [canSubmit, openOverlay, submitted, submitting]);
const controller = useMenuController(actions, { initialId: "report-back", onBack: back });
const submit = async (event: FormEvent) => {
event.preventDefault();
if (!canSubmit || busy.current || submitted) return;
busy.current = true;
setSubmitting(true);
setError("");
try {
await submitDungeonReport(session, dungeonId, comment);
setSubmitted(true);
} catch (failure) {
setError(failure instanceof Error ? failure.message : "Your report could not be submitted. Please try again.");
} finally {
busy.current = false;
setSubmitting(false);
}
};
return (
<div className="modal-backdrop" role="presentation">
<section className="pause-menu dungeon-issue-report" role="dialog" aria-modal="true" aria-labelledby="issue-report-title">
<p className="eyebrow">Dungeon feedback</p>
<h2 id="issue-report-title">Report an issue</h2>
<p>{dungeonById(dungeonId)?.title ?? dungeonId}</p>
{submitted ? <p role="status">Report submitted. You can follow its status in View Reports on the main menu.</p> : (
<form ref={form} onSubmit={(event) => void submit(event)}>
<label htmlFor="dungeon-issue-comment">Describe the issue</label>
<textarea id="dungeon-issue-comment" autoFocus required maxLength={DUNGEON_REPORT_COMMENT_MAX_LENGTH}
value={comment} disabled={submitting} onChange={(event) => setComment(event.target.value)}
aria-describedby="dungeon-issue-count" placeholder="What happened, and what were you doing when it happened?" />
<small id="dungeon-issue-count">{comment.length} / {DUNGEON_REPORT_COMMENT_MAX_LENGTH} characters</small>
{error && <p role="alert" className="dungeon-report-error">{error}</p>}
<ControllerButton controlId="report-submit" selectedId={controller.selectedId} select={controller.select}
className="button button--primary" type="submit" disabled={!canSubmit}>
{submitting ? "Submitting..." : "Submit report"}
</ControllerButton>
</form>
)}
<ControllerButton controlId="report-back" selectedId={controller.selectedId} select={controller.select}
className="button" type="button" onClick={back} disabled={submitting}>Back to pause menu</ControllerButton>
</section>
</div>
);
}
+70
View File
@@ -0,0 +1,70 @@
import { DUNGEON_LEADERBOARD_PAGE_SIZE, type LeaderboardScope } from "../app/dungeonLeaderboardClient";
import { formatDungeonDuration, type DungeonClearFilter, type DungeonLeaderboard as LeaderboardData } from "../game/dungeonRunTypes";
import { ControllerButton } from "./ControllerButton";
import { DungeonRunDetails } from "./DungeonRunDetails";
import "./dungeonRuns.css";
export function DungeonLeaderboard({ dungeonTitle, data, scope, loading, filter, onFilter,
expanded, onExpand, offset, onPage, onRefresh, difficultyLabel, onDifficulty,
selectedId, select }: {
readonly dungeonTitle: string;
readonly data: LeaderboardData;
readonly scope: LeaderboardScope;
readonly loading: boolean;
readonly filter: DungeonClearFilter;
readonly onFilter: (filter: DungeonClearFilter) => void;
readonly expanded: ReadonlySet<string>;
readonly onExpand: (id: string) => void;
readonly offset: number;
readonly onPage: (offset: number) => void;
readonly onRefresh: () => void;
readonly difficultyLabel: string;
readonly onDifficulty?: () => void;
readonly selectedId: string;
readonly select: (id: string) => void;
}) {
const controls = { selectedId, select };
return <section className="dungeon-leaderboard" aria-label={`${dungeonTitle} leaderboard`} aria-busy={loading}>
<header>
<p className="front-kicker">{scope === "world" ? "World leaderboard" : "This device · offline leaderboard"}</p>
<h3>Fastest expeditions</h3>
<div className="dungeon-leaderboard__filters" aria-label="Clear type">
<ControllerButton {...controls} controlId="leaderboard-final" aria-pressed={filter === "final"}
onClick={() => onFilter("final")}>Final Boss clears <small>All entries</small></ControllerButton>
<ControllerButton {...controls} controlId="leaderboard-full" aria-pressed={filter === "full"}
onClick={() => onFilter("full")}>Full dungeon clears <small>All bosses</small></ControllerButton>
</div>
<div className="dungeon-leaderboard__meta">
{onDifficulty ? <ControllerButton {...controls} controlId="leaderboard-difficulty" onClick={onDifficulty}
aria-label={`Leaderboard difficulty: ${difficultyLabel}. Change difficulty`}>{difficultyLabel} ↻</ControllerButton>
: <span>{difficultyLabel}</span>}
<span>{data.total} {data.total === 1 ? "run" : "runs"}</span>
<ControllerButton {...controls} controlId="leaderboard-refresh" onClick={onRefresh}>Refresh</ControllerButton>
</div>
</header>
<div className="dungeon-leaderboard__entries">
{loading ? <p className="dungeon-leaderboard__empty" role="status">Loading run times…</p>
: data.entries.length === 0 ? <p className="dungeon-leaderboard__empty">No {filter === "full" ? "full dungeon" : "final boss"} clears yet. Complete a run to set the first time.</p>
: <ol>
{data.entries.map((entry) => <li key={entry.id}>
<ControllerButton {...controls} controlId={`leaderboard-run-${entry.id}`}
className="dungeon-leaderboard__run" aria-expanded={expanded.has(entry.id)}
aria-controls={`run-details-${entry.id}`} onClick={() => onExpand(entry.id)}>
<b>#{entry.rank}</b><span><strong>{entry.party.find((member) => !member.npc)?.name ?? entry.party[0]?.name ?? "Party"}</strong>
<small>{entry.fullClear ? "All bosses" : `${entry.killedBossIds.length} bosses`} · {new Date(entry.finishedAt).toLocaleDateString()}</small></span>
<time>{formatDungeonDuration(entry.durationMs)}</time><em aria-hidden="true">{expanded.has(entry.id) ? "−" : "+"}</em>
</ControllerButton>
{expanded.has(entry.id) ? <div id={`run-details-${entry.id}`}><DungeonRunDetails dungeonId={entry.dungeonId}
difficultyId={entry.difficultyId} party={entry.party} killedBossIds={entry.killedBossIds} /></div> : null}
</li>)}
</ol>}
</div>
{data.total > DUNGEON_LEADERBOARD_PAGE_SIZE ? <nav className="dungeon-leaderboard__pages" aria-label="Leaderboard pages">
<ControllerButton {...controls} controlId="leaderboard-prev" disabled={offset === 0 || loading}
onClick={() => onPage(Math.max(0, offset - DUNGEON_LEADERBOARD_PAGE_SIZE))}>Previous</ControllerButton>
<span>{offset + 1}–{Math.min(offset + DUNGEON_LEADERBOARD_PAGE_SIZE, data.total)} of {data.total}</span>
<ControllerButton {...controls} controlId="leaderboard-next" disabled={offset + DUNGEON_LEADERBOARD_PAGE_SIZE >= data.total || loading}
onClick={() => onPage(offset + DUNGEON_LEADERBOARD_PAGE_SIZE)}>Next</ControllerButton>
</nav> : null}
</section>;
}
+148
View File
@@ -0,0 +1,148 @@
import { useMemo, useRef, useState } from "react";
import {
DUNGEON_REPORT_STATUSES, filterDungeonReports, sessionCanManageDungeonReports, updateDungeonReport,
type DungeonReport, type DungeonReportStatus,
} from "../app/dungeonReports";
import { useShellStore } from "../app/shellStore";
import { DualDisplayFrame } from "../components/DualDisplayFrame";
import { DUNGEON_CATALOG, dungeonById } from "../game/dungeonCatalog";
import { useMenuController, type MenuAction } from "../input/useMenuController";
import { BrandMark, ControllerLegend, FrontSurface } from "./FrontSurface";
import { ControllerButton } from "./ControllerButton";
import { LazyLocalCompanionPanel } from "./LazyLocalCompanionPanel";
import { useDungeonReports } from "./useDungeonReports";
function ReportCard({ report, canManage, saving, onUpdate, selectedId, select }: {
readonly report: DungeonReport;
readonly canManage: boolean;
readonly saving: boolean;
readonly onUpdate: (report: DungeonReport, status: DungeonReportStatus) => void;
readonly selectedId: string;
readonly select: (id: string) => void;
}) {
return (
<article className={`dungeon-report-card ${report.status === "Closed" ? "is-closed" : ""}`}>
<header><h2>{dungeonById(report.dungeonId)?.title ?? report.dungeonId}</h2><span className="dungeon-report-status">{report.status}</span></header>
<p className="dungeon-report-comment">{report.comment}</p>
<small>Submitted by {report.submittedBy} · <time dateTime={new Date(report.createdAt).toISOString()}>{new Date(report.createdAt).toLocaleString()}</time></small>
{report.updatedBy && <small>Updated by {report.updatedBy} · {new Date(report.updatedAt).toLocaleString()}</small>}
{canManage && (
<div className="dungeon-report-management">
<label>Status
<select aria-label={`Status for report ${report.id}`} value={report.status} disabled={saving}
className={selectedId === `report-status-${report.id}` ? "is-controller-selected" : ""}
onPointerEnter={() => select(`report-status-${report.id}`)}
onChange={(event) => onUpdate(report, event.target.value as DungeonReportStatus)}>
{DUNGEON_REPORT_STATUSES.map((status) => <option key={status} value={status}>{status}</option>)}
</select>
</label>
{report.status !== "Closed" && <ControllerButton controlId={`report-close-${report.id}`} selectedId={selectedId} select={select}
type="button" className="front-button" disabled={saving} onClick={() => onUpdate(report, "Closed")}>Close report</ControllerButton>}
</div>
)}
</article>
);
}
export function DungeonReportsScreen({ onClose }: { readonly onClose: () => void }) {
const session = useShellStore((state) => state.session);
const { snapshot, loading, error, refresh } = useDungeonReports();
const [status, setStatus] = useState<DungeonReportStatus | "all">("all");
const [dungeonId, setDungeonId] = useState("all");
const [saving, setSaving] = useState(false);
const [updateError, setUpdateError] = useState("");
const [message, setMessage] = useState("");
const busy = useRef(false);
const canManage = sessionCanManageDungeonReports(session);
const reports = filterDungeonReports(snapshot?.reports ?? [], status, dungeonId);
const dungeonOptions = useMemo(() => [...DUNGEON_CATALOG].sort((a, b) => a.title.localeCompare(b.title)), []);
const actions = useMemo<MenuAction[]>(() => [
{ id: "reports-back", run: onClose, enabled: !saving },
{ id: "reports-filter-status", run: () => {
const options = ["all", ...DUNGEON_REPORT_STATUSES] as const;
setStatus(options[(options.indexOf(status) + 1) % options.length]);
} },
{ id: "reports-filter-dungeon", run: () => {
const options = ["all", ...dungeonOptions.map((dungeon) => dungeon.id)];
setDungeonId(options[(options.indexOf(dungeonId) + 1) % options.length]);
} },
{ id: "reports-clear-filters", run: () => { setStatus("all"); setDungeonId("all"); } },
{ id: "reports-refresh", run: () => void refresh(), enabled: !loading && !saving },
...(canManage ? reports.flatMap((report) => [
{ id: `report-status-${report.id}`, enabled: !saving, run: () => {
const nextStatus = DUNGEON_REPORT_STATUSES[(DUNGEON_REPORT_STATUSES.indexOf(report.status) + 1) % DUNGEON_REPORT_STATUSES.length];
void update(report, nextStatus);
} },
...(report.status === "Closed" ? [] : [{ id: `report-close-${report.id}`, enabled: !saving, run: () => void update(report, "Closed") }]),
]) : []),
], [canManage, dungeonId, dungeonOptions, loading, onClose, refresh, reports, saving, session, status]);
const controller = useMenuController(actions, { initialId: "reports-back", onBack: saving ? undefined : onClose });
const update = async (report: DungeonReport, nextStatus: DungeonReportStatus) => {
if (!session || !canManage || busy.current) return;
busy.current = true;
setSaving(true);
setUpdateError("");
setMessage("");
try {
await updateDungeonReport(session, report, nextStatus);
setMessage(`Report status updated to ${nextStatus}.`);
} catch (failure) {
setUpdateError(failure instanceof Error ? failure.message : "The status could not be updated.");
} finally {
await refresh();
busy.current = false;
setSaving(false);
}
};
return (
<DualDisplayFrame contextLabel="Dungeon reports" top={
<FrontSurface className="dungeon-reports-screen" ariaLabel="Dungeon reports">
<header className="dungeon-reports-header">
<BrandMark compact />
<div><span>Main menu / Dungeon feedback</span><h1>View Reports</h1></div>
<ControllerButton controlId="reports-back" selectedId={controller.selectedId} select={controller.select}
className="front-button" type="button" onClick={onClose} disabled={saving}>Back to main menu</ControllerButton>
</header>
<main className="dungeon-reports-main">
<div className="dungeon-report-filters">
<label>Status
<select value={status} onChange={(event) => setStatus(event.target.value as DungeonReportStatus | "all")}
className={controller.selectedId === "reports-filter-status" ? "is-controller-selected" : ""}
onPointerEnter={() => controller.select("reports-filter-status")}>
<option value="all">All statuses</option>
{DUNGEON_REPORT_STATUSES.map((value) => <option key={value} value={value}>{value}</option>)}
</select>
</label>
<label>Dungeon
<select value={dungeonId} onChange={(event) => setDungeonId(event.target.value)}
className={controller.selectedId === "reports-filter-dungeon" ? "is-controller-selected" : ""}
onPointerEnter={() => controller.select("reports-filter-dungeon")}>
<option value="all">All dungeons</option>
{dungeonOptions.map((dungeon) => (
<option key={dungeon.id} value={dungeon.id}>{dungeon.title}</option>
))}
</select>
</label>
<ControllerButton controlId="reports-clear-filters" selectedId={controller.selectedId} select={controller.select}
type="button" className="front-button" onClick={() => { setStatus("all"); setDungeonId("all"); }}>Clear filters</ControllerButton>
<ControllerButton controlId="reports-refresh" selectedId={controller.selectedId} select={controller.select}
className="front-button" type="button" disabled={loading || saving} onClick={() => void refresh()}>
{loading ? "Refreshing..." : "Refresh"}
</ControllerButton>
</div>
<p className="dungeon-reports-summary">{reports.length} of {snapshot?.reports.length ?? 0} reports · {canManage ? "GM status controls enabled" : "All players can view reports. GMs manage their status."}</p>
{(error || updateError) && <p role="alert" className="front-notice">{updateError || error}{snapshot && error ? " Showing the last loaded reports." : ""}</p>}
{message && <p role="status" className="front-notice">{message}</p>}
<section className="dungeon-report-list" aria-label="Reports" aria-busy={loading || saving}>
{loading && !snapshot ? <p role="status">Loading reports...</p> : snapshot && reports.length === 0 ? (
<p>{snapshot.reports.length ? "No reports match these filters." : "No dungeon reports have been submitted yet."}</p>
) : reports.map((report) => <ReportCard key={report.id} report={report} canManage={canManage} saving={saving}
onUpdate={(item, nextStatus) => void update(item, nextStatus)} selectedId={controller.selectedId} select={controller.select} />)}
</section>
</main>
<ControllerLegend back />
</FrontSurface>
} bottom={<LazyLocalCompanionPanel />} />
);
}
+32
View File
@@ -0,0 +1,32 @@
import catalog from "../game/generated/dungeonLeaderboardCatalog.generated.json";
import type { DungeonRunMember } from "../game/dungeonRunTypes";
const className = (id: string) => catalog.classes.find((entry) => entry.id === id)?.name ?? id;
export function DungeonRunDetails({ dungeonId, difficultyId, party, killedBossIds }: {
readonly dungeonId: string;
readonly difficultyId: string;
readonly party: readonly DungeonRunMember[];
readonly killedBossIds: readonly string[];
}) {
const dungeon = catalog.dungeons.find((entry) => entry.id === dungeonId);
const ids = dungeon?.difficulties.find((entry) => entry.id === difficultyId)?.bossIds ?? [];
const bosses = dungeon?.bosses.filter((boss) => ids.includes(boss.id)) ?? [];
return <div className="dungeon-run-details">
<h4>Party</h4>
<ul className="dungeon-run-party">
{party.map((member, index) => <li key={`${member.name}-${index}`}>
<strong>{member.name}{member.npc ? <small> NPC</small> : null}</strong>
<span>Level {member.level} · {className(member.classId)}{member.secondaryClassId ? ` / ${className(member.secondaryClassId)}` : ""}</span>
<small>{member.role === "damage" ? "Damage" : member.role === "healer" ? "Healer" : "Tank"}</small>
</li>)}
</ul>
<h4>Bosses defeated · {killedBossIds.length}/{bosses.length}</h4>
<ul className="dungeon-run-bosses">
{bosses.map((boss) => <li key={boss.id} className={killedBossIds.includes(boss.id) ? "is-defeated" : ""}>
<span aria-hidden="true">{killedBossIds.includes(boss.id) ? "✓" : "—"}</span>
{boss.name}<small>{killedBossIds.includes(boss.id) ? "Defeated" : "Not defeated"}</small>
</li>)}
</ul>
</div>;
}
+103
View File
@@ -0,0 +1,103 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useShellStore } from "../app/shellStore";
import { awaitSharedDungeonRunPlacement, localDungeonRunPlacement, saveLocalDungeonRun, submitDungeonRun, type LeaderboardScope } from "../app/dungeonLeaderboardClient";
import { useOnlineSessionStore } from "../app/onlineSessionStore";
import { completedDungeonRun, useDungeonRunStore } from "../game/dungeonRunStore";
import { formatDungeonDuration, type DungeonRunPlacement, type DungeonRunProgress } from "../game/dungeonRunTypes";
import catalog from "../game/generated/dungeonLeaderboardCatalog.generated.json";
import { useGameStore } from "../game/store";
import { useMenuController } from "../input/useMenuController";
import { ControllerButton } from "./ControllerButton";
import { DungeonRunDetails } from "./DungeonRunDetails";
import "./dungeonRuns.css";
export interface RunPlacementState {
readonly scope: LeaderboardScope;
readonly placement: DungeonRunPlacement | null;
readonly loading: boolean;
}
export function DungeonRunResultsDialog({ progress, ranking, onFinish, onLoot, onRetry }: {
readonly progress: DungeonRunProgress;
readonly ranking: RunPlacementState;
readonly onFinish: () => void;
readonly onLoot: () => void;
readonly onRetry: () => void;
}) {
const definition = catalog.dungeons.find((dungeon) => dungeon.id === progress.dungeonId);
const fullClear = definition?.difficulties.find((entry) => entry.id === progress.difficultyId)?.bossIds
.every((id) => progress.killedBossIds.includes(id)) ?? false;
const controller = useMenuController([
{ id: "run-loot", run: onLoot }, { id: "run-finish", run: onFinish },
...(ranking.scope === "device" ? [{ id: "run-retry", run: onRetry }] : []),
], { initialId: "run-loot", onBack: onLoot });
const controls = { selectedId: controller.selectedId, select: controller.select };
const panel = useRef<HTMLElement>(null);
useEffect(() => {
const previous = document.activeElement;
panel.current?.focus();
return () => { if (previous instanceof HTMLElement && previous.isConnected) previous.focus(); };
}, []);
return <div className="modal-backdrop dungeon-results-backdrop">
<section ref={panel} tabIndex={-1} className="dungeon-results" role="dialog" aria-modal="true"
aria-labelledby="dungeon-results-title" onKeyDown={(event) => {
if (event.key !== "Tab") return;
const buttons = Array.from(panel.current?.querySelectorAll<HTMLButtonElement>("button:not(:disabled)") ?? []);
const first = buttons[0]; const last = buttons.at(-1);
if (event.shiftKey && (document.activeElement === first || document.activeElement === panel.current)) {
event.preventDefault(); last?.focus();
} else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first?.focus(); }
}}>
<header><p className="front-kicker">{fullClear ? "Full dungeon clear" : "Final boss clear"}</p>
<h2 id="dungeon-results-title">{definition?.title ?? progress.dungeonId} complete</h2>
<p>The final boss is defeated. Your expedition is complete.</p>
</header>
<div className="dungeon-results__score">
<div><small>Time to final boss</small><strong>{progress.timed && progress.durationMs !== null
? formatDungeonDuration(progress.durationMs) : "Untimed"}</strong></div>
<div aria-live="polite"><small>{ranking.scope === "world" ? "World leaderboard" : "This device leaderboard"}</small>
<strong>{ranking.loading ? "Recording…" : ranking.placement ? `#${ranking.placement.finalRank}` : "Unranked"}</strong>
{ranking.placement ? <span>of {ranking.placement.finalTotal} final boss clears</span> : null}
</div>
</div>
{ranking.placement?.fullRank ? <p className="dungeon-results__full-rank">Full dungeon clears: <strong>#{ranking.placement.fullRank}</strong> of {ranking.placement.fullTotal}</p> : null}
{!progress.timed ? <p className="dungeon-results__note">This resumed run has no saved timer. Start a new expedition to record a leaderboard time.</p> : null}
{ranking.scope === "device" ? <p className="dungeon-results__note">Your time is saved on this device. Reconnect to submit it to the world leaderboard.</p> : null}
<div className="dungeon-results__details"><DungeonRunDetails dungeonId={progress.dungeonId}
difficultyId={progress.difficultyId} party={progress.party} killedBossIds={progress.killedBossIds} /></div>
<footer>
<ControllerButton {...controls} controlId="run-loot" className="button" onClick={onLoot}>Stay and loot</ControllerButton>
<ControllerButton {...controls} controlId="run-finish" className="button button--primary" onClick={onFinish}>Return to main menu</ControllerButton>
{ranking.scope === "device" ? <ControllerButton {...controls} controlId="run-retry" className="button" onClick={onRetry}>Retry submission</ControllerButton> : null}
</footer>
</section>
</div>;
}
export function DungeonRunResults() {
const progress = useDungeonRunStore((state) => state.progress);
const overlay = useGameStore((state) => state.overlay);
const record = useMemo(() => progress ? completedDungeonRun(progress) : null, [progress]);
const [retry, setRetry] = useState(0);
const [ranking, setRanking] = useState<RunPlacementState>({ scope: "world", placement: null, loading: false });
useEffect(() => {
if (!record) { setRanking({ scope: "world", placement: null, loading: false }); return; }
let live = true;
saveLocalDungeonRun(record);
setRanking({ scope: "world", placement: null, loading: true });
const online = useOnlineSessionStore.getState().snapshot;
const submission = online?.activity.id === record.id && !online.authority
? awaitSharedDungeonRunPlacement(record.id) : submitDungeonRun(record, useShellStore.getState().session);
void submission
.then((placement) => { if (live) setRanking({ scope: "world", placement, loading: false }); })
.catch(() => { if (live) setRanking({ scope: "device", placement: localDungeonRunPlacement(record), loading: false }); });
return () => { live = false; };
}, [record, retry]);
if (overlay !== "dungeon-results" || !progress || progress.finishedAt === null) return null;
return <DungeonRunResultsDialog progress={progress} ranking={ranking} onRetry={() => setRetry((value) => value + 1)}
onLoot={() => useGameStore.getState().closeOverlay()} onFinish={() => {
useShellStore.getState().saveActiveDungeonRunCheckpoint(null);
useGameStore.getState().closeOverlay();
useShellStore.getState().returnToMainMenu();
}} />;
}
+46 -16
View File
@@ -21,11 +21,16 @@ import {
import { useMenuController, type MenuAction } from "../input/useMenuController"; import { useMenuController, type MenuAction } from "../input/useMenuController";
import { LazyLocalCompanionPanel } from "./LazyLocalCompanionPanel"; import { LazyLocalCompanionPanel } from "./LazyLocalCompanionPanel";
import { ControllerButton } from "./ControllerButton"; import { ControllerButton } from "./ControllerButton";
import { DungeonMapGraphic } from "./DungeonMapGraphic"; import { DungeonLeaderboard } from "./DungeonLeaderboard";
import { useDungeonLeaderboard } from "./useDungeonLeaderboard";
import type { DungeonClearFilter } from "../game/dungeonRunTypes";
import { DUNGEON_LEADERBOARD_PAGE_SIZE } from "../app/dungeonLeaderboardClient";
import { BrandMark, ControllerLegend, FrontSurface } from "./FrontSurface"; import { BrandMark, ControllerLegend, FrontSurface } from "./FrontSurface";
import { OnlineGroupPanel } from "./OnlineGroupPanel"; import { OnlineGroupPanel } from "./OnlineGroupPanel";
import { useOnlineGroupStore } from "../app/onlineGroupStore"; import { useOnlineGroupStore } from "../app/onlineGroupStore";
import { specializationById } from "../game/specializationCatalog"; import { specializationById } from "../game/specializationCatalog";
import { DUNGEON_RELEASE_STAGE_LABELS } from "../game/dungeonReleaseStage";
import { useDungeonReports } from "./useDungeonReports";
installDungeonShellRuntime(); installDungeonShellRuntime();
@@ -50,6 +55,7 @@ interface PendingDungeonDownload {
} }
export function DungeonSelectScreen() { export function DungeonSelectScreen() {
const reports = useDungeonReports();
const contentState = useContentManagerSnapshot(); const contentState = useContentManagerSnapshot();
const activeCharacter = useShellStore((state) => state.activeCharacter); const activeCharacter = useShellStore((state) => state.activeCharacter);
const rosterCharacter = useShellStore(selectedCharacter); const rosterCharacter = useShellStore(selectedCharacter);
@@ -74,6 +80,20 @@ export function DungeonSelectScreen() {
?? firstCategoryDungeon ?? firstCategoryDungeon
?? null; ?? null;
const selectedDefinition = selectedDungeon ? dungeonDefinitionById(selectedDungeon.id) : null; const selectedDefinition = selectedDungeon ? dungeonDefinitionById(selectedDungeon.id) : null;
const [clearFilter, setClearFilter] = useState<DungeonClearFilter>("final");
const [difficultyId, setDifficultyId] = useState("");
const [expandedRuns, setExpandedRuns] = useState<ReadonlySet<string>>(new Set());
const leaderboardDifficulty = selectedDefinition?.difficultyVariants.find((variant) => variant.id === difficultyId)
?? selectedDefinition?.difficultyVariants.find((variant) => variant.id === selectedDefinition.defaultDifficultyId);
const leaderboard = useDungeonLeaderboard(selectedDungeon?.id ?? "", leaderboardDifficulty?.id ?? "normal", clearFilter);
const toggleRun = (id: string) => setExpandedRuns((current) => {
const next = new Set(current); if (next.has(id)) next.delete(id); else next.add(id); return next;
});
const cycleDifficulty = () => {
const variants = selectedDefinition?.difficultyVariants ?? [];
const index = variants.findIndex((variant) => variant.id === leaderboardDifficulty?.id);
if (variants.length) setDifficultyId(variants[(index + 1) % variants.length].id);
};
const selectedPackInstalled = selectedDungeon ? dungeonContentIsUsable(selectedDungeon.id) : false; const selectedPackInstalled = selectedDungeon ? dungeonContentIsUsable(selectedDungeon.id) : false;
const canStart = Boolean(selectedDungeon?.available && selectedPackInstalled); const canStart = Boolean(selectedDungeon?.available && selectedPackInstalled);
@@ -173,8 +193,17 @@ export function DungeonSelectScreen() {
id: `dungeon-${dungeon.id}`, id: `dungeon-${dungeon.id}`,
run: () => selectDungeon(dungeon.id), run: () => selectDungeon(dungeon.id),
enabled: true, enabled: true,
neighbors: index === 0 ? { up: `dungeon-category-${selectedCategory}` } : undefined, neighbors: { ...(index === 0 ? { up: `dungeon-category-${selectedCategory}` } : {}), right: "leaderboard-final" },
})), })),
{ id: "leaderboard-final", run: () => setClearFilter("final") },
{ id: "leaderboard-full", run: () => setClearFilter("full") },
{ id: "leaderboard-difficulty", run: cycleDifficulty, enabled: (selectedDefinition?.difficultyVariants.length ?? 0) > 1 },
{ id: "leaderboard-refresh", run: leaderboard.refresh },
...leaderboard.data.entries.map((entry) => ({ id: `leaderboard-run-${entry.id}`, run: () => toggleRun(entry.id) })),
{ id: "leaderboard-prev", run: () => leaderboard.setOffset(Math.max(0, leaderboard.offset - DUNGEON_LEADERBOARD_PAGE_SIZE)),
enabled: !leaderboard.loading && leaderboard.offset > 0 },
{ id: "leaderboard-next", run: () => leaderboard.setOffset(leaderboard.offset + DUNGEON_LEADERBOARD_PAGE_SIZE),
enabled: !leaderboard.loading && leaderboard.offset + DUNGEON_LEADERBOARD_PAGE_SIZE < leaderboard.data.total },
...(rolePickerOpen ...(rolePickerOpen
? availableRoles.map((role) => ({ ? availableRoles.map((role) => ({
id: `dungeon-role-${role}`, id: `dungeon-role-${role}`,
@@ -190,7 +219,7 @@ export function DungeonSelectScreen() {
run: () => setRolePickerOpen(false), run: () => setRolePickerOpen(false),
}] : []), }] : []),
{ id: "dungeon-back", run: returnToMainMenu }, { id: "dungeon-back", run: returnToMainMenu },
], [availableRoles, categoryDungeons, downloading, firstCategoryDungeon, pendingDownload, returnToMainMenu, rolePickerOpen, selectedCategory, selectedDungeon]); ], [availableRoles, categoryDungeons, downloading, firstCategoryDungeon, pendingDownload, returnToMainMenu, rolePickerOpen, selectedCategory, selectedDungeon, selectedDefinition, leaderboard, expandedRuns, leaderboardDifficulty]);
const controller = useMenuController(actions, { const controller = useMenuController(actions, {
initialId: firstAvailable ? `dungeon-${firstAvailable.id}` : "dungeon-back", initialId: firstAvailable ? `dungeon-${firstAvailable.id}` : "dungeon-back",
onBack: pendingDownload onBack: pendingDownload
@@ -253,19 +282,24 @@ export function DungeonSelectScreen() {
controlId={`dungeon-${dungeon.id}`} controlId={`dungeon-${dungeon.id}`}
selectedId={controller.selectedId} selectedId={controller.selectedId}
select={controller.select} select={controller.select}
className={`dungeon-select-row ${dungeon.id === selectedDungeon?.id ? "is-active" : ""}`} className={`dungeon-select-row ${dungeon.id === selectedDungeon?.id ? "is-active" : ""} ${(reports.snapshot?.openCounts[dungeon.id] ?? 0) > 0 ? "has-open-reports" : ""}`}
type="button" type="button"
aria-pressed={dungeon.id === selectedDungeon?.id} aria-pressed={dungeon.id === selectedDungeon?.id}
onClick={() => selectDungeon(dungeon.id)} onClick={() => selectDungeon(dungeon.id)}
> >
<b aria-hidden="true">{String(index + 1).padStart(2, "0")}</b> <b aria-hidden="true">{String(index + 1).padStart(2, "0")}</b>
<span><strong>{dungeon.title}</strong><small>{dungeon.entranceName}</small></span> <span><strong>{dungeon.title}</strong><small>{DUNGEON_RELEASE_STAGE_LABELS[dungeon.releaseStage]} · {dungeon.entranceName}</small></span>
{(reports.snapshot?.openCounts[dungeon.id] ?? 0) > 0 && (
<sup className="dungeon-open-report-count" aria-label={`${reports.snapshot!.openCounts[dungeon.id]} open reports`}
title={`${reports.snapshot!.openCounts[dungeon.id]} open reports`}>{reports.snapshot!.openCounts[dungeon.id]}</sup>
)}
<em>{dungeon.available <em>{dungeon.available
? dungeonContentIsUsable(dungeon.id) ? "Ready" : "Download required" ? dungeonContentIsUsable(dungeon.id) ? "Ready" : "Download required"
: "Coming soon"}</em> : "Coming soon"}</em>
</ControllerButton> </ControllerButton>
))} ))}
</div> </div>
{reports.error && <small role="status" className="dungeon-report-load-error">Report counts unavailable. {reports.snapshot ? "Showing the last loaded counts." : ""}</small>}
<ControllerButton <ControllerButton
controlId="dungeon-back" controlId="dungeon-back"
selectedId={controller.selectedId} selectedId={controller.selectedId}
@@ -281,20 +315,16 @@ export function DungeonSelectScreen() {
<main className="dungeon-select-detail" aria-live="polite"> <main className="dungeon-select-detail" aria-live="polite">
{selectedDungeon ? ( {selectedDungeon ? (
<> <>
<div className="dungeon-select-map"> <DungeonLeaderboard dungeonTitle={selectedDungeon.title} data={leaderboard.data} scope={leaderboard.scope}
{selectedDefinition ? ( loading={leaderboard.loading} filter={clearFilter} onFilter={setClearFilter}
<DungeonMapGraphic expanded={expandedRuns} onExpand={toggleRun} offset={leaderboard.offset} onPage={leaderboard.setOffset}
dungeonId={selectedDefinition.id} onRefresh={leaderboard.refresh} difficultyLabel={leaderboardDifficulty?.difficulty === "heroic" ? "Heroic" : "Normal"}
position={selectedDefinition.entrance.footPosition} onDifficulty={(selectedDefinition?.difficultyVariants.length ?? 0) > 1 ? cycleDifficulty : undefined}
yaw={selectedDefinition.entrance.yaw} selectedId={controller.selectedId} select={controller.select} />
/>
) : (
<div className="dungeon-select-map__placeholder" aria-hidden="true">HM</div>
)}
</div>
<section className="dungeon-select-copy"> <section className="dungeon-select-copy">
<p className="front-kicker">{canStart ? "Playable expedition" : selectedDungeon.available ? "Content required" : "In development"}</p> <p className="front-kicker">{canStart ? "Playable expedition" : selectedDungeon.available ? "Content required" : "In development"}</p>
<h2>{selectedDungeon.title}</h2> <h2>{selectedDungeon.title}</h2>
<span className="dungeon-release-stage">{DUNGEON_RELEASE_STAGE_LABELS[selectedDungeon.releaseStage]}</span>
<p>{selectedDungeon.summary}</p> <p>{selectedDungeon.summary}</p>
{!canStart && selectedDungeon.availabilityReason && ( {!canStart && selectedDungeon.availabilityReason && (
<p className="front-notice" role="status">{selectedDungeon.availabilityReason}</p> <p className="front-notice" role="status">{selectedDungeon.availabilityReason}</p>
+26 -7
View File
@@ -13,7 +13,7 @@ import { useShellStore } from "../app/shellStore";
import { abilityAtLevel, resourceProfileForClass } from "../game/abilityCatalog"; import { abilityAtLevel, resourceProfileForClass } from "../game/abilityCatalog";
import { ACTION_CONTROLS, actionBindingId } from "../game/actionBindings"; import { ACTION_CONTROLS, actionBindingId } from "../game/actionBindings";
import { resolvedAbilityCosts, useCombatStore } from "../game/combatStore"; import { resolvedAbilityCosts, useCombatStore } from "../game/combatStore";
import { respawnDefeatedPlayer } from "../game/deathRespawn"; import { partyFightStillActive, respawnDefeatedPlayer } from "../game/deathRespawn";
import { requireDungeonDefinition } from "../game/dungeonRegistry"; import { requireDungeonDefinition } from "../game/dungeonRegistry";
import { resurrectManastormSession } from "../game/manastormSession"; import { resurrectManastormSession } from "../game/manastormSession";
import { resolveStagePresentation } from "../game/manastormStagePresentation"; import { resolveStagePresentation } from "../game/manastormStagePresentation";
@@ -37,6 +37,8 @@ import { ThreatMeterWidget } from "./ThreatMeterWidget";
import { BossLootWindow } from "./BossLootWindow"; import { BossLootWindow } from "./BossLootWindow";
import { EditableHudElement } from "./EditableHudElement"; import { EditableHudElement } from "./EditableHudElement";
import { DpsMeter } from "./DpsMeter"; import { DpsMeter } from "./DpsMeter";
import { useDungeonRunStore } from "../game/dungeonRunStore";
import { formatDungeonDuration } from "../game/dungeonRunTypes";
import { DesktopActionBars } from "./DesktopActionBars"; import { DesktopActionBars } from "./DesktopActionBars";
import { currentUiDevice, useUiLayoutStore } from "../game/uiLayoutStore"; import { currentUiDevice, useUiLayoutStore } from "../game/uiLayoutStore";
import { SummonFrames } from "./SummonFrames"; import { SummonFrames } from "./SummonFrames";
@@ -86,12 +88,14 @@ export function DefeatedPrompt({
onReturn, onReturn,
sharedOnline = false, sharedOnline = false,
checkpoints = [], checkpoints = [],
partyStillFighting = false,
}: { }: {
gameMode: "dungeon" | "manastorm"; gameMode: "dungeon" | "manastorm";
resurrectionCharges: number; resurrectionCharges: number;
onReturn: (checkpointId?: string) => void; onReturn: (checkpointId?: string) => void;
sharedOnline?: boolean; sharedOnline?: boolean;
checkpoints?: readonly ResurrectionCheckpoint[]; checkpoints?: readonly ResurrectionCheckpoint[];
partyStillFighting?: boolean;
}) { }) {
const [requestedCheckpoint, setRequestedCheckpoint] = useState("entrance"); const [requestedCheckpoint, setRequestedCheckpoint] = useState("entrance");
const checkpointId = checkpoints.some((checkpoint) => checkpoint.id === requestedCheckpoint) ? requestedCheckpoint : "entrance"; const checkpointId = checkpoints.some((checkpoint) => checkpoint.id === requestedCheckpoint) ? requestedCheckpoint : "entrance";
@@ -126,7 +130,9 @@ export function DefeatedPrompt({
<small>No shared resurrection charges remain. A living ally must resurrect you.</small> <small>No shared resurrection charges remain. A living ally must resurrect you.</small>
)} )}
{gameMode === "dungeon" && !sharedOnline ? ( {gameMode === "dungeon" && !sharedOnline ? (
<small>Respawning resets the current encounter and restores the party.</small> <small>{partyStillFighting
? "Your party will keep fighting where they are. Use Recall if you want to regroup."
: "Respawning resets the current encounter and restores the party."}</small>
) : null} ) : null}
</div> </div>
</section> </section>
@@ -136,20 +142,28 @@ export function DefeatedPrompt({
export function DungeonCompletePrompt({ export function DungeonCompletePrompt({
dungeonTitle, dungeonTitle,
onFinish, onFinish,
onResults,
durationMs,
finalBossOnly = false,
}: { }: {
readonly dungeonTitle: string; readonly dungeonTitle: string;
readonly onFinish: () => void; readonly onFinish: () => void;
readonly onResults?: () => void;
readonly durationMs?: number | null;
readonly finalBossOnly?: boolean;
}) { }) {
return ( return (
<section className="dungeon-complete-banner" role="status" aria-live="polite"> <section className="dungeon-complete-banner" role="status" aria-live="polite">
<span aria-hidden="true">◆</span> <span aria-hidden="true">◆</span>
<p> <p>
<strong>{dungeonTitle} complete</strong> <strong>{dungeonTitle} complete</strong>
<small>Every required encounter is defeated. Loot the room or finish the run.</small> <small>{finalBossOnly ? "Final boss defeated." : "Every required encounter is defeated."} Loot the room or finish the run.</small>
{durationMs != null ? <small>Time to final boss: {formatDungeonDuration(durationMs)}</small> : null}
</p> </p>
<button type="button" className="button button--primary" onClick={onFinish}> <button type="button" className="button button--primary" onClick={onFinish}>
Finish dungeon Finish dungeon
</button> </button>
{onResults ? <button type="button" className="button" onClick={onResults}>Run results</button> : null}
</section> </section>
); );
} }
@@ -204,6 +218,7 @@ export function Hud() {
: null); : null);
const selfSelected = usePartyStore((state) => state.selfSelected); const selfSelected = usePartyStore((state) => state.selfSelected);
const partyMembers = usePartyStore((state) => state.members); const partyMembers = usePartyStore((state) => state.members);
const partyStillFighting = useCombatStore((state) => partyFightStillActive(partyMembers, state.mobs, state.actors));
const playerRole = usePartyStore((state) => state.playerRole); const playerRole = usePartyStore((state) => state.playerRole);
const settings = useCombatStore((state) => state.settings); const settings = useCombatStore((state) => state.settings);
const onlineSession = useOnlineSessionStore((state) => state.snapshot); const onlineSession = useOnlineSessionStore((state) => state.snapshot);
@@ -213,8 +228,9 @@ export function Hud() {
activeDungeonEncounterRevision, activeDungeonEncounterRevision,
activeDungeonEncounterRevision, activeDungeonEncounterRevision,
); );
const dungeonComplete = gameMode === "dungeon" const runProgress = useDungeonRunStore((state) => state.progress);
&& activeDungeonRunIsComplete(activeDungeonId); const finalBossDefeated = runProgress?.dungeonId === activeDungeonId && runProgress.finishedAt !== null;
const dungeonComplete = gameMode === "dungeon" && (finalBossDefeated || activeDungeonRunIsComplete(activeDungeonId));
useEffect(() => { useEffect(() => {
const timer = window.setInterval(() => setClock(Date.now()), 100); const timer = window.setInterval(() => setClock(Date.now()), 100);
@@ -379,8 +395,10 @@ export function Hud() {
<ShelvedHud /> <ShelvedHud />
<DpsMeter /> <DpsMeter />
<BossLootWindow /> <BossLootWindow />
<EditableHudElement id="completion">{dungeonComplete && health > 0 ? ( <EditableHudElement id="completion">{dungeonComplete ? (
<DungeonCompletePrompt dungeonTitle={dungeon.title} onFinish={finishDungeon} /> <DungeonCompletePrompt dungeonTitle={dungeon.title} onFinish={finishDungeon}
finalBossOnly={!activeDungeonRunIsComplete(activeDungeonId)} durationMs={runProgress?.timed ? runProgress.durationMs : null}
onResults={finalBossDefeated ? () => useGameStore.getState().openOverlay("dungeon-results") : undefined} />
) : null}</EditableHudElement> ) : null}</EditableHudElement>
<EditableHudElement id="loot">{feedback?.kind === "loot" ? ( <EditableHudElement id="loot">{feedback?.kind === "loot" ? (
<section className="loot-reward" role="status" aria-live="polite"> <section className="loot-reward" role="status" aria-live="polite">
@@ -516,6 +534,7 @@ export function Hud() {
resurrectionCharges={resurrectionCharges} resurrectionCharges={resurrectionCharges}
onReturn={returnFromDefeat} onReturn={returnFromDefeat}
checkpoints={resurrectionCheckpoints} checkpoints={resurrectionCheckpoints}
partyStillFighting={partyStillFighting}
sharedOnline={onlineSession !== null} sharedOnline={onlineSession !== null}
/> />
) : null} ) : null}
+21 -5
View File
@@ -10,6 +10,7 @@ import { ContentManagerScreen } from "./ContentManagerScreen";
import { BrandMark, ControllerLegend, FrontSurface } from "./FrontSurface"; import { BrandMark, ControllerLegend, FrontSurface } from "./FrontSurface";
import { OnlineGroupPanel } from "./OnlineGroupPanel"; import { OnlineGroupPanel } from "./OnlineGroupPanel";
import { sessionCanUseShelvedGmTools, SHELVED_MODES_ENABLED } from "../app/releaseFeatures"; import { sessionCanUseShelvedGmTools, SHELVED_MODES_ENABLED } from "../app/releaseFeatures";
import { DungeonReportsScreen } from "./DungeonReportsScreen";
const GAME_MODES = [ const GAME_MODES = [
{ {
@@ -64,6 +65,16 @@ const GAME_MODES = [
export function MainMenuScreen() { export function MainMenuScreen() {
const [contentManagerOpen, setContentManagerOpen] = useState(false); const [contentManagerOpen, setContentManagerOpen] = useState(false);
const [reportsOpen, setReportsOpen] = useState(false);
if (contentManagerOpen) return <ContentManagerScreen onClose={() => setContentManagerOpen(false)} />;
if (reportsOpen) return <DungeonReportsScreen onClose={() => setReportsOpen(false)} />;
return <MainMenuDestinations onOpenDownloads={() => setContentManagerOpen(true)} onOpenReports={() => setReportsOpen(true)} />;
}
function MainMenuDestinations({ onOpenDownloads, onOpenReports }: {
readonly onOpenDownloads: () => void;
readonly onOpenReports: () => void;
}) {
const activeCharacter = useShellStore((state) => state.activeCharacter); const activeCharacter = useShellStore((state) => state.activeCharacter);
const rosterCharacter = useShellStore(selectedCharacter); const rosterCharacter = useShellStore(selectedCharacter);
const openDungeons = useShellStore((state) => state.openDungeons); const openDungeons = useShellStore((state) => state.openDungeons);
@@ -95,7 +106,7 @@ export function MainMenuScreen() {
}] : []), }] : []),
{ {
id: "mode-downloads", id: "mode-downloads",
run: () => setContentManagerOpen(true), run: onOpenDownloads,
neighbors: { left: SHELVED_MODES_ENABLED ? "mode-manastorms" : "mode-dungeons", down: "back-to-roster" }, neighbors: { left: SHELVED_MODES_ENABLED ? "mode-manastorms" : "mode-dungeons", down: "back-to-roster" },
}, },
{ {
@@ -105,7 +116,8 @@ export function MainMenuScreen() {
}, },
...(character ? [{ id: "mode-class-hall", run: openClassHall }] : []), ...(character ? [{ id: "mode-class-hall", run: openClassHall }] : []),
...(isGm ? [{ id: "mode-gm-tools", run: openGmAdmin }] : []), ...(isGm ? [{ id: "mode-gm-tools", run: openGmAdmin }] : []),
], [character, isGm, openArenas, openClassHall, openDungeons, openGmAdmin, openManastorms, returnToCharacters]); { id: "mode-reports", run: onOpenReports },
], [character, isGm, onOpenDownloads, onOpenReports, openArenas, openClassHall, openDungeons, openGmAdmin, openManastorms, returnToCharacters]);
const controller = useMenuController(actions, { const controller = useMenuController(actions, {
initialId: "mode-dungeons", initialId: "mode-dungeons",
onBack: returnToCharacters, onBack: returnToCharacters,
@@ -114,8 +126,6 @@ export function MainMenuScreen() {
const race = character ? raceById(character.raceId) : null; const race = character ? raceById(character.raceId) : null;
const characterClass = character ? classById(character.classId) : null; const characterClass = character ? classById(character.classId) : null;
if (contentManagerOpen) return <ContentManagerScreen onClose={() => setContentManagerOpen(false)} />;
return ( return (
<DualDisplayFrame <DualDisplayFrame
contextLabel="Main menu details" contextLabel="Main menu details"
@@ -163,6 +173,12 @@ export function MainMenuScreen() {
</header> </header>
<OnlineGroupPanel /> <OnlineGroupPanel />
<nav className="main-menu-mode-grid" aria-label="Game modes"> <nav className="main-menu-mode-grid" aria-label="Game modes">
<ControllerButton controlId="mode-reports" selectedId={controller.selectedId} select={controller.select}
className="front-button main-menu-mode main-menu-mode--available" type="button" onClick={onOpenReports}>
<b aria-hidden="true">!</b>
<span><strong>View Reports</strong><small>Browse dungeon issues and follow their status.</small></span>
<em>Open</em>
</ControllerButton>
{isGm && ( {isGm && (
<ControllerButton <ControllerButton
controlId="mode-gm-tools" controlId="mode-gm-tools"
@@ -204,7 +220,7 @@ export function MainMenuScreen() {
: mode.id === "arenas" : mode.id === "arenas"
? openArenas ? openArenas
: mode.id === "downloads" : mode.id === "downloads"
? () => setContentManagerOpen(true) ? onOpenDownloads
: openDungeons} : openDungeons}
> >
<b aria-hidden="true">{mode.number}</b> <b aria-hidden="true">{mode.number}</b>
+6 -2
View File
@@ -6,6 +6,7 @@ import { ControllerButton } from "./ControllerButton";
import { sessionCanUseShelvedGmTools } from "../app/releaseFeatures"; import { sessionCanUseShelvedGmTools } from "../app/releaseFeatures";
import { useInputBindingLabels } from "../input/useInputBindingLabels"; import { useInputBindingLabels } from "../input/useInputBindingLabels";
import { INPUT_ACTION_BY_ID } from "../input/keybinds"; import { INPUT_ACTION_BY_ID } from "../input/keybinds";
import { DungeonIssueReportPanel } from "./DungeonIssueReportPanel";
function PauseDialog() { function PauseDialog() {
const bindingLabel = useInputBindingLabels(); const bindingLabel = useInputBindingLabels();
@@ -47,6 +48,7 @@ function PauseDialog() {
{ id: "pause-keybinds", run: () => openOverlay("keybinds") }, { id: "pause-keybinds", run: () => openOverlay("keybinds") },
{ id: "pause-options", run: () => openOverlay("options") }, { id: "pause-options", run: () => openOverlay("options") },
{ id: "pause-ui-layout", run: () => openOverlay("ui-layout") }, { id: "pause-ui-layout", run: () => openOverlay("ui-layout") },
...(gameMode === "dungeon" ? [{ id: "pause-report-issue", run: () => openOverlay("report-issue") }] : []),
...(isGm ? [{ id: "pause-gm", run: () => openOverlay("gm") }] : []), ...(isGm ? [{ id: "pause-gm", run: () => openOverlay("gm") }] : []),
...(gameMode === "manastorm" ...(gameMode === "manastorm"
? [{ id: "pause-abandon-manastorm", run: abandonManastorm }] ? [{ id: "pause-abandon-manastorm", run: abandonManastorm }]
@@ -84,6 +86,7 @@ function PauseDialog() {
{menuButton("pause-keybinds", "Keybinds", () => openOverlay("keybinds"))} {menuButton("pause-keybinds", "Keybinds", () => openOverlay("keybinds"))}
{menuButton("pause-options", "Options", () => openOverlay("options"))} {menuButton("pause-options", "Options", () => openOverlay("options"))}
{menuButton("pause-ui-layout", "Edit UI & templates", () => openOverlay("ui-layout"))} {menuButton("pause-ui-layout", "Edit UI & templates", () => openOverlay("ui-layout"))}
{gameMode === "dungeon" ? menuButton("pause-report-issue", "Report an issue", () => openOverlay("report-issue")) : null}
{isGm ? menuButton("pause-gm", "GM placement editor", () => openOverlay("gm")) : null} {isGm ? menuButton("pause-gm", "GM placement editor", () => openOverlay("gm")) : null}
{gameMode === "manastorm" {gameMode === "manastorm"
? menuButton("pause-abandon-manastorm", "Abandon Manastorm run", abandonManastorm) ? menuButton("pause-abandon-manastorm", "Abandon Manastorm run", abandonManastorm)
@@ -103,6 +106,7 @@ function PauseDialog() {
} }
export function PauseMenu() { export function PauseMenu() {
const open = useGameStore((state) => state.overlay === "pause"); const overlay = useGameStore((state) => state.overlay);
return open ? <PauseDialog /> : null; if (overlay === "report-issue") return <DungeonIssueReportPanel />;
return overlay === "pause" ? <PauseDialog /> : null;
} }
+11
View File
@@ -14,6 +14,17 @@ describe("defeated prompt", () => {
expect(markup).toContain("wait for a party member to resurrect you"); expect(markup).toContain("wait for a party member to resurrect you");
expect(markup).toContain("Respawn at entrance"); expect(markup).toContain("Respawn at entrance");
expect(markup).toContain("Respawning resets the current encounter and restores the party.");
});
it("explains player-only resurrection and optional Recall during an ongoing party fight", () => {
const markup = renderToStaticMarkup(createElement(DefeatedPrompt, {
gameMode: "dungeon", resurrectionCharges: 0, partyStillFighting: true, onReturn: () => undefined,
}));
expect(markup).toContain("Your party will keep fighting where they are.");
expect(markup).toContain("Use Recall if you want to regroup.");
expect(markup).toContain("Respawn at entrance");
expect(markup).not.toContain("resets the current encounter");
}); });
it("does not offer a Manastorm resurrection when no shared charges remain", () => { it("does not offer a Manastorm resurrection when no shared charges remain", () => {
+89
View File
@@ -0,0 +1,89 @@
:root { --report-orange: #f1a044; }
.dungeon-select-row { position: relative; }
.dungeon-select-row.has-open-reports { border-color: var(--report-orange); }
.dungeon-select-row.has-open-reports.is-controller-selected { box-shadow: 0 0 0 2px rgba(241,160,68,.5) !important; }
.dungeon-select-row.has-open-reports.is-controller-selected, .dungeon-select-row.has-open-reports:focus-visible { outline-color: var(--report-orange) !important; }
.dungeon-open-report-count {
position: absolute;
top: 3px;
right: 4px;
display: grid;
min-width: 19px;
height: 19px;
padding: 0 4px;
place-items: center;
border-radius: 10px;
background: var(--report-orange);
color: #211608;
font: 700 11px/1 sans-serif;
}
.dungeon-release-stage {
display: inline-block;
margin-bottom: 9px;
padding: 3px 7px;
border: 1px solid var(--front-line);
border-radius: 5px;
color: var(--front-gold);
font-size: 10px;
}
.dungeon-report-load-error { margin: 0 0 5px; color: var(--report-orange); font-size: 10px; }
.dungeon-issue-report form { display: grid; gap: 10px; margin-bottom: 12px; }
.dungeon-issue-report textarea {
width: 100%;
min-height: 140px;
max-height: 30vh;
padding: 12px;
resize: vertical;
border: 1px solid var(--edge);
border-radius: 8px;
background: rgba(0,0,0,.25);
color: var(--ink);
font: inherit;
line-height: 1.5;
}
.dungeon-issue-report form > small { color: var(--muted); text-align: right; }
.dungeon-issue-report > .button { width: 100%; }
.dungeon-report-error { margin: 0; color: var(--report-orange); }
.dungeon-reports-screen { display: grid; grid-template-rows: auto minmax(0,1fr) auto; gap: 14px; }
.dungeon-reports-header { display: flex; align-items: center; gap: 20px; padding-bottom: 12px; border-bottom: 1px solid var(--front-line); }
.dungeon-reports-header > div:nth-child(2) { flex: 1; }
.dungeon-reports-header > div > span { color: var(--front-dim); font-size: 10px; }
.dungeon-reports-header h1 { margin: 3px 0 0; font: 26px Georgia, serif; }
.dungeon-reports-main { display: flex; min-height: 0; flex-direction: column; }
.dungeon-report-filters { display: flex; flex-wrap: wrap; align-items: end; gap: 10px; }
.dungeon-report-filters label, .dungeon-report-management label { display: grid; min-width: 0; gap: 5px; color: var(--front-dim); font-size: 12px; }
.dungeon-report-filters select, .dungeon-report-management select {
width: 100%;
max-width: 360px;
min-height: 40px;
padding: 7px 10px;
border: 1px solid var(--front-line);
border-radius: 7px;
background: #182015;
color: var(--front-cream);
font: inherit;
}
.dungeon-report-filters > label:nth-child(2) { flex: 1; }
.dungeon-reports-summary { color: var(--front-dim); font-size: 12px; }
.dungeon-report-list { display: grid; min-height: 0; align-content: start; gap: 12px; overflow: auto; padding: 2px 4px 10px 0; }
.dungeon-report-card { padding: 16px; border: 1px solid var(--report-orange); border-radius: 9px; background: rgba(255,255,255,.025); }
.dungeon-report-card.is-closed { border-color: var(--front-line); }
.dungeon-report-card header { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; }
.dungeon-report-card h2 { flex: 1; margin: 0; font: 19px Georgia, serif; }
.dungeon-report-status { padding: 4px 8px; border-radius: 5px; background: rgba(241,160,68,.12); color: var(--report-orange); font-size: 12px; }
.is-closed .dungeon-report-status { color: var(--front-dim); background: rgba(255,255,255,.05); }
.dungeon-report-comment { white-space: pre-wrap; overflow-wrap: anywhere; font-size: 14px; line-height: 1.5; }
.dungeon-report-card > small { display: block; margin-top: 5px; color: var(--front-dim); font-size: 11px; }
.dungeon-report-management { display: flex; flex-wrap: wrap; align-items: end; gap: 12px; margin-top: 14px; }
.dungeon-reports-screen > .controller-legend { position: static; justify-self: end; }
.dungeon-issue-report textarea:focus-visible, .dungeon-reports-screen select:focus-visible { outline: 2px solid var(--report-orange); outline-offset: 2px; }
.dungeon-reports-screen select.is-controller-selected { outline: 2px solid var(--front-gold); outline-offset: 2px; }
@media (max-width: 600px) {
.dungeon-reports-header { gap: 10px; }
.dungeon-reports-header > .front-brand { display: none; }
.dungeon-reports-header h1 { font-size: 21px; }
.dungeon-report-filters { display: grid; grid-template-columns: minmax(0,1fr) minmax(0,1fr); }
.dungeon-report-card { padding: 12px; }
.dungeon-report-card h2 { font-size: 17px; }
.dungeon-reports-screen > .controller-legend { display: none; }
}
+102
View File
@@ -0,0 +1,102 @@
.dungeon-leaderboard, .dungeon-results {
--run-gold: var(--front-gold, #d8ae57);
--run-green: var(--front-green-bright, #c8e28d);
--run-line: var(--front-line, rgba(204, 224, 180, .18));
--run-muted: #b1b7a3;
color: #f0ecdc;
}
.dungeon-leaderboard {
display: flex;
min-width: 0;
min-height: 0;
flex-direction: column;
overflow: hidden;
border: 1px solid var(--run-line);
border-radius: 13px 4px 13px 4px;
background: rgba(4, 16, 11, .9);
}
.dungeon-leaderboard > header { padding: 15px 12px 10px; border-bottom: 1px solid var(--run-line); }
.dungeon-leaderboard h3 { margin: 6px 0 14px; color: var(--run-gold); font: 24px Georgia, serif; }
.dungeon-leaderboard__filters { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; }
.dungeon-leaderboard button { color: inherit; cursor: pointer; }
.dungeon-leaderboard__filters button {
min-height: 48px;
padding: 6px;
border: 1px solid var(--run-line);
border-radius: 5px;
background: #0c1a12;
font-size: 11px;
}
.dungeon-leaderboard__filters button[aria-pressed="true"] { border-color: var(--run-gold); background: #27301a; color: #f2dfaa; }
.dungeon-leaderboard__filters small { display: block; margin-top: 3px; color: var(--run-muted); font-size: 9px; }
.dungeon-leaderboard__meta { display: flex; margin-top: 10px; align-items: center; justify-content: space-between; gap: 7px; color: var(--run-muted); font-size: 10px; }
.dungeon-leaderboard__meta button { padding: 5px 2px; border: 0; background: transparent; color: var(--run-green); font-size: 10px; }
.dungeon-leaderboard__entries { min-height: 0; flex: 1; overflow: auto; overscroll-behavior: contain; }
.dungeon-leaderboard__entries ol { margin: 0; padding: 0; list-style: none; }
.dungeon-leaderboard__entries > ol > li { border-bottom: 1px solid var(--run-line); }
.dungeon-leaderboard__run { display: grid; width: 100%; min-height: 63px; grid-template-columns: 31px minmax(0, 1fr) auto 10px; gap: 7px; padding: 10px; align-items: center; border: 0; background: transparent; text-align: left; }
.dungeon-leaderboard__run:hover, .dungeon-leaderboard__run[aria-expanded="true"] { background: rgba(143, 181, 92, .09); }
.dungeon-leaderboard__run > b { color: var(--run-gold); font: 16px Georgia, serif; }
.dungeon-leaderboard__run > span { display: flex; min-width: 0; flex-direction: column; gap: 4px; }
.dungeon-leaderboard__run strong { overflow: hidden; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
.dungeon-leaderboard__run small { color: var(--run-muted); font-size: 9px; }
.dungeon-leaderboard__run time { color: var(--run-green); font-size: 14px; font-variant-numeric: tabular-nums; }
.dungeon-leaderboard__run em { color: var(--run-gold); font-size: 16px; font-style: normal; }
.dungeon-leaderboard__empty { padding: 22px 15px; color: var(--run-muted); font-size: 12px; line-height: 1.7; }
.dungeon-leaderboard__pages { display: flex; padding: 8px; align-items: center; justify-content: space-between; gap: 5px; border-top: 1px solid var(--run-line); font-size: 9px; }
.dungeon-leaderboard__pages button { padding: 7px; border: 1px solid var(--run-line); border-radius: 4px; background: #122019; font-size: 10px; }
.dungeon-leaderboard button:disabled { opacity: .4; cursor: default; }
.dungeon-run-details { padding: 8px 12px 12px; }
.dungeon-run-details h4 { margin: 9px 0; color: var(--run-gold); font-size: 11px; font-weight: 500; }
.dungeon-run-details ul { margin: 0; padding: 0; list-style: none; }
.dungeon-run-party li { display: flex; padding: 6px 0; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: 4px 9px; border-bottom: 1px solid rgba(204, 224, 180, .07); }
.dungeon-run-party strong { font-size: 11px; }
.dungeon-run-party strong small { color: var(--run-muted); font-size: 8px; font-weight: 400; }
.dungeon-run-party span { width: 100%; order: 3; color: #d6dcc9; font-size: 10px; }
.dungeon-run-party li > small { color: var(--run-muted); font-size: 9px; }
.dungeon-run-bosses li { display: flex; padding: 5px 0; align-items: center; gap: 7px; color: var(--run-muted); font-size: 10px; }
.dungeon-run-bosses li.is-defeated { color: var(--run-green); }
.dungeon-run-bosses small { margin-left: auto; color: var(--run-muted); font-size: 8px; }
.dungeon-results-backdrop { z-index: 110; padding: 16px; pointer-events: auto; }
.dungeon-results {
display: flex;
width: min(580px, calc(100vw - 32px));
max-height: calc(100dvh - 32px);
flex-direction: column;
padding: 23px;
border: 1px solid var(--run-gold);
border-radius: 12px;
outline: 0;
background: linear-gradient(145deg, #172319, #07100d);
box-shadow: 0 20px 65px #0009;
}
.dungeon-results > header { flex: 0 0 auto; }
.dungeon-results h2 { margin: 7px 0; color: #f3deb0; font: 29px Georgia, serif; }
.dungeon-results header > p:last-child { margin: 9px 0 16px; color: var(--run-muted); font-size: 12px; }
.dungeon-results__score { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; flex: 0 0 auto; }
.dungeon-results__score > div { display: flex; padding: 14px; flex-direction: column; gap: 6px; border: 1px solid var(--run-line); border-radius: 6px; background: #ffffff04; }
.dungeon-results__score small { color: var(--run-muted); font-size: 11px; }
.dungeon-results__score strong { color: var(--run-green); font: 31px Georgia, serif; font-variant-numeric: tabular-nums; }
.dungeon-results__score span { color: var(--run-muted); font-size: 10px; }
.dungeon-results__full-rank { margin: 12px 0 0; color: var(--run-gold); font-size: 12px; }
.dungeon-results__note { margin: 12px 0 0; color: var(--run-muted); font-size: 11px; line-height: 1.5; }
.dungeon-results__details { min-height: 40px; margin-top: 13px; overflow: auto; overscroll-behavior: contain; border-block: 1px solid var(--run-line); }
.dungeon-results__details .dungeon-run-details { padding-inline: 0; }
.dungeon-results > footer { display: flex; margin-top: 16px; flex: 0 0 auto; flex-wrap: wrap; gap: 8px; }
.dungeon-results > footer button { min-height: 38px; flex: 1; white-space: nowrap; }
@media (max-width: 960px) {
.dungeon-select-detail { grid-template-rows: minmax(145px, 1fr) minmax(120px, .8fr); gap: 9px; }
.dungeon-leaderboard > header { padding: 9px; }
.dungeon-leaderboard h3 { margin: 4px 0 8px; font-size: 19px; }
.dungeon-leaderboard__filters button { min-height: 39px; font-size: 10px; }
.dungeon-leaderboard__meta { margin-top: 5px; }
.dungeon-leaderboard__run { min-height: 52px; }
}
@media (max-height: 520px) {
.dungeon-results { padding: 13px; }
.dungeon-results h2 { font-size: 23px; }
.dungeon-results header > p:last-child { margin-bottom: 9px; }
.dungeon-results__score > div { padding: 8px 12px; }
.dungeon-results__score strong { font-size: 25px; }
.dungeon-results > footer { margin-top: 9px; }
}
+39
View File
@@ -0,0 +1,39 @@
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { DungeonLeaderboard } from "./DungeonLeaderboard";
import { DungeonRunResultsDialog } from "./DungeonRunResults";
import catalog from "../game/generated/dungeonLeaderboardCatalog.generated.json";
import type { DungeonRunProgress } from "../game/dungeonRunTypes";
const caves = catalog.dungeons.find((entry) => entry.id === "barren-caves")!;
const progress: DungeonRunProgress = { id: "ui-caves-test", dungeonId: caves.id, difficultyId: "normal", timed: true,
startedAt: 1000, finishedAt: 13345, durationMs: 12345, killedBossIds: [caves.finalBossId],
party: [{ name: "Tester", classId: "rom-priest", level: 3, role: "healer", npc: false },
{ name: "Garrick", classId: "warrior", level: 3, role: "tank", npc: true }] };
const noop = () => undefined;
describe("dungeon results and leaderboard", () => {
it("announces the final boss, frozen time, both rankings, party levels, and explicit loot/menu choices", () => {
const html = renderToStaticMarkup(createElement(DungeonRunResultsDialog, { progress,
ranking: { scope: "world", loading: false, placement: { finalRank: 2, finalTotal: 10, fullRank: 1, fullTotal: 4 } },
onFinish: noop, onLoot: noop, onRetry: noop }));
for (const text of ["Barren Caves complete", "00:12.3", "#2", "#1", "Anglo", "Level 3", "Garrick", "Stay and loot", "Return to main menu"])
expect(html).toContain(text);
expect(html).toContain('aria-modal="true"');
});
it("labels disconnected rankings as device-only and offers submission retry", () => {
const html = renderToStaticMarkup(createElement(DungeonRunResultsDialog, { progress,
ranking: { scope: "device", loading: false, placement: { finalRank: 1, finalTotal: 1, fullRank: 1, fullTotal: 1 } },
onFinish: noop, onLoot: noop, onRetry: noop }));
expect(html).toContain("This device leaderboard"); expect(html).toContain("Retry submission");
});
it("offers both clear filters and expandable party/boss records in the dungeon picker", () => {
const html = renderToStaticMarkup(createElement(DungeonLeaderboard, { dungeonTitle: caves.title,
data: { entries: [{ ...progress, durationMs: progress.durationMs!, finishedAt: progress.finishedAt!, fullClear: true, rank: 1 }], total: 1 },
scope: "world", loading: false, filter: "full", onFilter: noop, expanded: new Set([progress.id]),
onExpand: noop, offset: 0, onPage: noop, onRefresh: noop, difficultyLabel: "Normal", selectedId: "", select: noop }));
for (const text of ["Final Boss clears", "All entries", "Full dungeon clears", "All bosses", "Anglo", "Level 3", "00:12.3"])
expect(html).toContain(text);
expect(html).toContain('aria-expanded="true"');
});
});
+32
View File
@@ -0,0 +1,32 @@
import { useEffect, useState } from "react";
import { fetchDungeonLeaderboard, localDungeonLeaderboard, subscribeDungeonLeaderboard,
type LeaderboardScope } from "../app/dungeonLeaderboardClient";
import type { DungeonClearFilter, DungeonLeaderboard } from "../game/dungeonRunTypes";
export function useDungeonLeaderboard(dungeonId: string, difficultyId: string, filter: DungeonClearFilter) {
const [offset, setOffset] = useState(0);
const [revision, setRevision] = useState(0);
const [state, setState] = useState<{ data: DungeonLeaderboard; scope: LeaderboardScope; loading: boolean }>({
data: { entries: [], total: 0 }, scope: "world", loading: true,
});
useEffect(() => { setOffset(0); }, [dungeonId, difficultyId, filter]);
useEffect(() => subscribeDungeonLeaderboard(() => setRevision((value) => value + 1)), []);
useEffect(() => {
const refresh = () => setRevision((value) => value + 1);
window.addEventListener("focus", refresh);
return () => window.removeEventListener("focus", refresh);
}, []);
useEffect(() => {
const controller = new AbortController();
let live = true;
const timeout = setTimeout(() => controller.abort(), 8000);
setState({ data: { entries: [], total: 0 }, scope: "world", loading: true });
if (dungeonId) void fetchDungeonLeaderboard(dungeonId, difficultyId, filter, offset, controller.signal)
.then((data) => { if (live) setState({ data, scope: "world", loading: false }); })
.catch(() => { if (live) setState({ data: localDungeonLeaderboard(dungeonId, difficultyId, filter, offset),
scope: "device", loading: false }); });
else setState({ data: { entries: [], total: 0 }, scope: "world", loading: false });
return () => { live = false; controller.abort(); clearTimeout(timeout); };
}, [dungeonId, difficultyId, filter, offset, revision]);
return { ...state, offset, setOffset, refresh: () => setRevision((value) => value + 1) };
}
+40
View File
@@ -0,0 +1,40 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { fetchDungeonReports, type DungeonReportSnapshot } from "../app/dungeonReports";
export function useDungeonReports() {
const [snapshot, setSnapshot] = useState<DungeonReportSnapshot | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const sequence = useRef(0);
const refresh = useCallback(async () => {
const request = ++sequence.current;
setLoading(true);
try {
const next = await fetchDungeonReports();
if (request !== sequence.current) return;
setSnapshot(next);
setError("");
} catch (failure) {
if (request === sequence.current) setError(failure instanceof Error ? failure.message : "Reports could not be loaded.");
} finally {
if (request === sequence.current) setLoading(false);
}
}, []);
useEffect(() => {
const refreshVisible = () => { if (document.visibilityState === "visible") void refresh(); };
void refresh();
const timer = window.setInterval(refreshVisible, 30_000);
window.addEventListener("focus", refreshVisible);
document.addEventListener("visibilitychange", refreshVisible);
return () => {
sequence.current += 1;
window.clearInterval(timer);
window.removeEventListener("focus", refreshVisible);
document.removeEventListener("visibilitychange", refreshVisible);
};
}, [refresh]);
return { snapshot, loading, error, refresh };
}