Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ed0d2fea3d | ||
|
|
b417b7c3c3 |
No files matched your search
@@ -2558,3 +2558,324 @@ Vite's existing large-chunk advisory remains informational.
|
||||
checks passed. Browser error logs were empty. Physical Thor hardware and a
|
||||
multiplayer session were not exercised; the new per-party totals consume
|
||||
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.
|
||||
@@ -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.
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "healer-man",
|
||||
"version": "0.1.37",
|
||||
"version": "0.1.39",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "healer-man",
|
||||
"version": "0.1.37",
|
||||
"version": "0.1.39",
|
||||
"dependencies": {
|
||||
"@capacitor/android": "8.4.1",
|
||||
"@capacitor/core": "8.4.1",
|
||||
|
||||
+3
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "healer-man",
|
||||
"private": true,
|
||||
"version": "0.1.37",
|
||||
"version": "0.1.39",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"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: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: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: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",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"versionName": "0.1.37",
|
||||
"versionCode": 1037
|
||||
"versionName": "0.1.39",
|
||||
"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 { inflateSync } from "node:zlib";
|
||||
import sharp from "sharp";
|
||||
import { vocationGrowth } from "./vocation-growth.mjs";
|
||||
|
||||
const scriptDirectory = path.dirname(fileURLToPath(import.meta.url));
|
||||
const projectRoot = path.resolve(scriptDirectory, "../..");
|
||||
@@ -38,17 +39,6 @@ const RACE_MATRIX = {
|
||||
"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 LEARN_RECORD_SIZE = 12184;
|
||||
const LEARN_ENTRY_SIZE = 24;
|
||||
@@ -578,7 +568,7 @@ const catalog = {
|
||||
schemaVersion: 1,
|
||||
source: { rootHint: "RuneWaker source snapshot", hashes: sourceHashes },
|
||||
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])])),
|
||||
eliteLevels: ELITE_LEVELS,
|
||||
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/);
|
||||
});
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
} from "./manastorm-config.mjs";
|
||||
import { arenaRatingBand, calculateArenaRating, clampArenaRating } from "./arena-rules.mjs";
|
||||
import { arenaRoleForSpecialization } from "./arena-specializations.mjs";
|
||||
import { createDungeonReportRepository } from "./dungeon-reports.mjs";
|
||||
import { createDungeonLeaderboardRepository } from "./dungeon-leaderboards.mjs";
|
||||
|
||||
const scrypt = promisify(scryptCallback);
|
||||
|
||||
@@ -895,6 +897,8 @@ export function openGameDatabase(options = {}) {
|
||||
|
||||
return {
|
||||
databasePath,
|
||||
...createDungeonReportRepository(database, { isGameMaster, ErrorType: GameDatabaseError }),
|
||||
...createDungeonLeaderboardRepository(database, { ErrorType: GameDatabaseError }),
|
||||
register,
|
||||
login,
|
||||
authenticate,
|
||||
|
||||
@@ -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) };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}));
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
@@ -181,6 +181,8 @@ export function createGameServer(options = {}) {
|
||||
const onlineSessions = createOnlineSessionManager();
|
||||
const arenaService = createArenaService({ database: gameDatabase, autoPump: enableShelvedModes });
|
||||
const allowAuthRequest = createAuthLimiter();
|
||||
const allowReportRequest = createAuthLimiter();
|
||||
const allowDungeonRunRequest = createAuthLimiter();
|
||||
gameDatabase.pruneExpiredSessions();
|
||||
|
||||
const server = createServer(async (request, response) => {
|
||||
@@ -239,6 +241,49 @@ export function createGameServer(options = {}) {
|
||||
json(response, 200, { account: gameDatabase.authenticate(bearerToken(request)) });
|
||||
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") {
|
||||
const account = gameDatabase.authenticate(bearerToken(request));
|
||||
json(response, 200, gameDatabase.getCloudSave(account.id));
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"commentMaxLength": 500,
|
||||
"statuses": ["Submitted", "In-Progress", "Testing Fixes", "Closed"]
|
||||
}
|
||||
+5
-1
@@ -20,6 +20,8 @@ import { PauseMenu } from "./ui/PauseMenu";
|
||||
import { SceneErrorBoundary } from "./ui/SceneErrorBoundary";
|
||||
import { OnlineSessionBridge } from "./game/OnlineSessionBridge";
|
||||
import { DungeonRunCheckpointBridge } from "./game/DungeonRunCheckpointBridge";
|
||||
import { DungeonRunBridge } from "./game/DungeonRunBridge";
|
||||
import { DungeonRunResults } from "./ui/DungeonRunResults";
|
||||
import { DungeonEncounterEffectBridge } from "./game/DungeonEncounterEffectBridge";
|
||||
import { interactWithActiveDungeonObject } from "./game/dungeonWorldInteraction";
|
||||
import { DungeonInteractionPrompt } from "./ui/DungeonInteractionPrompt";
|
||||
@@ -37,7 +39,7 @@ function InputBridge() {
|
||||
|
||||
useEffect(() => installInput({
|
||||
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) => {
|
||||
const preferences = useUiLayoutStore.getState().preferences;
|
||||
const bindings = profileInputBindings(preferences, device);
|
||||
@@ -115,6 +117,7 @@ export default function GameRuntime() {
|
||||
<DungeonRunCheckpointBridge />
|
||||
<CombatBridge />
|
||||
<PartyBridge />
|
||||
<DungeonRunBridge />
|
||||
<ShelvedGameBridges />
|
||||
<SceneErrorBoundary><GameScene /></SceneErrorBoundary>
|
||||
<Hud />
|
||||
@@ -122,6 +125,7 @@ export default function GameRuntime() {
|
||||
<MapPanel />
|
||||
<GameplayPanels />
|
||||
<PauseMenu />
|
||||
<DungeonRunResults />
|
||||
<UiLayoutEditor />
|
||||
<KeybindsPanel />
|
||||
</main>
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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); }
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -61,6 +61,8 @@ function enterDungeon(dungeonId: string, requestedRole?: PartyRole): boolean {
|
||||
if (!activateDungeonSession(dungeonId, resume?.difficultyId, {
|
||||
spawn: resume?.spawn,
|
||||
encounterSnapshots: resume?.encounters,
|
||||
runProgress: resume?.run,
|
||||
legacyResume: Boolean(resume && !resume.run),
|
||||
})) {
|
||||
useShellStore.setState({
|
||||
notice: dungeon.availabilityReason ?? "That dungeon failed its latest validation gate.",
|
||||
|
||||
@@ -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,6 +6,7 @@ import {
|
||||
} from "./dungeonEncounterRuntime";
|
||||
import { DUNGEON_RUN_CHECKPOINT_VERSION } from "./dungeonRunCheckpoint";
|
||||
import { useGameStore } from "./store";
|
||||
import { useDungeonRunStore } from "./dungeonRunStore";
|
||||
|
||||
function checkpointSignature(dungeonId: string): string {
|
||||
const game = useGameStore.getState();
|
||||
@@ -15,6 +16,7 @@ function checkpointSignature(dungeonId: string): string {
|
||||
difficultyId: game.activeDifficultyId,
|
||||
spawn: game.activeSpawn,
|
||||
encounters,
|
||||
run: useDungeonRunStore.getState().progress,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -41,10 +43,13 @@ export function DungeonRunCheckpointBridge() {
|
||||
spawn: game.activeSpawn,
|
||||
encounters,
|
||||
savedAt: Date.now(),
|
||||
run: useDungeonRunStore.getState().progress ?? undefined,
|
||||
});
|
||||
};
|
||||
save();
|
||||
return subscribeDungeonEncounterSnapshots(save);
|
||||
const unsubscribeEncounters = subscribeDungeonEncounterSnapshots(save);
|
||||
const unsubscribeRun = useDungeonRunStore.subscribe(save);
|
||||
return () => { unsubscribeEncounters(); unsubscribeRun(); };
|
||||
}, [activeCharacterId, phase]);
|
||||
|
||||
return null;
|
||||
|
||||
@@ -14,6 +14,16 @@ import {
|
||||
} from "./coaLiveCatalog";
|
||||
|
||||
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 ?? [{
|
||||
rank: 1,
|
||||
spellId: ability.dbcSpellId,
|
||||
|
||||
@@ -987,7 +987,8 @@ function effectsForSpell(
|
||||
}
|
||||
|
||||
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 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 });
|
||||
|
||||
@@ -33,6 +33,7 @@ export interface CombatActorCastState {
|
||||
/** Simulation-space origin used for movement cancellation and completion validation. */
|
||||
readonly origin?: CombatActorPosition;
|
||||
readonly targetId?: string | null;
|
||||
readonly channelTicksCompleted?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -129,6 +129,7 @@ import {
|
||||
} from "./combatAuras";
|
||||
import { rollBossLoot } from "./lootCatalog";
|
||||
import { adaptRunewakerEnemyDamage } from "./runewakerCombatFormula";
|
||||
import { channelAmountEffects } from "./combatChannelAmounts";
|
||||
import { normalizeInventory, type InventoryItem } from "./lootTypes";
|
||||
import {
|
||||
PLAYER_EQUIPMENT_OWNER_ID,
|
||||
@@ -721,6 +722,8 @@ export interface CombatState {
|
||||
damageSummon: (id: string, amount: number) => number;
|
||||
healPlayer: (amount: 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;
|
||||
engageNearbyMobs: (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 rangedPhysicalKeyword = /\b(?:shot|arrow|bow|gun|bullet|javelin)\b/.test(searchable);
|
||||
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";
|
||||
return "spell";
|
||||
}
|
||||
@@ -1202,7 +1205,7 @@ export function abilityDamageSchool(classId: ClassId, ability: AbilityDefinition
|
||||
return "arcane";
|
||||
}
|
||||
|
||||
function equipmentAdjustedCombatAmount(
|
||||
export function equipmentAdjustedCombatAmount(
|
||||
scaling: number | (AbilityAmountScaling & { readonly coefficient: number }),
|
||||
level: number,
|
||||
gearStats: ItemStats,
|
||||
@@ -3586,11 +3589,11 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
0,
|
||||
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(
|
||||
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,
|
||||
));
|
||||
const mob: MobCombatState = existing
|
||||
@@ -3618,7 +3621,9 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
leashRange,
|
||||
moveSpeed,
|
||||
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,
|
||||
hasLoot,
|
||||
}
|
||||
@@ -6106,12 +6111,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
const sourceActor = state.actors[sourceActorId];
|
||||
const sourceMember = usePartyStore.getState().members.find((member) => member.id === sourceActorId);
|
||||
const amount = sourceMember
|
||||
? Math.max(1, Math.round((effect.basePoints ?? 0) + Math.max(0.1, effect.coefficient) * (
|
||||
sourceMember.level * 4
|
||||
+ sourceMember.gearStats.spellPower
|
||||
+ sourceMember.gearStats.attackPower * 0.35
|
||||
+ sourceMember.gearStats.rangedAttackPower * 0.35
|
||||
)))
|
||||
? equipmentAdjustedCombatAmount(effect, sourceMember.level, sourceMember.gearStats, sourceMember.classId, ability)
|
||||
: Math.max(1, Math.round((effect.basePoints ?? 0) + Math.max(0.1, effect.coefficient) * sourceActor.level * 4));
|
||||
const next = state.effects.filter((entry) => !(
|
||||
entry.kind === effect.kind
|
||||
@@ -6417,6 +6417,51 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
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.") => {
|
||||
const cast = get().activeCast;
|
||||
if (!cast) return false;
|
||||
@@ -8151,30 +8196,31 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
: "Channel interrupted: target unavailable.");
|
||||
return;
|
||||
}
|
||||
const damageAmount = ability.effects.reduce(
|
||||
(total, effect) => total + (effect.kind === "damage" || effect.kind === "dot"
|
||||
const channelEffects = channelAmountEffects(ability, cast.totalTicks);
|
||||
const damageAmount = channelEffects.reduce(
|
||||
(total, { effect, multiplier }) => total + (effect.kind === "damage" || effect.kind === "dot"
|
||||
? equipmentAdjustedCombatAmount(
|
||||
effect,
|
||||
get().level,
|
||||
get().gearStats,
|
||||
get().classId,
|
||||
ability,
|
||||
) * (effect.kind === "dot" ? effect.ticks : 1)
|
||||
) * multiplier
|
||||
: 0),
|
||||
0,
|
||||
) / cast.totalTicks;
|
||||
const healingAmount = ability.effects.reduce(
|
||||
(total, effect) => total + (effect.kind === "heal" || effect.kind === "hot"
|
||||
);
|
||||
const healingAmount = channelEffects.reduce(
|
||||
(total, { effect, multiplier }) => total + (effect.kind === "heal" || effect.kind === "hot"
|
||||
? equipmentAdjustedCombatAmount(
|
||||
effect,
|
||||
get().level,
|
||||
get().gearStats,
|
||||
get().classId,
|
||||
ability,
|
||||
) * (effect.kind === "hot" ? effect.ticks : 1)
|
||||
) * multiplier
|
||||
: 0),
|
||||
0,
|
||||
) / cast.totalTicks;
|
||||
);
|
||||
if (damageAmount > 0) {
|
||||
const channelCenter = channelHostile ? targetPosition ?? cast.origin : get().playerPosition;
|
||||
const channelTargets = ability.radius === undefined
|
||||
|
||||
@@ -1,23 +1,41 @@
|
||||
import { useCombatStore } from "./combatStore";
|
||||
import { useCombatStore, type MobCombatState } from "./combatStore";
|
||||
import type { CombatActorState } from "./combatActors";
|
||||
import { requireDungeonDefinition } from "./dungeonRegistry";
|
||||
import { useGameStore } from "./store";
|
||||
import { resolveResurrectionCheckpoint } from "./resurrectionCheckpoints";
|
||||
import { usePartyStore, type PartyMember } from "./partyStore";
|
||||
import {
|
||||
activeDungeonCompletedObjectiveIds,
|
||||
resetDungeonEncounters,
|
||||
} from "./dungeonEncounterRuntime";
|
||||
|
||||
/**
|
||||
* Atomically returns a defeated player to the saved run spawn and resets the
|
||||
* current pull. Defeated enemies and persisted dungeon progress remain clear,
|
||||
* matching a wipe instead of silently starting a brand-new dungeon.
|
||||
*/
|
||||
export function partyFightStillActive(
|
||||
members: readonly Pick<PartyMember, "id" | "health">[],
|
||||
mobs: Readonly<Record<string, MobCombatState>>,
|
||||
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 {
|
||||
const combat = useCombatStore.getState();
|
||||
if (combat.health > 0) return false;
|
||||
const game = useGameStore.getState();
|
||||
if (game.gameMode !== "dungeon") return false;
|
||||
const checkpoint = checkpointId === undefined ? null : resolveResurrectionCheckpoint(checkpointId);
|
||||
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 completedObjectiveIds = new Set(activeDungeonCompletedObjectiveIds(dungeonId));
|
||||
const retrySpawnIds = new Set(
|
||||
|
||||
@@ -7,16 +7,22 @@ import {
|
||||
} from "./dungeonCatalog";
|
||||
import { DUNGEON_MANIFEST } from "./dungeonManifest";
|
||||
import { RUNEWAKER_DUNGEON_DEFINITIONS } from "./generated/runewakerDungeonDefinitions.generated";
|
||||
import { compareDungeonReleaseOrder } from "./dungeonReleaseStage";
|
||||
import availability from "./generated/dungeonAvailability.json";
|
||||
|
||||
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[0]).toMatchObject({
|
||||
id: DUNGEON_MANIFEST.id,
|
||||
id: "stormwind-stockade",
|
||||
releaseStage: "beta",
|
||||
available: true,
|
||||
});
|
||||
expect(dungeonById(DUNGEON_MANIFEST.id)).toMatchObject({
|
||||
title: DUNGEON_MANIFEST.title,
|
||||
mapId: DUNGEON_MANIFEST.mapId,
|
||||
entranceName: DUNGEON_MANIFEST.entrance.name,
|
||||
available: true,
|
||||
releaseStage: "alpha",
|
||||
});
|
||||
expect(DUNGEON_CATALOG.find((dungeon) => dungeon.id === "deadmines")).toMatchObject({
|
||||
id: "deadmines",
|
||||
@@ -33,10 +39,10 @@ describe("dungeon catalog", () => {
|
||||
|
||||
it("separates WoW and RoM dungeons with all RuneWaker imports under 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",
|
||||
...RUNEWAKER_DUNGEON_DEFINITIONS.map((definition) => definition.id),
|
||||
]);
|
||||
].sort());
|
||||
expect(dungeonsByCategory("wow")).not.toContainEqual(
|
||||
expect.objectContaining({ id: "forsaken-abbey" }),
|
||||
);
|
||||
@@ -46,7 +52,7 @@ describe("dungeon catalog", () => {
|
||||
it("keeps stable unique ids and resolves only catalogued dungeons", () => {
|
||||
const ids = DUNGEON_CATALOG.map((dungeon) => dungeon.id);
|
||||
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();
|
||||
});
|
||||
|
||||
@@ -57,4 +63,30 @@ describe("dungeon catalog", () => {
|
||||
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());
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { DUNGEON_DEFINITIONS, dungeonCanEnter, type DungeonId } from "./dungeonRegistry";
|
||||
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[];
|
||||
export type DungeonCategoryId = (typeof DUNGEON_CATEGORY_IDS)[number];
|
||||
@@ -27,10 +28,11 @@ export interface DungeonCatalogEntry {
|
||||
readonly theme: string;
|
||||
readonly summary: string;
|
||||
readonly available: boolean;
|
||||
readonly releaseStage: DungeonReleaseStage;
|
||||
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(
|
||||
DUNGEON_DEFINITIONS.map<DungeonCatalogEntry>((definition) => ({
|
||||
id: definition.id as DungeonId,
|
||||
@@ -42,8 +44,9 @@ export const DUNGEON_CATALOG: readonly DungeonCatalogEntry[] = Object.freeze(
|
||||
theme: definition.presentation.theme,
|
||||
summary: definition.presentation.summary,
|
||||
available: dungeonCanEnter(definition.id),
|
||||
releaseStage: dungeonReleaseStage(definition.id),
|
||||
availabilityReason: "availabilityReason" in definition ? definition.availabilityReason : undefined,
|
||||
})),
|
||||
})).sort(compareDungeonReleaseOrder),
|
||||
);
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { scaledDungeonEnemyLevel } from "./dungeonLevelScaling";
|
||||
import { dungeonEnemyHealthBaseline, scaledDungeonEnemyLevel } from "./dungeonLevelScaling";
|
||||
|
||||
describe("normal dungeon level scaling", () => {
|
||||
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, 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { clampPlayerLevel } from "./progression";
|
||||
import type { DungeonExpansion } from "./dungeonTypes";
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
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;
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import compressedCampaign from "./generated/dungeonCampaignCatalog.compressed";
|
||||
import { gunzipSync, strFromU8 } from "fflate";
|
||||
import { DEFAULT_DUNGEON_ID as DEFAULT_DUNGEON_KEY } from "./dungeonDefaults";
|
||||
import { withRunewakerEncounterContent } from "./runewakerEncounterCatalog";
|
||||
import { withRunewakerCompletion } from "./runewakerCompletion";
|
||||
import { withWowEncounterContent } from "./wowEncounterContentCatalog";
|
||||
import { withWowProgressionObjectContent } from "./wowProgressionObjectContentCatalog";
|
||||
import { withAscensionCustomEncounterContent } from "./ascensionCustomEncounterCatalog";
|
||||
@@ -19,6 +20,7 @@ import { withWowVerifiedNoOpInstanceContent } from "./wowVerifiedNoOpInstanceCat
|
||||
import { withWowClassicEncounterBatchTwo } from "./wowClassicEncounterBatchTwoCatalog";
|
||||
import { withWowClassicEncounterBatchThree } from "./wowClassicEncounterBatchThreeCatalog";
|
||||
import { withWowTbcEncounterBatchTwoContent } from "./wowTbcEncounterBatchTwoCatalog";
|
||||
import { withWowDungeonEnvironment } from "./wowDungeonEnvironment";
|
||||
|
||||
export { DEFAULT_DUNGEON_ID } from "./dungeonDefaults";
|
||||
|
||||
@@ -151,6 +153,7 @@ if (authoredWailingCaverns.id !== DEFAULT_DUNGEON_KEY) {
|
||||
const DUNGEON_CONTENT_ADAPTERS: readonly (
|
||||
(definition: DungeonDefinition) => DungeonDefinition
|
||||
)[] = Object.freeze([
|
||||
withWowDungeonEnvironment,
|
||||
withWowEncounterContent,
|
||||
withWowClassicEncounterContent,
|
||||
withWowClassicEncounterBatchTwo,
|
||||
@@ -164,6 +167,7 @@ const DUNGEON_CONTENT_ADAPTERS: readonly (
|
||||
withWowTbcEncounterBatchTwoContent,
|
||||
withAscensionCustomEncounterContent,
|
||||
withRunewakerEncounterContent,
|
||||
withRunewakerCompletion,
|
||||
]);
|
||||
|
||||
function applyDungeonContentAdapters(definition: DungeonDefinition): DungeonDefinition {
|
||||
|
||||
@@ -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" });
|
||||
}
|
||||
@@ -14,6 +14,14 @@ const definition: DungeonEncounterDefinition = {
|
||||
};
|
||||
|
||||
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", () => {
|
||||
const result = normalizeDungeonRunCheckpoint({
|
||||
version: DUNGEON_RUN_CHECKPOINT_VERSION,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { DungeonEncounterSnapshot } from "./dungeonEncounterTypes";
|
||||
import { DUNGEON_ENCOUNTER_SNAPSHOT_SCHEMA_VERSION } from "./dungeonEncounterTypes";
|
||||
import type { Vector3Tuple, WorldSpawn } from "./dungeonTypes";
|
||||
import { normalizeDungeonRunProgress, type DungeonRunProgress } from "./dungeonRunTypes";
|
||||
|
||||
// Version 1 snapshots sorted consumed action ids and cannot be migrated
|
||||
// reliably when multiple durable actions write the same state slot. Failing
|
||||
@@ -15,6 +16,7 @@ export interface DungeonRunCheckpoint {
|
||||
readonly spawn: WorldSpawn;
|
||||
readonly encounters: readonly DungeonEncounterSnapshot[];
|
||||
readonly savedAt: number;
|
||||
readonly run?: DungeonRunProgress;
|
||||
}
|
||||
|
||||
function finiteVector(value: unknown): Vector3Tuple | null {
|
||||
@@ -115,6 +117,8 @@ export function normalizeDungeonRunCheckpoint(value: unknown): DungeonRunCheckpo
|
||||
) return null;
|
||||
const encounters = input.encounters.map(normalizeSnapshot);
|
||||
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({
|
||||
version: DUNGEON_RUN_CHECKPOINT_VERSION,
|
||||
dungeonId: input.dungeonId,
|
||||
@@ -123,5 +127,6 @@ export function normalizeDungeonRunCheckpoint(value: unknown): DungeonRunCheckpo
|
||||
spawn: { footPosition: position, yaw },
|
||||
encounters: Object.freeze(encounters as DungeonEncounterSnapshot[]),
|
||||
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(); }
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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] } : {}) } });
|
||||
},
|
||||
}));
|
||||
@@ -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 } : {}) })) };
|
||||
}
|
||||
@@ -18,6 +18,8 @@ import {
|
||||
} from "./dungeonEncounterRuntime";
|
||||
import type { DungeonEncounterSnapshot } from "./dungeonEncounterTypes";
|
||||
import { dungeonContentIsUsable } from "../content/contentManager";
|
||||
import { useDungeonRunStore } from "./dungeonRunStore";
|
||||
import type { DungeonRunProgress } from "./dungeonRunTypes";
|
||||
|
||||
export interface DungeonSessionOptions {
|
||||
readonly gameMode?: GameMode;
|
||||
@@ -28,6 +30,8 @@ export interface DungeonSessionOptions {
|
||||
readonly preserveCharacter?: boolean;
|
||||
/** Hydrates save-safe state after definitions have been activated and reset. */
|
||||
readonly encounterSnapshots?: readonly DungeonEncounterSnapshot[];
|
||||
readonly runProgress?: DungeonRunProgress;
|
||||
readonly legacyResume?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -47,6 +51,10 @@ export function activateDungeonSession(
|
||||
|
||||
const previousDefinition = dungeonDefinitionById(useGameStore.getState().activeDungeonId);
|
||||
if (previousDefinition) releaseDungeonGltfCache(previousDefinition);
|
||||
if (!options.preserveCharacter) {
|
||||
useDungeonRunStore.getState().begin(dungeonId, difficultyId ?? definition.defaultDifficultyId,
|
||||
options.runProgress, options.legacyResume);
|
||||
}
|
||||
activateDungeonEncounterDefinitions(
|
||||
dungeonId,
|
||||
definition.encounters ?? [],
|
||||
|
||||
@@ -37,8 +37,8 @@ describe("reviewed dungeon stair proxies", () => {
|
||||
it("bridges exactly the existing reviewed portal vertices and rejects stale asset coordinates", () => {
|
||||
const geometry = new BufferGeometry();
|
||||
geometry.setAttribute("position", new Float32BufferAttribute([
|
||||
...RAMP.navigationBridge.startPortal.flat(),
|
||||
...RAMP.navigationBridge.endPortal.flat(),
|
||||
...RAMP.navigationBridge!.startPortal.flat(),
|
||||
...RAMP.navigationBridge!.endPortal.flat(),
|
||||
], 3));
|
||||
geometry.setIndex([]);
|
||||
smoothDungeonStairGeometry(geometry, [RAMP], 0.05);
|
||||
|
||||
@@ -6,7 +6,7 @@ import type { DungeonTraversalRamp, Vector3Tuple } from "./dungeonTypes";
|
||||
const MAX_STAIR_SURFACE_DELTA = 0.85;
|
||||
const PORTAL_VERTEX_TOLERANCE = 0.002;
|
||||
|
||||
function rampHeight(
|
||||
export function dungeonRampHeight(
|
||||
ramp: DungeonTraversalRamp,
|
||||
x: number,
|
||||
z: number,
|
||||
@@ -36,10 +36,38 @@ export function smoothDungeonStairGeometry(
|
||||
surfaceClearance = 0,
|
||||
): void {
|
||||
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 (const ramp of ramps) {
|
||||
const height = rampHeight(ramp, position.getX(vertex), position.getZ(vertex));
|
||||
if (height === null || Math.abs(position.getY(vertex) - height) > MAX_STAIR_SURFACE_DELTA) continue;
|
||||
const x = position.getX(vertex); const z = position.getZ(vertex);
|
||||
for (const { ramp, dx, dz, length } of cells.get(`${Math.floor(x / cellSize)}:${Math.floor(z / cellSize)}`) ?? []) {
|
||||
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);
|
||||
break;
|
||||
}
|
||||
@@ -57,7 +85,7 @@ export function bridgeDungeonStairNavigation(
|
||||
? Array.from(geometry.index.array)
|
||||
: Array.from({ length: position.count }, (_, vertex) => vertex);
|
||||
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.`);
|
||||
for (let vertex = 0; vertex < position.count; vertex += 1) {
|
||||
if (Math.hypot(
|
||||
@@ -69,9 +97,15 @@ export function bridgeDungeonStairNavigation(
|
||||
throw new Error(`${ramp.id}: reviewed navigation portal no longer matches the source GLB.`);
|
||||
};
|
||||
for (const ramp of ramps) {
|
||||
const [a, b] = ramp.navigationBridge.startPortal.map((point) => portalVertex(ramp, point));
|
||||
const [c, d] = ramp.navigationBridge.endPortal.map((point) => portalVertex(ramp, point));
|
||||
indices.push(a, b, d, a, d, c);
|
||||
for (const bridge of dungeonRampNavigationBridges(ramp)) {
|
||||
const [a, b] = bridge.startPortal.map((point) => portalVertex(ramp, point));
|
||||
const [c, d] = bridge.endPortal.map((point) => portalVertex(ramp, point));
|
||||
indices.push(a, b, d, a, d, c);
|
||||
}
|
||||
}
|
||||
geometry.setIndex(indices);
|
||||
}
|
||||
|
||||
export function dungeonRampNavigationBridges(ramp: DungeonTraversalRamp) {
|
||||
return [...(ramp.navigationBridge ? [ramp.navigationBridge] : []), ...(ramp.navigationBridges ?? [])];
|
||||
}
|
||||
@@ -162,11 +162,16 @@ export interface DungeonTraversalRamp {
|
||||
readonly start: Vector3Tuple;
|
||||
readonly end: Vector3Tuple;
|
||||
readonly width: number;
|
||||
readonly navigationBridge: {
|
||||
readonly navigationBridge?: {
|
||||
readonly linkId: string;
|
||||
readonly startPortal: 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 {
|
||||
|
||||
@@ -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
@@ -3,7 +3,7 @@ import { abilitiesForClass, abilityAtLevel, resourceProfileForClass } from "./ab
|
||||
import { aiHintsForAbility } from "./abilityRules";
|
||||
import { useCombatStore } from "./combatStore";
|
||||
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 { deriveCharacterStats } from "./wotlkStats";
|
||||
|
||||
@@ -87,4 +87,19 @@ describe("catalog-driven party rotations", () => {
|
||||
.find((ability) => aiHintsForAbility(ability).categories.includes("heal") && ability.cost.amount > 0)!;
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
} from "./abilityCatalog";
|
||||
import { aiHintsForAbility, runeCostForAbility } from "./abilityRules";
|
||||
import { spendDeathKnightRunes } from "./combatResources";
|
||||
import type { MobCombatState } from "./combatStore";
|
||||
import { equipmentAdjustedCombatAmount, type MobCombatState } from "./combatStore";
|
||||
import type { PartyMember } from "./partyStore";
|
||||
|
||||
export interface PartyAbilityContext {
|
||||
@@ -20,6 +20,7 @@ export interface PartyAbilityContext {
|
||||
readonly fallenAlly?: boolean;
|
||||
readonly injuredAllies?: number;
|
||||
readonly lowestHealthFraction?: number;
|
||||
readonly canUseAbility?: (ability: AbilityDefinition) => boolean;
|
||||
}
|
||||
|
||||
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 {
|
||||
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.schoolLockedUntil[ability.castRules?.school ?? "physical"] ?? 0) > 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[] {
|
||||
return abilitiesForClass(member.classId)
|
||||
.filter((ability) => partyAbilityIsReady(member, abilityAtLevel(ability, member.level), now))
|
||||
.map((ability) => abilityAtLevel(ability, member.level));
|
||||
.map((ability) => abilityAtLevel(ability, member.level, ability.talentEntryId === undefined
|
||||
? undefined : member.talentRanks[`coa-entry-${ability.talentEntryId}`] ?? 0))
|
||||
.filter((ability) => partyAbilityIsReady(member, ability, now));
|
||||
}
|
||||
|
||||
function scoreAbility(member: PartyMember, ability: AbilityDefinition, context: PartyAbilityContext): number {
|
||||
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;
|
||||
if (member.role === "tank") {
|
||||
if (context.target?.activeCast && categories.has("interrupt")) score += 1_000;
|
||||
@@ -89,6 +97,7 @@ export function choosePartyAbility(
|
||||
context: PartyAbilityContext,
|
||||
): AbilityDefinition | null {
|
||||
return candidateAbilities(member, context.now)
|
||||
.filter((ability) => context.canUseAbility?.(ability) ?? true)
|
||||
.map((ability) => ({ ability, score: scoreAbility(member, ability, context) }))
|
||||
.filter((candidate) => candidate.score > 0)
|
||||
.sort((left, right) => right.score - left.score
|
||||
@@ -142,9 +151,11 @@ export function partyAbilityAmount(
|
||||
: effect.kind === "finisher-damage"
|
||||
? effect.baseCoefficient + effect.perComboCoefficient * 5
|
||||
: 0;
|
||||
const power = member.level * 4
|
||||
+ member.gearStats.spellPower
|
||||
+ member.gearStats.attackPower * 0.35
|
||||
+ member.gearStats.rangedAttackPower * 0.35;
|
||||
return Math.max(1, Math.round(Math.max(0.1, coefficient) * Math.max(8, power)));
|
||||
return equipmentAdjustedCombatAmount(
|
||||
"coefficient" in effect ? effect : coefficient,
|
||||
member.level,
|
||||
member.gearStats,
|
||||
member.classId,
|
||||
ability,
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { BufferGeometry, Float32BufferAttribute, type Group, type Mesh } from "three";
|
||||
import type { DungeonNavigationLink, DungeonTraversalRamp } from "./dungeonTypes";
|
||||
import { bridgeDungeonStairNavigation, smoothDungeonStairGeometry } from "./dungeonTraversalRamps";
|
||||
import { bridgeDungeonStairNavigation, dungeonRampNavigationBridges, smoothDungeonStairGeometry } from "./dungeonTraversalRamps";
|
||||
import {
|
||||
buildPartyNavigationGraph,
|
||||
type PartyNavigationGraph,
|
||||
@@ -77,7 +77,7 @@ export function preparePartyNavigationGraph(
|
||||
smoothDungeonStairGeometry(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, {
|
||||
maxSourceTriangles: MAX_SYNCHRONOUS_PARTY_NAVIGATION_TRIANGLES,
|
||||
offMeshLinks: offMeshLinks.filter((link) => !bridgedLinks.has(link.id)),
|
||||
|
||||
+114
-15
@@ -28,6 +28,7 @@ import {
|
||||
updatePartyRuntimePosition,
|
||||
} from "./partyRuntimeRegistry";
|
||||
import { usePartyStore, type PartyCommand, type PartyMember } from "./partyStore";
|
||||
import { baseClassFor, classById } from "../app/characterCatalog";
|
||||
import { useGameStore } from "./store";
|
||||
import { useManastormStore } from "./manastormStore";
|
||||
import { activeDungeonCompletedObjectiveIds } from "./dungeonEncounterRuntime";
|
||||
@@ -54,6 +55,7 @@ import {
|
||||
} from "./partyAbilityAi";
|
||||
import { normalizeActorResourcePool } from "./combatActors";
|
||||
import { advanceCombatResourcePool } from "./combatResources";
|
||||
import { channelAmountEffects } from "./combatChannelAmounts";
|
||||
import {
|
||||
combatHasLineOfSight,
|
||||
combatSpatialActor,
|
||||
@@ -69,6 +71,7 @@ const TANK_TAUNT_COOLDOWN_MS = 8_000;
|
||||
const TANK_TAUNT_DURATION_MS = 3_000;
|
||||
let lastAdvanceAt = 0;
|
||||
let nextRegenAt = 0;
|
||||
const nextWeaponAttackAt = new Map<string, number>();
|
||||
|
||||
export interface PartyPresentationIdentity {
|
||||
readonly spellId: number;
|
||||
@@ -141,6 +144,7 @@ export function activePartyBossObjectives(): readonly PartyBossObjective[] {
|
||||
export function resetPartyRuntimeClock(): void {
|
||||
lastAdvanceAt = 0;
|
||||
nextRegenAt = 0;
|
||||
nextWeaponAttackAt.clear();
|
||||
}
|
||||
|
||||
export function activePartyTargetId(
|
||||
@@ -167,7 +171,7 @@ export function partyAttackDamage(
|
||||
specialization: member.specialization ?? "",
|
||||
role: member.role,
|
||||
})
|
||||
? classId === "hunter" ? "ranged" : "spell"
|
||||
? baseClassFor(classId) === "hunter" ? "ranged" : "spell"
|
||||
: "melee";
|
||||
const weapon = kind === "ranged" ? gear.rangedWeapon : kind === "melee" ? gear.meleeWeapon : null;
|
||||
const equipmentPower = kind === "spell"
|
||||
@@ -193,15 +197,30 @@ export function partyAttackDamage(
|
||||
}
|
||||
|
||||
export function partyUsesRangedAttacks(
|
||||
member: Pick<PartyMember, "classId" | "specialization" | "role">,
|
||||
member: Pick<PartyMember, "classId" | "specialization" | "role"> & Partial<Pick<PartyMember, "level" | "talentRanks">>,
|
||||
): boolean {
|
||||
if (member.role === "tank") return false;
|
||||
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)
|
||||
|| member.specialization === "Elemental"
|
||||
|| 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 {
|
||||
if (member.role === "tank") return 3.8;
|
||||
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.
|
||||
*/
|
||||
export function partyCombatRangeBand(
|
||||
member: Pick<PartyMember, "classId" | "specialization" | "role">,
|
||||
member: Pick<PartyMember, "classId" | "specialization" | "role"> & Partial<Pick<PartyMember, "level" | "talentRanks">>,
|
||||
): 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;
|
||||
}
|
||||
|
||||
@@ -599,7 +627,7 @@ function memberSwingMs(member: PartyMember): number {
|
||||
ratingPercent(
|
||||
member.gearStats.hasteRating,
|
||||
partyUsesRangedAttacks(member)
|
||||
? member.classId === "hunter" ? "rangedHaste" : "spellHaste"
|
||||
? baseClassFor(member.classId) === "hunter" ? "rangedHaste" : "spellHaste"
|
||||
: "meleeHaste",
|
||||
member.level,
|
||||
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 {
|
||||
if (actorId === PLAYER_AGGRO_ID) return useCombatStore.getState().playerPosition;
|
||||
return getPartyRuntimePosition(actorId);
|
||||
@@ -661,8 +716,11 @@ function partyAbilityTargetIsValid(
|
||||
if (!hostile && (!targetActor?.alive || targetActor.health <= 0)
|
||||
&& !ability.effects.some((effect) => effect.kind === "resurrection")) return false;
|
||||
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 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 (hostile && targeting.requiresFacing) {
|
||||
const sourceSpatial = combatSpatialActor(member.id);
|
||||
@@ -706,7 +764,7 @@ function partyHostileTargets(
|
||||
.filter((mob) => {
|
||||
const position = getMobPosition(mob.id);
|
||||
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)
|
||||
&& (!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) {
|
||||
const radius = targeting.radius ?? ability.radius ?? 0;
|
||||
const center = ability.target === "self" ? origin : primaryPosition;
|
||||
return Object.values(combat.mobs)
|
||||
.filter((mob) => {
|
||||
const position = getMobPosition(mob.id);
|
||||
return !mob.dead && Boolean(position
|
||||
&& distanceSquared(primaryPosition, position) <= radius * radius
|
||||
&& combatHasLineOfSight(primaryPosition, position));
|
||||
&& distanceSquared(center, position) <= radius * radius
|
||||
&& combatHasLineOfSight(center, position));
|
||||
})
|
||||
.map((mob) => mob.id)
|
||||
.sort();
|
||||
@@ -856,7 +915,7 @@ function executePartyAbility(
|
||||
? Boolean(useCombatStore.getState().createActorAreaEffect(
|
||||
member.id,
|
||||
ability,
|
||||
getMobPosition(targetId) ?? partyFriendlyPosition(targetId) ?? origin,
|
||||
ability.target === "self" ? origin : getMobPosition(targetId) ?? partyFriendlyPosition(targetId) ?? origin,
|
||||
now,
|
||||
))
|
||||
: false;
|
||||
@@ -1143,6 +1202,7 @@ export function advanceDungeonPartyCombat(now = Date.now()): void {
|
||||
member = activeMember;
|
||||
const currentCombat = useCombatStore.getState();
|
||||
if (member.activeCast) {
|
||||
const activeCast = member.activeCast;
|
||||
const castingAbility = abilityById(member.activeCast.abilityId);
|
||||
const castTargetId = member.activeCastTargetId;
|
||||
if (!castingAbility || !castTargetId) {
|
||||
@@ -1163,7 +1223,31 @@ export function advanceDungeonPartyCombat(now = Date.now()): void {
|
||||
status: "advancing", nextActionAt: now + 250 };
|
||||
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 };
|
||||
if (hostileCast) memberTargetIds[member.id] = castTargetId;
|
||||
continue;
|
||||
@@ -1173,12 +1257,14 @@ export function advanceDungeonPartyCombat(now = Date.now()): void {
|
||||
completedMember,
|
||||
castingAbility,
|
||||
castTargetId,
|
||||
member.activeCast.origin,
|
||||
activeCast.origin,
|
||||
);
|
||||
if (valid) {
|
||||
const execution = executePartyAbility(
|
||||
completedMember,
|
||||
castingAbility,
|
||||
activeCast.mode === "channel"
|
||||
? { ...castingAbility, effects: castingAbility.effects.filter((effect) => !["damage", "dot", "heal", "hot"].includes(effect.kind)) }
|
||||
: castingAbility,
|
||||
castTargetId,
|
||||
now,
|
||||
nextMembers,
|
||||
@@ -1284,7 +1370,11 @@ export function advanceDungeonPartyCombat(now = Date.now()): void {
|
||||
const targetVisible = Boolean(
|
||||
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];
|
||||
if (now < member.nextActionAt) {
|
||||
if (runtimeMember.status !== desiredStatus) {
|
||||
@@ -1297,9 +1387,18 @@ export function advanceDungeonPartyCombat(now = Date.now()): void {
|
||||
now,
|
||||
target: targetMob ?? null,
|
||||
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) {
|
||||
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;
|
||||
}
|
||||
const inRange = targetVisible && partyAbilityTargetIsValid(runtimeMember, selectedAbility, memberTargetId);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
APPEARANCE_KEYS,
|
||||
ROM_CLASS_IDS,
|
||||
classesForRace,
|
||||
raceById,
|
||||
supportedGenders,
|
||||
@@ -80,6 +81,51 @@ describe("dungeon party composition", () => {
|
||||
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", () => {
|
||||
expect(createDungeonParty("sizes", 18)).toHaveLength(4);
|
||||
for (const size of [1, 2, 3, 4, 5] as const) {
|
||||
|
||||
@@ -496,7 +496,9 @@ export const usePartyStore = create<PartyState>((set, get) => ({
|
||||
resource,
|
||||
maxResource,
|
||||
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),
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 @@
|
||||
{"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
|
||||
@@ -122,7 +122,7 @@ describe("generated RuneWaker player catalog", () => {
|
||||
103 * MANA_POOL_MULTIPLIER,
|
||||
);
|
||||
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([
|
||||
{ resource: "mana", amount: 12, percentage: false },
|
||||
@@ -131,4 +131,17 @@ describe("generated RuneWaker player catalog", () => {
|
||||
{ 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 });
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -8,7 +8,7 @@ import type { GmReviewTarget } from "./gmContentReview";
|
||||
export type AssetStatus = "checking" | "loading" | "ready" | "proxy" | "error";
|
||||
export type InputMode = "keyboard" | "gamepad";
|
||||
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 {
|
||||
position: Vector3Tuple;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import { startControllerInput } from "./input/controller";
|
||||
import { currentDisplayLayout } from "./platform/displayLayout";
|
||||
import { initializeContentSystem } from "./content/contentManager";
|
||||
import "./styles.css";
|
||||
import "./ui/dungeonReports.css";
|
||||
|
||||
const BottomDisplayApp = lazy(() => import("./platform/BottomDisplayApp").then((module) => ({ default: module.BottomDisplayApp })));
|
||||
|
||||
|
||||
@@ -45,7 +45,8 @@ import {
|
||||
} from "../game/mobRuntimeRegistry";
|
||||
import { combatHasLineOfSight } from "../game/combatSpatial";
|
||||
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 {
|
||||
clusterStaticMobSpawns,
|
||||
@@ -884,6 +885,7 @@ function ProxyMob({
|
||||
const selected = useCombatStore((state) => state.selectedTargetId === instanceId);
|
||||
const showAggroRanges = useCombatStore((state) => state.settings.showMobAggroRanges);
|
||||
const gameMode = useGameStore((state) => state.gameMode);
|
||||
const dungeonExpansion = useGameStore((state) => requireDungeonDefinition(state.activeDungeonId).expansion);
|
||||
const characterLevel = useCombatStore((state) => state.level);
|
||||
const manastormLevel = useManastormStore((state) => state.level);
|
||||
const manastormPartySize = useManastormStore((state) => state.partySize);
|
||||
@@ -923,16 +925,22 @@ function ProxyMob({
|
||||
useEffect(() => {
|
||||
const baseHealthMultiplier = definition.combat?.healthMultiplier ?? 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, {
|
||||
name: definition.name,
|
||||
serverEntry: definition.combat?.serverEntry,
|
||||
boss,
|
||||
level: gameMode === "manastorm"
|
||||
? definition.combat?.level
|
||||
: scaledDungeonEnemyLevel(characterLevel, boss),
|
||||
level,
|
||||
maxHealth: healthBaseline === undefined ? undefined : healthBaseline * healthMultiplier,
|
||||
hasLoot: Boolean((boss && bossHasLoot(definition.id)) || definition.hasLoot),
|
||||
lootSourceId: boss ? definition.id : undefined,
|
||||
healthMultiplier: baseHealthMultiplier * (manastormModifiers?.enemyStatMultiplier ?? 1),
|
||||
healthMultiplier,
|
||||
damageMultiplier: baseDamageMultiplier * (manastormModifiers?.enemyStatMultiplier ?? 1),
|
||||
bonusLootChance: manastormModifiers?.bonusLootChance,
|
||||
moveSpeed: definition.combat?.moveSpeed,
|
||||
@@ -959,6 +967,7 @@ function ProxyMob({
|
||||
definition.combat,
|
||||
instanceId,
|
||||
gameMode,
|
||||
dungeonExpansion,
|
||||
manastormLevel,
|
||||
manastormMapRewardMultiplier,
|
||||
manastormModeId,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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 />} />
|
||||
);
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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();
|
||||
}} />;
|
||||
}
|
||||
@@ -21,11 +21,16 @@ import {
|
||||
import { useMenuController, type MenuAction } from "../input/useMenuController";
|
||||
import { LazyLocalCompanionPanel } from "./LazyLocalCompanionPanel";
|
||||
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 { OnlineGroupPanel } from "./OnlineGroupPanel";
|
||||
import { useOnlineGroupStore } from "../app/onlineGroupStore";
|
||||
import { specializationById } from "../game/specializationCatalog";
|
||||
import { DUNGEON_RELEASE_STAGE_LABELS } from "../game/dungeonReleaseStage";
|
||||
import { useDungeonReports } from "./useDungeonReports";
|
||||
|
||||
installDungeonShellRuntime();
|
||||
|
||||
@@ -50,6 +55,7 @@ interface PendingDungeonDownload {
|
||||
}
|
||||
|
||||
export function DungeonSelectScreen() {
|
||||
const reports = useDungeonReports();
|
||||
const contentState = useContentManagerSnapshot();
|
||||
const activeCharacter = useShellStore((state) => state.activeCharacter);
|
||||
const rosterCharacter = useShellStore(selectedCharacter);
|
||||
@@ -74,6 +80,20 @@ export function DungeonSelectScreen() {
|
||||
?? firstCategoryDungeon
|
||||
?? 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 canStart = Boolean(selectedDungeon?.available && selectedPackInstalled);
|
||||
|
||||
@@ -173,8 +193,17 @@ export function DungeonSelectScreen() {
|
||||
id: `dungeon-${dungeon.id}`,
|
||||
run: () => selectDungeon(dungeon.id),
|
||||
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
|
||||
? availableRoles.map((role) => ({
|
||||
id: `dungeon-role-${role}`,
|
||||
@@ -190,7 +219,7 @@ export function DungeonSelectScreen() {
|
||||
run: () => setRolePickerOpen(false),
|
||||
}] : []),
|
||||
{ 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, {
|
||||
initialId: firstAvailable ? `dungeon-${firstAvailable.id}` : "dungeon-back",
|
||||
onBack: pendingDownload
|
||||
@@ -253,19 +282,24 @@ export function DungeonSelectScreen() {
|
||||
controlId={`dungeon-${dungeon.id}`}
|
||||
selectedId={controller.selectedId}
|
||||
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"
|
||||
aria-pressed={dungeon.id === selectedDungeon?.id}
|
||||
onClick={() => selectDungeon(dungeon.id)}
|
||||
>
|
||||
<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
|
||||
? dungeonContentIsUsable(dungeon.id) ? "Ready" : "Download required"
|
||||
: "Coming soon"}</em>
|
||||
</ControllerButton>
|
||||
))}
|
||||
</div>
|
||||
{reports.error && <small role="status" className="dungeon-report-load-error">Report counts unavailable. {reports.snapshot ? "Showing the last loaded counts." : ""}</small>}
|
||||
<ControllerButton
|
||||
controlId="dungeon-back"
|
||||
selectedId={controller.selectedId}
|
||||
@@ -281,20 +315,16 @@ export function DungeonSelectScreen() {
|
||||
<main className="dungeon-select-detail" aria-live="polite">
|
||||
{selectedDungeon ? (
|
||||
<>
|
||||
<div className="dungeon-select-map">
|
||||
{selectedDefinition ? (
|
||||
<DungeonMapGraphic
|
||||
dungeonId={selectedDefinition.id}
|
||||
position={selectedDefinition.entrance.footPosition}
|
||||
yaw={selectedDefinition.entrance.yaw}
|
||||
/>
|
||||
) : (
|
||||
<div className="dungeon-select-map__placeholder" aria-hidden="true">HM</div>
|
||||
)}
|
||||
</div>
|
||||
<DungeonLeaderboard dungeonTitle={selectedDungeon.title} data={leaderboard.data} scope={leaderboard.scope}
|
||||
loading={leaderboard.loading} filter={clearFilter} onFilter={setClearFilter}
|
||||
expanded={expandedRuns} onExpand={toggleRun} offset={leaderboard.offset} onPage={leaderboard.setOffset}
|
||||
onRefresh={leaderboard.refresh} difficultyLabel={leaderboardDifficulty?.difficulty === "heroic" ? "Heroic" : "Normal"}
|
||||
onDifficulty={(selectedDefinition?.difficultyVariants.length ?? 0) > 1 ? cycleDifficulty : undefined}
|
||||
selectedId={controller.selectedId} select={controller.select} />
|
||||
<section className="dungeon-select-copy">
|
||||
<p className="front-kicker">{canStart ? "Playable expedition" : selectedDungeon.available ? "Content required" : "In development"}</p>
|
||||
<h2>{selectedDungeon.title}</h2>
|
||||
<span className="dungeon-release-stage">{DUNGEON_RELEASE_STAGE_LABELS[selectedDungeon.releaseStage]}</span>
|
||||
<p>{selectedDungeon.summary}</p>
|
||||
{!canStart && selectedDungeon.availabilityReason && (
|
||||
<p className="front-notice" role="status">{selectedDungeon.availabilityReason}</p>
|
||||
|
||||
+26
-7
@@ -13,7 +13,7 @@ import { useShellStore } from "../app/shellStore";
|
||||
import { abilityAtLevel, resourceProfileForClass } from "../game/abilityCatalog";
|
||||
import { ACTION_CONTROLS, actionBindingId } from "../game/actionBindings";
|
||||
import { resolvedAbilityCosts, useCombatStore } from "../game/combatStore";
|
||||
import { respawnDefeatedPlayer } from "../game/deathRespawn";
|
||||
import { partyFightStillActive, respawnDefeatedPlayer } from "../game/deathRespawn";
|
||||
import { requireDungeonDefinition } from "../game/dungeonRegistry";
|
||||
import { resurrectManastormSession } from "../game/manastormSession";
|
||||
import { resolveStagePresentation } from "../game/manastormStagePresentation";
|
||||
@@ -37,6 +37,8 @@ import { ThreatMeterWidget } from "./ThreatMeterWidget";
|
||||
import { BossLootWindow } from "./BossLootWindow";
|
||||
import { EditableHudElement } from "./EditableHudElement";
|
||||
import { DpsMeter } from "./DpsMeter";
|
||||
import { useDungeonRunStore } from "../game/dungeonRunStore";
|
||||
import { formatDungeonDuration } from "../game/dungeonRunTypes";
|
||||
import { DesktopActionBars } from "./DesktopActionBars";
|
||||
import { currentUiDevice, useUiLayoutStore } from "../game/uiLayoutStore";
|
||||
import { SummonFrames } from "./SummonFrames";
|
||||
@@ -86,12 +88,14 @@ export function DefeatedPrompt({
|
||||
onReturn,
|
||||
sharedOnline = false,
|
||||
checkpoints = [],
|
||||
partyStillFighting = false,
|
||||
}: {
|
||||
gameMode: "dungeon" | "manastorm";
|
||||
resurrectionCharges: number;
|
||||
onReturn: (checkpointId?: string) => void;
|
||||
sharedOnline?: boolean;
|
||||
checkpoints?: readonly ResurrectionCheckpoint[];
|
||||
partyStillFighting?: boolean;
|
||||
}) {
|
||||
const [requestedCheckpoint, setRequestedCheckpoint] = useState("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>
|
||||
)}
|
||||
{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}
|
||||
</div>
|
||||
</section>
|
||||
@@ -136,20 +142,28 @@ export function DefeatedPrompt({
|
||||
export function DungeonCompletePrompt({
|
||||
dungeonTitle,
|
||||
onFinish,
|
||||
onResults,
|
||||
durationMs,
|
||||
finalBossOnly = false,
|
||||
}: {
|
||||
readonly dungeonTitle: string;
|
||||
readonly onFinish: () => void;
|
||||
readonly onResults?: () => void;
|
||||
readonly durationMs?: number | null;
|
||||
readonly finalBossOnly?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<section className="dungeon-complete-banner" role="status" aria-live="polite">
|
||||
<span aria-hidden="true">◆</span>
|
||||
<p>
|
||||
<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>
|
||||
<button type="button" className="button button--primary" onClick={onFinish}>
|
||||
Finish dungeon
|
||||
</button>
|
||||
{onResults ? <button type="button" className="button" onClick={onResults}>Run results</button> : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -204,6 +218,7 @@ export function Hud() {
|
||||
: null);
|
||||
const selfSelected = usePartyStore((state) => state.selfSelected);
|
||||
const partyMembers = usePartyStore((state) => state.members);
|
||||
const partyStillFighting = useCombatStore((state) => partyFightStillActive(partyMembers, state.mobs, state.actors));
|
||||
const playerRole = usePartyStore((state) => state.playerRole);
|
||||
const settings = useCombatStore((state) => state.settings);
|
||||
const onlineSession = useOnlineSessionStore((state) => state.snapshot);
|
||||
@@ -213,8 +228,9 @@ export function Hud() {
|
||||
activeDungeonEncounterRevision,
|
||||
activeDungeonEncounterRevision,
|
||||
);
|
||||
const dungeonComplete = gameMode === "dungeon"
|
||||
&& activeDungeonRunIsComplete(activeDungeonId);
|
||||
const runProgress = useDungeonRunStore((state) => state.progress);
|
||||
const finalBossDefeated = runProgress?.dungeonId === activeDungeonId && runProgress.finishedAt !== null;
|
||||
const dungeonComplete = gameMode === "dungeon" && (finalBossDefeated || activeDungeonRunIsComplete(activeDungeonId));
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setInterval(() => setClock(Date.now()), 100);
|
||||
@@ -379,8 +395,10 @@ export function Hud() {
|
||||
<ShelvedHud />
|
||||
<DpsMeter />
|
||||
<BossLootWindow />
|
||||
<EditableHudElement id="completion">{dungeonComplete && health > 0 ? (
|
||||
<DungeonCompletePrompt dungeonTitle={dungeon.title} onFinish={finishDungeon} />
|
||||
<EditableHudElement id="completion">{dungeonComplete ? (
|
||||
<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>
|
||||
<EditableHudElement id="loot">{feedback?.kind === "loot" ? (
|
||||
<section className="loot-reward" role="status" aria-live="polite">
|
||||
@@ -516,6 +534,7 @@ export function Hud() {
|
||||
resurrectionCharges={resurrectionCharges}
|
||||
onReturn={returnFromDefeat}
|
||||
checkpoints={resurrectionCheckpoints}
|
||||
partyStillFighting={partyStillFighting}
|
||||
sharedOnline={onlineSession !== null}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { ContentManagerScreen } from "./ContentManagerScreen";
|
||||
import { BrandMark, ControllerLegend, FrontSurface } from "./FrontSurface";
|
||||
import { OnlineGroupPanel } from "./OnlineGroupPanel";
|
||||
import { sessionCanUseShelvedGmTools, SHELVED_MODES_ENABLED } from "../app/releaseFeatures";
|
||||
import { DungeonReportsScreen } from "./DungeonReportsScreen";
|
||||
|
||||
const GAME_MODES = [
|
||||
{
|
||||
@@ -64,6 +65,16 @@ const GAME_MODES = [
|
||||
|
||||
export function MainMenuScreen() {
|
||||
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 rosterCharacter = useShellStore(selectedCharacter);
|
||||
const openDungeons = useShellStore((state) => state.openDungeons);
|
||||
@@ -95,7 +106,7 @@ export function MainMenuScreen() {
|
||||
}] : []),
|
||||
{
|
||||
id: "mode-downloads",
|
||||
run: () => setContentManagerOpen(true),
|
||||
run: onOpenDownloads,
|
||||
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 }] : []),
|
||||
...(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, {
|
||||
initialId: "mode-dungeons",
|
||||
onBack: returnToCharacters,
|
||||
@@ -114,8 +126,6 @@ export function MainMenuScreen() {
|
||||
const race = character ? raceById(character.raceId) : null;
|
||||
const characterClass = character ? classById(character.classId) : null;
|
||||
|
||||
if (contentManagerOpen) return <ContentManagerScreen onClose={() => setContentManagerOpen(false)} />;
|
||||
|
||||
return (
|
||||
<DualDisplayFrame
|
||||
contextLabel="Main menu details"
|
||||
@@ -163,6 +173,12 @@ export function MainMenuScreen() {
|
||||
</header>
|
||||
<OnlineGroupPanel />
|
||||
<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 && (
|
||||
<ControllerButton
|
||||
controlId="mode-gm-tools"
|
||||
@@ -204,7 +220,7 @@ export function MainMenuScreen() {
|
||||
: mode.id === "arenas"
|
||||
? openArenas
|
||||
: mode.id === "downloads"
|
||||
? () => setContentManagerOpen(true)
|
||||
? onOpenDownloads
|
||||
: openDungeons}
|
||||
>
|
||||
<b aria-hidden="true">{mode.number}</b>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { ControllerButton } from "./ControllerButton";
|
||||
import { sessionCanUseShelvedGmTools } from "../app/releaseFeatures";
|
||||
import { useInputBindingLabels } from "../input/useInputBindingLabels";
|
||||
import { INPUT_ACTION_BY_ID } from "../input/keybinds";
|
||||
import { DungeonIssueReportPanel } from "./DungeonIssueReportPanel";
|
||||
|
||||
function PauseDialog() {
|
||||
const bindingLabel = useInputBindingLabels();
|
||||
@@ -47,6 +48,7 @@ function PauseDialog() {
|
||||
{ id: "pause-keybinds", run: () => openOverlay("keybinds") },
|
||||
{ id: "pause-options", run: () => openOverlay("options") },
|
||||
{ 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") }] : []),
|
||||
...(gameMode === "manastorm"
|
||||
? [{ id: "pause-abandon-manastorm", run: abandonManastorm }]
|
||||
@@ -84,6 +86,7 @@ function PauseDialog() {
|
||||
{menuButton("pause-keybinds", "Keybinds", () => openOverlay("keybinds"))}
|
||||
{menuButton("pause-options", "Options", () => openOverlay("options"))}
|
||||
{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}
|
||||
{gameMode === "manastorm"
|
||||
? menuButton("pause-abandon-manastorm", "Abandon Manastorm run", abandonManastorm)
|
||||
@@ -103,6 +106,7 @@ function PauseDialog() {
|
||||
}
|
||||
|
||||
export function PauseMenu() {
|
||||
const open = useGameStore((state) => state.overlay === "pause");
|
||||
return open ? <PauseDialog /> : null;
|
||||
const overlay = useGameStore((state) => state.overlay);
|
||||
if (overlay === "report-issue") return <DungeonIssueReportPanel />;
|
||||
return overlay === "pause" ? <PauseDialog /> : null;
|
||||
}
|
||||
@@ -14,6 +14,17 @@ describe("defeated prompt", () => {
|
||||
|
||||
expect(markup).toContain("wait for a party member to resurrect you");
|
||||
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", () => {
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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"');
|
||||
});
|
||||
});
|
||||
@@ -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) };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in new issue
Block a user