forked from phenom/healer-man
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ed0d2fea3d | ||
|
|
b417b7c3c3 | ||
|
|
361734bc41 | ||
|
|
03a4e3f51c | ||
|
|
40d9f346d8 | ||
|
|
46c272f889 | ||
|
|
05b25c4341 |
No files matched your search
@@ -2212,3 +2212,670 @@ Vite's existing large-chunk advisory remains informational.
|
|||||||
Effects without source mappings retain fallback. This is representative
|
Effects without source mappings retain fallback. This is representative
|
||||||
desktop browser QA, not exhaustive spell-by-spell fidelity or a physical
|
desktop browser QA, not exhaustive spell-by-spell fidelity or a physical
|
||||||
Android/GPU performance test. No hosted release was published.
|
Android/GPU performance test. No hosted release was published.
|
||||||
|
|
||||||
|
## 2026-09-12 - Stockades party routing and indoor lighting
|
||||||
|
|
||||||
|
- Fixed quantized navigation preparation: normalized integer positions now
|
||||||
|
expand to floats before the GLB node transform is baked. Previously the
|
||||||
|
Stockades world coordinates clamped into roughly [-1, 1], leaving the live
|
||||||
|
party unable to attach to its graph even when an offline route test passed.
|
||||||
|
- Added three reviewed bidirectional links across the 1.537m Recast seams at
|
||||||
|
the entrance/central stairs and the central/north and central/south stairs.
|
||||||
|
The shipping 3,538-triangle graph connects the entrance and all five boss
|
||||||
|
approaches while discarding the remaining small decorative islands.
|
||||||
|
- Added navigation-only review state and compiler-preserved runtime overrides.
|
||||||
|
Stockades navigation is green; its wider encounter, wipe/reset, checkpoint,
|
||||||
|
and completion validation remains unvalidated.
|
||||||
|
- Leader and follower formation paths now use the graph when a breadcrumb
|
||||||
|
rejoin is blocked. Fallback comfort goals and recalls attach to the reviewed
|
||||||
|
surface, preventing an off-boundary stair target from causing a false stuck
|
||||||
|
loop. Combat approach connectors respect each member's personalized range.
|
||||||
|
- Replaced the generic dark expansion palette only for Stockades with warmer,
|
||||||
|
stronger ambient/hemisphere fill and a warm directional light. No dungeon
|
||||||
|
textures or source meshes were changed, and other dungeon palettes remain
|
||||||
|
unchanged pending individual visual review.
|
||||||
|
- Live desktop browser check at 1280 x 720 used offline level-15 Priest
|
||||||
|
Rolecheck as healer with Blood DK tank Morvane, Combat Rogue Vale, Unholy DK
|
||||||
|
Thorne, and Frost Mage Ilyra. Fresh Defend mode showed PARTY 5/5, Recall on the
|
||||||
|
entrance stairs recovered without a route warning, and Attack resumed with
|
||||||
|
visible party movement down the stairs. The tank advanced into combat and
|
||||||
|
took Defias Basic Attack/Kick/Backhand aggro; Backstab and Icy Touch events
|
||||||
|
appeared while the player remained at the entrance.
|
||||||
|
- Inline task screenshots captured the warm, readable stonework/stairs and
|
||||||
|
equipped party silhouettes in Defend and Recall-to-Attack states. The final
|
||||||
|
runtime produced no new navigation errors; the existing deprecated physics
|
||||||
|
initialization warning remains. Temporary diagnostic logging was removed.
|
||||||
|
- Automated validation: all 223 Vitest files passed, 1,373 tests passed, and
|
||||||
|
six existing tests remained skipped. Regressions cover the actual quantized
|
||||||
|
Stockades GLB through the shipping asset-preparation path, normalized integer
|
||||||
|
transforms, formation projection, entrance-to-boss routing, and tank melee
|
||||||
|
approach. This is an entrance/combat smoke pass, not a complete dungeon clear
|
||||||
|
or physical Android test.
|
||||||
|
- Production TypeScript/Vite build, texture/performance audits, bundle
|
||||||
|
verification, and the final release candidate check passed. Existing
|
||||||
|
bundle-size/import advisories remain. No hosted release was published.
|
||||||
|
- The optional all-dungeon functionality audit was stopped after an extended
|
||||||
|
run without output; its existing report files were not changed. Stockades
|
||||||
|
navigation evidence comes from the actual shipping-asset regressions and
|
||||||
|
the live smoke pass above, not a new fleet-wide functionality certification.
|
||||||
|
|
||||||
|
## 2026-09-12 - Stockades stair-ramp follow-up
|
||||||
|
|
||||||
|
- Follow-up player feedback reported companions still sticking near stairs.
|
||||||
|
Actual collision profiles contain approximately 0.44-0.59m risers, above
|
||||||
|
the party sweep's 0.32m lower-leg clearance. Collision-backed regressions
|
||||||
|
reproduce uphill rejection on the original shipping collision GLB.
|
||||||
|
- Added three reviewed package-space traversal proxies for the entrance and
|
||||||
|
both prison-wing stair flights. Collision tread/riser vertices within the
|
||||||
|
reviewed stair relief are reshaped onto smooth ramp planes; upper walls,
|
||||||
|
ceilings, other floors, and geometry outside those footprints remain intact.
|
||||||
|
Source visual/collision GLBs are unchanged. Only prepared walking collision
|
||||||
|
and party navigation use the overrides; the rendered stairs remain steps.
|
||||||
|
- Navigation receives the same ramp planes with 0.05m surface clearance and
|
||||||
|
six new triangles stitching the exact source boundary portals. This replaces
|
||||||
|
the three vertex-only connections with filled triangle corridors, allowing
|
||||||
|
mid-stair replans and recalls to project onto the interior walking surface.
|
||||||
|
The reviewed graph now has 3,544 source triangles and 31 components, retaining
|
||||||
|
entrance-to-all-five-boss routes and discarding minor decorative islands.
|
||||||
|
- Live QA caught a second issue: terrain queries included the player's
|
||||||
|
kinematic capsule. A failing collision-backed regression confirmed false
|
||||||
|
route blockage through a nearby player. Party direct, route, and grounding
|
||||||
|
queries now exclude dynamic/kinematic actor bodies and sensors; crowd spacing
|
||||||
|
remains a separate party-separation concern. Static walls still block paths.
|
||||||
|
- Automated tests exercise shipping normalized/quantized GLBs through runtime
|
||||||
|
preparation, downhill/uphill party capsule checks on all three flights at
|
||||||
|
center and lateral offsets, repeated mid-stair replans, all boss routes with
|
||||||
|
live wall checks, tank melee approach with real collision validation, and
|
||||||
|
player capsule traversal down/back up all three ramps without jumping.
|
||||||
|
- Final desktop browser smoke pass at 1280 x 720 used offline level-15 Priest
|
||||||
|
Rolecheck and the same Blood DK/Combat Rogue/Unholy DK/Frost Mage party.
|
||||||
|
Attack began without ROUTE BLOCKED, companions changed position down the
|
||||||
|
entrance staircase, the tank moved out of the entrance view and took aggro,
|
||||||
|
and Backstab/Icy Touch/Plague Strike combat events appeared. Experience rose
|
||||||
|
from 6,576 to 11,098 as the party defeated trash with the player stationary.
|
||||||
|
Recall brought companions back to the entrance stairs. The unattended tank
|
||||||
|
eventually died during continued combat; this was not a healed full clear.
|
||||||
|
- Inline task screenshots captured the unchanged visible steps, equipped
|
||||||
|
character bodies, warm lighting, and party movement. Both wing flights have
|
||||||
|
collision-backed automated coverage; live end-to-end wing traversal, a full
|
||||||
|
dungeon clear, wipe/checkpoint behavior, and physical Android QA remain open.
|
||||||
|
- Validation: all 224 Vitest files passed, 1,380 tests passed, and six existing
|
||||||
|
tests remained skipped. Production TypeScript/Vite compilation passed using
|
||||||
|
the project's 8GB heap setting; texture/performance audits, static bundle
|
||||||
|
compression, and release-bundle verification passed. Existing build-size
|
||||||
|
advisories and deprecated physics initialization warnings remain. No hosted
|
||||||
|
release was published.
|
||||||
|
|
||||||
|
## 2026-09-12 - Non-blocking players and combat actors
|
||||||
|
|
||||||
|
- Removed party soft separation and crowd-triggered combat repositioning.
|
||||||
|
Companions may overlap or cross one another without push-apart corrections
|
||||||
|
changing their routes. Normal follow formations and role-specific attack
|
||||||
|
ranges remain; a tank already in melee range does not move just because an
|
||||||
|
ally occupies the same position.
|
||||||
|
- Added a shared actor collision layer that interacts only with physical
|
||||||
|
terrain. The player capsule uses this layer. Player controller movement,
|
||||||
|
ground probes, camera sweeps, party direct/route validation, party grounding,
|
||||||
|
and combat ground-position queries use terrain-only filters. Actor body type
|
||||||
|
cannot turn an actor into a floor or movement obstruction. Party members,
|
||||||
|
remote players, mobs, and bosses currently render without physical colliders;
|
||||||
|
any future actor capsule must use the shared actor layer.
|
||||||
|
- Walls, floors, ceilings, dungeon objects, and the Stockades walking ramps
|
||||||
|
retain their terrain collision. Target-selection meshes, combat visibility,
|
||||||
|
damage, and attack-range checks were not removed.
|
||||||
|
- Ten new Rapier regressions cover overlapping actors with no contacts or
|
||||||
|
displacement, movement through fixed/kinematic/dynamic actors (including an
|
||||||
|
overlapped starting position), continued wall/floor collision, and ignoring
|
||||||
|
actors in ground, party-route, and camera queries. A terrain-layer control
|
||||||
|
confirms real actor/terrain contacts are still detected. Stockades shipping-
|
||||||
|
asset regressions now use the live player collision policy and still pass
|
||||||
|
down/up all three ramps without jumping.
|
||||||
|
- Browser smoke pass at 1280 x 720 used offline level-15 Priest Rolecheck with
|
||||||
|
Blood DK, Combat Rogue, Unholy DK, and Frost Mage companions. Inline roster
|
||||||
|
and dungeon screenshots show character bodies, weapons, unchanged visible
|
||||||
|
stairs, and the existing warm lighting. Attack moved companions down from
|
||||||
|
the entrance and out of its view without ROUTE BLOCKED; the tank took aggro
|
||||||
|
and melee/caster combat events continued. Experience rose from 11,098 to
|
||||||
|
13,359. Stop followed by Recall returned the party to the nearby player.
|
||||||
|
Selecting Morvane and casting Power Word: Shield applied the buff successfully.
|
||||||
|
- Live QA was an entrance/trash/recall smoke pass, not a full dungeon clear or
|
||||||
|
a manual traversal of both wings. Actor overlap and passage guarantees are
|
||||||
|
backed by the physics regressions; physical Android and multiplayer QA remain
|
||||||
|
open.
|
||||||
|
- Validation: all 225 Vitest files passed, 1,390 tests passed, and six existing
|
||||||
|
tests remained skipped. The final focused checks passed again after cleanup.
|
||||||
|
Production TypeScript/Vite build, texture/performance audits, static bundle
|
||||||
|
compression, and release-bundle verification passed. Existing chunk-size
|
||||||
|
advisories and deprecated physics initialization warnings remain. No hosted
|
||||||
|
release was published.
|
||||||
|
|
||||||
|
## 2026-09-12 - Individual companion navigation recovery
|
||||||
|
|
||||||
|
- Replaced the old distant, whole-party stuck Recall with an individual
|
||||||
|
navigation watchdog in a separately tested game module. Two seconds without
|
||||||
|
meaningful waypoint progress invalidates that companion's route; five seconds
|
||||||
|
requests a safe recovery landing. Failed replans do not restart the timer,
|
||||||
|
back-and-forth movement cancels forward progress, and dense waypoint chains
|
||||||
|
still count genuine movement around corners.
|
||||||
|
- Recovery prefers a recently visited party/player trail point near the group
|
||||||
|
on the same floor, then falls back to a validated player position. Actual
|
||||||
|
terrain checks require walkable ground, clear companion body space, and a
|
||||||
|
route back to the group. Blocked or invalid landings are not applied. Each
|
||||||
|
companion has an independent ten-second teleport cooldown.
|
||||||
|
- Intentional attack-range holds, casting/channeling, crowd control, Stop,
|
||||||
|
death, inactive simulation, and UI pauses do not accumulate stuck time.
|
||||||
|
Automatic recovery changes only the affected companion's position and routing
|
||||||
|
state; it does not heal, resurrect, reset threat, or teleport other companions.
|
||||||
|
Manual Recall keeps its existing whole-party behavior and party order.
|
||||||
|
- Added regressions for recovery timing, oscillation, replanning, independent
|
||||||
|
cooldowns and member isolation, exclusions, safe-trail/player selection, wall
|
||||||
|
and ceiling rejection, missing floors, and actor-independent landing checks.
|
||||||
|
Shipping Stockades navigation/collision assets validate recovery landings on
|
||||||
|
all three ramps and along approaches to all five boss objectives.
|
||||||
|
- Browser smoke pass at 1280 x 720 used offline Priest Rolecheck and Blood DK,
|
||||||
|
Combat Rogue, Unholy DK, and Frost Mage companions. Inline roster and dungeon
|
||||||
|
screenshots show unchanged character bodies, equipment, stairs, and warm
|
||||||
|
lighting. The first catalog load needed a retry. Attack moved companions down
|
||||||
|
from the entrance without ROUTE BLOCKED, and combat continued with the player
|
||||||
|
stationary. The unattended party eventually died; Stop remained selected
|
||||||
|
after Recall returned the fallen companions without resurrecting them.
|
||||||
|
- The intermittent side-room trap and its automatic recovery were not reproduced
|
||||||
|
live. Recovery triggers and terrain safety are backed by automated tests;
|
||||||
|
extended side-room playtesting, a healed full clear, and physical Android QA
|
||||||
|
remain open. This pass did not modify existing ramp or actor-collision policy.
|
||||||
|
- Validation: all 227 Vitest files passed, 1,416 tests passed, and six existing
|
||||||
|
tests remained skipped. The final 37 focused checks passed after correcting a
|
||||||
|
test-helper type annotation. Production TypeScript/Vite compilation, texture
|
||||||
|
and performance audits, static compression, and bundle verification passed.
|
||||||
|
Existing chunk-size advisories and deprecated physics initialization warnings
|
||||||
|
remain. No hosted release was published.
|
||||||
|
|
||||||
|
## 2026-09-12 - Wall-aware combat pursuit and individual mob recovery
|
||||||
|
|
||||||
|
- Hostile mob actions now require both attack range and terrain line of sight.
|
||||||
|
Losing either cancels a pending hostile cast so pursuit can resume; companion
|
||||||
|
hostile casts also cancel when sight is lost. Self buffs and friendly heals
|
||||||
|
cannot make an otherwise obstructed mob hold an attack position. Proximity
|
||||||
|
aggro also checks sight instead of pulling enemies through nearby walls.
|
||||||
|
- Static enemies, roaming packs, and summoned enemies share separately tested
|
||||||
|
imperative pursuit rules over the already-loaded dungeon navigation surface.
|
||||||
|
Blocked direct approaches use approved routes through doorways. Routes may
|
||||||
|
finish at a reachable attack position only when that position is both in
|
||||||
|
range and visible; off-surface connectors still require body clearance.
|
||||||
|
- Two seconds without meaningful waypoint progress requests a replan. After
|
||||||
|
five seconds, a stuck mob within ten meters of its target may recover to a
|
||||||
|
validated nearby landing on the same floor and within its home leash. Ground,
|
||||||
|
body clearance, route connectivity, attack range, and sight must all pass.
|
||||||
|
This teleport preserves health and threat and has a ten-second cooldown.
|
||||||
|
- Distant, wrong-floor, out-of-leash, or unsafe near-target recovery requests
|
||||||
|
instead reset only that mob's combat state and restore its health. A validated
|
||||||
|
home landing is preferred; otherwise it evades and navigates home without a
|
||||||
|
blind teleport. Ten seconds of evade prevents immediate proximity, damage,
|
||||||
|
or taunt re-engagement. Dead enemies are never resurrected. Casting, control,
|
||||||
|
pauses, inactive simulation, and non-authoritative online clients do not
|
||||||
|
accumulate navigation failures or trigger automatic recovery.
|
||||||
|
- Regression coverage includes range-plus-sight attack/cast gating, immediate
|
||||||
|
cast cancellation, doorway detours, recovery timing and safety, reset/member
|
||||||
|
isolation, cooldown bypass protection, and near-teleport health/threat
|
||||||
|
preservation. Shipping Stockades meshes independently verify real wall
|
||||||
|
occlusion in both directions and clear sight across all three stair ramps.
|
||||||
|
Pursuing mobs traverse each actual ramp uphill and downhill without false
|
||||||
|
teleport/reset recovery. Existing party and player ramp checks still pass.
|
||||||
|
- Browser smoke pass at 1280 x 720 used offline level-16 Priest Rolecheck and
|
||||||
|
Blood DK, Combat Rogue, Unholy DK, and Frost Mage companions. Power Word:
|
||||||
|
Shield applied to Morvane; Attack moved companions down from the entrance
|
||||||
|
and out of its view without ROUTE BLOCKED, and Defias melee/cast events and
|
||||||
|
tank damage followed. Stop plus Recall returned all four living companions
|
||||||
|
to the nearby player while retaining Stop. Inline screenshots show character
|
||||||
|
bodies, equipment, unchanged visible steps, and the existing warm lighting.
|
||||||
|
The browser console had no errors and the existing physics initialization
|
||||||
|
deprecation warning only.
|
||||||
|
- Live QA was a short entrance/combat/recall smoke test, not a healed full clear
|
||||||
|
or a manually reproduced side-room wall trap. Wall-blocked pursuit and mob
|
||||||
|
recovery triggers are backed by automated tests; extended side-room combat,
|
||||||
|
physical Android, and live multiplayer QA remain open. Existing lighting,
|
||||||
|
ramp assets, and non-blocking actor collision policy were not changed.
|
||||||
|
- Validation: all 229 Vitest files passed, 1,444 tests passed, and six existing
|
||||||
|
tests remained skipped. Final production TypeScript/Vite compilation, texture
|
||||||
|
and performance audits, static compression, and bundle verification passed.
|
||||||
|
Existing chunk-size advisories remain. No hosted release was published.
|
||||||
|
|
||||||
|
## 2026-09-12 - HoTs and regeneration cannot heal corpses
|
||||||
|
|
||||||
|
- Reproduced the reported death-with-HoT state through lethal damage followed
|
||||||
|
by overdue Rejuvenation ticks; the original implementation restored health
|
||||||
|
while retaining the death animation. Separate failures confirmed passive
|
||||||
|
spirit/equipment regeneration, ground healing, triggered healing procs, and
|
||||||
|
a lingering damage tick's level-up refill could also restore corpse health.
|
||||||
|
- Kept the fix in combat simulation rather than scene movement. Ordinary
|
||||||
|
player healing in mutable combat work now uses a shared living-target guard;
|
||||||
|
passive regeneration preserves zero health. Timed effects validate their
|
||||||
|
target before ticks and procs, discard HoTs on dead targets even before the
|
||||||
|
next tick, and retain HoTs on living allies when their caster dies. Newly
|
||||||
|
applied actor HoTs also reject dead targets. Ground healing skips corpses
|
||||||
|
while continuing to heal living teammates. Experience may still be awarded
|
||||||
|
after death, but its level-up refill no longer implicitly resurrects.
|
||||||
|
- Explicit player and actor-based party resurrection remain available and
|
||||||
|
synchronize restored actor health/alive state. Party resurrection also
|
||||||
|
clears the death animation using the existing ability-cancel event. Normal
|
||||||
|
checkpoint respawn still restores health and clears lingering effects.
|
||||||
|
The existing player movement gate remains unchanged and blocks zero health.
|
||||||
|
- Fifteen new regressions cover the original self-HoT failure, actual Priest
|
||||||
|
Renew, overdue/future ticks, explicit companion-to-player HoTs and their
|
||||||
|
healing logs, dead allies, regeneration, ground healing, proc healing,
|
||||||
|
post-death level rewards, living-target healing, resurrection, and respawn.
|
||||||
|
The final focused combat/death/support checks passed 101 tests; the full
|
||||||
|
suite passed all 230 files and 1,459 tests with six existing skips.
|
||||||
|
- No live corpse-movement replay or physical Android check was performed in
|
||||||
|
this pass. Evidence is combat-state regression coverage, not a new visual
|
||||||
|
claim. Character rendering, camera/input, dungeon assets, lighting, and
|
||||||
|
navigation policy were not modified. No hosted release was published.
|
||||||
|
|
||||||
|
## 2026-09-12 - Selectable resurrection checkpoints and fallen NPC recovery
|
||||||
|
|
||||||
|
- Normal dungeons now offer the entrance plus every defeated boss as named
|
||||||
|
resurrection destinations. Choices use authored boss locations, not corpse
|
||||||
|
coordinates, and unlock from actual defeated actors or durable encounter
|
||||||
|
objective completion. Alive/script-despawned bosses and invalid destination
|
||||||
|
IDs cannot grant a checkpoint. Boss-wipe resets retain completed checkpoints;
|
||||||
|
a fresh dungeon run clears its progress.
|
||||||
|
- The player defeat prompt includes a destination chooser. Respawning at the
|
||||||
|
entrance or a cleared boss retains defeated enemies and run progress while
|
||||||
|
resetting the current pull and restoring the party through the existing
|
||||||
|
respawn lifecycle. Legacy callers without a choice retain saved-spawn behavior.
|
||||||
|
- A contextual `Rez dead members` button and matching destination chooser appear
|
||||||
|
below the party frames whenever an NPC teammate is dead. Only fallen NPCs are
|
||||||
|
revived, at full health, into their formation slots at the selected checkpoint.
|
||||||
|
Living NPCs, the player's health/location, current enemies, and party orders
|
||||||
|
are not reset. Lingering timed effects on revived NPCs are removed without
|
||||||
|
removing living allies' HoTs. Manastorm charges and remote human resurrection
|
||||||
|
behavior are unchanged; non-authority online clients cannot mutate NPC revival.
|
||||||
|
- Living, engaged bosses disable the NPC chooser/button with an explanation;
|
||||||
|
the same restriction is checked again when the action executes. Trash combat
|
||||||
|
does not block it. Individual return tasks use approved navigation toward the
|
||||||
|
player's current location, including while the party is on Stop, then resume
|
||||||
|
the current order. Returning members do not cast/taunt from the checkpoint or
|
||||||
|
skew the engaged squad's target-selection center. Existing validated stuck
|
||||||
|
recovery remains available. Recall, death, actor unmount, and run reset cancel
|
||||||
|
pending return tasks. Scene relocation clears the corpse animation explicitly.
|
||||||
|
- Live localhost Stockades screenshots at 1280x720 showed the panel with a dead
|
||||||
|
Morvane tank, an enabled entrance chooser/button during trash combat, and the
|
||||||
|
tank restored to 1707 health at the entrance after clicking while Stop remained
|
||||||
|
selected. The button disappeared when no NPC was dead; living Thorne continued
|
||||||
|
taking damage rather than receiving a party-wide refill. At 384x844 both native
|
||||||
|
chooser and button remained within the viewport, about 176px wide and 44px high.
|
||||||
|
The narrow screenshot capture was backend-scaled, so DOM bounds supplemented
|
||||||
|
the visual check. Normal viewport sizing was restored and the temporary tab
|
||||||
|
and development server were closed.
|
||||||
|
- Twenty-nine new regressions cover checkpoint unlocking/retention, invalid and
|
||||||
|
locked choices, player checkpoint respawn, boss/trash restrictions, selective
|
||||||
|
NPC revival/effect cleanup, authority/Manastorm isolation, Stop/rejoin behavior,
|
||||||
|
wall-aware return navigation, registry lifecycle, and UI visibility/placement.
|
||||||
|
The shipping Stockades navigation/collision test also simulates all four NPC
|
||||||
|
formation slots walking from the entrance and every boss checkpoint back to
|
||||||
|
the entrance through approved corridors, without requiring stuck recovery.
|
||||||
|
Final Vitest validation passed all 234 files and 1,488 tests, with six existing
|
||||||
|
skips. No live boss-kill destination-selection, distant-player return replay,
|
||||||
|
multiplayer session, or physical Android check is claimed in this pass.
|
||||||
|
- Browser logs contained no errors; Rapier initialization and KTX2-loader warnings
|
||||||
|
occurred during development hot reloads. Character assets, camera/input,
|
||||||
|
dungeon geometry, and lighting were not edited. Final production TypeScript/Vite
|
||||||
|
compilation, static compression, and release-bundle verification passed; texture
|
||||||
|
and environment-performance audits also passed during prebuild. Existing build
|
||||||
|
chunk advisories remain. No hosted release was published.
|
||||||
|
|
||||||
|
## Optional DPS meter — September 12, 2026
|
||||||
|
|
||||||
|
- Added a compact party DPS meter with current/last-fight damage, class-colored
|
||||||
|
rankings, and a shared fight timer. It consumes the lossless combat event
|
||||||
|
stream and counts effective damage, including pets/summons under their owner.
|
||||||
|
Completed fights remain visible until the next pull; encounter resets clear
|
||||||
|
the meter. Hiding it does not stop recording. Local menu/background pauses
|
||||||
|
do not dilute DPS.
|
||||||
|
- Automatic placement uses the PC lower-left corner and the Thor companion
|
||||||
|
display. The UI editor supports visibility, dragging/keyboard nudges, width,
|
||||||
|
and screen selection. These settings participate in saved layouts, all six
|
||||||
|
template slots, and export/import, including migration of older UI files.
|
||||||
|
Dedicated companion snapshots carry meter data and draft placement; lower
|
||||||
|
display movement commands update the authoritative editor draft.
|
||||||
|
- Live localhost Stockades checks at 1280x720 showed increasing party damage
|
||||||
|
and readable bars in the PC lower-left corner. Resizing from 230 to 240 pixels
|
||||||
|
worked; dragging moved the handle from approximately (9, 509) to (269, 400).
|
||||||
|
Saving visibility off hid the meter, and reopening the editor retained that
|
||||||
|
setting while damage continued accumulating. Default position/width and
|
||||||
|
visibility were restored after this check.
|
||||||
|
- In Thor preview, the meter appeared only inside the 620x540 companion surface.
|
||||||
|
Populated bars remained clear of the party strip, vital controls, and action
|
||||||
|
bar. With the main editor open, dragging on the bottom screen moved its handle
|
||||||
|
from approximately (526, 322) to (661, 369) within companion bounds. Those
|
||||||
|
preview movement edits were canceled. Screenshots are saved in
|
||||||
|
`artifacts/dps-meter/pc-default.png`, `thor-default.png`, and
|
||||||
|
`thor-editor-moved.png`. Full-page capture scaled incorrectly in the browser
|
||||||
|
backend, so the Thor screenshots use the scrolled normal viewport.
|
||||||
|
- All 47 targeted tests passed across seven files, including damage accounting,
|
||||||
|
ownership, reset/pause behavior, UI migration/persistence, and dedicated
|
||||||
|
companion publication/unsubscription. The production build and release
|
||||||
|
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.
|
||||||
@@ -39,11 +39,11 @@
|
|||||||
"agentMaxSlope": 50
|
"agentMaxSlope": 50
|
||||||
},
|
},
|
||||||
"review": {
|
"review": {
|
||||||
"entrance": "required",
|
"entrance": "approved",
|
||||||
"bossOrder": "required",
|
"bossOrder": "required",
|
||||||
"optionalEncounters": "required",
|
"optionalEncounters": "required",
|
||||||
"presentation": "required",
|
"presentation": "approved",
|
||||||
"offMeshLinks": "required"
|
"offMeshLinks": "approved"
|
||||||
},
|
},
|
||||||
"exceptions": []
|
"exceptions": []
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"dungeonId": "stormwind-stockade",
|
||||||
|
"navigationLinks": [
|
||||||
|
{
|
||||||
|
"id": "entrance-to-central-stairs",
|
||||||
|
"start": [-66.674328, -23.150503, 5.501851],
|
||||||
|
"end": [-67.664446, -23.755576, 4.493397],
|
||||||
|
"bidirectional": true,
|
||||||
|
"authoredReason": "Bridges the reviewed 1.537m Recast seam between the source entrance stairs and the central cell block."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "central-to-north-wing-stairs",
|
||||||
|
"start": [-124.46789, -27.349338, 16.099782],
|
||||||
|
"end": [-125.476344, -27.95441, 17.0899],
|
||||||
|
"bidirectional": true,
|
||||||
|
"authoredReason": "Bridges the reviewed 1.537m Recast seam from the central cell block to the north wing stairs."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "central-to-south-wing-stairs",
|
||||||
|
"start": [-124.46789, -27.349338, -14.703893],
|
||||||
|
"end": [-125.476344, -27.95441, -15.694011],
|
||||||
|
"bidirectional": true,
|
||||||
|
"authoredReason": "Bridges the reviewed 1.537m Recast seam from the central cell block to the south wing stairs."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"traversalRamps": [
|
||||||
|
{
|
||||||
|
"id": "entrance-stair-ramp",
|
||||||
|
"start": [-52.8, -18.16, 0.8],
|
||||||
|
"end": [-72.4, -25.609, 0.8],
|
||||||
|
"width": 10.8,
|
||||||
|
"navigationBridge": {
|
||||||
|
"linkId": "entrance-to-central-stairs",
|
||||||
|
"startPortal": [[-66.674328, -23.150503, -3.904271], [-66.674328, -23.150503, 5.501851]],
|
||||||
|
"endPortal": [[-67.664446, -23.755576, -2.895818], [-67.664446, -23.755576, 4.493397]]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "north-wing-stair-ramp",
|
||||||
|
"start": [-129.17, -25.609, 11.7],
|
||||||
|
"end": [-129.17, -33.944, 32.85],
|
||||||
|
"width": 10.8,
|
||||||
|
"navigationBridge": {
|
||||||
|
"linkId": "central-to-north-wing-stairs",
|
||||||
|
"startPortal": [[-133.878601, -27.349338, 16.099782], [-124.46789, -27.349338, 16.099782]],
|
||||||
|
"endPortal": [[-132.668457, -27.95441, 17.0899], [-125.476344, -27.95441, 17.0899]]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "south-wing-stair-ramp",
|
||||||
|
"start": [-129.17, -25.609, -10.3],
|
||||||
|
"end": [-129.17, -33.944, -31.45],
|
||||||
|
"width": 10.8,
|
||||||
|
"navigationBridge": {
|
||||||
|
"linkId": "central-to-south-wing-stairs",
|
||||||
|
"startPortal": [[-133.878601, -27.349338, -14.703893], [-124.46789, -27.349338, -14.703893]],
|
||||||
|
"endPortal": [[-132.668457, -27.95441, -15.694011], [-125.476344, -27.95441, -15.694011]]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"presentation": {
|
||||||
|
"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]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"validation": {
|
||||||
|
"navigation": "green",
|
||||||
|
"report": "PLAYTEST_NOTES.md",
|
||||||
|
"warnings": [
|
||||||
|
"Three reviewed smooth stair collision proxies and filled navigation bridges connect the entrance, central block, and both prison wings; minor decorative navmesh islands remain intentionally discarded.",
|
||||||
|
"Stockades uses a dungeon-specific warm indoor lighting profile because the imported WMO has no baked lightmap or vertex-lighting channel."
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "healer-man",
|
"name": "healer-man",
|
||||||
"version": "0.1.32",
|
"version": "0.1.39",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "healer-man",
|
"name": "healer-man",
|
||||||
"version": "0.1.32",
|
"version": "0.1.39",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@capacitor/android": "8.4.1",
|
"@capacitor/android": "8.4.1",
|
||||||
"@capacitor/core": "8.4.1",
|
"@capacitor/core": "8.4.1",
|
||||||
|
|||||||
+3
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "healer-man",
|
"name": "healer-man",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.32",
|
"version": "0.1.39",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --host 0.0.0.0",
|
"dev": "vite --host 0.0.0.0",
|
||||||
@@ -56,6 +56,8 @@
|
|||||||
"dungeon:five-player:verify": "node scripts/dungeon-pipeline/verify-five-player-dungeon-imports.mjs",
|
"dungeon:five-player:verify": "node scripts/dungeon-pipeline/verify-five-player-dungeon-imports.mjs",
|
||||||
"dungeon:wow:audit": "vite-node scripts/dungeon-pipeline/audit-wow-dungeon-content.mjs",
|
"dungeon:wow:audit": "vite-node scripts/dungeon-pipeline/audit-wow-dungeon-content.mjs",
|
||||||
"dungeon:wow:functionality:audit": "vite-node scripts/dungeon-pipeline/audit-wow-dungeon-functionality.ts",
|
"dungeon:wow:functionality:audit": "vite-node scripts/dungeon-pipeline/audit-wow-dungeon-functionality.ts",
|
||||||
|
"dungeon:wow:stairs:audit": "node --max-old-space-size=8192 scripts/dungeon-pipeline/audit-wow-stairs.ts",
|
||||||
|
"dungeon:wow:stairs:refresh": "node --max-old-space-size=8192 scripts/dungeon-pipeline/audit-wow-stairs.ts --write",
|
||||||
"dungeon:wow:test": "vitest run src/game/wowDungeonContent.test.ts",
|
"dungeon:wow:test": "vitest run src/game/wowDungeonContent.test.ts",
|
||||||
"dungeon:wow:export-tool:test": "node --test scripts/dungeon-pipeline/wow-export-tool.test.mjs",
|
"dungeon:wow:export-tool:test": "node --test scripts/dungeon-pipeline/wow-export-tool.test.mjs",
|
||||||
"dungeon:ascension:recovery:validate": "node scripts/dungeon-pipeline/validate-ascension-custom-dungeon-recovery.mjs --skip-dbc",
|
"dungeon:ascension:recovery:validate": "node scripts/dungeon-pipeline/validate-ascension-custom-dungeon-recovery.mjs --skip-dbc",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"versionName": "0.1.32",
|
"versionName": "0.1.39",
|
||||||
"versionCode": 1032
|
"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`);
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import { DEFAULT_AZEROTHCORE_COMMIT } from "./import-azerothcore-world.mjs";
|
|||||||
|
|
||||||
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||||
const recipesRoot = path.join(projectRoot, "dungeon-pipeline", "recipes");
|
const recipesRoot = path.join(projectRoot, "dungeon-pipeline", "recipes");
|
||||||
|
const runtimeOverridesRoot = path.join(projectRoot, "dungeon-pipeline", "runtime-overrides");
|
||||||
const workRoot = path.resolve(process.env.DUNGEON_PIPELINE_WORK ?? path.join(projectRoot, "..", "HealerMan-Storage", "pipeline-work", "dungeons"));
|
const workRoot = path.resolve(process.env.DUNGEON_PIPELINE_WORK ?? path.join(projectRoot, "..", "HealerMan-Storage", "pipeline-work", "dungeons"));
|
||||||
const outputFile = path.join(projectRoot, "src", "game", "generated", "dungeonCampaignCatalog.json");
|
const outputFile = path.join(projectRoot, "src", "game", "generated", "dungeonCampaignCatalog.json");
|
||||||
const gameObjectAssetManifestFile = path.join(
|
const gameObjectAssetManifestFile = path.join(
|
||||||
@@ -1445,13 +1446,13 @@ function finiteBounds(points) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function presentationFor(recipe, assetPackage, environmentMode, bounds) {
|
function presentationFor(recipe, assetPackage, environmentMode, bounds, override = {}) {
|
||||||
const palette = EXPANSION_PRESENTATION[recipe.expansion] ?? EXPANSION_PRESENTATION.classic;
|
const palette = EXPANSION_PRESENTATION[recipe.expansion] ?? EXPANSION_PRESENTATION.classic;
|
||||||
const span = Math.max(
|
const span = Math.max(
|
||||||
bounds.max[0] - bounds.min[0],
|
bounds.max[0] - bounds.min[0],
|
||||||
bounds.max[2] - bounds.min[2],
|
bounds.max[2] - bounds.min[2],
|
||||||
);
|
);
|
||||||
return {
|
const presentation = {
|
||||||
location: assetPackage.location || recipe.title,
|
location: assetPackage.location || recipe.title,
|
||||||
theme: environmentMode === "adt-hybrid" ? "Open-world expedition" : "Instanced dungeon",
|
theme: environmentMode === "adt-hybrid" ? "Open-world expedition" : "Instanced dungeon",
|
||||||
summary: `Fight through ${recipe.title}, defeat its encounter roster, and keep the party alive.`,
|
summary: `Fight through ${recipe.title}, defeat its encounter roster, and keep the party alive.`,
|
||||||
@@ -1480,6 +1481,16 @@ function presentationFor(recipe, assetPackage, environmentMode, bounds) {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
return {
|
||||||
|
...presentation,
|
||||||
|
...override,
|
||||||
|
fog: { ...presentation.fog, ...override.fog },
|
||||||
|
ambientLight: { ...presentation.ambientLight, ...override.ambientLight },
|
||||||
|
hemisphereLight: { ...presentation.hemisphereLight, ...override.hemisphereLight },
|
||||||
|
directionalLight: { ...presentation.directionalLight, ...override.directionalLight },
|
||||||
|
materials: presentation.materials,
|
||||||
|
map: presentation.map,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function compile() {
|
async function compile() {
|
||||||
@@ -1535,6 +1546,19 @@ async function compile() {
|
|||||||
const importsBySlug = new Map(
|
const importsBySlug = new Map(
|
||||||
(fivePlayerImports.imports ?? []).map((entry) => [entry.slug, entry]),
|
(fivePlayerImports.imports ?? []).map((entry) => [entry.slug, entry]),
|
||||||
);
|
);
|
||||||
|
const runtimeOverrides = new Map();
|
||||||
|
for (const recipe of recipes) {
|
||||||
|
const file = path.join(runtimeOverridesRoot, `${recipe.slug}.json`);
|
||||||
|
if (!await exists(file)) continue;
|
||||||
|
const override = await readJson(file);
|
||||||
|
if (override.schemaVersion !== 1 || override.dungeonId !== recipe.slug) {
|
||||||
|
throw new Error(`${recipe.slug}: runtime override identity or schema is invalid.`);
|
||||||
|
}
|
||||||
|
if (override.validation?.navigation === "green" && !(override.navigationLinks?.length > 0)) {
|
||||||
|
throw new Error(`${recipe.slug}: green navigation requires at least one reviewed link.`);
|
||||||
|
}
|
||||||
|
runtimeOverrides.set(recipe.slug, override);
|
||||||
|
}
|
||||||
const drafts = new Map();
|
const drafts = new Map();
|
||||||
const wantedSpellIds = new Set();
|
const wantedSpellIds = new Set();
|
||||||
for (const recipe of recipes) {
|
for (const recipe of recipes) {
|
||||||
@@ -1584,6 +1608,7 @@ async function compile() {
|
|||||||
const coverage = [];
|
const coverage = [];
|
||||||
for (const recipe of recipes) {
|
for (const recipe of recipes) {
|
||||||
const dungeonImport = importsBySlug.get(recipe.slug);
|
const dungeonImport = importsBySlug.get(recipe.slug);
|
||||||
|
const runtimeOverride = runtimeOverrides.get(recipe.slug) ?? {};
|
||||||
// Manastorm packages contain the complete optimized dungeon geometry.
|
// Manastorm packages contain the complete optimized dungeon geometry.
|
||||||
// Reuse them for full dungeons so the install ships one map package.
|
// Reuse them for full dungeons so the install ships one map package.
|
||||||
const assetPackage = packagesByMap.get(recipe.mapId) ?? dungeonImport?.environment;
|
const assetPackage = packagesByMap.get(recipe.mapId) ?? dungeonImport?.environment;
|
||||||
@@ -1951,7 +1976,7 @@ async function compile() {
|
|||||||
difficultyVariants: recipe.difficultyVariants,
|
difficultyVariants: recipe.difficultyVariants,
|
||||||
defaultDifficultyId: recipe.difficultyVariants[0]?.id ?? "normal",
|
defaultDifficultyId: recipe.difficultyVariants[0]?.id ?? "normal",
|
||||||
availability: "available",
|
availability: "available",
|
||||||
presentation: presentationFor(recipe, assetPackage, environmentMode, bounds),
|
presentation: presentationFor(recipe, assetPackage, environmentMode, bounds, runtimeOverride.presentation),
|
||||||
assets: {
|
assets: {
|
||||||
visual: assetPackage.visual.map(assetChunk),
|
visual: assetPackage.visual.map(assetChunk),
|
||||||
collision: assetPackage.collision.map(assetChunk),
|
collision: assetPackage.collision.map(assetChunk),
|
||||||
@@ -1979,7 +2004,26 @@ async function compile() {
|
|||||||
roamingPacks,
|
roamingPacks,
|
||||||
bosses,
|
bosses,
|
||||||
objectiveOrder: bosses,
|
objectiveOrder: bosses,
|
||||||
navigationLinks: [],
|
navigationLinks: (runtimeOverride.navigationLinks ?? []).map((link) => ({
|
||||||
|
id: String(link.id),
|
||||||
|
start: vector(link.start),
|
||||||
|
end: vector(link.end),
|
||||||
|
bidirectional: link.bidirectional !== false,
|
||||||
|
authoredReason: String(link.authoredReason),
|
||||||
|
})),
|
||||||
|
...(runtimeOverride.traversalRamps?.length ? {
|
||||||
|
traversalRamps: runtimeOverride.traversalRamps.map((ramp) => ({
|
||||||
|
id: String(ramp.id),
|
||||||
|
start: vector(ramp.start),
|
||||||
|
end: vector(ramp.end),
|
||||||
|
width: Number(ramp.width),
|
||||||
|
navigationBridge: {
|
||||||
|
linkId: String(ramp.navigationBridge.linkId),
|
||||||
|
startPortal: ramp.navigationBridge.startPortal.map(vector),
|
||||||
|
endPortal: ramp.navigationBridge.endPortal.map(vector),
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
} : {}),
|
||||||
encounters: [compileBossProgressionEncounter(recipe, bosses, syntheticTrash)],
|
encounters: [compileBossProgressionEncounter(recipe, bosses, syntheticTrash)],
|
||||||
serverGameObjects,
|
serverGameObjects,
|
||||||
provenance: {
|
provenance: {
|
||||||
@@ -2009,8 +2053,17 @@ async function compile() {
|
|||||||
},
|
},
|
||||||
validation: {
|
validation: {
|
||||||
status: "unvalidated",
|
status: "unvalidated",
|
||||||
|
...(runtimeOverride.validation?.navigation
|
||||||
|
? { navigation: runtimeOverride.validation.navigation }
|
||||||
|
: {}),
|
||||||
|
...(runtimeOverride.validation?.report
|
||||||
|
? { report: runtimeOverride.validation.report }
|
||||||
|
: {}),
|
||||||
blockers: [],
|
blockers: [],
|
||||||
warnings: sourceWarnings,
|
warnings: [
|
||||||
|
...sourceWarnings,
|
||||||
|
...(runtimeOverride.validation?.warnings ?? []),
|
||||||
|
],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
coverage.push({
|
coverage.push({
|
||||||
|
|||||||
@@ -0,0 +1,249 @@
|
|||||||
|
import { NodeIO } from "@gltf-transform/core";
|
||||||
|
import { ALL_EXTENSIONS } from "@gltf-transform/extensions";
|
||||||
|
import { MeshoptDecoder } from "meshoptimizer";
|
||||||
|
import { BufferAttribute, BufferGeometry, Float32BufferAttribute, Matrix4 } from "three";
|
||||||
|
import type { DungeonTraversalRamp } from "../../src/game/dungeonTypes";
|
||||||
|
import { dungeonRampHeight } from "../../src/game/dungeonTraversalRamps.ts";
|
||||||
|
|
||||||
|
const io = new NodeIO().registerExtensions(ALL_EXTENSIONS)
|
||||||
|
.registerDependencies({ "meshopt.decoder": MeshoptDecoder });
|
||||||
|
|
||||||
|
/** Decode exactly as GLTFLoader does, expanding quantized coordinates before baking transforms. */
|
||||||
|
export async function readDungeonGeometry(file: string): Promise<BufferGeometry[]> {
|
||||||
|
await MeshoptDecoder.ready;
|
||||||
|
const document = await io.read(file);
|
||||||
|
const geometries: BufferGeometry[] = [];
|
||||||
|
for (const node of document.getRoot().listNodes()) {
|
||||||
|
for (const primitive of node.getMesh()?.listPrimitives() ?? []) {
|
||||||
|
const accessor = primitive.getAttribute("POSITION");
|
||||||
|
if (!accessor?.getArray()) continue;
|
||||||
|
const source = new BufferAttribute(accessor.getArray()!, 3, accessor.getNormalized());
|
||||||
|
const positions = new Float32Array(source.count * 3);
|
||||||
|
for (let index = 0; index < source.count; index += 1) {
|
||||||
|
positions.set([source.getX(index), source.getY(index), source.getZ(index)], index * 3);
|
||||||
|
}
|
||||||
|
const geometry = new BufferGeometry();
|
||||||
|
geometry.setAttribute("position", new Float32BufferAttribute(positions, 3));
|
||||||
|
const indices = primitive.getIndices()?.getArray();
|
||||||
|
if (indices) geometry.setIndex(Array.from(indices));
|
||||||
|
geometry.applyMatrix4(new Matrix4().fromArray(node.getWorldMatrix()));
|
||||||
|
geometries.push(geometry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return geometries;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Riser {
|
||||||
|
direction: number;
|
||||||
|
along: number;
|
||||||
|
low: number;
|
||||||
|
high: number;
|
||||||
|
left: number;
|
||||||
|
right: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Repeated short vertical faces, not arbitrary slopes or nearby disconnected floors. */
|
||||||
|
export function dungeonStairRisers(geometry: BufferGeometry): Riser[] {
|
||||||
|
const p = geometry.getAttribute("position");
|
||||||
|
const indices = geometry.index?.array ?? Array.from({ length: p.count }, (_, i) => i);
|
||||||
|
const faces = new Map<string, Riser>();
|
||||||
|
for (let offset = 0; offset + 2 < indices.length; offset += 3) {
|
||||||
|
const vertices = [indices[offset], indices[offset + 1], indices[offset + 2]];
|
||||||
|
const points = vertices.map((i) => [p.getX(i), p.getY(i), p.getZ(i)]);
|
||||||
|
const [a, b, c] = points;
|
||||||
|
const ab = b.map((v, i) => v - a[i]);
|
||||||
|
const ac = c.map((v, i) => v - a[i]);
|
||||||
|
const nx = ab[1] * ac[2] - ab[2] * ac[1];
|
||||||
|
const ny = ab[2] * ac[0] - ab[0] * ac[2];
|
||||||
|
const nz = ab[0] * ac[1] - ab[1] * ac[0];
|
||||||
|
if (Math.hypot(nx, nz) < 0.1 || Math.abs(ny) / Math.hypot(nx, ny, nz) > 0.025) continue;
|
||||||
|
const low = Math.min(...points.map((v) => v[1]));
|
||||||
|
const high = Math.max(...points.map((v) => v[1]));
|
||||||
|
if (high - low < 0.08 || high - low > 0.82) continue;
|
||||||
|
// Opposite winding still describes the same plane. Quantization permits ~1 degree noise.
|
||||||
|
const direction = ((Math.round(Math.atan2(nz, nx) / Math.PI * 180) % 180) + 180) % 180;
|
||||||
|
const angle = direction * Math.PI / 180;
|
||||||
|
const along = points.reduce((sum, v) => sum + v[0] * Math.cos(angle) + v[2] * Math.sin(angle), 0) / 3;
|
||||||
|
const across = points.map((v) => -v[0] * Math.sin(angle) + v[2] * Math.cos(angle));
|
||||||
|
const left = Math.min(...across);
|
||||||
|
const right = Math.max(...across);
|
||||||
|
if (right - left < 0.9) continue;
|
||||||
|
const key = [direction, Math.round(along / 0.04), Math.round(low / 0.04), Math.round(high / 0.04)].join(":");
|
||||||
|
const previous = faces.get(key);
|
||||||
|
if (previous && Math.min(previous.right, right) - Math.max(previous.left, left) >= -0.04) {
|
||||||
|
previous.left = Math.min(previous.left, left);
|
||||||
|
previous.right = Math.max(previous.right, right);
|
||||||
|
} else faces.set(`${key}${previous ? `:${Math.round(left / 0.04)}` : ""}`, { direction, along, low, high, left, right });
|
||||||
|
}
|
||||||
|
return [...faces.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function detectDungeonStairRamps(risers: Riser[], merge = true): DungeonTraversalRamp[] {
|
||||||
|
const ramps: DungeonTraversalRamp[] = [];
|
||||||
|
const used = new Set<Riser>();
|
||||||
|
const sorted = [...risers].sort((a, b) => a.direction - b.direction || a.along - b.along || a.low - b.low);
|
||||||
|
for (const first of sorted) {
|
||||||
|
if (used.has(first)) continue;
|
||||||
|
const neighbors = sorted.filter((r) => Math.min(Math.abs(r.direction - first.direction), 180 - Math.abs(r.direction - first.direction)) <= 2)
|
||||||
|
.map((source) => {
|
||||||
|
const difference = (source.direction - first.direction) * Math.PI / 180;
|
||||||
|
const across = (source.left + source.right) / 2;
|
||||||
|
const halfWidth = (source.right - source.left) / 2 * Math.abs(Math.cos(difference));
|
||||||
|
const center = source.along * Math.sin(difference) + across * Math.cos(difference);
|
||||||
|
return { source, ...source, along: source.along * Math.cos(difference) - across * Math.sin(difference), left: center - halfWidth, right: center + halfWidth };
|
||||||
|
}).sort((a, b) => a.along - b.along || a.low - b.low);
|
||||||
|
const chain = [first];
|
||||||
|
const chainSources = [first];
|
||||||
|
let sign = 0;
|
||||||
|
while (true) {
|
||||||
|
const current = chain[chain.length - 1];
|
||||||
|
const next = neighbors.find((candidate) => {
|
||||||
|
if (used.has(candidate.source)) return false;
|
||||||
|
const run = candidate.along - current.along;
|
||||||
|
const rise = candidate.low - current.low;
|
||||||
|
const overlap = Math.min(...chain.map((r) => r.right), candidate.right)
|
||||||
|
- Math.max(...chain.map((r) => r.left), candidate.left);
|
||||||
|
return run > 0.12 && run < 2.5 && Math.abs(rise) >= 0.08 && Math.abs(rise) <= 0.82
|
||||||
|
&& Math.abs(rise) / run < 0.85 && overlap >= 1
|
||||||
|
&& (!sign || Math.sign(rise) === sign)
|
||||||
|
&& Math.min(Math.abs(candidate.low - current.high), Math.abs(candidate.high - current.low)) < 0.22;
|
||||||
|
});
|
||||||
|
if (!next) break;
|
||||||
|
if (chain.length > 1) {
|
||||||
|
const run = chain[1].along - first.along;
|
||||||
|
const rise = chain[1].low - first.low;
|
||||||
|
if (Math.abs(next.low - (first.low + rise / run * (next.along - first.along))) > 0.1) break;
|
||||||
|
}
|
||||||
|
chain.push(next);
|
||||||
|
chainSources.push(next.source);
|
||||||
|
sign = Math.sign(next.low - current.low);
|
||||||
|
}
|
||||||
|
if (chain.length < 3) continue;
|
||||||
|
const last = chain[chain.length - 1];
|
||||||
|
const run = (last.along - first.along) / (chain.length - 1);
|
||||||
|
const rise = (last.low - first.low) / (chain.length - 1);
|
||||||
|
const left = Math.max(...chain.map((r) => r.left));
|
||||||
|
const right = Math.min(...chain.map((r) => r.right));
|
||||||
|
const across = (left + right) / 2;
|
||||||
|
const angle = first.direction * Math.PI / 180;
|
||||||
|
const point = (along: number, y: number): [number, number, number] => [
|
||||||
|
along * Math.cos(angle) - across * Math.sin(angle), y,
|
||||||
|
along * Math.sin(angle) + across * Math.cos(angle),
|
||||||
|
];
|
||||||
|
// Meet the lower/upper landings, including the first/last riser, without a lip.
|
||||||
|
ramps.push({
|
||||||
|
id: `stair-flight-${ramps.length + 1}`,
|
||||||
|
start: point(first.along - (sign < 0 ? run : 0.04), sign < 0 ? first.high : first.low),
|
||||||
|
end: point(last.along + (sign > 0 ? run : 0.04), sign > 0 ? last.high : last.low),
|
||||||
|
width: right - left + 0.02,
|
||||||
|
});
|
||||||
|
chainSources.forEach((r) => used.add(r));
|
||||||
|
}
|
||||||
|
return merge ? mergeDungeonStairRamps(ramps) : ramps;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Keep one plane per continuous flight; independent overlapping fragments introduce new lips. */
|
||||||
|
export function mergeDungeonStairRamps(input: DungeonTraversalRamp[]): DungeonTraversalRamp[] {
|
||||||
|
const ramps = [...input].sort((a, b) => Math.hypot(b.end[0] - b.start[0], b.end[2] - b.start[2])
|
||||||
|
- Math.hypot(a.end[0] - a.start[0], a.end[2] - a.start[2]));
|
||||||
|
const result: DungeonTraversalRamp[] = [];
|
||||||
|
for (const ramp of ramps) {
|
||||||
|
let combined = false;
|
||||||
|
for (let index = 0; index < result.length; index += 1) {
|
||||||
|
const previous = result[index];
|
||||||
|
const dx = previous.end[0] - previous.start[0]; const dz = previous.end[2] - previous.start[2];
|
||||||
|
const length = Math.hypot(dx, dz);
|
||||||
|
const rdX = ramp.end[0] - ramp.start[0]; const rdZ = ramp.end[2] - ramp.start[2];
|
||||||
|
const rLength = Math.hypot(rdX, rdZ);
|
||||||
|
if ((dx * rdX + dz * rdZ) / length / rLength < 0.999) continue;
|
||||||
|
const along = (p: readonly number[]) => ((p[0] - previous.start[0]) * dx + (p[2] - previous.start[2]) * dz) / length;
|
||||||
|
const across = (p: readonly number[]) => (-(p[0] - previous.start[0]) * dz + (p[2] - previous.start[2]) * dx) / length;
|
||||||
|
const start = along(ramp.start); const end = along(ramp.end);
|
||||||
|
const acrossOffset = (across(ramp.start) + across(ramp.end)) / 2;
|
||||||
|
const left = Math.max(-previous.width / 2, acrossOffset - ramp.width / 2);
|
||||||
|
const right = Math.min(previous.width / 2, acrossOffset + ramp.width / 2);
|
||||||
|
if (right - left < 1 || start > length + 1.5 || end < -1.5) continue;
|
||||||
|
const slope = (previous.end[1] - previous.start[1]) / length;
|
||||||
|
const rSlope = (ramp.end[1] - ramp.start[1]) / rLength;
|
||||||
|
if (Math.abs(slope - rSlope) > 0.12
|
||||||
|
|| Math.abs(ramp.start[1] - (previous.start[1] + start * slope)) > 0.32
|
||||||
|
|| Math.abs(ramp.end[1] - (previous.start[1] + end * slope)) > 0.32) continue;
|
||||||
|
const low = Math.min(0, start); const high = Math.max(length, end);
|
||||||
|
const center = (left + right) / 2;
|
||||||
|
const point = (t: number, y: number): [number, number, number] => [
|
||||||
|
previous.start[0] + dx / length * t - dz / length * center, y,
|
||||||
|
previous.start[2] + dz / length * t + dx / length * center,
|
||||||
|
];
|
||||||
|
result[index] = {
|
||||||
|
id: previous.id,
|
||||||
|
start: point(low, start < 0 ? ramp.start[1] : previous.start[1]),
|
||||||
|
end: point(high, end > length ? ramp.end[1] : previous.end[1]),
|
||||||
|
width: right - left,
|
||||||
|
};
|
||||||
|
combined = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (!combined) result.push(ramp);
|
||||||
|
}
|
||||||
|
return result.map((ramp, index) => ({ ...ramp, id: `stair-flight-${index + 1}` }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fill only separate Recast boundary portals wholly inside a verified stair footprint. */
|
||||||
|
export function dungeonStairNavigationBridges(geometry: BufferGeometry, ramps: DungeonTraversalRamp[]): DungeonTraversalRamp[] {
|
||||||
|
const p = geometry.getAttribute("position");
|
||||||
|
const indices = geometry.index?.array ?? Array.from({ length: p.count }, (_, i) => i);
|
||||||
|
const parents = Array.from({ length: p.count }, (_, i) => i);
|
||||||
|
const root = (i: number): number => {
|
||||||
|
while (parents[i] !== i) { parents[i] = parents[parents[i]]; i = parents[i]; }
|
||||||
|
return i;
|
||||||
|
};
|
||||||
|
const weld = new Map<string, number>();
|
||||||
|
const point = (i: number): [number, number, number] => [p.getX(i), p.getY(i), p.getZ(i)];
|
||||||
|
for (let i = 0; i < p.count; i += 1) {
|
||||||
|
const key = point(i).map((v) => Math.round(v / 0.002)).join(":");
|
||||||
|
const previous = weld.get(key);
|
||||||
|
if (previous !== undefined) parents[i] = previous;
|
||||||
|
else weld.set(key, i);
|
||||||
|
}
|
||||||
|
const edges = new Map<string, { a: number; b: number; count: number }>();
|
||||||
|
for (let offset = 0; offset + 2 < indices.length; offset += 3) {
|
||||||
|
const triangle = [indices[offset], indices[offset + 1], indices[offset + 2]];
|
||||||
|
for (let side = 0; side < 3; side += 1) {
|
||||||
|
const a = triangle[side]; const b = triangle[(side + 1) % 3];
|
||||||
|
const wa = weld.get(point(a).map((v) => Math.round(v / 0.002)).join(":"))!;
|
||||||
|
const wb = weld.get(point(b).map((v) => Math.round(v / 0.002)).join(":"))!;
|
||||||
|
parents[root(a)] = root(b);
|
||||||
|
const key = wa < wb ? `${wa}:${wb}` : `${wb}:${wa}`;
|
||||||
|
const edge = edges.get(key);
|
||||||
|
if (edge) edge.count += 1;
|
||||||
|
else edges.set(key, { a, b, count: 1 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const boundary = [...edges.values()].filter((e) => e.count === 1);
|
||||||
|
return ramps.map((ramp) => {
|
||||||
|
const dx = ramp.end[0] - ramp.start[0]; const dz = ramp.end[2] - ramp.start[2];
|
||||||
|
const length = Math.hypot(dx, dz);
|
||||||
|
const along = (v: number[]) => ((v[0] - ramp.start[0]) * dx + (v[2] - ramp.start[2]) * dz) / length;
|
||||||
|
const across = (v: number[]) => (-(v[0] - ramp.start[0]) * dz + (v[2] - ramp.start[2]) * dx) / length;
|
||||||
|
const portals = boundary.flatMap((e) => {
|
||||||
|
const points = [point(e.a), point(e.b)].sort((a, b) => across(a) - across(b));
|
||||||
|
if (points.some((v) => {
|
||||||
|
const y = dungeonRampHeight(ramp, v[0], v[2]);
|
||||||
|
return y === null || Math.abs(v[1] - y) > 0.8;
|
||||||
|
})) return [];
|
||||||
|
if (Math.abs(along(points[0]) - along(points[1])) > 0.15 || across(points[1]) - across(points[0]) < 0.7) return [];
|
||||||
|
return [{ ...e, points: points as [[number, number, number], [number, number, number]], along: (along(points[0]) + along(points[1])) / 2 }];
|
||||||
|
}).sort((a, b) => a.along - b.along);
|
||||||
|
const bridges = [];
|
||||||
|
for (let index = 1; index < portals.length; index += 1) {
|
||||||
|
const a = portals[index - 1]; const b = portals[index];
|
||||||
|
const gap = b.along - a.along;
|
||||||
|
if (gap < 0.15 || gap > 2.5 || root(a.a) === root(b.a)) continue;
|
||||||
|
const overlap = Math.min(across(a.points[1]), across(b.points[1])) - Math.max(across(a.points[0]), across(b.points[0]));
|
||||||
|
if (overlap < 0.7) continue;
|
||||||
|
bridges.push({ linkId: `${ramp.id}-seam-${bridges.length + 1}`, startPortal: a.points, endPortal: b.points });
|
||||||
|
parents[root(a.a)] = root(b.a);
|
||||||
|
}
|
||||||
|
return bridges.length ? { ...ramp, navigationBridges: bridges } : ramp;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import path from "node:path";
|
|||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { inflateSync } from "node:zlib";
|
import { inflateSync } from "node:zlib";
|
||||||
import sharp from "sharp";
|
import sharp from "sharp";
|
||||||
|
import { vocationGrowth } from "./vocation-growth.mjs";
|
||||||
|
|
||||||
const scriptDirectory = path.dirname(fileURLToPath(import.meta.url));
|
const scriptDirectory = path.dirname(fileURLToPath(import.meta.url));
|
||||||
const projectRoot = path.resolve(scriptDirectory, "../..");
|
const projectRoot = path.resolve(scriptDirectory, "../..");
|
||||||
@@ -38,17 +39,6 @@ const RACE_MATRIX = {
|
|||||||
"rom-dwarf": [1, 3, 4, 5, 9, 10],
|
"rom-dwarf": [1, 3, 4, 5, 9, 10],
|
||||||
};
|
};
|
||||||
|
|
||||||
function vocationGrowth(classIndex) {
|
|
||||||
const recordSize = 816;
|
|
||||||
const offset = DATA_START + classIndex * recordSize + 8;
|
|
||||||
const values = Array.from({ length: 21 }, (_, index) => cleanFloat(float32(buffers.vocationTable, offset + index * 4)));
|
|
||||||
const keys = ["strength", "stamina", "intellect", "wisdom", "agility", "health", "mana"];
|
|
||||||
return Object.fromEntries(keys.map((key, index) => [key, {
|
|
||||||
levelOne: values[index],
|
|
||||||
flatGrowth: values[index + 7],
|
|
||||||
percentGrowth: values[index + 14],
|
|
||||||
}]));
|
|
||||||
}
|
|
||||||
const DATA_START = 140;
|
const DATA_START = 140;
|
||||||
const LEARN_RECORD_SIZE = 12184;
|
const LEARN_RECORD_SIZE = 12184;
|
||||||
const LEARN_ENTRY_SIZE = 24;
|
const LEARN_ENTRY_SIZE = 24;
|
||||||
@@ -578,7 +568,7 @@ const catalog = {
|
|||||||
schemaVersion: 1,
|
schemaVersion: 1,
|
||||||
source: { rootHint: "RuneWaker source snapshot", hashes: sourceHashes },
|
source: { rootHint: "RuneWaker source snapshot", hashes: sourceHashes },
|
||||||
canonicalClasses: CLASS_IDS.map((id, index) => ({ id, sourceClassId: index + 1, name: CLASS_NAMES[index], resource: RESOURCES[index] })),
|
canonicalClasses: CLASS_IDS.map((id, index) => ({ id, sourceClassId: index + 1, name: CLASS_NAMES[index], resource: RESOURCES[index] })),
|
||||||
baseStatGrowth: Object.fromEntries(CLASS_IDS.map((id, index) => [id, vocationGrowth(index + 1)])),
|
baseStatGrowth: Object.fromEntries(CLASS_IDS.map((id, index) => [id, vocationGrowth(buffers.vocationTable, index + 1)])),
|
||||||
raceMatrix: Object.fromEntries(Object.entries(RACE_MATRIX).map(([raceId, classes]) => [raceId, classes.map((index) => CLASS_IDS[index - 1])])),
|
raceMatrix: Object.fromEntries(Object.entries(RACE_MATRIX).map(([raceId, classes]) => [raceId, classes.map((index) => CLASS_IDS[index - 1])])),
|
||||||
eliteLevels: ELITE_LEVELS,
|
eliteLevels: ELITE_LEVELS,
|
||||||
skills,
|
skills,
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
const DATA_START = 140;
|
||||||
|
const RECORD_SIZE = 408;
|
||||||
|
const STAT_KEYS = ["strength", "stamina", "intellect", "wisdom", "agility", "health", "mana"];
|
||||||
|
|
||||||
|
/** Players use FixArgTableID = 1; each vocation has two adjacent source records. */
|
||||||
|
export function vocationGrowth(buffer, classIndex) {
|
||||||
|
const recordId = 301000 + classIndex * 2 + 1;
|
||||||
|
const offset = DATA_START + (classIndex * 2 + 1) * RECORD_SIZE;
|
||||||
|
if (offset + RECORD_SIZE > buffer.length
|
||||||
|
|| buffer.readInt32LE(offset) !== recordId || buffer.readInt32LE(offset + 4) !== recordId) {
|
||||||
|
throw new Error(`Player vocation ${classIndex} record sentinel drifted.`);
|
||||||
|
}
|
||||||
|
const values = Array.from({ length: 21 }, (_, index) => {
|
||||||
|
const value = buffer.readFloatLE(offset + 8 + index * 4);
|
||||||
|
if (!Number.isFinite(value)) throw new Error(`Player vocation ${classIndex} has invalid growth.`);
|
||||||
|
return Number(value.toFixed(5));
|
||||||
|
});
|
||||||
|
return Object.fromEntries(STAT_KEYS.map((key, index) => [key, {
|
||||||
|
levelOne: values[index],
|
||||||
|
flatGrowth: values[index + 7],
|
||||||
|
percentGrowth: values[index + 14],
|
||||||
|
}]));
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { test } from "node:test";
|
||||||
|
import { vocationGrowth } from "./vocation-growth.mjs";
|
||||||
|
|
||||||
|
test("reads the player table instead of the adjacent alternate growth table", () => {
|
||||||
|
const buffer = Buffer.alloc(140 + 22 * 408);
|
||||||
|
const offset = 140 + 21 * 408;
|
||||||
|
buffer.writeInt32LE(301021, offset);
|
||||||
|
buffer.writeInt32LE(301021, offset + 4);
|
||||||
|
const values = [14, 13, 5, 6, 12, 60, 20, 5, 4.5, 1.9, 2.1, 4.25, 2, 2, ...Array(7).fill(0.02)];
|
||||||
|
values.forEach((value, index) => buffer.writeFloatLE(value, offset + 8 + index * 4));
|
||||||
|
buffer.writeFloatLE(2.8, offset - 408 + 8 + 14 * 4);
|
||||||
|
assert.deepEqual(vocationGrowth(buffer, 10).strength, { levelOne: 14, flatGrowth: 5, percentGrowth: 0.02 });
|
||||||
|
buffer.writeInt32LE(301020, offset);
|
||||||
|
assert.throws(() => vocationGrowth(buffer, 10), /sentinel drifted/);
|
||||||
|
});
|
||||||
@@ -10,6 +10,8 @@ import {
|
|||||||
} from "./manastorm-config.mjs";
|
} from "./manastorm-config.mjs";
|
||||||
import { arenaRatingBand, calculateArenaRating, clampArenaRating } from "./arena-rules.mjs";
|
import { arenaRatingBand, calculateArenaRating, clampArenaRating } from "./arena-rules.mjs";
|
||||||
import { arenaRoleForSpecialization } from "./arena-specializations.mjs";
|
import { arenaRoleForSpecialization } from "./arena-specializations.mjs";
|
||||||
|
import { createDungeonReportRepository } from "./dungeon-reports.mjs";
|
||||||
|
import { createDungeonLeaderboardRepository } from "./dungeon-leaderboards.mjs";
|
||||||
|
|
||||||
const scrypt = promisify(scryptCallback);
|
const scrypt = promisify(scryptCallback);
|
||||||
|
|
||||||
@@ -895,6 +897,8 @@ export function openGameDatabase(options = {}) {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
databasePath,
|
databasePath,
|
||||||
|
...createDungeonReportRepository(database, { isGameMaster, ErrorType: GameDatabaseError }),
|
||||||
|
...createDungeonLeaderboardRepository(database, { ErrorType: GameDatabaseError }),
|
||||||
register,
|
register,
|
||||||
login,
|
login,
|
||||||
authenticate,
|
authenticate,
|
||||||
|
|||||||
@@ -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 onlineSessions = createOnlineSessionManager();
|
||||||
const arenaService = createArenaService({ database: gameDatabase, autoPump: enableShelvedModes });
|
const arenaService = createArenaService({ database: gameDatabase, autoPump: enableShelvedModes });
|
||||||
const allowAuthRequest = createAuthLimiter();
|
const allowAuthRequest = createAuthLimiter();
|
||||||
|
const allowReportRequest = createAuthLimiter();
|
||||||
|
const allowDungeonRunRequest = createAuthLimiter();
|
||||||
gameDatabase.pruneExpiredSessions();
|
gameDatabase.pruneExpiredSessions();
|
||||||
|
|
||||||
const server = createServer(async (request, response) => {
|
const server = createServer(async (request, response) => {
|
||||||
@@ -239,6 +241,49 @@ export function createGameServer(options = {}) {
|
|||||||
json(response, 200, { account: gameDatabase.authenticate(bearerToken(request)) });
|
json(response, 200, { account: gameDatabase.authenticate(bearerToken(request)) });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (url.pathname === "/api/dungeon-leaderboards" && request.method === "GET") {
|
||||||
|
json(response, 200, gameDatabase.listDungeonLeaderboard({ dungeonId: url.searchParams.get("dungeon"),
|
||||||
|
difficultyId: url.searchParams.get("difficulty"), clear: url.searchParams.get("clear"),
|
||||||
|
offset: url.searchParams.get("offset"), limit: url.searchParams.get("limit") }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (url.pathname === "/api/dungeon-runs" && request.method === "POST") {
|
||||||
|
const token = bearerToken(request);
|
||||||
|
const account = token ? gameDatabase.authenticate(token) : null;
|
||||||
|
if (!allowDungeonRunRequest(account?.id ?? request.socket.remoteAddress))
|
||||||
|
throw new GameDatabaseError("Too many run submissions. Please try again shortly.", 429, "rate_limited");
|
||||||
|
json(response, 201, gameDatabase.submitDungeonRun(account, await readJson(request, 32 * 1024)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const dungeonRunMatch = /^\/api\/dungeon-runs\/([A-Za-z0-9_-]{8,100})$/.exec(url.pathname);
|
||||||
|
if (dungeonRunMatch && request.method === "GET") {
|
||||||
|
json(response, 200, gameDatabase.getDungeonRun(dungeonRunMatch[1]));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (url.pathname === "/api/dungeon-reports" && request.method === "GET") {
|
||||||
|
json(response, 200, gameDatabase.listDungeonReports({
|
||||||
|
status: url.searchParams.get("status"),
|
||||||
|
dungeonId: url.searchParams.get("dungeon"),
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (url.pathname === "/api/dungeon-reports" && request.method === "POST") {
|
||||||
|
const token = bearerToken(request);
|
||||||
|
const account = token ? gameDatabase.authenticate(token) : null;
|
||||||
|
if (!allowReportRequest(account?.id ?? request.socket.remoteAddress)) {
|
||||||
|
throw new GameDatabaseError("Too many reports. Please wait a minute and try again.", 429, "rate_limited");
|
||||||
|
}
|
||||||
|
const body = await readJson(request, 8 * 1024);
|
||||||
|
json(response, 201, { report: gameDatabase.submitDungeonReport(account, body) });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const reportMatch = /^\/api\/dungeon-reports\/([^/]+)$/.exec(url.pathname);
|
||||||
|
if (reportMatch && request.method === "PATCH") {
|
||||||
|
const account = gameDatabase.authenticate(bearerToken(request));
|
||||||
|
const body = await readJson(request, 8 * 1024);
|
||||||
|
json(response, 200, { report: gameDatabase.updateDungeonReport(account, reportMatch[1], body) });
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (url.pathname === "/api/cloud-save" && request.method === "GET") {
|
if (url.pathname === "/api/cloud-save" && request.method === "GET") {
|
||||||
const account = gameDatabase.authenticate(bearerToken(request));
|
const account = gameDatabase.authenticate(bearerToken(request));
|
||||||
json(response, 200, gameDatabase.getCloudSave(account.id));
|
json(response, 200, gameDatabase.getCloudSave(account.id));
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"commentMaxLength": 500,
|
||||||
|
"statuses": ["Submitted", "In-Progress", "Testing Fixes", "Closed"]
|
||||||
|
}
|
||||||
+7
-1
@@ -3,6 +3,7 @@ import "./ui/uiLayout.css";
|
|||||||
import { DualDisplayFrame } from "./components/DualDisplayFrame";
|
import { DualDisplayFrame } from "./components/DualDisplayFrame";
|
||||||
import { installInput, releasePointerLock } from "./game/inputManager";
|
import { installInput, releasePointerLock } from "./game/inputManager";
|
||||||
import { CombatBridge } from "./game/CombatBridge";
|
import { CombatBridge } from "./game/CombatBridge";
|
||||||
|
import { connectDpsMeter } from "./game/dpsMeterStore";
|
||||||
import { useCombatStore } from "./game/combatStore";
|
import { useCombatStore } from "./game/combatStore";
|
||||||
import { releaseDungeonGltfCache } from "./game/dungeonAssetCache";
|
import { releaseDungeonGltfCache } from "./game/dungeonAssetCache";
|
||||||
import { clearDungeonRuntimeCaches } from "./game/dungeonRuntimeReset";
|
import { clearDungeonRuntimeCaches } from "./game/dungeonRuntimeReset";
|
||||||
@@ -19,6 +20,8 @@ import { PauseMenu } from "./ui/PauseMenu";
|
|||||||
import { SceneErrorBoundary } from "./ui/SceneErrorBoundary";
|
import { SceneErrorBoundary } from "./ui/SceneErrorBoundary";
|
||||||
import { OnlineSessionBridge } from "./game/OnlineSessionBridge";
|
import { OnlineSessionBridge } from "./game/OnlineSessionBridge";
|
||||||
import { DungeonRunCheckpointBridge } from "./game/DungeonRunCheckpointBridge";
|
import { DungeonRunCheckpointBridge } from "./game/DungeonRunCheckpointBridge";
|
||||||
|
import { DungeonRunBridge } from "./game/DungeonRunBridge";
|
||||||
|
import { DungeonRunResults } from "./ui/DungeonRunResults";
|
||||||
import { DungeonEncounterEffectBridge } from "./game/DungeonEncounterEffectBridge";
|
import { DungeonEncounterEffectBridge } from "./game/DungeonEncounterEffectBridge";
|
||||||
import { interactWithActiveDungeonObject } from "./game/dungeonWorldInteraction";
|
import { interactWithActiveDungeonObject } from "./game/dungeonWorldInteraction";
|
||||||
import { DungeonInteractionPrompt } from "./ui/DungeonInteractionPrompt";
|
import { DungeonInteractionPrompt } from "./ui/DungeonInteractionPrompt";
|
||||||
@@ -36,7 +39,7 @@ function InputBridge() {
|
|||||||
|
|
||||||
useEffect(() => installInput({
|
useEffect(() => installInput({
|
||||||
desktopActionBarsActive: () => currentUiDevice() === "pc" && activeUiLayout().wowBars,
|
desktopActionBarsActive: () => currentUiDevice() === "pc" && activeUiLayout().wowBars,
|
||||||
inputSuspended: () => ["ui-layout", "keybinds"].includes(useGameStore.getState().overlay ?? ""),
|
inputSuspended: () => ["ui-layout", "keybinds", "dungeon-results"].includes(useGameStore.getState().overlay ?? ""),
|
||||||
getBindings: (device) => {
|
getBindings: (device) => {
|
||||||
const preferences = useUiLayoutStore.getState().preferences;
|
const preferences = useUiLayoutStore.getState().preferences;
|
||||||
const bindings = profileInputBindings(preferences, device);
|
const bindings = profileInputBindings(preferences, device);
|
||||||
@@ -93,6 +96,7 @@ function InputBridge() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function GameRuntime() {
|
export default function GameRuntime() {
|
||||||
|
useEffect(connectDpsMeter, []);
|
||||||
const editingControls = useGameStore((state) => state.overlay === "keybinds" || state.overlay === "ui-layout");
|
const editingControls = useGameStore((state) => state.overlay === "keybinds" || state.overlay === "ui-layout");
|
||||||
useEffect(() => () => {
|
useEffect(() => () => {
|
||||||
const activeDungeon = requireDungeonDefinition(useGameStore.getState().activeDungeonId);
|
const activeDungeon = requireDungeonDefinition(useGameStore.getState().activeDungeonId);
|
||||||
@@ -113,6 +117,7 @@ export default function GameRuntime() {
|
|||||||
<DungeonRunCheckpointBridge />
|
<DungeonRunCheckpointBridge />
|
||||||
<CombatBridge />
|
<CombatBridge />
|
||||||
<PartyBridge />
|
<PartyBridge />
|
||||||
|
<DungeonRunBridge />
|
||||||
<ShelvedGameBridges />
|
<ShelvedGameBridges />
|
||||||
<SceneErrorBoundary><GameScene /></SceneErrorBoundary>
|
<SceneErrorBoundary><GameScene /></SceneErrorBoundary>
|
||||||
<Hud />
|
<Hud />
|
||||||
@@ -120,6 +125,7 @@ export default function GameRuntime() {
|
|||||||
<MapPanel />
|
<MapPanel />
|
||||||
<GameplayPanels />
|
<GameplayPanels />
|
||||||
<PauseMenu />
|
<PauseMenu />
|
||||||
|
<DungeonRunResults />
|
||||||
<UiLayoutEditor />
|
<UiLayoutEditor />
|
||||||
<KeybindsPanel />
|
<KeybindsPanel />
|
||||||
</main>
|
</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, {
|
if (!activateDungeonSession(dungeonId, resume?.difficultyId, {
|
||||||
spawn: resume?.spawn,
|
spawn: resume?.spawn,
|
||||||
encounterSnapshots: resume?.encounters,
|
encounterSnapshots: resume?.encounters,
|
||||||
|
runProgress: resume?.run,
|
||||||
|
legacyResume: Boolean(resume && !resume.run),
|
||||||
})) {
|
})) {
|
||||||
useShellStore.setState({
|
useShellStore.setState({
|
||||||
notice: dungeon.availabilityReason ?? "That dungeon failed its latest validation gate.",
|
notice: dungeon.availabilityReason ?? "That dungeon failed its latest validation gate.",
|
||||||
|
|||||||
@@ -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";
|
} from "./dungeonEncounterRuntime";
|
||||||
import { DUNGEON_RUN_CHECKPOINT_VERSION } from "./dungeonRunCheckpoint";
|
import { DUNGEON_RUN_CHECKPOINT_VERSION } from "./dungeonRunCheckpoint";
|
||||||
import { useGameStore } from "./store";
|
import { useGameStore } from "./store";
|
||||||
|
import { useDungeonRunStore } from "./dungeonRunStore";
|
||||||
|
|
||||||
function checkpointSignature(dungeonId: string): string {
|
function checkpointSignature(dungeonId: string): string {
|
||||||
const game = useGameStore.getState();
|
const game = useGameStore.getState();
|
||||||
@@ -15,6 +16,7 @@ function checkpointSignature(dungeonId: string): string {
|
|||||||
difficultyId: game.activeDifficultyId,
|
difficultyId: game.activeDifficultyId,
|
||||||
spawn: game.activeSpawn,
|
spawn: game.activeSpawn,
|
||||||
encounters,
|
encounters,
|
||||||
|
run: useDungeonRunStore.getState().progress,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,10 +43,13 @@ export function DungeonRunCheckpointBridge() {
|
|||||||
spawn: game.activeSpawn,
|
spawn: game.activeSpawn,
|
||||||
encounters,
|
encounters,
|
||||||
savedAt: Date.now(),
|
savedAt: Date.now(),
|
||||||
|
run: useDungeonRunStore.getState().progress ?? undefined,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
save();
|
save();
|
||||||
return subscribeDungeonEncounterSnapshots(save);
|
const unsubscribeEncounters = subscribeDungeonEncounterSnapshots(save);
|
||||||
|
const unsubscribeRun = useDungeonRunStore.subscribe(save);
|
||||||
|
return () => { unsubscribeEncounters(); unsubscribeRun(); };
|
||||||
}, [activeCharacterId, phase]);
|
}, [activeCharacterId, phase]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -14,6 +14,16 @@ import {
|
|||||||
} from "./coaLiveCatalog";
|
} from "./coaLiveCatalog";
|
||||||
|
|
||||||
const abilities = Object.values(COA_ABILITIES_BY_CLASS).flat();
|
const abilities = Object.values(COA_ABILITIES_BY_CLASS).flat();
|
||||||
|
|
||||||
|
it("keeps Drakkari Barrier friendly and its retaliation in the damage-taken proc", () => {
|
||||||
|
const barrier = COA_ABILITIES_BY_CLASS["witch-doctor"].find((ability) => ability.name === "Drakkari Barrier")!;
|
||||||
|
expect(barrier.target).toBe("friendly");
|
||||||
|
for (const rank of barrier.ranks ?? []) {
|
||||||
|
expect(rank.effects.some((effect) => effect.kind === "damage")).toBe(false);
|
||||||
|
expect(rank.effects.some((effect) => effect.kind === "apply-aura"
|
||||||
|
&& effect.aura.procs?.some((proc) => proc.triggers === "damage-taken" && proc.action.kind === "damage"))).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
const ranksFor = (ability: AbilityDefinition): readonly AbilityRankDefinition[] => ability.ranks ?? [{
|
const ranksFor = (ability: AbilityDefinition): readonly AbilityRankDefinition[] => ability.ranks ?? [{
|
||||||
rank: 1,
|
rank: 1,
|
||||||
spellId: ability.dbcSpellId,
|
spellId: ability.dbcSpellId,
|
||||||
|
|||||||
@@ -987,7 +987,8 @@ function effectsForSpell(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const hasHealing = effects.some((effect) => ["heal", "hot"].includes(effect.kind));
|
const hasHealing = effects.some((effect) => ["heal", "hot"].includes(effect.kind));
|
||||||
const hasDamage = effects.some((effect) => ["damage", "dot"].includes(effect.kind));
|
const hasDamage = effects.some((effect) => ["damage", "dot"].includes(effect.kind)
|
||||||
|
|| (effect.kind === "apply-aura" && effect.aura.procs?.some((proc) => proc.action.kind === "damage")));
|
||||||
const describesHealing = /\b(?:heal(?:s|ing|ed)?|restores? .*health|mends?|rejuvenat\w*)\b/i.test(description);
|
const describesHealing = /\b(?:heal(?:s|ing|ed)?|restores? .*health|mends?|rejuvenat\w*)\b/i.test(description);
|
||||||
const describesDamage = /\b(?:deal(?:s|ing)?|inflict(?:s|ing)?|caus(?:e|es|ing)|damag(?:e|es|ing))\b[^.]*\bdamage\b/i.test(description);
|
const describesDamage = /\b(?:deal(?:s|ing)?|inflict(?:s|ing)?|caus(?:e|es|ing)|damag(?:e|es|ing))\b[^.]*\bdamage\b/i.test(description);
|
||||||
if (!hasHealing && describesHealing) effects.unshift({ kind: "heal", coefficient: 1 });
|
if (!hasHealing && describesHealing) effects.unshift({ kind: "heal", coefficient: 1 });
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ export interface CombatActorCastState {
|
|||||||
/** Simulation-space origin used for movement cancellation and completion validation. */
|
/** Simulation-space origin used for movement cancellation and completion validation. */
|
||||||
readonly origin?: CombatActorPosition;
|
readonly origin?: CombatActorPosition;
|
||||||
readonly targetId?: string | null;
|
readonly targetId?: string | null;
|
||||||
|
readonly channelTicksCompleted?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type { PartyNavigationGraph } from "./partyNavigation";
|
||||||
|
|
||||||
export type CombatSpatialPosition = readonly [x: number, y: number, z: number];
|
export type CombatSpatialPosition = readonly [x: number, y: number, z: number];
|
||||||
|
|
||||||
export interface CombatSpatialActorSnapshot {
|
export interface CombatSpatialActorSnapshot {
|
||||||
@@ -8,6 +10,8 @@ export interface CombatSpatialActorSnapshot {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface CombatSpatialProvider {
|
export interface CombatSpatialProvider {
|
||||||
|
/** The already-loaded, reviewed dungeon surface shared by actor navigation. */
|
||||||
|
readonly navigationGraph?: () => PartyNavigationGraph | null;
|
||||||
readonly actor: (actorId: string) => CombatSpatialActorSnapshot | null;
|
readonly actor: (actorId: string) => CombatSpatialActorSnapshot | null;
|
||||||
readonly hasLineOfSight: (
|
readonly hasLineOfSight: (
|
||||||
source: CombatSpatialPosition,
|
source: CombatSpatialPosition,
|
||||||
@@ -45,6 +49,10 @@ export function combatHasLineOfSight(
|
|||||||
return provider?.hasLineOfSight(source, target) ?? true;
|
return provider?.hasLineOfSight(source, target) ?? true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function combatNavigationGraph(): PartyNavigationGraph | null {
|
||||||
|
return provider?.navigationGraph?.() ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
export function projectCombatGroundPoint(
|
export function projectCombatGroundPoint(
|
||||||
requested: CombatSpatialPosition,
|
requested: CombatSpatialPosition,
|
||||||
maximumDistance: number,
|
maximumDistance: number,
|
||||||
|
|||||||
+208
-89
@@ -129,6 +129,7 @@ import {
|
|||||||
} from "./combatAuras";
|
} from "./combatAuras";
|
||||||
import { rollBossLoot } from "./lootCatalog";
|
import { rollBossLoot } from "./lootCatalog";
|
||||||
import { adaptRunewakerEnemyDamage } from "./runewakerCombatFormula";
|
import { adaptRunewakerEnemyDamage } from "./runewakerCombatFormula";
|
||||||
|
import { channelAmountEffects } from "./combatChannelAmounts";
|
||||||
import { normalizeInventory, type InventoryItem } from "./lootTypes";
|
import { normalizeInventory, type InventoryItem } from "./lootTypes";
|
||||||
import {
|
import {
|
||||||
PLAYER_EQUIPMENT_OWNER_ID,
|
PLAYER_EQUIPMENT_OWNER_ID,
|
||||||
@@ -372,6 +373,7 @@ export interface MobCombatState {
|
|||||||
readonly lastDamageAt: number;
|
readonly lastDamageAt: number;
|
||||||
readonly engaged: boolean;
|
readonly engaged: boolean;
|
||||||
readonly combatPhase: "idle" | "chasing" | "attacking" | "returning";
|
readonly combatPhase: "idle" | "chasing" | "attacking" | "returning";
|
||||||
|
readonly navigationEvadeUntil?: number;
|
||||||
readonly homePosition: CombatPosition | null;
|
readonly homePosition: CombatPosition | null;
|
||||||
readonly nextAttackAt: number;
|
readonly nextAttackAt: number;
|
||||||
readonly abilityReadyAt: Readonly<Record<string, number>>;
|
readonly abilityReadyAt: Readonly<Record<string, number>>;
|
||||||
@@ -720,9 +722,12 @@ export interface CombatState {
|
|||||||
damageSummon: (id: string, amount: number) => number;
|
damageSummon: (id: string, amount: number) => number;
|
||||||
healPlayer: (amount: number) => number;
|
healPlayer: (amount: number) => number;
|
||||||
revivePlayer: (percentMaxHealth?: number) => number;
|
revivePlayer: (percentMaxHealth?: number) => number;
|
||||||
|
/** Checkpoint recovery of the player only; keeps the party and current pull intact. */
|
||||||
|
respawnPlayerOnly: (position: CombatPosition, now?: number) => boolean;
|
||||||
cancelCast: (message?: string) => boolean;
|
cancelCast: (message?: string) => boolean;
|
||||||
engageNearbyMobs: (now?: number, playerPosition?: CombatPosition) => number;
|
engageNearbyMobs: (now?: number, playerPosition?: CombatPosition) => number;
|
||||||
advanceMobCombat: (now?: number, playerPosition?: CombatPosition) => number;
|
advanceMobCombat: (now?: number, playerPosition?: CombatPosition) => number;
|
||||||
|
resetMobForNavigation: (id: string, atHome: boolean, now?: number) => boolean;
|
||||||
tick: (deltaSeconds: number, now?: number) => void;
|
tick: (deltaSeconds: number, now?: number) => void;
|
||||||
resetEncounter: (options?: {
|
resetEncounter: (options?: {
|
||||||
readonly preserveDefeated?: boolean;
|
readonly preserveDefeated?: boolean;
|
||||||
@@ -1181,7 +1186,7 @@ function abilityAttackKind(classId: ClassId, ability: AbilityDefinition): WotlkA
|
|||||||
const physicalKeyword = /\b(?:strike|slash|stab|shot|arrow|mangle|claw|bite|weapon|melee)\b/.test(searchable);
|
const physicalKeyword = /\b(?:strike|slash|stab|shot|arrow|mangle|claw|bite|weapon|melee)\b/.test(searchable);
|
||||||
const rangedPhysicalKeyword = /\b(?:shot|arrow|bow|gun|bullet|javelin)\b/.test(searchable);
|
const rangedPhysicalKeyword = /\b(?:shot|arrow|bow|gun|bullet|javelin)\b/.test(searchable);
|
||||||
if (healing) return "spell";
|
if (healing) return "spell";
|
||||||
if (ability.range.max > 4 && (rangedPhysicalKeyword || baseClassFor(classId) === "hunter")) return "ranged";
|
if (ability.range.max > 5 && (rangedPhysicalKeyword || baseClassFor(classId) === "hunter")) return "ranged";
|
||||||
if (["warrior", "rogue", "death-knight"].includes(baseClassFor(classId)) || physicalKeyword) return "melee";
|
if (["warrior", "rogue", "death-knight"].includes(baseClassFor(classId)) || physicalKeyword) return "melee";
|
||||||
return "spell";
|
return "spell";
|
||||||
}
|
}
|
||||||
@@ -1200,7 +1205,7 @@ export function abilityDamageSchool(classId: ClassId, ability: AbilityDefinition
|
|||||||
return "arcane";
|
return "arcane";
|
||||||
}
|
}
|
||||||
|
|
||||||
function equipmentAdjustedCombatAmount(
|
export function equipmentAdjustedCombatAmount(
|
||||||
scaling: number | (AbilityAmountScaling & { readonly coefficient: number }),
|
scaling: number | (AbilityAmountScaling & { readonly coefficient: number }),
|
||||||
level: number,
|
level: number,
|
||||||
gearStats: ItemStats,
|
gearStats: ItemStats,
|
||||||
@@ -2173,10 +2178,7 @@ function executeTriggeredAuraProcsInWork(
|
|||||||
const maximum = work.maxResourcePools[resource] ?? defaultResourceMaximum(resource);
|
const maximum = work.maxResourcePools[resource] ?? defaultResourceMaximum(resource);
|
||||||
work.resourcePools[resource] = clamp((work.resourcePools[resource] ?? 0) + action.amount, 0, maximum);
|
work.resourcePools[resource] = clamp((work.resourcePools[resource] ?? 0) + action.amount, 0, maximum);
|
||||||
} else if (action.kind === "heal" && actionTarget === PLAYER_AURA_ENTITY_ID) {
|
} else if (action.kind === "heal" && actionTarget === PLAYER_AURA_ENTITY_ID) {
|
||||||
const healed = Math.min(Math.max(0, action.amount), work.maxHealth - work.health);
|
work.totalHealing += healPlayerInWork(work, action.amount);
|
||||||
work.health += healed;
|
|
||||||
work.progression.health = work.health;
|
|
||||||
work.totalHealing += healed;
|
|
||||||
} else if (action.kind === "heal" && actionTarget && !work.mobs[actionTarget]) {
|
} else if (action.kind === "heal" && actionTarget && !work.mobs[actionTarget]) {
|
||||||
work.totalHealing += healCompanionActorInWork(work, actionTarget, Math.max(0, action.amount));
|
work.totalHealing += healCompanionActorInWork(work, actionTarget, Math.max(0, action.amount));
|
||||||
} else if (action.kind === "damage" && actionTarget && work.mobs[actionTarget]) {
|
} else if (action.kind === "damage" && actionTarget && work.mobs[actionTarget]) {
|
||||||
@@ -2377,7 +2379,8 @@ function grantMobExperience(
|
|||||||
work.secondaryClassId,
|
work.secondaryClassId,
|
||||||
work.auras,
|
work.auras,
|
||||||
);
|
);
|
||||||
progression.health = progression.maxHealth;
|
// A lingering DoT may grant experience after death, but leveling is not resurrection.
|
||||||
|
progression.health = work.health > 0 ? progression.maxHealth : 0;
|
||||||
work.health = progression.health;
|
work.health = progression.health;
|
||||||
work.maxHealth = progression.maxHealth;
|
work.maxHealth = progression.maxHealth;
|
||||||
work.maxResource = talentAdjustedMaxResource(
|
work.maxResource = talentAdjustedMaxResource(
|
||||||
@@ -2418,6 +2421,7 @@ function engageMobGroup(
|
|||||||
let changed = false;
|
let changed = false;
|
||||||
for (const [id, mob] of Object.entries(mobs)) {
|
for (const [id, mob] of Object.entries(mobs)) {
|
||||||
if (mob.dead || mob.despawned || mobGroupId(id) !== groupId) continue;
|
if (mob.dead || mob.despawned || mobGroupId(id) !== groupId) continue;
|
||||||
|
if (mob.combatPhase === "returning" || (mob.navigationEvadeUntil ?? 0) > now) continue;
|
||||||
const threatByActor = groupWasEngaged
|
const threatByActor = groupWasEngaged
|
||||||
? mob.threatByActor
|
? mob.threatByActor
|
||||||
: seedActorThreat(mob.threatByActor, sourceActorId);
|
: seedActorThreat(mob.threatByActor, sourceActorId);
|
||||||
@@ -2459,7 +2463,7 @@ function nearestAggroActorId(
|
|||||||
let nearestDistanceSquared = aggroRange * aggroRange;
|
let nearestDistanceSquared = aggroRange * aggroRange;
|
||||||
if (state.health > 0) {
|
if (state.health > 0) {
|
||||||
const playerDistanceSquared = distanceSquared(mobPosition, state.playerPosition);
|
const playerDistanceSquared = distanceSquared(mobPosition, state.playerPosition);
|
||||||
if (playerDistanceSquared <= nearestDistanceSquared) {
|
if (playerDistanceSquared <= nearestDistanceSquared && combatHasLineOfSight(mobPosition, state.playerPosition)) {
|
||||||
nearestId = PLAYER_AGGRO_ID;
|
nearestId = PLAYER_AGGRO_ID;
|
||||||
nearestDistanceSquared = playerDistanceSquared;
|
nearestDistanceSquared = playerDistanceSquared;
|
||||||
}
|
}
|
||||||
@@ -2469,7 +2473,7 @@ function nearestAggroActorId(
|
|||||||
const position = getPartyRuntimePosition(member.id);
|
const position = getPartyRuntimePosition(member.id);
|
||||||
if (!position) continue;
|
if (!position) continue;
|
||||||
const memberDistanceSquared = distanceSquared(mobPosition, position);
|
const memberDistanceSquared = distanceSquared(mobPosition, position);
|
||||||
if (memberDistanceSquared < nearestDistanceSquared) {
|
if (memberDistanceSquared < nearestDistanceSquared && combatHasLineOfSight(mobPosition, position)) {
|
||||||
nearestId = member.id;
|
nearestId = member.id;
|
||||||
nearestDistanceSquared = memberDistanceSquared;
|
nearestDistanceSquared = memberDistanceSquared;
|
||||||
}
|
}
|
||||||
@@ -2479,7 +2483,7 @@ function nearestAggroActorId(
|
|||||||
const position = getPartyRuntimePosition(actor.id);
|
const position = getPartyRuntimePosition(actor.id);
|
||||||
if (!position) continue;
|
if (!position) continue;
|
||||||
const actorDistanceSquared = distanceSquared(mobPosition, position);
|
const actorDistanceSquared = distanceSquared(mobPosition, position);
|
||||||
if (actorDistanceSquared < nearestDistanceSquared) {
|
if (actorDistanceSquared < nearestDistanceSquared && combatHasLineOfSight(mobPosition, position)) {
|
||||||
nearestId = actor.id;
|
nearestId = actor.id;
|
||||||
nearestDistanceSquared = actorDistanceSquared;
|
nearestDistanceSquared = actorDistanceSquared;
|
||||||
}
|
}
|
||||||
@@ -2487,7 +2491,7 @@ function nearestAggroActorId(
|
|||||||
for (const summon of state.summons) {
|
for (const summon of state.summons) {
|
||||||
if (summon.health <= 0) continue;
|
if (summon.health <= 0) continue;
|
||||||
const summonDistanceSquared = distanceSquared(mobPosition, summon.position);
|
const summonDistanceSquared = distanceSquared(mobPosition, summon.position);
|
||||||
if (summonDistanceSquared < nearestDistanceSquared) {
|
if (summonDistanceSquared < nearestDistanceSquared && combatHasLineOfSight(mobPosition, summon.position)) {
|
||||||
nearestId = summon.id;
|
nearestId = summon.id;
|
||||||
nearestDistanceSquared = summonDistanceSquared;
|
nearestDistanceSquared = summonDistanceSquared;
|
||||||
}
|
}
|
||||||
@@ -2533,7 +2537,9 @@ function damageMobInWork(
|
|||||||
combatResult?: CombatResult,
|
combatResult?: CombatResult,
|
||||||
): DamageResult {
|
): DamageResult {
|
||||||
const mob = work.mobs[id];
|
const mob = work.mobs[id];
|
||||||
if (!mob || mob.dead || amount <= 0) return { amount: 0, killed: false, xpAwarded: 0, levelsGained: 0 };
|
if (!mob || mob.dead || amount <= 0 || (mob.navigationEvadeUntil ?? 0) > now) {
|
||||||
|
return { amount: 0, killed: false, xpAwarded: 0, levelsGained: 0 };
|
||||||
|
}
|
||||||
const mitigatedBeforeAuras = combatResult?.finalDamage ?? damageAfterMobResistance(
|
const mitigatedBeforeAuras = combatResult?.finalDamage ?? damageAfterMobResistance(
|
||||||
amount,
|
amount,
|
||||||
mob,
|
mob,
|
||||||
@@ -2678,7 +2684,7 @@ function resolveAndDamageMobInWork(
|
|||||||
rules: CombatResultRules = {},
|
rules: CombatResultRules = {},
|
||||||
): DamageResult {
|
): DamageResult {
|
||||||
const mob = work.mobs[id];
|
const mob = work.mobs[id];
|
||||||
if (!mob || mob.dead || amount <= 0) {
|
if (!mob || mob.dead || amount <= 0 || (mob.navigationEvadeUntil ?? 0) > now) {
|
||||||
return { amount: 0, killed: false, xpAwarded: 0, levelsGained: 0 };
|
return { amount: 0, killed: false, xpAwarded: 0, levelsGained: 0 };
|
||||||
}
|
}
|
||||||
const sourceAuras = aurasForEntity(work.auras, source.actorId);
|
const sourceAuras = aurasForEntity(work.auras, source.actorId);
|
||||||
@@ -2872,10 +2878,7 @@ function executeTriggeredSpellInWork(
|
|||||||
"taken",
|
"taken",
|
||||||
));
|
));
|
||||||
if (recipient === PLAYER_AURA_ENTITY_ID) {
|
if (recipient === PLAYER_AURA_ENTITY_ID) {
|
||||||
const healed = Math.min(amount, work.maxHealth - work.health);
|
work.totalHealing += healPlayerInWork(work, amount);
|
||||||
work.health += healed;
|
|
||||||
work.progression.health = work.health;
|
|
||||||
work.totalHealing += healed;
|
|
||||||
} else {
|
} else {
|
||||||
const healed = healCompanionActorInWork(work, recipient, amount);
|
const healed = healCompanionActorInWork(work, recipient, amount);
|
||||||
work.totalHealing += healed;
|
work.totalHealing += healed;
|
||||||
@@ -3183,6 +3186,18 @@ function applyAuraHealthTransitionInWork(
|
|||||||
mirrorCompanionActor(nextActor);
|
mirrorCompanionActor(nextActor);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Ordinary healing never transitions a corpse back to a living actor. */
|
||||||
|
function healPlayerInWork(work: MutableCastWork, amount: number): number {
|
||||||
|
const requested = Math.max(0, Math.round(Number.isFinite(amount) ? amount : 0));
|
||||||
|
if (work.health <= 0 || requested <= 0) return 0;
|
||||||
|
const healed = Math.min(requested, Math.max(0, work.maxHealth - work.health));
|
||||||
|
work.health += healed;
|
||||||
|
work.progression.health = work.health;
|
||||||
|
const actor = work.actors[PLAYER_AURA_ENTITY_ID];
|
||||||
|
if (actor) work.actors[PLAYER_AURA_ENTITY_ID] = { ...actor, health: work.health, alive: true };
|
||||||
|
return healed;
|
||||||
|
}
|
||||||
|
|
||||||
function healCompanionActorInWork(work: MutableCastWork, id: string, amount: number): number {
|
function healCompanionActorInWork(work: MutableCastWork, id: string, amount: number): number {
|
||||||
const actor = ensureCompanionActorInWork(work, id);
|
const actor = ensureCompanionActorInWork(work, id);
|
||||||
const requested = Math.max(0, Math.round(Number.isFinite(amount) ? amount : 0));
|
const requested = Math.max(0, Math.round(Number.isFinite(amount) ? amount : 0));
|
||||||
@@ -3574,11 +3589,11 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
|||||||
0,
|
0,
|
||||||
registration.moveSpeed ?? existing?.moveSpeed ?? (boss ? 3.15 : 3.5),
|
registration.moveSpeed ?? existing?.moveSpeed ?? (boss ? 3.15 : 3.5),
|
||||||
);
|
);
|
||||||
const attackDamage = existing && registration.damageMultiplier !== undefined
|
const attackDamage = existing && level === existing.level && registration.damageMultiplier !== undefined
|
||||||
? Math.max(1, Math.round(
|
? Math.max(1, Math.round(
|
||||||
existing.attackDamage * (damageMultiplier / Math.max(0.01, existing.damageMultiplier)),
|
existing.attackDamage * (damageMultiplier / Math.max(0.01, existing.damageMultiplier)),
|
||||||
))
|
))
|
||||||
: existing?.attackDamage ?? Math.max(1, Math.round(
|
: (existing && level === existing.level ? existing.attackDamage : undefined) ?? Math.max(1, Math.round(
|
||||||
defaults.attackDamage * damageMultiplier,
|
defaults.attackDamage * damageMultiplier,
|
||||||
));
|
));
|
||||||
const mob: MobCombatState = existing
|
const mob: MobCombatState = existing
|
||||||
@@ -3606,7 +3621,9 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
|||||||
leashRange,
|
leashRange,
|
||||||
moveSpeed,
|
moveSpeed,
|
||||||
attackDamage,
|
attackDamage,
|
||||||
health: Math.min(existing.health, maxHealth),
|
health: existing.dead || existing.health <= 0
|
||||||
|
? 0
|
||||||
|
: clamp(Math.round(existing.health / Math.max(1, existing.maxHealth) * maxHealth), 1, maxHealth),
|
||||||
xpReward,
|
xpReward,
|
||||||
hasLoot,
|
hasLoot,
|
||||||
}
|
}
|
||||||
@@ -4695,7 +4712,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
|||||||
? Math.min(resolution.rawAmount, friendlySummon.maxHealth - friendlySummon.health)
|
? Math.min(resolution.rawAmount, friendlySummon.maxHealth - friendlySummon.health)
|
||||||
: friendlyOnline
|
: friendlyOnline
|
||||||
? Math.min(resolution.rawAmount, friendlyOnline.maxHealth - friendlyOnline.health)
|
? Math.min(resolution.rawAmount, friendlyOnline.maxHealth - friendlyOnline.health)
|
||||||
: resolution.effectiveAmount;
|
: healPlayerInWork(work, resolution.rawAmount);
|
||||||
if (friendlySummon && healed > 0) {
|
if (friendlySummon && healed > 0) {
|
||||||
work.summons = work.summons.map((summon) => summon.id === healingTargetId
|
work.summons = work.summons.map((summon) => summon.id === healingTargetId
|
||||||
? { ...summon, health: summon.health + healed }
|
? { ...summon, health: summon.health + healed }
|
||||||
@@ -4705,9 +4722,6 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
|||||||
} else if (friendlyOnline && healed > 0) {
|
} else if (friendlyOnline && healed > 0) {
|
||||||
const actor = work.actors[healingTargetId] ?? friendlyOnline;
|
const actor = work.actors[healingTargetId] ?? friendlyOnline;
|
||||||
work.actors[healingTargetId] = { ...actor, health: actor.health + healed, alive: true };
|
work.actors[healingTargetId] = { ...actor, health: actor.health + healed, alive: true };
|
||||||
} else if (!friendlyTarget) {
|
|
||||||
work.health += healed;
|
|
||||||
work.progression.health = work.health;
|
|
||||||
}
|
}
|
||||||
recordCombatEventInWork(work, {
|
recordCombatEventInWork(work, {
|
||||||
occurredAt: now,
|
occurredAt: now,
|
||||||
@@ -4739,10 +4753,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
|||||||
&& aura.definition.utilityTags?.includes("beacon-of-light")
|
&& aura.definition.utilityTags?.includes("beacon-of-light")
|
||||||
));
|
));
|
||||||
if (beacon?.targetId === PLAYER_AURA_ENTITY_ID) {
|
if (beacon?.targetId === PLAYER_AURA_ENTITY_ID) {
|
||||||
const beaconHealing = Math.min(directHealing, work.maxHealth - work.health);
|
work.totalHealing += healPlayerInWork(work, directHealing);
|
||||||
work.health += beaconHealing;
|
|
||||||
work.progression.health = work.health;
|
|
||||||
work.totalHealing += beaconHealing;
|
|
||||||
} else if (beacon) {
|
} else if (beacon) {
|
||||||
work.totalHealing += healCompanionActorInWork(work, beacon.targetId, directHealing);
|
work.totalHealing += healCompanionActorInWork(work, beacon.targetId, directHealing);
|
||||||
}
|
}
|
||||||
@@ -5326,10 +5337,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
|||||||
work.resource = clamp(work.resource + restored, 0, work.maxResource);
|
work.resource = clamp(work.resource + restored, 0, work.maxResource);
|
||||||
work.resourcePools[activeResourceType] = work.resource;
|
work.resourcePools[activeResourceType] = work.resource;
|
||||||
} else if (/food|refreshment|health|firestone/i.test(ability.name)) {
|
} else if (/food|refreshment|health|firestone/i.test(ability.name)) {
|
||||||
const healed = Math.min(Math.max(1, Math.round(work.maxHealth * 0.25)), work.maxHealth - work.health);
|
work.totalHealing += healPlayerInWork(work, Math.max(1, Math.round(work.maxHealth * 0.25)));
|
||||||
work.health += healed;
|
|
||||||
work.progression.health = work.health;
|
|
||||||
work.totalHealing += healed;
|
|
||||||
}
|
}
|
||||||
} else if (effect.action === "portal") {
|
} else if (effect.action === "portal") {
|
||||||
summonSequence += 1;
|
summonSequence += 1;
|
||||||
@@ -5358,13 +5366,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
|||||||
aura.targetId === PLAYER_AURA_ENTITY_ID
|
aura.targetId === PLAYER_AURA_ENTITY_ID
|
||||||
&& aura.definition.utilityTags?.includes("rom-revival-charge")
|
&& aura.definition.utilityTags?.includes("rom-revival-charge")
|
||||||
));
|
));
|
||||||
const restored = Math.min(
|
work.totalHealing += healPlayerInWork(work, Math.round(work.maxHealth * 0.05 * (revival?.stacks ?? 0)));
|
||||||
work.maxHealth - work.health,
|
|
||||||
Math.round(work.maxHealth * 0.05 * (revival?.stacks ?? 0)),
|
|
||||||
);
|
|
||||||
work.health += restored;
|
|
||||||
work.progression.health = work.health;
|
|
||||||
work.totalHealing += restored;
|
|
||||||
if (revival) work.auras = work.auras.filter((aura) => aura.instanceId !== revival.instanceId);
|
if (revival) work.auras = work.auras.filter((aura) => aura.instanceId !== revival.instanceId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -5407,9 +5409,8 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
|||||||
|
|
||||||
// Judgement of Light restores 2% health when the marked target is struck.
|
// Judgement of Light restores 2% health when the marked target is struck.
|
||||||
if (work.totalDamage > 0 && targetId && work.mobs[targetId]?.statuses.some((entry) => entry.kind === "marked")) {
|
if (work.totalDamage > 0 && targetId && work.mobs[targetId]?.statuses.some((entry) => entry.kind === "marked")) {
|
||||||
const healed = Math.min(talentAdjustedAmount(Math.round(work.maxHealth * 0.02), modifiers, "healing-percent", ability), work.maxHealth - work.health);
|
const healed = healPlayerInWork(work,
|
||||||
work.health += healed;
|
talentAdjustedAmount(Math.round(work.maxHealth * 0.02), modifiers, "healing-percent", ability));
|
||||||
work.progression.health = work.health;
|
|
||||||
work.totalHealing += healed;
|
work.totalHealing += healed;
|
||||||
addHealingThreatInWork(work, castThreatSource, healed);
|
addHealingThreatInWork(work, castThreatSource, healed);
|
||||||
}
|
}
|
||||||
@@ -5656,7 +5657,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
|||||||
|
|
||||||
tauntMob: (id, source, durationMs, now = combatNow()) => {
|
tauntMob: (id, source, durationMs, now = combatNow()) => {
|
||||||
const target = get().mobs[id];
|
const target = get().mobs[id];
|
||||||
if (!target || target.dead || durationMs <= 0) return false;
|
if (!target || target.dead || durationMs <= 0 || (target.navigationEvadeUntil ?? 0) > now) return false;
|
||||||
const previous = target.tauntDiminishingReturns;
|
const previous = target.tauntDiminishingReturns;
|
||||||
const applications = target.boss && previous && previous.resetAt > now ? previous.applications : 0;
|
const applications = target.boss && previous && previous.resetAt > now ? previous.applications : 0;
|
||||||
const multiplier = target.boss ? [1, 0.65, 0.4225, 0][Math.min(3, applications)] : 1;
|
const multiplier = target.boss ? [1, 0.65, 0.4225, 0][Math.min(3, applications)] : 1;
|
||||||
@@ -5676,6 +5677,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
|||||||
));
|
));
|
||||||
for (const [mobId, mob] of Object.entries(mobs)) {
|
for (const [mobId, mob] of Object.entries(mobs)) {
|
||||||
if (mob.dead || mobGroupId(mobId) !== groupId) continue;
|
if (mob.dead || mobGroupId(mobId) !== groupId) continue;
|
||||||
|
if ((mob.navigationEvadeUntil ?? 0) > now) continue;
|
||||||
const seeded = groupWasEngaged
|
const seeded = groupWasEngaged
|
||||||
? mob.threatByActor
|
? mob.threatByActor
|
||||||
: seedActorThreat(mob.threatByActor, source.actorId);
|
: seedActorThreat(mob.threatByActor, source.actorId);
|
||||||
@@ -5868,10 +5870,15 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
|||||||
if (id === PLAYER_AURA_ENTITY_ID) {
|
if (id === PLAYER_AURA_ENTITY_ID) {
|
||||||
const restored = state.health > 0 ? 0 : Math.max(1, Math.round(state.maxHealth * fraction));
|
const restored = state.health > 0 ? 0 : Math.max(1, Math.round(state.maxHealth * fraction));
|
||||||
if (restored <= 0) return 0;
|
if (restored <= 0) return 0;
|
||||||
|
const playerActor = state.actors[id];
|
||||||
set({
|
set({
|
||||||
health: restored,
|
health: restored,
|
||||||
controlledUntil: 0,
|
controlledUntil: 0,
|
||||||
controlMechanic: null,
|
controlMechanic: null,
|
||||||
|
playerAnimationEvent: nextAnimationEvent("ability-cancel"),
|
||||||
|
actors: playerActor ? { ...state.actors, [id]: {
|
||||||
|
...playerActor, health: restored, alive: true, controlledUntil: 0, cast: null,
|
||||||
|
} } : state.actors,
|
||||||
feedback: nextFeedback("heal", `Resurrected with ${restored} health.`, { amount: restored, abilityId }),
|
feedback: nextFeedback("heal", `Resurrected with ${restored} health.`, { amount: restored, abilityId }),
|
||||||
combatEvents: recordCombatEvent(state.combatEvents, {
|
combatEvents: recordCombatEvent(state.combatEvents, {
|
||||||
occurredAt: now,
|
occurredAt: now,
|
||||||
@@ -6095,16 +6102,16 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
|||||||
const state = get();
|
const state = get();
|
||||||
if (!state.actors[sourceActorId]) return false;
|
if (!state.actors[sourceActorId]) return false;
|
||||||
if (effect.kind === "dot" && (!state.mobs[targetActorId] || state.mobs[targetActorId].dead)) return false;
|
if (effect.kind === "dot" && (!state.mobs[targetActorId] || state.mobs[targetActorId].dead)) return false;
|
||||||
if (effect.kind === "hot" && !state.actors[targetActorId]) return false;
|
if (effect.kind === "hot") {
|
||||||
|
const targetHealth = targetActorId === PLAYER_AURA_ENTITY_ID ? state.health
|
||||||
|
: state.summons.find((summon) => summon.id === targetActorId)?.health
|
||||||
|
?? state.actors[targetActorId]?.health ?? 0;
|
||||||
|
if (!state.actors[targetActorId] || targetHealth <= 0) return false;
|
||||||
|
}
|
||||||
const sourceActor = state.actors[sourceActorId];
|
const sourceActor = state.actors[sourceActorId];
|
||||||
const sourceMember = usePartyStore.getState().members.find((member) => member.id === sourceActorId);
|
const sourceMember = usePartyStore.getState().members.find((member) => member.id === sourceActorId);
|
||||||
const amount = sourceMember
|
const amount = sourceMember
|
||||||
? Math.max(1, Math.round((effect.basePoints ?? 0) + Math.max(0.1, effect.coefficient) * (
|
? equipmentAdjustedCombatAmount(effect, sourceMember.level, sourceMember.gearStats, sourceMember.classId, ability)
|
||||||
sourceMember.level * 4
|
|
||||||
+ sourceMember.gearStats.spellPower
|
|
||||||
+ sourceMember.gearStats.attackPower * 0.35
|
|
||||||
+ sourceMember.gearStats.rangedAttackPower * 0.35
|
|
||||||
)))
|
|
||||||
: Math.max(1, Math.round((effect.basePoints ?? 0) + Math.max(0.1, effect.coefficient) * sourceActor.level * 4));
|
: Math.max(1, Math.round((effect.basePoints ?? 0) + Math.max(0.1, effect.coefficient) * sourceActor.level * 4));
|
||||||
const next = state.effects.filter((entry) => !(
|
const next = state.effects.filter((entry) => !(
|
||||||
entry.kind === effect.kind
|
entry.kind === effect.kind
|
||||||
@@ -6396,16 +6403,65 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
|||||||
const restored = Math.max(1, Math.round(
|
const restored = Math.max(1, Math.round(
|
||||||
state.maxHealth * Math.max(0.01, Math.min(1, percentMaxHealth)),
|
state.maxHealth * Math.max(0.01, Math.min(1, percentMaxHealth)),
|
||||||
));
|
));
|
||||||
|
const playerActor = state.actors[PLAYER_AURA_ENTITY_ID];
|
||||||
set({
|
set({
|
||||||
health: restored,
|
health: restored,
|
||||||
controlledUntil: 0,
|
controlledUntil: 0,
|
||||||
controlMechanic: null,
|
controlMechanic: null,
|
||||||
playerAnimationEvent: nextAnimationEvent("ability-cancel"),
|
playerAnimationEvent: nextAnimationEvent("ability-cancel"),
|
||||||
|
actors: playerActor ? { ...state.actors, [PLAYER_AURA_ENTITY_ID]: {
|
||||||
|
...playerActor, health: restored, alive: true, controlledUntil: 0, cast: null,
|
||||||
|
} } : state.actors,
|
||||||
feedback: nextFeedback("heal", `Resurrected with ${restored} health.`, { amount: restored }),
|
feedback: nextFeedback("heal", `Resurrected with ${restored} health.`, { amount: restored }),
|
||||||
});
|
});
|
||||||
return restored;
|
return restored;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
respawnPlayerOnly: (position, now = combatNow()) => {
|
||||||
|
const state = get();
|
||||||
|
if (state.health > 0 || state.maxHealth <= 0 || !Number.isFinite(now)
|
||||||
|
|| position.length !== 3 || !position.every(Number.isFinite)) return false;
|
||||||
|
const auras = syncPassiveAuras(state.auras.filter((aura) => (
|
||||||
|
aura.targetId !== PLAYER_AURA_ENTITY_ID || aura.definition.disposition === "buff"
|
||||||
|
)), state.abilities, state.level, state.talentRanks, now);
|
||||||
|
const maxHealth = auraAdjustedPlayerMaxHealth(state.classId, state.level, state.talentModifiers,
|
||||||
|
state.gearStats, state.raceId, state.secondaryClassId, auras);
|
||||||
|
const patch: Partial<CombatState> = {
|
||||||
|
health: maxHealth,
|
||||||
|
maxHealth,
|
||||||
|
resource: initialResourceForMaximum(state.classId, state.maxResource),
|
||||||
|
resourcePools: Object.fromEntries(Object.entries(state.maxResourcePools).map(([type, maximum]) => [
|
||||||
|
type, type === resourceProfileForClass(state.classId).type
|
||||||
|
? initialResourceForMaximum(state.classId, maximum ?? state.maxResource) : 0,
|
||||||
|
])),
|
||||||
|
controlledUntil: 0,
|
||||||
|
controlMechanic: null,
|
||||||
|
schoolLockedUntil: {},
|
||||||
|
shield: 0,
|
||||||
|
shieldExpiresAt: 0,
|
||||||
|
activeCast: null,
|
||||||
|
pendingGroundTarget: null,
|
||||||
|
autoAttackTargetId: null,
|
||||||
|
nextAutoAttackAt: 0,
|
||||||
|
comboPoints: 0,
|
||||||
|
comboTargetId: null,
|
||||||
|
playerPosition: [...position],
|
||||||
|
playerAnimationEvent: nextAnimationEvent("ability-cancel"),
|
||||||
|
effects: state.effects.filter((effect) => effect.targetId !== null && effect.targetId !== PLAYER_AURA_ENTITY_ID),
|
||||||
|
auras,
|
||||||
|
combatEvents: recordCombatEvent(state.combatEvents, {
|
||||||
|
occurredAt: now, kind: "resurrection", sourceActorId: PLAYER_AURA_ENTITY_ID,
|
||||||
|
targetActorId: PLAYER_AURA_ENTITY_ID, abilityId: "player-checkpoint-resurrection",
|
||||||
|
rawAmount: maxHealth, effectiveAmount: maxHealth, overheal: 0, absorbed: 0, critical: false,
|
||||||
|
}),
|
||||||
|
feedback: nextFeedback("success", "You have revived. Your party is still fighting; use Recall only if you want to regroup."),
|
||||||
|
};
|
||||||
|
// Publish only the player snapshot; never replace or mirror companion/enemy actors.
|
||||||
|
const player = combatActorSnapshots({ ...state, ...patch })[PLAYER_AURA_ENTITY_ID];
|
||||||
|
set({ ...patch, actors: { ...state.actors, [PLAYER_AURA_ENTITY_ID]: player } });
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
|
||||||
cancelCast: (message = "Casting interrupted.") => {
|
cancelCast: (message = "Casting interrupted.") => {
|
||||||
const cast = get().activeCast;
|
const cast = get().activeCast;
|
||||||
if (!cast) return false;
|
if (!cast) return false;
|
||||||
@@ -6441,7 +6497,8 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
|||||||
let engagedGroups = 0;
|
let engagedGroups = 0;
|
||||||
for (const id of Object.keys(mobs)) {
|
for (const id of Object.keys(mobs)) {
|
||||||
const mob = mobs[id];
|
const mob = mobs[id];
|
||||||
if (mob.dead || mob.despawned || mob.engaged || mob.aggroRange <= 0) continue;
|
if (mob.dead || mob.despawned || mob.engaged || mob.aggroRange <= 0
|
||||||
|
|| mob.combatPhase === "returning" || (mob.navigationEvadeUntil ?? 0) > now) continue;
|
||||||
const position = getMobPosition(id);
|
const position = getMobPosition(id);
|
||||||
if (!position) continue;
|
if (!position) continue;
|
||||||
const actorId = nearestAggroActorId(runtimeState, position, mob.aggroRange);
|
const actorId = nearestAggroActorId(runtimeState, position, mob.aggroRange);
|
||||||
@@ -6452,6 +6509,38 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
|||||||
return engagedGroups;
|
return engagedGroups;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
resetMobForNavigation: (id, atHome, now = combatNow()) => {
|
||||||
|
const state = get();
|
||||||
|
const mob = state.mobs[id];
|
||||||
|
if (!mob || mob.dead || mob.despawned || !Number.isFinite(now)) return false;
|
||||||
|
set({
|
||||||
|
mobs: { ...state.mobs, [id]: {
|
||||||
|
...mob,
|
||||||
|
engaged: false,
|
||||||
|
combatPhase: atHome ? "idle" : "returning",
|
||||||
|
navigationEvadeUntil: Math.max(mob.navigationEvadeUntil ?? 0, now + 10_000),
|
||||||
|
health: mob.maxHealth,
|
||||||
|
statuses: [],
|
||||||
|
controlDiminishingReturns: {},
|
||||||
|
tauntDiminishingReturns: { applications: 0, resetAt: 0 },
|
||||||
|
auras: [],
|
||||||
|
abilityReadyAt: {},
|
||||||
|
abilityCastCounts: {},
|
||||||
|
activeCast: null,
|
||||||
|
schoolLockedUntil: {},
|
||||||
|
nextAttackAt: 0,
|
||||||
|
nextAttackIndex: 0,
|
||||||
|
threatByActor: {},
|
||||||
|
targetActorId: null,
|
||||||
|
forcedTarget: null,
|
||||||
|
} },
|
||||||
|
effects: state.effects.filter((effect) => effect.targetId !== id && effect.sourceActorId !== id),
|
||||||
|
auras: state.auras.filter((aura) => aura.targetId !== id && aura.sourceId !== id),
|
||||||
|
areaEffects: state.areaEffects.filter((area) => area.sourceActorId !== id),
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
|
||||||
advanceMobCombat: (now = combatNow(), playerPosition) => {
|
advanceMobCombat: (now = combatNow(), playerPosition) => {
|
||||||
const state = get();
|
const state = get();
|
||||||
const runtimeState = playerPosition
|
const runtimeState = playerPosition
|
||||||
@@ -6527,6 +6616,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
|||||||
const dy = position[1] - targetPosition[1];
|
const dy = position[1] - targetPosition[1];
|
||||||
const dz = position[2] - targetPosition[2];
|
const dz = position[2] - targetPosition[2];
|
||||||
const distanceSquared = dx * dx + dy * dy + dz * dz;
|
const distanceSquared = dx * dx + dy * dy + dz * dz;
|
||||||
|
const targetVisible = combatHasLineOfSight(position, targetPosition);
|
||||||
const abilityReadyAt = Object.keys(mob.abilityReadyAt).length
|
const abilityReadyAt = Object.keys(mob.abilityReadyAt).length
|
||||||
? { ...mob.abilityReadyAt }
|
? { ...mob.abilityReadyAt }
|
||||||
: { ...initialEnemyAbilityReadyAt(mob, now) };
|
: { ...initialEnemyAbilityReadyAt(mob, now) };
|
||||||
@@ -6537,6 +6627,20 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
|||||||
let enemyActiveCast = cannotAttack || (silenced && mob.activeCast?.school !== "physical")
|
let enemyActiveCast = cannotAttack || (silenced && mob.activeCast?.school !== "physical")
|
||||||
? null
|
? null
|
||||||
: mob.activeCast;
|
: mob.activeCast;
|
||||||
|
if (enemyActiveCast) {
|
||||||
|
const pendingAttack = mob.attacks.find((attack) => attack.id === enemyActiveCast!.attackId);
|
||||||
|
const pendingPosition = enemyActiveCast.targetActorId
|
||||||
|
? actorPosition(runtimeState, enemyActiveCast.targetActorId) : null;
|
||||||
|
if (pendingAttack && enemyAbilityNeedsHostileRange(pendingAttack)
|
||||||
|
&& (!pendingPosition || !combatHasLineOfSight(position, pendingPosition)
|
||||||
|
|| Math.hypot(position[0] - pendingPosition[0], position[1] - pendingPosition[1],
|
||||||
|
position[2] - pendingPosition[2]) > pendingAttack.range)) {
|
||||||
|
emitEnemyAttackPresentation(mob, pendingAttack, "cast-cancel", now, position,
|
||||||
|
enemyActiveCast.targetActorId, pendingPosition);
|
||||||
|
abilityReadyAt[pendingAttack.id] = now + 250;
|
||||||
|
enemyActiveCast = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
let completingEnemyCast = false;
|
let completingEnemyCast = false;
|
||||||
let selectedAttackIndex = -1;
|
let selectedAttackIndex = -1;
|
||||||
let selectedAttack: EnemyAttackDefinition | null = null;
|
let selectedAttack: EnemyAttackDefinition | null = null;
|
||||||
@@ -6551,7 +6655,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
|||||||
const attack = mob.attacks[index];
|
const attack = mob.attacks[index];
|
||||||
const attackKind = enemyAttackKind(attack);
|
const attackKind = enemyAttackKind(attack);
|
||||||
const inAbilityRange = !enemyAbilityNeedsHostileRange(attack)
|
const inAbilityRange = !enemyAbilityNeedsHostileRange(attack)
|
||||||
|| distanceSquared <= attack.range * attack.range;
|
|| (targetVisible && distanceSquared <= attack.range * attack.range);
|
||||||
if (
|
if (
|
||||||
inAbilityRange
|
inAbilityRange
|
||||||
&& !(silenced && attackKind === "spell")
|
&& !(silenced && attackKind === "spell")
|
||||||
@@ -6567,10 +6671,24 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const inRange = mob.attacks.some((attack) => (
|
// A self buff/heal must not pin an otherwise blocked attacker in place.
|
||||||
!enemyAbilityNeedsHostileRange(attack)
|
const hostileAttacks = mob.attacks.filter(enemyAbilityNeedsHostileRange);
|
||||||
|| distanceSquared <= attack.range * attack.range
|
const inRange = targetVisible && (hostileAttacks.length
|
||||||
));
|
? hostileAttacks.some((attack) => distanceSquared <= attack.range * attack.range)
|
||||||
|
: distanceSquared <= mob.attackRange * mob.attackRange);
|
||||||
|
if (selectedAttack && completingEnemyCast && enemyAbilityNeedsHostileRange(selectedAttack)) {
|
||||||
|
const castTargetPosition = enemyActiveCast?.targetActorId
|
||||||
|
? actorPosition(runtimeState, enemyActiveCast.targetActorId) : null;
|
||||||
|
if (!castTargetPosition || !combatHasLineOfSight(position, castTargetPosition)
|
||||||
|
|| Math.hypot(position[0] - castTargetPosition[0], position[1] - castTargetPosition[1],
|
||||||
|
position[2] - castTargetPosition[2]) > selectedAttack.range) {
|
||||||
|
emitEnemyAttackPresentation(mob, selectedAttack, "cast-cancel", now, position,
|
||||||
|
enemyActiveCast?.targetActorId ?? resolution.targetActorId, castTargetPosition);
|
||||||
|
abilityReadyAt[selectedAttack.id] = now + 250;
|
||||||
|
enemyActiveCast = null;
|
||||||
|
selectedAttack = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
const phase: MobCombatState["combatPhase"] = inRange ? "attacking" : rooted ? "idle" : "chasing";
|
const phase: MobCombatState["combatPhase"] = inRange ? "attacking" : rooted ? "idle" : "chasing";
|
||||||
let nextAttackAt = Math.min(...Object.values(abilityReadyAt), Number.POSITIVE_INFINITY);
|
let nextAttackAt = Math.min(...Object.values(abilityReadyAt), Number.POSITIVE_INFINITY);
|
||||||
let attackRevision = mob.attackRevision;
|
let attackRevision = mob.attackRevision;
|
||||||
@@ -7510,7 +7628,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
|||||||
combatEvents: [...state.combatEvents],
|
combatEvents: [...state.combatEvents],
|
||||||
actors: { ...state.actors },
|
actors: { ...state.actors },
|
||||||
cooldowns: { ...state.cooldowns },
|
cooldowns: { ...state.cooldowns },
|
||||||
health: Math.min(
|
health: state.health <= 0 ? 0 : Math.min(
|
||||||
state.maxHealth,
|
state.maxHealth,
|
||||||
state.health + resolveAuraStatValue(
|
state.health + resolveAuraStatValue(
|
||||||
state.gearStats.healthPer5 / 5 + state.gearStats.spirit * 0.01,
|
state.gearStats.healthPer5 / 5 + state.gearStats.spirit * 0.01,
|
||||||
@@ -7639,10 +7757,24 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const timedEffectTargetAlive = (effect: TimedCombatEffect): boolean => {
|
||||||
|
if (effect.kind === "dot") {
|
||||||
|
const mob = effect.targetId ? work.mobs[effect.targetId] : null;
|
||||||
|
return Boolean(mob && !mob.dead && mob.health > 0);
|
||||||
|
}
|
||||||
|
const targetId = effect.targetId ?? PLAYER_AURA_ENTITY_ID;
|
||||||
|
if (targetId === PLAYER_AURA_ENTITY_ID) return work.health > 0;
|
||||||
|
const summon = work.summons.find((candidate) => candidate.id === targetId);
|
||||||
|
if (summon) return summon.health > 0;
|
||||||
|
const actor = ensureCompanionActorInWork(work, targetId) ?? work.actors[targetId];
|
||||||
|
return Boolean(actor?.alive && actor.health > 0);
|
||||||
|
};
|
||||||
for (const effect of state.effects) {
|
for (const effect of state.effects) {
|
||||||
|
// Check before due ticks/procs, not only after applying their health changes.
|
||||||
|
if (!timedEffectTargetAlive(effect)) continue;
|
||||||
let remainingTicks = effect.remainingTicks;
|
let remainingTicks = effect.remainingTicks;
|
||||||
let nextTickAt = effect.nextTickAt;
|
let nextTickAt = effect.nextTickAt;
|
||||||
while (remainingTicks > 0 && nextTickAt <= now) {
|
while (remainingTicks > 0 && nextTickAt <= now && timedEffectTargetAlive(effect)) {
|
||||||
const ability = abilityById(effect.sourceAbilityId);
|
const ability = abilityById(effect.sourceAbilityId);
|
||||||
const baseAmount = effect.fixedAmount ?? (ability
|
const baseAmount = effect.fixedAmount ?? (ability
|
||||||
? equipmentAdjustedCombatAmount(
|
? equipmentAdjustedCombatAmount(
|
||||||
@@ -7696,10 +7828,10 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
|||||||
"taken",
|
"taken",
|
||||||
));
|
));
|
||||||
if (effect.targetId) {
|
if (effect.targetId) {
|
||||||
const target = work.actors[effect.targetId]
|
const target = ensureCompanionActorInWork(work, hotTargetId)
|
||||||
?? ensureCompanionActorInWork(work, effect.targetId)
|
?? work.actors[hotTargetId]
|
||||||
?? (work.summons.some((summon) => summon.id === effect.targetId)
|
?? (work.summons.some((summon) => summon.id === hotTargetId)
|
||||||
? combatActorFromSummon(work.summons.find((summon) => summon.id === effect.targetId)!, progression.level, work.auras)
|
? combatActorFromSummon(work.summons.find((summon) => summon.id === hotTargetId)!, progression.level, work.auras)
|
||||||
: null);
|
: null);
|
||||||
if (target?.alive) partyHotTicks.push({
|
if (target?.alive) partyHotTicks.push({
|
||||||
memberId: target.id,
|
memberId: target.id,
|
||||||
@@ -7708,9 +7840,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
|||||||
sourceAbilityId: effect.sourceAbilityId,
|
sourceAbilityId: effect.sourceAbilityId,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
const healed = Math.min(modifiedHealing, work.maxHealth - work.health);
|
const healed = healPlayerInWork(work, modifiedHealing);
|
||||||
work.health += healed;
|
|
||||||
work.progression.health = work.health;
|
|
||||||
work.totalHealing += healed;
|
work.totalHealing += healed;
|
||||||
addHealingThreatInWork(
|
addHealingThreatInWork(
|
||||||
work,
|
work,
|
||||||
@@ -7755,12 +7885,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
|||||||
remainingTicks -= 1;
|
remainingTicks -= 1;
|
||||||
nextTickAt += effect.intervalMs;
|
nextTickAt += effect.intervalMs;
|
||||||
}
|
}
|
||||||
const targetAlive = effect.kind === "dot"
|
if (remainingTicks > 0 && timedEffectTargetAlive(effect)) {
|
||||||
? Boolean(effect.targetId && !work.mobs[effect.targetId]?.dead)
|
|
||||||
: !effect.targetId || usePartyStore.getState().members.some(
|
|
||||||
(member) => member.id === effect.targetId && member.health > 0,
|
|
||||||
);
|
|
||||||
if (remainingTicks > 0 && targetAlive) {
|
|
||||||
work.effects.push(
|
work.effects.push(
|
||||||
remainingTicks === effect.remainingTicks && nextTickAt === effect.nextTickAt
|
remainingTicks === effect.remainingTicks && nextTickAt === effect.nextTickAt
|
||||||
? effect
|
? effect
|
||||||
@@ -7816,13 +7941,10 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
|||||||
|| Math.sqrt(distanceSquared(area.position, position)) > area.radius
|
|| Math.sqrt(distanceSquared(area.position, position)) > area.radius
|
||||||
|| !combatHasLineOfSight(area.position, position)) continue;
|
|| !combatHasLineOfSight(area.position, position)) continue;
|
||||||
const member = partyMembers.find((candidate) => candidate.id === friendlyId);
|
const member = partyMembers.find((candidate) => candidate.id === friendlyId);
|
||||||
|
if (member ? member.health <= 0 : work.health <= 0) continue;
|
||||||
const healed = member
|
const healed = member
|
||||||
? healCompanionActorInWork(work, friendlyId, amount)
|
? healCompanionActorInWork(work, friendlyId, amount)
|
||||||
: Math.min(amount, work.maxHealth - work.health);
|
: healPlayerInWork(work, amount);
|
||||||
if (!member) {
|
|
||||||
work.health += healed;
|
|
||||||
work.progression.health = work.health;
|
|
||||||
}
|
|
||||||
work.totalHealing += healed;
|
work.totalHealing += healed;
|
||||||
recordCombatEventInWork(work, {
|
recordCombatEventInWork(work, {
|
||||||
occurredAt: nextTickAt,
|
occurredAt: nextTickAt,
|
||||||
@@ -7873,12 +7995,8 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
|||||||
const target = injured[0];
|
const target = injured[0];
|
||||||
if (target) {
|
if (target) {
|
||||||
const healed = target.id === PLAYER_AURA_ENTITY_ID
|
const healed = target.id === PLAYER_AURA_ENTITY_ID
|
||||||
? Math.min(summon.attackDamage, work.maxHealth - work.health)
|
? healPlayerInWork(work, summon.attackDamage)
|
||||||
: healCompanionActorInWork(work, target.id, summon.attackDamage);
|
: healCompanionActorInWork(work, target.id, summon.attackDamage);
|
||||||
if (target.id === PLAYER_AURA_ENTITY_ID) {
|
|
||||||
work.health += healed;
|
|
||||||
work.progression.health = work.health;
|
|
||||||
}
|
|
||||||
work.totalHealing += healed;
|
work.totalHealing += healed;
|
||||||
addHealingThreatInWork(work, { actorId: summon.ownerActorId, role: "healer", abilityId: summon.sourceAbilityId }, healed);
|
addHealingThreatInWork(work, { actorId: summon.ownerActorId, role: "healer", abilityId: summon.sourceAbilityId }, healed);
|
||||||
recordCombatEventInWork(work, {
|
recordCombatEventInWork(work, {
|
||||||
@@ -8078,30 +8196,31 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
|||||||
: "Channel interrupted: target unavailable.");
|
: "Channel interrupted: target unavailable.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const damageAmount = ability.effects.reduce(
|
const channelEffects = channelAmountEffects(ability, cast.totalTicks);
|
||||||
(total, effect) => total + (effect.kind === "damage" || effect.kind === "dot"
|
const damageAmount = channelEffects.reduce(
|
||||||
|
(total, { effect, multiplier }) => total + (effect.kind === "damage" || effect.kind === "dot"
|
||||||
? equipmentAdjustedCombatAmount(
|
? equipmentAdjustedCombatAmount(
|
||||||
effect,
|
effect,
|
||||||
get().level,
|
get().level,
|
||||||
get().gearStats,
|
get().gearStats,
|
||||||
get().classId,
|
get().classId,
|
||||||
ability,
|
ability,
|
||||||
) * (effect.kind === "dot" ? effect.ticks : 1)
|
) * multiplier
|
||||||
: 0),
|
: 0),
|
||||||
0,
|
0,
|
||||||
) / cast.totalTicks;
|
);
|
||||||
const healingAmount = ability.effects.reduce(
|
const healingAmount = channelEffects.reduce(
|
||||||
(total, effect) => total + (effect.kind === "heal" || effect.kind === "hot"
|
(total, { effect, multiplier }) => total + (effect.kind === "heal" || effect.kind === "hot"
|
||||||
? equipmentAdjustedCombatAmount(
|
? equipmentAdjustedCombatAmount(
|
||||||
effect,
|
effect,
|
||||||
get().level,
|
get().level,
|
||||||
get().gearStats,
|
get().gearStats,
|
||||||
get().classId,
|
get().classId,
|
||||||
ability,
|
ability,
|
||||||
) * (effect.kind === "hot" ? effect.ticks : 1)
|
) * multiplier
|
||||||
: 0),
|
: 0),
|
||||||
0,
|
0,
|
||||||
) / cast.totalTicks;
|
);
|
||||||
if (damageAmount > 0) {
|
if (damageAmount > 0) {
|
||||||
const channelCenter = channelHostile ? targetPosition ?? cast.origin : get().playerPosition;
|
const channelCenter = channelHostile ? targetPosition ?? cast.origin : get().playerPosition;
|
||||||
const channelTargets = ability.radius === undefined
|
const channelTargets = ability.radius === undefined
|
||||||
|
|||||||
@@ -0,0 +1,238 @@
|
|||||||
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { abilityAtLevel } from "./abilityCatalog";
|
||||||
|
import { applyAura } from "./combatAuras";
|
||||||
|
import { PLAYER_AURA_ENTITY_ID, useCombatStore, type TimedCombatEffect } from "./combatStore";
|
||||||
|
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
|
||||||
|
import { clearPartyRuntimeRegistry, registerPartyRuntimePosition } from "./partyRuntimeRegistry";
|
||||||
|
import { usePartyStore } from "./partyStore";
|
||||||
|
import { respawnDefeatedPlayer } from "./deathRespawn";
|
||||||
|
import { useGameStore } from "./store";
|
||||||
|
|
||||||
|
function killPlayer(): void {
|
||||||
|
useCombatStore.getState().damagePlayer(useCombatStore.getState().maxHealth * 10);
|
||||||
|
expect(useCombatStore.getState().health).toBe(0);
|
||||||
|
expect(useCombatStore.getState().playerAnimationEvent?.kind).toBe("death");
|
||||||
|
}
|
||||||
|
|
||||||
|
function selfHot(): TimedCombatEffect {
|
||||||
|
expect(useCombatStore.getState().castAbility("druid-rejuvenation", undefined, 1_000).ok).toBe(true);
|
||||||
|
const effect = useCombatStore.getState().effects.find((entry) => entry.kind === "hot" && entry.targetId === null);
|
||||||
|
expect(effect).toBeDefined();
|
||||||
|
return effect!;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setMemberHealth(id: string, health: number): void {
|
||||||
|
const member = usePartyStore.getState().members.find((candidate) => candidate.id === id)!;
|
||||||
|
useCombatStore.getState().commitCompanionActors([{ ...member, health, status: health <= 0 ? "down" : "ready" }]);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("healing must not implicitly resurrect defeated players", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
clearMobRuntimeRegistry();
|
||||||
|
clearPartyRuntimeRegistry();
|
||||||
|
useCombatStore.getState().initializeCharacter({ classId: "druid", level: 1, xp: 0 });
|
||||||
|
usePartyStore.getState().initializeParty("death-healing", 1, useCombatStore.getState().health);
|
||||||
|
usePartyStore.getState().selectMember(null);
|
||||||
|
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
|
||||||
|
useCombatStore.getState().synchronizeActors();
|
||||||
|
useGameStore.getState().resetAtEntrance();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reproduces death with a self HoT and rejects every overdue tick", () => {
|
||||||
|
const effect = selfHot();
|
||||||
|
killPlayer();
|
||||||
|
const deathEvent = useCombatStore.getState().playerAnimationEvent;
|
||||||
|
useCombatStore.getState().tick(0.1, effect.nextTickAt + effect.intervalMs * 10);
|
||||||
|
expect(useCombatStore.getState().health).toBe(0);
|
||||||
|
expect(useCombatStore.getState().playerAnimationEvent).toBe(deathEvent);
|
||||||
|
expect(useCombatStore.getState().effects).toHaveLength(0);
|
||||||
|
expect(useCombatStore.getState().castAbility("druid-rejuvenation", undefined, 100_000))
|
||||||
|
.toMatchObject({ ok: false, reason: "player-dead" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes a dead player's HoT even before its next tick is due", () => {
|
||||||
|
const effect = selfHot();
|
||||||
|
killPlayer();
|
||||||
|
useCombatStore.getState().tick(0, effect.nextTickAt - 1);
|
||||||
|
expect(useCombatStore.getState().health).toBe(0);
|
||||||
|
expect(useCombatStore.getState().effects).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects the actual Priest Renew tick after lethal damage", () => {
|
||||||
|
useCombatStore.getState().initializeCharacter({ classId: "priest", level: 14 });
|
||||||
|
const renew = useCombatStore.getState().abilities.find((ability) => ability.name === "Renew"
|
||||||
|
&& ability.effects.some((effect) => effect.kind === "hot"));
|
||||||
|
expect(renew).toBeDefined();
|
||||||
|
expect(useCombatStore.getState().castAbility(renew!.id, undefined, 1_000).ok).toBe(true);
|
||||||
|
const effect = useCombatStore.getState().effects.find((entry) => entry.kind === "hot")!;
|
||||||
|
killPlayer();
|
||||||
|
useCombatStore.getState().tick(0.1, effect.nextTickAt);
|
||||||
|
expect(useCombatStore.getState().health).toBe(0);
|
||||||
|
expect(useCombatStore.getState().effects).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not let passive spirit or equipment regeneration heal a corpse", () => {
|
||||||
|
const state = useCombatStore.getState();
|
||||||
|
useCombatStore.setState({ gearStats: { ...state.gearStats, healthPer5: 500, spirit: 500 } });
|
||||||
|
killPlayer();
|
||||||
|
for (let frame = 0; frame < 30; frame += 1) {
|
||||||
|
useCombatStore.getState().tick(0.1, 10_000 + frame * 100);
|
||||||
|
expect(useCombatStore.getState().health).toBe(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects companion HoTs with an explicit player target after death", () => {
|
||||||
|
const effect = selfHot();
|
||||||
|
const source = usePartyStore.getState().members[0].id;
|
||||||
|
useCombatStore.setState({ effects: [{ ...effect, sourceActorId: source, targetId: PLAYER_AURA_ENTITY_ID }] });
|
||||||
|
killPlayer();
|
||||||
|
useCombatStore.getState().tick(0.1, effect.nextTickAt);
|
||||||
|
expect(useCombatStore.getState().health).toBe(0);
|
||||||
|
expect(useCombatStore.getState().effects).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("living explicit player HoTs retain future ticks and healing combat events", () => {
|
||||||
|
const effect = selfHot();
|
||||||
|
const source = usePartyStore.getState().members[0].id;
|
||||||
|
useCombatStore.setState({ effects: [{ ...effect, sourceActorId: source, targetId: PLAYER_AURA_ENTITY_ID }] });
|
||||||
|
useCombatStore.getState().damagePlayer(50);
|
||||||
|
const before = useCombatStore.getState().health;
|
||||||
|
useCombatStore.getState().tick(0, effect.nextTickAt);
|
||||||
|
expect(useCombatStore.getState().health).toBeGreaterThan(before);
|
||||||
|
expect(useCombatStore.getState().effects).toHaveLength(1);
|
||||||
|
expect(useCombatStore.getState().combatEvents.some((event) => event.kind === "healing"
|
||||||
|
&& event.sourceActorId === source && event.targetActorId === PLAYER_AURA_ENTITY_ID
|
||||||
|
&& event.effectiveAmount > 0)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects newly applied teammate HoTs on dead targets", () => {
|
||||||
|
const ability = abilityAtLevel(useCombatStore.getState().abilities.find((entry) => entry.id === "druid-rejuvenation")!, 1);
|
||||||
|
const hot = ability.effects.find((effect) => effect.kind === "hot")!;
|
||||||
|
expect(hot.kind).toBe("hot");
|
||||||
|
if (hot.kind !== "hot") throw new Error("Expected Rejuvenation HoT");
|
||||||
|
useCombatStore.getState().synchronizeActors();
|
||||||
|
const source = usePartyStore.getState().members[0].id;
|
||||||
|
killPlayer();
|
||||||
|
expect(useCombatStore.getState().applyTimedActorEffect(source, PLAYER_AURA_ENTITY_ID, ability, hot, 1_000)).toBe(false);
|
||||||
|
const member = usePartyStore.getState().members[1];
|
||||||
|
setMemberHealth(member.id, 0);
|
||||||
|
expect(useCombatStore.getState().applyTimedActorEffect(source, member.id, ability, hot, 1_000)).toBe(false);
|
||||||
|
expect(useCombatStore.getState().effects).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes a fallen ally's HoT without changing other party members", () => {
|
||||||
|
const member = usePartyStore.getState().members[0];
|
||||||
|
registerPartyRuntimePosition(member.id, [2, 0, 0]);
|
||||||
|
usePartyStore.getState().selectMember(member.id);
|
||||||
|
expect(useCombatStore.getState().castAbility("druid-rejuvenation", undefined, 1_000).ok).toBe(true);
|
||||||
|
const effect = useCombatStore.getState().effects[0];
|
||||||
|
setMemberHealth(member.id, 0);
|
||||||
|
const others = usePartyStore.getState().members.slice(1);
|
||||||
|
useCombatStore.getState().tick(0, effect.nextTickAt);
|
||||||
|
expect(usePartyStore.getState().members[0].health).toBe(0);
|
||||||
|
expect(usePartyStore.getState().members.slice(1)).toEqual(others);
|
||||||
|
expect(useCombatStore.getState().effects).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ground healing skips the dead player while continuing to heal living allies", () => {
|
||||||
|
const effect = selfHot();
|
||||||
|
const member = usePartyStore.getState().members[0];
|
||||||
|
registerPartyRuntimePosition(member.id, [0, 0, 0]);
|
||||||
|
setMemberHealth(member.id, 1);
|
||||||
|
useCombatStore.setState({ effects: [], areaEffects: [{
|
||||||
|
id: "healing-ground", sourceActorId: member.id, sourceAbilityId: effect.sourceAbilityId,
|
||||||
|
position: [0, 0, 0], radius: 10, createdAt: 1_000, expiresAt: 20_000,
|
||||||
|
nextTickAt: 4_000, tickIntervalMs: 3_000, fixedHealingPerTick: 50,
|
||||||
|
}] });
|
||||||
|
killPlayer();
|
||||||
|
useCombatStore.getState().tick(0.1, 4_000);
|
||||||
|
expect(useCombatStore.getState().health).toBe(0);
|
||||||
|
expect(usePartyStore.getState().members[0].health).toBeGreaterThan(1);
|
||||||
|
expect(useCombatStore.getState().combatEvents.some((event) => event.kind === "healing"
|
||||||
|
&& event.targetActorId === PLAYER_AURA_ENTITY_ID && event.effectiveAmount > 0)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a HoT on a living ally continues even when its player caster dies", () => {
|
||||||
|
const member = usePartyStore.getState().members[0];
|
||||||
|
registerPartyRuntimePosition(member.id, [2, 0, 0]);
|
||||||
|
setMemberHealth(member.id, 1);
|
||||||
|
usePartyStore.getState().selectMember(member.id);
|
||||||
|
expect(useCombatStore.getState().castAbility("druid-rejuvenation", undefined, 1_000).ok).toBe(true);
|
||||||
|
const effect = useCombatStore.getState().effects[0];
|
||||||
|
killPlayer();
|
||||||
|
useCombatStore.getState().tick(0.1, effect.nextTickAt);
|
||||||
|
expect(useCombatStore.getState().health).toBe(0);
|
||||||
|
expect(usePartyStore.getState().members[0].health).toBeGreaterThan(1);
|
||||||
|
expect(useCombatStore.getState().effects).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("periodic damage cannot proc an ordinary self heal after its caster dies", () => {
|
||||||
|
useCombatStore.getState().registerMob("dot-target", { name: "Target", maxHealth: 1_000, xpReward: 0 });
|
||||||
|
setMobPosition("dot-target", [2, 0, 0]);
|
||||||
|
const aura = applyAura([], { sourceId: PLAYER_AURA_ENTITY_ID, targetId: PLAYER_AURA_ENTITY_ID,
|
||||||
|
now: 1_000, definition: {
|
||||||
|
id: "periodic-heal-proc", name: "Periodic heal", disposition: "buff", durationMs: null,
|
||||||
|
procs: [{ id: "heal", triggers: "periodic", chance: 1,
|
||||||
|
action: { kind: "heal", target: "aura-target", amount: 100 } }],
|
||||||
|
},
|
||||||
|
}).auras;
|
||||||
|
useCombatStore.setState({ auras: [...aura], effects: [{
|
||||||
|
id: "dot", kind: "dot", sourceActorId: PLAYER_AURA_ENTITY_ID,
|
||||||
|
sourceAbilityId: "druid-rejuvenation", targetId: "dot-target", coefficient: 1,
|
||||||
|
fixedAmount: 1, remainingTicks: 3, intervalMs: 3_000, nextTickAt: 4_000,
|
||||||
|
}] });
|
||||||
|
killPlayer();
|
||||||
|
useCombatStore.getState().tick(0, 4_000);
|
||||||
|
expect(useCombatStore.getState().mobs["dot-target"].health).toBeLessThan(1_000);
|
||||||
|
expect(useCombatStore.getState().health).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a lingering damage tick can award a level without resurrecting its dead caster", () => {
|
||||||
|
useCombatStore.getState().registerMob("dot-target", { name: "Target", maxHealth: 1, xpReward: 100_000 });
|
||||||
|
setMobPosition("dot-target", [2, 0, 0]);
|
||||||
|
useCombatStore.setState({ effects: [{
|
||||||
|
id: "dot", kind: "dot", sourceActorId: PLAYER_AURA_ENTITY_ID,
|
||||||
|
sourceAbilityId: "druid-rejuvenation", targetId: "dot-target", coefficient: 1,
|
||||||
|
fixedAmount: 100, remainingTicks: 1, intervalMs: 3_000, nextTickAt: 4_000,
|
||||||
|
}] });
|
||||||
|
killPlayer();
|
||||||
|
useCombatStore.getState().tick(0, 4_000);
|
||||||
|
expect(useCombatStore.getState().level).toBeGreaterThan(1);
|
||||||
|
expect(useCombatStore.getState().health).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("living self HoTs and regeneration still restore health", () => {
|
||||||
|
useCombatStore.getState().damagePlayer(50);
|
||||||
|
const effect = selfHot();
|
||||||
|
const before = useCombatStore.getState().health;
|
||||||
|
useCombatStore.getState().tick(0.1, effect.nextTickAt);
|
||||||
|
expect(useCombatStore.getState().health).toBeGreaterThan(before);
|
||||||
|
expect(useCombatStore.getState().health).toBeLessThanOrEqual(useCombatStore.getState().maxHealth);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("explicit resurrection and normal respawn still work after discarding corpse HoTs", () => {
|
||||||
|
const effect = selfHot();
|
||||||
|
killPlayer();
|
||||||
|
useCombatStore.getState().tick(0.1, effect.nextTickAt);
|
||||||
|
const restored = useCombatStore.getState().revivePlayer(0.35);
|
||||||
|
expect(restored).toBeGreaterThan(0);
|
||||||
|
expect(useCombatStore.getState().playerAnimationEvent?.kind).toBe("ability-cancel");
|
||||||
|
expect(useCombatStore.getState().castAbility("druid-rejuvenation", undefined, 20_000).ok).toBe(true);
|
||||||
|
killPlayer();
|
||||||
|
expect(respawnDefeatedPlayer()).toBe(true);
|
||||||
|
expect(useCombatStore.getState().health).toBe(useCombatStore.getState().maxHealth);
|
||||||
|
expect(useCombatStore.getState().effects).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("actor-based party resurrection clears the corpse animation and actor death state", () => {
|
||||||
|
const effect = selfHot();
|
||||||
|
killPlayer();
|
||||||
|
useCombatStore.getState().synchronizeActors();
|
||||||
|
useCombatStore.getState().tick(0.1, effect.nextTickAt);
|
||||||
|
const restored = useCombatStore.getState().reviveActor(PLAYER_AURA_ENTITY_ID, 0.35,
|
||||||
|
usePartyStore.getState().members[0].id, "party-resurrection", 10_000);
|
||||||
|
expect(restored).toBeGreaterThan(0);
|
||||||
|
expect(useCombatStore.getState().playerAnimationEvent?.kind).toBe("ability-cancel");
|
||||||
|
expect(useCombatStore.getState().actors[PLAYER_AURA_ENTITY_ID]).toMatchObject({ alive: true, health: restored });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,19 +1,40 @@
|
|||||||
import { useCombatStore } from "./combatStore";
|
import { useCombatStore, type MobCombatState } from "./combatStore";
|
||||||
|
import type { CombatActorState } from "./combatActors";
|
||||||
import { requireDungeonDefinition } from "./dungeonRegistry";
|
import { requireDungeonDefinition } from "./dungeonRegistry";
|
||||||
import { useGameStore } from "./store";
|
import { useGameStore } from "./store";
|
||||||
|
import { resolveResurrectionCheckpoint } from "./resurrectionCheckpoints";
|
||||||
|
import { usePartyStore, type PartyMember } from "./partyStore";
|
||||||
import {
|
import {
|
||||||
activeDungeonCompletedObjectiveIds,
|
activeDungeonCompletedObjectiveIds,
|
||||||
resetDungeonEncounters,
|
resetDungeonEncounters,
|
||||||
} from "./dungeonEncounterRuntime";
|
} from "./dungeonEncounterRuntime";
|
||||||
|
|
||||||
/**
|
export function partyFightStillActive(
|
||||||
* Atomically returns a defeated player to the saved run spawn and resets the
|
members: readonly Pick<PartyMember, "id" | "health">[],
|
||||||
* current pull. Defeated enemies and persisted dungeon progress remain clear,
|
mobs: Readonly<Record<string, MobCombatState>>,
|
||||||
* matching a wipe instead of silently starting a brand-new dungeon.
|
actors: Readonly<Record<string, Pick<CombatActorState, "health">>> = {},
|
||||||
*/
|
): boolean {
|
||||||
export function respawnDefeatedPlayer(): 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();
|
const combat = useCombatStore.getState();
|
||||||
if (combat.health > 0) return false;
|
if (combat.health > 0) return false;
|
||||||
|
const game = useGameStore.getState();
|
||||||
|
if (game.gameMode !== "dungeon") return false;
|
||||||
|
const checkpoint = checkpointId === undefined ? null : resolveResurrectionCheckpoint(checkpointId);
|
||||||
|
if (checkpointId !== undefined && !checkpoint) return false;
|
||||||
|
|
||||||
|
if (partyFightStillActive(usePartyStore.getState().members, combat.mobs, combat.actors)) {
|
||||||
|
const spawn = checkpoint?.spawn ?? game.activeSpawn;
|
||||||
|
if (!combat.respawnPlayerOnly(spawn.footPosition)) return false;
|
||||||
|
// Relocation does not fire the resetRevision watched by PartyBridge/PartyPopulation.
|
||||||
|
useGameStore.getState().relocatePlayer(spawn, true);
|
||||||
|
useGameStore.getState().closeOverlay();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
const dungeonId = useGameStore.getState().activeDungeonId;
|
const dungeonId = useGameStore.getState().activeDungeonId;
|
||||||
const completedObjectiveIds = new Set(activeDungeonCompletedObjectiveIds(dungeonId));
|
const completedObjectiveIds = new Set(activeDungeonCompletedObjectiveIds(dungeonId));
|
||||||
@@ -31,6 +52,8 @@ export function respawnDefeatedPlayer(): boolean {
|
|||||||
if (typeof document !== "undefined" && document.pointerLockElement) {
|
if (typeof document !== "undefined" && document.pointerLockElement) {
|
||||||
document.exitPointerLock();
|
document.exitPointerLock();
|
||||||
}
|
}
|
||||||
|
// Select the saved return spawn without relocating first or resetting twice.
|
||||||
|
if (checkpoint) useGameStore.setState({ activeSpawn: checkpoint.spawn });
|
||||||
useGameStore.getState().resetAfterDefeat();
|
useGameStore.getState().resetAfterDefeat();
|
||||||
resetDungeonEncounters("boss-wipe", dungeonId);
|
resetDungeonEncounters("boss-wipe", dungeonId);
|
||||||
const respawnPosition = useGameStore.getState().playerPosition;
|
const respawnPosition = useGameStore.getState().playerPosition;
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { advanceDpsMeter, dpsMeterSnapshot, emptyDpsMeter, recordDpsDamage } from "./dpsMeter";
|
||||||
|
import type { StructuredCombatEvent } from "./combatEvents";
|
||||||
|
|
||||||
|
const actor = { id: "player", name: "You", color: "#fff" };
|
||||||
|
function damage(id: number, effectiveAmount = 10): StructuredCombatEvent {
|
||||||
|
return { id, occurredAt: 1_000 + id * 10, kind: "damage", sourceActorId: actor.id,
|
||||||
|
targetActorId: "enemy", rawAmount: 100, effectiveAmount, overheal: 0, absorbed: 30, critical: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("DPS fight recorder", () => {
|
||||||
|
it("keeps effective damage from more than 200 events and ranks everyone over the same fight duration", () => {
|
||||||
|
let state = advanceDpsMeter(emptyDpsMeter(), 1_000, true);
|
||||||
|
for (let id = 1; id <= 1_000; id++) state = recordDpsDamage(state, damage(id), actor);
|
||||||
|
const ally = { id: "ally", name: "Ally", color: "#f00" };
|
||||||
|
state = recordDpsDamage(state, { ...damage(1_000), effectiveAmount: 5_000 }, ally);
|
||||||
|
const snapshot = dpsMeterSnapshot(state, [{ id: "healer", name: "Healer", color: "#0f0" }]);
|
||||||
|
expect(snapshot).toMatchObject({ totalDamage: 15_000, totalDps: 1_500, durationSeconds: 10 });
|
||||||
|
expect(snapshot.rows.map((row) => [row.id, row.dps, row.percent])).toEqual([
|
||||||
|
["player", 1_000, 100], ["ally", 500, 50], ["healer", 0, 0],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
it("freezes the last fight, starts fresh on the next pull, and excludes menu time", () => {
|
||||||
|
let state = recordDpsDamage(emptyDpsMeter(), damage(0, 100), actor);
|
||||||
|
state = advanceDpsMeter(state, 2_000, true);
|
||||||
|
state = advanceDpsMeter(state, 12_000, true, true);
|
||||||
|
state = advanceDpsMeter(state, 13_000, false);
|
||||||
|
expect(dpsMeterSnapshot(state)).toMatchObject({ active: false, durationSeconds: 2, totalDps: 50 });
|
||||||
|
expect(advanceDpsMeter(state, 30_000, false)).toBe(state);
|
||||||
|
state = recordDpsDamage(state, { ...damage(0, 20), occurredAt: 31_000 }, actor);
|
||||||
|
expect(dpsMeterSnapshot(state)).toMatchObject({ active: true, durationSeconds: 0, totalDamage: 20, totalDps: 20 });
|
||||||
|
});
|
||||||
|
it("ignores misses, healing, invalid damage, and clock regressions", () => {
|
||||||
|
const empty = emptyDpsMeter();
|
||||||
|
expect(recordDpsDamage(empty, damage(0, 0), actor)).toBe(empty);
|
||||||
|
expect(recordDpsDamage(empty, damage(0, NaN), actor)).toBe(empty);
|
||||||
|
expect(recordDpsDamage(empty, { ...damage(0), kind: "healing" }, actor)).toBe(empty);
|
||||||
|
let state = recordDpsDamage(empty, damage(0), actor);
|
||||||
|
state = advanceDpsMeter(state, 900, true);
|
||||||
|
expect(state.elapsedMs).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import type { StructuredCombatEvent } from "./combatEvents";
|
||||||
|
|
||||||
|
export interface DpsMeterActor {
|
||||||
|
readonly id: string;
|
||||||
|
readonly name: string;
|
||||||
|
readonly color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DpsMeterState {
|
||||||
|
readonly startedAt: number | null;
|
||||||
|
readonly endedAt: number | null;
|
||||||
|
readonly updatedAt: number | null;
|
||||||
|
readonly elapsedMs: number;
|
||||||
|
readonly damage: Readonly<Record<string, DpsMeterActor & { total: number }>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DpsMeterRow extends DpsMeterActor {
|
||||||
|
readonly total: number;
|
||||||
|
readonly dps: number;
|
||||||
|
readonly percent: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DpsMeterSnapshot {
|
||||||
|
readonly active: boolean;
|
||||||
|
readonly durationSeconds: number;
|
||||||
|
readonly totalDamage: number;
|
||||||
|
readonly totalDps: number;
|
||||||
|
readonly rows: readonly DpsMeterRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function emptyDpsMeter(): DpsMeterState {
|
||||||
|
return { startedAt: null, endedAt: null, updatedAt: null, elapsedMs: 0, damage: {} };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Keep the last pull visible until the next starts; menus do not add combat time. */
|
||||||
|
export function advanceDpsMeter(state: DpsMeterState, now: number, inCombat: boolean, suspended = false): DpsMeterState {
|
||||||
|
if (!Number.isFinite(now)) return state;
|
||||||
|
if (state.startedAt === null || state.endedAt !== null) {
|
||||||
|
return inCombat ? { ...emptyDpsMeter(), startedAt: now, updatedAt: now } : state;
|
||||||
|
}
|
||||||
|
const updatedAt = Math.max(state.updatedAt ?? now, now);
|
||||||
|
return { ...state, updatedAt,
|
||||||
|
elapsedMs: state.elapsedMs + (suspended ? 0 : updatedAt - (state.updatedAt ?? updatedAt)),
|
||||||
|
endedAt: inCombat ? null : updatedAt };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Consume the lossless stream, never the truncated combat-log history. */
|
||||||
|
export function recordDpsDamage(state: DpsMeterState, event: StructuredCombatEvent, actor: DpsMeterActor): DpsMeterState {
|
||||||
|
if (event.kind !== "damage" || !Number.isFinite(event.effectiveAmount) || event.effectiveAmount <= 0) return state;
|
||||||
|
const next = advanceDpsMeter(state, event.occurredAt, true);
|
||||||
|
return { ...next, damage: { ...next.damage,
|
||||||
|
[actor.id]: { ...actor, total: (next.damage[actor.id]?.total ?? 0) + event.effectiveAmount } } };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dpsMeterSnapshot(state: DpsMeterState, actors: readonly DpsMeterActor[] = []): DpsMeterSnapshot {
|
||||||
|
const durationSeconds = state.elapsedMs / 1_000;
|
||||||
|
const seconds = Math.max(1, durationSeconds);
|
||||||
|
const damage = state.startedAt === null ? {} : {
|
||||||
|
...Object.fromEntries(actors.map((actor) => [actor.id, { ...actor, total: 0 }])), ...state.damage,
|
||||||
|
};
|
||||||
|
const ordered = Object.values(damage).sort((a, b) => b.total - a.total || a.name.localeCompare(b.name));
|
||||||
|
const maximum = Math.max(1, ordered[0]?.total ?? 0);
|
||||||
|
const totalDamage = ordered.reduce((sum, actor) => sum + actor.total, 0);
|
||||||
|
return { active: state.startedAt !== null && state.endedAt === null, durationSeconds, totalDamage,
|
||||||
|
totalDps: totalDamage / seconds,
|
||||||
|
rows: ordered.map((actor) => ({ ...actor, dps: actor.total / seconds, percent: actor.total / maximum * 100 })) };
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { PLAYER_AGGRO_ID } from "./aggro";
|
||||||
|
import { useCombatStore } from "./combatStore";
|
||||||
|
import { appendCombatEvent } from "./combatEvents";
|
||||||
|
import { connectDpsMeter, currentDpsMeterSnapshot, useDpsMeterStore } from "./dpsMeterStore";
|
||||||
|
import { useGameStore } from "./store";
|
||||||
|
import { useUiLayoutStore } from "./uiLayoutStore";
|
||||||
|
import { currentCompanionSnapshot, executeCompanionCommand } from "../platform/dualScreenSync";
|
||||||
|
|
||||||
|
describe("live DPS tracking and companion editor", () => {
|
||||||
|
let disconnect: () => void;
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers(); vi.setSystemTime(1_000);
|
||||||
|
useGameStore.setState({ overlay: null, paused: false, companionOpen: false });
|
||||||
|
useCombatStore.getState().initializeCharacter({ classId: "mage", level: 5 });
|
||||||
|
useCombatStore.getState().registerMob("dps-target", { name: "Target", maxHealth: 1_000_000 });
|
||||||
|
disconnect = connectDpsMeter();
|
||||||
|
});
|
||||||
|
afterEach(() => { disconnect(); useUiLayoutStore.getState().finish(false); vi.useRealTimers(); });
|
||||||
|
|
||||||
|
it("tracks real damage while hidden, caps overkill, freezes on defeat, and clears on reset", () => {
|
||||||
|
useUiLayoutStore.getState().begin();
|
||||||
|
useUiLayoutStore.getState().updateDraft((layout) => ({ ...layout, elements: { ...layout.elements, dps: { ...layout.elements.dps, visible: false } } }));
|
||||||
|
useCombatStore.getState().damageMob("dps-target", 100, Date.now());
|
||||||
|
vi.advanceTimersByTime(2_000);
|
||||||
|
expect(currentDpsMeterSnapshot()).toMatchObject({ totalDamage: 100, totalDps: 50 });
|
||||||
|
const remaining = useCombatStore.getState().mobs["dps-target"].health;
|
||||||
|
useCombatStore.getState().damageMob("dps-target", 2_000_000, Date.now());
|
||||||
|
expect(currentDpsMeterSnapshot()).toMatchObject({ active: false, totalDamage: 100 + remaining });
|
||||||
|
vi.advanceTimersByTime(10_000);
|
||||||
|
expect(currentDpsMeterSnapshot().durationSeconds).toBe(2);
|
||||||
|
useCombatStore.getState().resetEncounter();
|
||||||
|
expect(useDpsMeterStore.getState().startedAt).toBeNull();
|
||||||
|
});
|
||||||
|
it("merges summon damage into its owner and excludes hostile and friendly-target damage", () => {
|
||||||
|
const actors = useCombatStore.getState().actors;
|
||||||
|
useCombatStore.setState({ actors: { ...actors, "meter-pet": { ...actors[PLAYER_AGGRO_ID], id: "meter-pet", kind: "pet", ownerId: PLAYER_AGGRO_ID } } });
|
||||||
|
for (const [id, sourceActorId, targetActorId] of [[1, "meter-pet", "dps-target"], [2, "dps-target", PLAYER_AGGRO_ID], [3, PLAYER_AGGRO_ID, PLAYER_AGGRO_ID]] as const) {
|
||||||
|
appendCombatEvent([], { occurredAt: Date.now(), kind: "damage", sourceActorId, targetActorId,
|
||||||
|
rawAmount: 100, effectiveAmount: 80, overheal: 0, absorbed: 20, critical: false }, id);
|
||||||
|
}
|
||||||
|
expect(currentDpsMeterSnapshot().totalDamage).toBe(80);
|
||||||
|
expect(currentDpsMeterSnapshot().rows.find((row) => row.total > 0)?.id).toBe(PLAYER_AGGRO_ID);
|
||||||
|
});
|
||||||
|
it("does not dilute DPS while a local game menu pauses combat", () => {
|
||||||
|
useCombatStore.getState().damageMob("dps-target", 100, Date.now());
|
||||||
|
vi.advanceTimersByTime(1_000);
|
||||||
|
useGameStore.setState({ paused: true, overlay: "ui-layout" });
|
||||||
|
vi.advanceTimersByTime(10_000);
|
||||||
|
expect(currentDpsMeterSnapshot()).toMatchObject({ durationSeconds: 1, totalDps: 100 });
|
||||||
|
useGameStore.setState({ paused: false, overlay: null });
|
||||||
|
vi.advanceTimersByTime(1_000);
|
||||||
|
expect(currentDpsMeterSnapshot()).toMatchObject({ durationSeconds: 2, totalDps: 50 });
|
||||||
|
});
|
||||||
|
it("publishes draft visibility and accepts lower-screen movement only while editing", () => {
|
||||||
|
useUiLayoutStore.getState().begin();
|
||||||
|
const command = { name: "editDpsMeter", update: { position: { x: .7, y: .8 }, width: 300 } } as const;
|
||||||
|
executeCompanionCommand(command);
|
||||||
|
expect(useUiLayoutStore.getState().draft?.elements.dps.position).toBeNull();
|
||||||
|
useGameStore.setState({ overlay: "ui-layout" });
|
||||||
|
executeCompanionCommand(command);
|
||||||
|
expect(currentCompanionSnapshot().game.dpsMeter).toMatchObject({ editing: true, layout: { position: { x: .7, y: .8 }, width: 300 } });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { create } from "zustand";
|
||||||
|
import { classById, CLASSES } from "../app/characterCatalog";
|
||||||
|
import { useShellStore } from "../app/shellStore";
|
||||||
|
import { useOnlineSessionStore } from "../app/onlineSessionStore";
|
||||||
|
import { PLAYER_AGGRO_ID } from "./aggro";
|
||||||
|
import { useCombatStore } from "./combatStore";
|
||||||
|
import { subscribeCombatEvents } from "./combatEvents";
|
||||||
|
import { advanceDpsMeter, dpsMeterSnapshot, emptyDpsMeter, recordDpsDamage, type DpsMeterActor } from "./dpsMeter";
|
||||||
|
import { onlinePlayerActorId } from "./onlineSessionRuntime";
|
||||||
|
import { usePartyStore } from "./partyStore";
|
||||||
|
import { useGameStore } from "./store";
|
||||||
|
|
||||||
|
export const useDpsMeterStore = create(() => emptyDpsMeter());
|
||||||
|
|
||||||
|
function meterActors(): DpsMeterActor[] {
|
||||||
|
const combat = useCombatStore.getState();
|
||||||
|
const online = useOnlineSessionStore.getState().snapshot;
|
||||||
|
return [
|
||||||
|
{ id: PLAYER_AGGRO_ID, name: useShellStore.getState().activeCharacter?.name ?? "You", color: classById(combat.classId).color },
|
||||||
|
...usePartyStore.getState().members.map((member) => ({ id: member.id, name: member.name, color: classById(member.classId).color })),
|
||||||
|
...(online?.players ?? []).filter((player) => player.accountId !== online?.localAccountId).map((player) => ({
|
||||||
|
id: onlinePlayerActorId(player.accountId), name: player.character?.name ?? player.username,
|
||||||
|
color: CLASSES.find((entry) => entry.id === player.character?.classId)?.color ?? "#d5c28b",
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dpsSourceActor(sourceId: string): DpsMeterActor | null {
|
||||||
|
const combat = useCombatStore.getState();
|
||||||
|
const localAccount = useOnlineSessionStore.getState().snapshot?.localAccountId;
|
||||||
|
const visited = new Set<string>();
|
||||||
|
let id = sourceId;
|
||||||
|
while (!visited.has(id)) {
|
||||||
|
visited.add(id);
|
||||||
|
if (localAccount && id === onlinePlayerActorId(localAccount)) id = PLAYER_AGGRO_ID;
|
||||||
|
const known = meterActors().find((actor) => actor.id === id);
|
||||||
|
if (known) return known;
|
||||||
|
const ownerId = combat.actors[id]?.ownerId ?? combat.summons.find((summon) => summon.id === id)?.ownerActorId;
|
||||||
|
if (!ownerId) return null;
|
||||||
|
id = ownerId;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function currentDpsMeterSnapshot() {
|
||||||
|
return dpsMeterSnapshot(useDpsMeterStore.getState(), meterActors());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Owned by the gameplay runtime so hiding or moving the meter never loses damage. */
|
||||||
|
export function connectDpsMeter(): () => void {
|
||||||
|
useDpsMeterStore.setState(emptyDpsMeter(), true);
|
||||||
|
let suspendedPreviously = false;
|
||||||
|
const sample = () => {
|
||||||
|
const combat = useCombatStore.getState();
|
||||||
|
const game = useGameStore.getState();
|
||||||
|
const inCombat = Object.values(combat.mobs).some((mob) => mob.engaged && !mob.dead && !mob.despawned);
|
||||||
|
const sharedOnline = Boolean(useOnlineSessionStore.getState().snapshot);
|
||||||
|
const suspended = !sharedOnline && (game.paused || game.companionOpen || (typeof document !== "undefined" && document.hidden));
|
||||||
|
const state = useDpsMeterStore.getState();
|
||||||
|
const next = advanceDpsMeter(state, Date.now(), inCombat, suspendedPreviously);
|
||||||
|
suspendedPreviously = suspended;
|
||||||
|
if (next !== state) useDpsMeterStore.setState(next, true);
|
||||||
|
};
|
||||||
|
const unsubscribeEvents = subscribeCombatEvents((event) => {
|
||||||
|
if (event.kind !== "damage" || !useCombatStore.getState().mobs[event.targetActorId]) return;
|
||||||
|
const actor = dpsSourceActor(event.sourceActorId);
|
||||||
|
if (actor) useDpsMeterStore.setState(recordDpsDamage(useDpsMeterStore.getState(), event, actor), true);
|
||||||
|
});
|
||||||
|
const unsubscribeCombat = useCombatStore.subscribe((state, previous) => {
|
||||||
|
if (state.combatEvents !== previous.combatEvents && state.combatEvents.length === 0) {
|
||||||
|
useDpsMeterStore.setState(emptyDpsMeter(), true);
|
||||||
|
} else sample();
|
||||||
|
});
|
||||||
|
const unsubscribeGame = useGameStore.subscribe(sample);
|
||||||
|
if (typeof document !== "undefined") document.addEventListener("visibilitychange", sample);
|
||||||
|
const timer = setInterval(sample, 250);
|
||||||
|
sample();
|
||||||
|
return () => {
|
||||||
|
clearInterval(timer);
|
||||||
|
unsubscribeEvents(); unsubscribeCombat(); unsubscribeGame();
|
||||||
|
if (typeof document !== "undefined") document.removeEventListener("visibilitychange", sample);
|
||||||
|
useDpsMeterStore.setState(emptyDpsMeter(), true);
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -7,16 +7,22 @@ import {
|
|||||||
} from "./dungeonCatalog";
|
} from "./dungeonCatalog";
|
||||||
import { DUNGEON_MANIFEST } from "./dungeonManifest";
|
import { DUNGEON_MANIFEST } from "./dungeonManifest";
|
||||||
import { RUNEWAKER_DUNGEON_DEFINITIONS } from "./generated/runewakerDungeonDefinitions.generated";
|
import { RUNEWAKER_DUNGEON_DEFINITIONS } from "./generated/runewakerDungeonDefinitions.generated";
|
||||||
|
import { compareDungeonReleaseOrder } from "./dungeonReleaseStage";
|
||||||
|
import availability from "./generated/dungeonAvailability.json";
|
||||||
|
|
||||||
describe("dungeon catalog", () => {
|
describe("dungeon catalog", () => {
|
||||||
it("lists the complete generated campaign with authored Wailing Caverns first", () => {
|
it("lists the complete generated campaign with Stormwind Stockades in Beta first", () => {
|
||||||
expect(DUNGEON_CATALOG).toHaveLength(84);
|
expect(DUNGEON_CATALOG).toHaveLength(84);
|
||||||
expect(DUNGEON_CATALOG[0]).toMatchObject({
|
expect(DUNGEON_CATALOG[0]).toMatchObject({
|
||||||
id: DUNGEON_MANIFEST.id,
|
id: "stormwind-stockade",
|
||||||
|
releaseStage: "beta",
|
||||||
|
available: true,
|
||||||
|
});
|
||||||
|
expect(dungeonById(DUNGEON_MANIFEST.id)).toMatchObject({
|
||||||
title: DUNGEON_MANIFEST.title,
|
title: DUNGEON_MANIFEST.title,
|
||||||
mapId: DUNGEON_MANIFEST.mapId,
|
mapId: DUNGEON_MANIFEST.mapId,
|
||||||
entranceName: DUNGEON_MANIFEST.entrance.name,
|
entranceName: DUNGEON_MANIFEST.entrance.name,
|
||||||
available: true,
|
releaseStage: "alpha",
|
||||||
});
|
});
|
||||||
expect(DUNGEON_CATALOG.find((dungeon) => dungeon.id === "deadmines")).toMatchObject({
|
expect(DUNGEON_CATALOG.find((dungeon) => dungeon.id === "deadmines")).toMatchObject({
|
||||||
id: "deadmines",
|
id: "deadmines",
|
||||||
@@ -33,10 +39,10 @@ describe("dungeon catalog", () => {
|
|||||||
|
|
||||||
it("separates WoW and RoM dungeons with all RuneWaker imports under RoM", () => {
|
it("separates WoW and RoM dungeons with all RuneWaker imports under RoM", () => {
|
||||||
expect(DUNGEON_CATEGORIES.map((category) => category.id)).toEqual(["wow", "rom"]);
|
expect(DUNGEON_CATEGORIES.map((category) => category.id)).toEqual(["wow", "rom"]);
|
||||||
expect(dungeonsByCategory("rom").map((dungeon) => dungeon.id)).toEqual([
|
expect(dungeonsByCategory("rom").map((dungeon) => dungeon.id).sort()).toEqual([
|
||||||
"forsaken-abbey",
|
"forsaken-abbey",
|
||||||
...RUNEWAKER_DUNGEON_DEFINITIONS.map((definition) => definition.id),
|
...RUNEWAKER_DUNGEON_DEFINITIONS.map((definition) => definition.id),
|
||||||
]);
|
].sort());
|
||||||
expect(dungeonsByCategory("wow")).not.toContainEqual(
|
expect(dungeonsByCategory("wow")).not.toContainEqual(
|
||||||
expect.objectContaining({ id: "forsaken-abbey" }),
|
expect.objectContaining({ id: "forsaken-abbey" }),
|
||||||
);
|
);
|
||||||
@@ -46,7 +52,7 @@ describe("dungeon catalog", () => {
|
|||||||
it("keeps stable unique ids and resolves only catalogued dungeons", () => {
|
it("keeps stable unique ids and resolves only catalogued dungeons", () => {
|
||||||
const ids = DUNGEON_CATALOG.map((dungeon) => dungeon.id);
|
const ids = DUNGEON_CATALOG.map((dungeon) => dungeon.id);
|
||||||
expect(new Set(ids).size).toBe(ids.length);
|
expect(new Set(ids).size).toBe(ids.length);
|
||||||
expect(dungeonById(DUNGEON_MANIFEST.id)).toBe(DUNGEON_CATALOG[0]);
|
expect(dungeonById(DUNGEON_MANIFEST.id)).toBe(DUNGEON_CATALOG.find((dungeon) => dungeon.id === DUNGEON_MANIFEST.id));
|
||||||
expect(dungeonById("missing-dungeon")).toBeNull();
|
expect(dungeonById("missing-dungeon")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -57,4 +63,30 @@ describe("dungeon catalog", () => {
|
|||||||
expect(dungeon.summary.trim()).not.toBe("");
|
expect(dungeon.summary.trim()).not.toBe("");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps only Stockades in Beta and alphabetizes each release group", () => {
|
||||||
|
expect(DUNGEON_CATALOG.filter((dungeon) => dungeon.releaseStage === "beta").map((dungeon) => dungeon.id))
|
||||||
|
.toEqual(["stormwind-stockade"]);
|
||||||
|
expect(DUNGEON_CATALOG.filter((dungeon) => dungeon.id !== "stormwind-stockade").every((dungeon) => dungeon.releaseStage === "alpha"))
|
||||||
|
.toBe(true);
|
||||||
|
for (const category of DUNGEON_CATEGORIES) {
|
||||||
|
const dungeons = dungeonsByCategory(category.id);
|
||||||
|
expect(dungeons).toEqual([...dungeons].sort(compareDungeonReleaseOrder));
|
||||||
|
}
|
||||||
|
const stages = [
|
||||||
|
{ title: "Zeta", releaseStage: "alpha" as const },
|
||||||
|
{ title: "Beta Z", releaseStage: "beta" as const },
|
||||||
|
{ title: "Released Z", releaseStage: "full-release" as const },
|
||||||
|
{ title: "Alpha", releaseStage: "alpha" as const },
|
||||||
|
{ title: "Beta A", releaseStage: "beta" as const },
|
||||||
|
{ title: "Released A", releaseStage: "full-release" as const },
|
||||||
|
];
|
||||||
|
expect(stages.sort(compareDungeonReleaseOrder).map((entry) => entry.title))
|
||||||
|
.toEqual(["Released A", "Released Z", "Beta A", "Beta Z", "Alpha", "Zeta"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shares the complete dungeon identity list used to validate reports on the server", () => {
|
||||||
|
expect(DUNGEON_CATALOG.map((dungeon) => dungeon.id).sort())
|
||||||
|
.toEqual(["forsaken-abbey", ...Object.keys(availability.enabled)].sort());
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { DUNGEON_DEFINITIONS, dungeonCanEnter, type DungeonId } from "./dungeonRegistry";
|
import { DUNGEON_DEFINITIONS, dungeonCanEnter, type DungeonId } from "./dungeonRegistry";
|
||||||
import { contentCategoryById, type ContentCategoryId } from "../app/contentCategories";
|
import { contentCategoryById, type ContentCategoryId } from "../app/contentCategories";
|
||||||
|
import { compareDungeonReleaseOrder, dungeonReleaseStage, type DungeonReleaseStage } from "./dungeonReleaseStage";
|
||||||
|
|
||||||
const DUNGEON_CATEGORY_IDS = ["wow", "rom"] as const satisfies readonly ContentCategoryId[];
|
const DUNGEON_CATEGORY_IDS = ["wow", "rom"] as const satisfies readonly ContentCategoryId[];
|
||||||
export type DungeonCategoryId = (typeof DUNGEON_CATEGORY_IDS)[number];
|
export type DungeonCategoryId = (typeof DUNGEON_CATEGORY_IDS)[number];
|
||||||
@@ -27,10 +28,11 @@ export interface DungeonCatalogEntry {
|
|||||||
readonly theme: string;
|
readonly theme: string;
|
||||||
readonly summary: string;
|
readonly summary: string;
|
||||||
readonly available: boolean;
|
readonly available: boolean;
|
||||||
|
readonly releaseStage: DungeonReleaseStage;
|
||||||
readonly availabilityReason?: string;
|
readonly availabilityReason?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Stable browser ordering is the registry order; identity is never duplicated. */
|
/** Release readiness first, then alphabetical title within each release stage. */
|
||||||
export const DUNGEON_CATALOG: readonly DungeonCatalogEntry[] = Object.freeze(
|
export const DUNGEON_CATALOG: readonly DungeonCatalogEntry[] = Object.freeze(
|
||||||
DUNGEON_DEFINITIONS.map<DungeonCatalogEntry>((definition) => ({
|
DUNGEON_DEFINITIONS.map<DungeonCatalogEntry>((definition) => ({
|
||||||
id: definition.id as DungeonId,
|
id: definition.id as DungeonId,
|
||||||
@@ -42,8 +44,9 @@ export const DUNGEON_CATALOG: readonly DungeonCatalogEntry[] = Object.freeze(
|
|||||||
theme: definition.presentation.theme,
|
theme: definition.presentation.theme,
|
||||||
summary: definition.presentation.summary,
|
summary: definition.presentation.summary,
|
||||||
available: dungeonCanEnter(definition.id),
|
available: dungeonCanEnter(definition.id),
|
||||||
|
releaseStage: dungeonReleaseStage(definition.id),
|
||||||
availabilityReason: "availabilityReason" in definition ? definition.availabilityReason : undefined,
|
availabilityReason: "availabilityReason" in definition ? definition.availabilityReason : undefined,
|
||||||
})),
|
})).sort(compareDungeonReleaseOrder),
|
||||||
);
|
);
|
||||||
|
|
||||||
export type { DungeonId } from "./dungeonRegistry";
|
export type { DungeonId } from "./dungeonRegistry";
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { readFileSync, writeFileSync } from "node:fs";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { CLASSES } from "../app/characterCatalog";
|
||||||
|
import { DUNGEON_DEFINITIONS } from "./dungeonRegistry";
|
||||||
|
import { dungeonBossesForDifficulty, finalDungeonBoss } from "./dungeonRunStore";
|
||||||
|
|
||||||
|
describe("dungeon leaderboard catalog", () => {
|
||||||
|
it("keeps the server's final bosses, boss lists, difficulties, and classes aligned with the game", () => {
|
||||||
|
const catalog = {
|
||||||
|
dungeons: DUNGEON_DEFINITIONS.map((dungeon) => ({ id: dungeon.id, title: dungeon.title,
|
||||||
|
finalBossId: finalDungeonBoss(dungeon)?.id ?? "",
|
||||||
|
bosses: dungeon.bosses.map((boss) => ({ id: boss.id, name: boss.name })),
|
||||||
|
difficulties: dungeon.difficultyVariants.map((difficulty) => ({ id: difficulty.id, name: difficulty.difficulty,
|
||||||
|
bossIds: dungeonBossesForDifficulty(dungeon, difficulty.id).map((boss) => boss.id),
|
||||||
|
finalBossId: finalDungeonBoss(dungeon, difficulty.id)?.id ?? "" })),
|
||||||
|
defaultDifficultyId: dungeon.defaultDifficultyId,
|
||||||
|
})).sort((a, b) => a.id.localeCompare(b.id)),
|
||||||
|
classes: CLASSES.map((entry) => ({ id: entry.id, name: entry.name })),
|
||||||
|
};
|
||||||
|
expect(catalog.dungeons).toHaveLength(84);
|
||||||
|
expect(catalog.dungeons.filter((dungeon) => dungeon.bosses.length > 0 && !dungeon.finalBossId)).toEqual([]);
|
||||||
|
const path = "src/game/generated/dungeonLeaderboardCatalog.generated.json";
|
||||||
|
if (process.env.WRITE_DUNGEON_LEADERBOARD_CATALOG === "1") writeFileSync(path, `${JSON.stringify(catalog, null, 2)}\n`);
|
||||||
|
expect(JSON.parse(readFileSync(path, "utf8"))).toEqual(catalog);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { scaledDungeonEnemyLevel } from "./dungeonLevelScaling";
|
import { dungeonEnemyHealthBaseline, scaledDungeonEnemyLevel } from "./dungeonLevelScaling";
|
||||||
|
|
||||||
describe("normal dungeon level scaling", () => {
|
describe("normal dungeon level scaling", () => {
|
||||||
it("scales opening-tier trash to the character and gives bosses a one-level edge", () => {
|
it("scales opening-tier trash to the character and gives bosses a one-level edge", () => {
|
||||||
@@ -13,4 +13,19 @@ describe("normal dungeon level scaling", () => {
|
|||||||
expect(scaledDungeonEnemyLevel(80, false)).toBe(80);
|
expect(scaledDungeonEnemyLevel(80, false)).toBe(80);
|
||||||
expect(scaledDungeonEnemyLevel(80, true)).toBe(80);
|
expect(scaledDungeonEnemyLevel(80, true)).toBe(80);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("grows RoM health with ability ranks and clamps invalid levels", () => {
|
||||||
|
expect(dungeonEnemyHealthBaseline(1, "runewaker")).toBeGreaterThan(1_000);
|
||||||
|
expect(dungeonEnemyHealthBaseline(20, "runewaker")).toBeGreaterThan(dungeonEnemyHealthBaseline(8, "runewaker")!);
|
||||||
|
expect(dungeonEnemyHealthBaseline(70, "runewaker")).toBeGreaterThan(dungeonEnemyHealthBaseline(20, "runewaker")! * 5);
|
||||||
|
expect(dungeonEnemyHealthBaseline(0, "runewaker")).toBe(dungeonEnemyHealthBaseline(1, "runewaker"));
|
||||||
|
expect(dungeonEnemyHealthBaseline(100, "runewaker")).toBe(dungeonEnemyHealthBaseline(80, "runewaker"));
|
||||||
|
expect(dungeonEnemyHealthBaseline(Number.NaN, "runewaker")).toBe(dungeonEnemyHealthBaseline(1, "runewaker"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retains native WoW creature health across its three expansions", () => {
|
||||||
|
for (const expansion of ["classic", "burning-crusade", "wrath"] as const) {
|
||||||
|
expect(dungeonEnemyHealthBaseline(20, expansion)).toBeUndefined();
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { clampPlayerLevel } from "./progression";
|
import { clampPlayerLevel } from "./progression";
|
||||||
|
import type { DungeonExpansion } from "./dungeonTypes";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Normal campaign dungeons scale to the character instead of retaining the
|
* Normal campaign dungeons scale to the character instead of retaining the
|
||||||
@@ -8,3 +9,15 @@ import { clampPlayerLevel } from "./progression";
|
|||||||
export function scaledDungeonEnemyLevel(characterLevel: number, boss: boolean): number {
|
export function scaledDungeonEnemyLevel(characterLevel: number, boss: boolean): number {
|
||||||
return clampPlayerLevel(characterLevel + (boss ? 1 : 0));
|
return clampPlayerLevel(characterLevel + (boss ? 1 : 0));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RoM creatures do not have packaged native health tables. Give their dungeon
|
||||||
|
* baseline enough growth for a full party's rank-scaled abilities; the old
|
||||||
|
* 72 + level * 22 solo-target fallback was overwhelmed by the opening volley.
|
||||||
|
* Authored creature and boss health multipliers are applied separately.
|
||||||
|
*/
|
||||||
|
export function dungeonEnemyHealthBaseline(level: number, expansion: DungeonExpansion): number | undefined {
|
||||||
|
if (expansion !== "runewaker") return undefined;
|
||||||
|
const safeLevel = clampPlayerLevel(level);
|
||||||
|
return 1_000 + safeLevel * 100 + safeLevel * safeLevel * 8;
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import compressedCampaign from "./generated/dungeonCampaignCatalog.compressed";
|
|||||||
import { gunzipSync, strFromU8 } from "fflate";
|
import { gunzipSync, strFromU8 } from "fflate";
|
||||||
import { DEFAULT_DUNGEON_ID as DEFAULT_DUNGEON_KEY } from "./dungeonDefaults";
|
import { DEFAULT_DUNGEON_ID as DEFAULT_DUNGEON_KEY } from "./dungeonDefaults";
|
||||||
import { withRunewakerEncounterContent } from "./runewakerEncounterCatalog";
|
import { withRunewakerEncounterContent } from "./runewakerEncounterCatalog";
|
||||||
|
import { withRunewakerCompletion } from "./runewakerCompletion";
|
||||||
import { withWowEncounterContent } from "./wowEncounterContentCatalog";
|
import { withWowEncounterContent } from "./wowEncounterContentCatalog";
|
||||||
import { withWowProgressionObjectContent } from "./wowProgressionObjectContentCatalog";
|
import { withWowProgressionObjectContent } from "./wowProgressionObjectContentCatalog";
|
||||||
import { withAscensionCustomEncounterContent } from "./ascensionCustomEncounterCatalog";
|
import { withAscensionCustomEncounterContent } from "./ascensionCustomEncounterCatalog";
|
||||||
@@ -19,6 +20,7 @@ import { withWowVerifiedNoOpInstanceContent } from "./wowVerifiedNoOpInstanceCat
|
|||||||
import { withWowClassicEncounterBatchTwo } from "./wowClassicEncounterBatchTwoCatalog";
|
import { withWowClassicEncounterBatchTwo } from "./wowClassicEncounterBatchTwoCatalog";
|
||||||
import { withWowClassicEncounterBatchThree } from "./wowClassicEncounterBatchThreeCatalog";
|
import { withWowClassicEncounterBatchThree } from "./wowClassicEncounterBatchThreeCatalog";
|
||||||
import { withWowTbcEncounterBatchTwoContent } from "./wowTbcEncounterBatchTwoCatalog";
|
import { withWowTbcEncounterBatchTwoContent } from "./wowTbcEncounterBatchTwoCatalog";
|
||||||
|
import { withWowDungeonEnvironment } from "./wowDungeonEnvironment";
|
||||||
|
|
||||||
export { DEFAULT_DUNGEON_ID } from "./dungeonDefaults";
|
export { DEFAULT_DUNGEON_ID } from "./dungeonDefaults";
|
||||||
|
|
||||||
@@ -151,6 +153,7 @@ if (authoredWailingCaverns.id !== DEFAULT_DUNGEON_KEY) {
|
|||||||
const DUNGEON_CONTENT_ADAPTERS: readonly (
|
const DUNGEON_CONTENT_ADAPTERS: readonly (
|
||||||
(definition: DungeonDefinition) => DungeonDefinition
|
(definition: DungeonDefinition) => DungeonDefinition
|
||||||
)[] = Object.freeze([
|
)[] = Object.freeze([
|
||||||
|
withWowDungeonEnvironment,
|
||||||
withWowEncounterContent,
|
withWowEncounterContent,
|
||||||
withWowClassicEncounterContent,
|
withWowClassicEncounterContent,
|
||||||
withWowClassicEncounterBatchTwo,
|
withWowClassicEncounterBatchTwo,
|
||||||
@@ -164,6 +167,7 @@ const DUNGEON_CONTENT_ADAPTERS: readonly (
|
|||||||
withWowTbcEncounterBatchTwoContent,
|
withWowTbcEncounterBatchTwoContent,
|
||||||
withAscensionCustomEncounterContent,
|
withAscensionCustomEncounterContent,
|
||||||
withRunewakerEncounterContent,
|
withRunewakerEncounterContent,
|
||||||
|
withRunewakerCompletion,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
function applyDungeonContentAdapters(definition: DungeonDefinition): DungeonDefinition {
|
function applyDungeonContentAdapters(definition: DungeonDefinition): DungeonDefinition {
|
||||||
|
|||||||
@@ -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", () => {
|
describe("dungeon run checkpoints", () => {
|
||||||
|
it("round-trips a saved timer and rejects a timer belonging to another dungeon", () => {
|
||||||
|
const input = { version: DUNGEON_RUN_CHECKPOINT_VERSION, dungeonId: "test", difficultyId: "normal",
|
||||||
|
checkpointId: "entrance", spawn: { footPosition: [1, 2, 3], yaw: 0.5 }, encounters: [], savedAt: 123,
|
||||||
|
run: { id: "checkpoint-timer", dungeonId: "test", difficultyId: "normal", startedAt: 100,
|
||||||
|
finishedAt: null, durationMs: null, timed: true, killedBossIds: [], party: [] } };
|
||||||
|
expect(normalizeDungeonRunCheckpoint(JSON.parse(JSON.stringify(input)))?.run).toEqual(input.run);
|
||||||
|
expect(normalizeDungeonRunCheckpoint({ ...input, run: { ...input.run, dungeonId: "other" } })).toBeNull();
|
||||||
|
});
|
||||||
it("normalizes JSON-safe encounter progress and world spawn", () => {
|
it("normalizes JSON-safe encounter progress and world spawn", () => {
|
||||||
const result = normalizeDungeonRunCheckpoint({
|
const result = normalizeDungeonRunCheckpoint({
|
||||||
version: DUNGEON_RUN_CHECKPOINT_VERSION,
|
version: DUNGEON_RUN_CHECKPOINT_VERSION,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { DungeonEncounterSnapshot } from "./dungeonEncounterTypes";
|
import type { DungeonEncounterSnapshot } from "./dungeonEncounterTypes";
|
||||||
import { DUNGEON_ENCOUNTER_SNAPSHOT_SCHEMA_VERSION } from "./dungeonEncounterTypes";
|
import { DUNGEON_ENCOUNTER_SNAPSHOT_SCHEMA_VERSION } from "./dungeonEncounterTypes";
|
||||||
import type { Vector3Tuple, WorldSpawn } from "./dungeonTypes";
|
import type { Vector3Tuple, WorldSpawn } from "./dungeonTypes";
|
||||||
|
import { normalizeDungeonRunProgress, type DungeonRunProgress } from "./dungeonRunTypes";
|
||||||
|
|
||||||
// Version 1 snapshots sorted consumed action ids and cannot be migrated
|
// Version 1 snapshots sorted consumed action ids and cannot be migrated
|
||||||
// reliably when multiple durable actions write the same state slot. Failing
|
// reliably when multiple durable actions write the same state slot. Failing
|
||||||
@@ -15,6 +16,7 @@ export interface DungeonRunCheckpoint {
|
|||||||
readonly spawn: WorldSpawn;
|
readonly spawn: WorldSpawn;
|
||||||
readonly encounters: readonly DungeonEncounterSnapshot[];
|
readonly encounters: readonly DungeonEncounterSnapshot[];
|
||||||
readonly savedAt: number;
|
readonly savedAt: number;
|
||||||
|
readonly run?: DungeonRunProgress;
|
||||||
}
|
}
|
||||||
|
|
||||||
function finiteVector(value: unknown): Vector3Tuple | null {
|
function finiteVector(value: unknown): Vector3Tuple | null {
|
||||||
@@ -115,6 +117,8 @@ export function normalizeDungeonRunCheckpoint(value: unknown): DungeonRunCheckpo
|
|||||||
) return null;
|
) return null;
|
||||||
const encounters = input.encounters.map(normalizeSnapshot);
|
const encounters = input.encounters.map(normalizeSnapshot);
|
||||||
if (encounters.some((entry) => entry === null)) return null;
|
if (encounters.some((entry) => entry === null)) return null;
|
||||||
|
const run = input.run === undefined ? undefined : normalizeDungeonRunProgress(input.run);
|
||||||
|
if (run === null || (run && (run.dungeonId !== input.dungeonId || run.difficultyId !== input.difficultyId))) return null;
|
||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
version: DUNGEON_RUN_CHECKPOINT_VERSION,
|
version: DUNGEON_RUN_CHECKPOINT_VERSION,
|
||||||
dungeonId: input.dungeonId,
|
dungeonId: input.dungeonId,
|
||||||
@@ -123,5 +127,6 @@ export function normalizeDungeonRunCheckpoint(value: unknown): DungeonRunCheckpo
|
|||||||
spawn: { footPosition: position, yaw },
|
spawn: { footPosition: position, yaw },
|
||||||
encounters: Object.freeze(encounters as DungeonEncounterSnapshot[]),
|
encounters: Object.freeze(encounters as DungeonEncounterSnapshot[]),
|
||||||
savedAt: input.savedAt,
|
savedAt: input.savedAt,
|
||||||
|
...(run ? { run } : {}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { mkdirSync, writeFileSync } from "node:fs";
|
||||||
|
import { expect, it } from "vitest";
|
||||||
|
import { createCharacter } from "../app/accountRepository";
|
||||||
|
import { randomAppearance } from "../app/characterCatalog";
|
||||||
|
import { abilitiesForClass } from "./abilityCatalog";
|
||||||
|
import { useCombatStore } from "./combatStore";
|
||||||
|
import { subscribeCombatEvents } from "./combatEvents";
|
||||||
|
import { deriveDungeonEncounterCombatEvents, type DungeonEncounterMobCursor } from "./dungeonEncounterCombatEvents";
|
||||||
|
import { activateDungeonEncounterDefinitions, activeDungeonCompletedObjectiveIds, activeDungeonRunIsComplete,
|
||||||
|
advanceDungeonEncounters, dispatchActiveDungeonEntityEvent, exportDungeonEncounterCheckpointSnapshots, resetDungeonEncounters } from "./dungeonEncounterRuntime";
|
||||||
|
import { requireDungeonDefinition } from "./dungeonRegistry";
|
||||||
|
import { dungeonEnemyHealthBaseline, scaledDungeonEnemyLevel } from "./dungeonLevelScaling";
|
||||||
|
import { completedDungeonRun, finalDungeonBoss, useDungeonRunStore } from "./dungeonRunStore";
|
||||||
|
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
|
||||||
|
import { usePartyStore } from "./partyStore";
|
||||||
|
import { advanceDungeonPartyCombat, partyCombatRangeBand, resetPartyRuntimeClock } from "./partyRuntime";
|
||||||
|
import { clearPartyRuntimeRegistry, registerPartyRuntimePosition, updatePartyRuntimeTargetVisibility } from "./partyRuntimeRegistry";
|
||||||
|
import { useGameStore } from "./store";
|
||||||
|
|
||||||
|
it("turns an actual RoM party's lethal damage to Anglo into one timed completion and a resumable result", () => {
|
||||||
|
const dungeon = requireDungeonDefinition("barren-caves");
|
||||||
|
const boss = finalDungeonBoss(dungeon)!;
|
||||||
|
const spawn = dungeon.staticSpawns.find((entry) => entry.id === boss.id)!;
|
||||||
|
const entity = dungeon.entities[spawn.entityId];
|
||||||
|
const bossCombat = entity.combat;
|
||||||
|
if (!bossCombat) throw new Error("Anglo must have a combat definition");
|
||||||
|
const healthMultiplier = bossCombat.healthMultiplier ?? 1;
|
||||||
|
clearMobRuntimeRegistry(); clearPartyRuntimeRegistry(); resetPartyRuntimeClock();
|
||||||
|
useGameStore.setState({ activeDungeonId: dungeon.id, activeDifficultyId: "normal", gameMode: "dungeon", gmTestMode: false });
|
||||||
|
activateDungeonEncounterDefinitions(dungeon.id, dungeon.encounters ?? [], "normal");
|
||||||
|
resetDungeonEncounters("dungeon", dungeon.id);
|
||||||
|
const combat = useCombatStore.getState();
|
||||||
|
combat.initializeCharacter({ classId: "rom-priest", raceId: "rom-human", level: 1 });
|
||||||
|
combat.setPlayerPosition([-18, 0, 0]);
|
||||||
|
usePartyStore.getState().initializeParty("completion-run", 1, useCombatStore.getState().health, "healer", 5, "rom");
|
||||||
|
combat.synchronizeActors();
|
||||||
|
const bossLevel = scaledDungeonEnemyLevel(1, true);
|
||||||
|
combat.registerMob(boss.id, { name: entity.name, boss: true, level: bossLevel, xpReward: 0,
|
||||||
|
maxHealth: dungeonEnemyHealthBaseline(bossLevel, "runewaker")! * healthMultiplier,
|
||||||
|
healthMultiplier, attacks: bossCombat.attacks });
|
||||||
|
setMobPosition(boss.id, [0, 0, 0]);
|
||||||
|
for (const member of usePartyStore.getState().members) {
|
||||||
|
registerPartyRuntimePosition(member.id, [-partyCombatRangeBand(member).preferred, 0, 0]);
|
||||||
|
updatePartyRuntimeTargetVisibility(member.id, boss.id, true);
|
||||||
|
}
|
||||||
|
const party = () => [{ name: "Runview", classId: "rom-priest", level: 1, role: "healer", npc: false },
|
||||||
|
...usePartyStore.getState().members.map((member) => ({ name: member.name, classId: member.classId,
|
||||||
|
level: member.level, role: member.role, npc: true }))];
|
||||||
|
const epoch = Date.now() - 181000;
|
||||||
|
const run = useDungeonRunStore.getState(); run.begin(dungeon.id, "normal"); run.start(epoch, party());
|
||||||
|
combat.damageMob(boss.id, 1, epoch);
|
||||||
|
const heal = abilitiesForClass("rom-priest").find((ability) => ability.name === "Urgent Heal")!;
|
||||||
|
let cursor: DungeonEncounterMobCursor = {};
|
||||||
|
let npcDamage = 0; let healing = 0; let deaths = 0;
|
||||||
|
const stop = subscribeCombatEvents((event) => {
|
||||||
|
if (event.kind === "damage" && event.targetActorId === boss.id && event.sourceActorId !== "player") npcDamage += event.effectiveAmount;
|
||||||
|
if (event.kind === "healing") healing += event.effectiveAmount;
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
for (let now = epoch + 100; now <= epoch + 181000; now += 100) {
|
||||||
|
combat.tick(.1, now); advanceDungeonPartyCombat(now); combat.advanceMobCombat(now);
|
||||||
|
const tank = usePartyStore.getState().members.find((member) => member.role === "tank")!;
|
||||||
|
if (tank.health < tank.maxHealth * .65 && !useCombatStore.getState().activeCast) {
|
||||||
|
usePartyStore.getState().selectMember(tank.id); combat.castAbility(heal.id, undefined, now);
|
||||||
|
}
|
||||||
|
const mobs = useCombatStore.getState().mobs;
|
||||||
|
const events = deriveDungeonEncounterCombatEvents(cursor, mobs, now); cursor = events.cursor;
|
||||||
|
for (const event of events.events) { dispatchActiveDungeonEntityEvent(event); if (event.kind === "entity-death") deaths += 1; }
|
||||||
|
const dead = Object.values(mobs).filter((mob) => mob.dead).map((mob) => mob.id);
|
||||||
|
advanceDungeonEncounters(dungeon.id, { kind: "tick", now, facts: { registeredEntityIds: Object.keys(mobs), deadEntityIds: dead } });
|
||||||
|
run.advance(activeDungeonCompletedObjectiveIds(dungeon.id), dead, now, party());
|
||||||
|
if (useDungeonRunStore.getState().progress?.finishedAt !== null) break;
|
||||||
|
}
|
||||||
|
const progress = useDungeonRunStore.getState().progress!;
|
||||||
|
expect(useCombatStore.getState().mobs[boss.id].dead).toBe(true);
|
||||||
|
expect(activeDungeonRunIsComplete(dungeon.id)).toBe(true);
|
||||||
|
expect(npcDamage).toBeGreaterThan(0); expect(healing).toBeGreaterThan(0); expect(deaths).toBe(1);
|
||||||
|
expect(completedDungeonRun(progress)).toMatchObject({ fullClear: true, killedBossIds: [boss.id] });
|
||||||
|
expect(progress.durationMs).toBeGreaterThan(20000);
|
||||||
|
expect(usePartyStore.getState().members.every((member) => member.health > 0)).toBe(true);
|
||||||
|
const snapshots = exportDungeonEncounterCheckpointSnapshots(dungeon.id, 0);
|
||||||
|
if (process.env.DUNGEON_RUN_REPORT === "1") {
|
||||||
|
const values = new Map<string, string>();
|
||||||
|
const character = createCharacter({ ownerId: "qa-owner", name: "Runview", raceId: "rom-human", classId: "rom-priest",
|
||||||
|
categoryId: "rom", gender: "female", appearance: randomAppearance("rom-human", "female", () => 0) },
|
||||||
|
{ getItem: (key) => values.get(key) ?? null, setItem: (key, value) => { values.set(key, value); },
|
||||||
|
removeItem: (key) => { values.delete(key); } }).character!;
|
||||||
|
mkdirSync("artifacts/dungeon-runs", { recursive: true });
|
||||||
|
writeFileSync("artifacts/dungeon-runs/combat-completion.json", `${JSON.stringify({ npcDamage, healing, deaths, progress,
|
||||||
|
character: { ...character, dungeonRunCheckpoint: { version: 2, dungeonId: dungeon.id, difficultyId: "normal",
|
||||||
|
checkpointId: "entrance", spawn: { footPosition: dungeon.entrance.footPosition, yaw: dungeon.entrance.yaw },
|
||||||
|
encounters: snapshots, savedAt: Date.now(), run: progress } } }, null, 2)}\n`);
|
||||||
|
}
|
||||||
|
} finally { stop(); clearMobRuntimeRegistry(); clearPartyRuntimeRegistry(); }
|
||||||
|
});
|
||||||
@@ -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";
|
} from "./dungeonEncounterRuntime";
|
||||||
import type { DungeonEncounterSnapshot } from "./dungeonEncounterTypes";
|
import type { DungeonEncounterSnapshot } from "./dungeonEncounterTypes";
|
||||||
import { dungeonContentIsUsable } from "../content/contentManager";
|
import { dungeonContentIsUsable } from "../content/contentManager";
|
||||||
|
import { useDungeonRunStore } from "./dungeonRunStore";
|
||||||
|
import type { DungeonRunProgress } from "./dungeonRunTypes";
|
||||||
|
|
||||||
export interface DungeonSessionOptions {
|
export interface DungeonSessionOptions {
|
||||||
readonly gameMode?: GameMode;
|
readonly gameMode?: GameMode;
|
||||||
@@ -28,6 +30,8 @@ export interface DungeonSessionOptions {
|
|||||||
readonly preserveCharacter?: boolean;
|
readonly preserveCharacter?: boolean;
|
||||||
/** Hydrates save-safe state after definitions have been activated and reset. */
|
/** Hydrates save-safe state after definitions have been activated and reset. */
|
||||||
readonly encounterSnapshots?: readonly DungeonEncounterSnapshot[];
|
readonly encounterSnapshots?: readonly DungeonEncounterSnapshot[];
|
||||||
|
readonly runProgress?: DungeonRunProgress;
|
||||||
|
readonly legacyResume?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -47,6 +51,10 @@ export function activateDungeonSession(
|
|||||||
|
|
||||||
const previousDefinition = dungeonDefinitionById(useGameStore.getState().activeDungeonId);
|
const previousDefinition = dungeonDefinitionById(useGameStore.getState().activeDungeonId);
|
||||||
if (previousDefinition) releaseDungeonGltfCache(previousDefinition);
|
if (previousDefinition) releaseDungeonGltfCache(previousDefinition);
|
||||||
|
if (!options.preserveCharacter) {
|
||||||
|
useDungeonRunStore.getState().begin(dungeonId, difficultyId ?? definition.defaultDifficultyId,
|
||||||
|
options.runProgress, options.legacyResume);
|
||||||
|
}
|
||||||
activateDungeonEncounterDefinitions(
|
activateDungeonEncounterDefinitions(
|
||||||
dungeonId,
|
dungeonId,
|
||||||
definition.encounters ?? [],
|
definition.encounters ?? [],
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { BufferGeometry, Float32BufferAttribute } from "three";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { DungeonTraversalRamp } from "./dungeonTypes";
|
||||||
|
import { bridgeDungeonStairNavigation, smoothDungeonStairGeometry } from "./dungeonTraversalRamps";
|
||||||
|
|
||||||
|
const RAMP: DungeonTraversalRamp = {
|
||||||
|
id: "stairs",
|
||||||
|
start: [0, 0, 0],
|
||||||
|
end: [0, 2, 6],
|
||||||
|
width: 4,
|
||||||
|
navigationBridge: {
|
||||||
|
linkId: "stair-seam",
|
||||||
|
startPortal: [[-1, 0.5, 2], [1, 0.5, 2]],
|
||||||
|
endPortal: [[-1, 1, 3], [1, 1, 3]],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("reviewed dungeon stair proxies", () => {
|
||||||
|
it("smooths tread and riser vertices but preserves upper walls, ceilings and geometry outside the flight", () => {
|
||||||
|
const geometry = new BufferGeometry();
|
||||||
|
geometry.setAttribute("position", new Float32BufferAttribute([
|
||||||
|
0, 0.5, 2,
|
||||||
|
0, 1, 2,
|
||||||
|
0, 3, 2,
|
||||||
|
0, 5, 2,
|
||||||
|
3, 0.5, 2,
|
||||||
|
0, 0.5, -1,
|
||||||
|
], 3));
|
||||||
|
smoothDungeonStairGeometry(geometry, [RAMP]);
|
||||||
|
const position = geometry.getAttribute("position");
|
||||||
|
expect(position.getY(0)).toBeCloseTo(2 / 3);
|
||||||
|
expect(position.getY(1)).toBeCloseTo(2 / 3);
|
||||||
|
expect([2, 3, 4, 5].map((vertex) => position.getY(vertex))).toEqual([3, 5, 0.5, 0.5]);
|
||||||
|
geometry.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
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(),
|
||||||
|
], 3));
|
||||||
|
geometry.setIndex([]);
|
||||||
|
smoothDungeonStairGeometry(geometry, [RAMP], 0.05);
|
||||||
|
bridgeDungeonStairNavigation(geometry, [RAMP], 0.05);
|
||||||
|
expect(Array.from(geometry.index!.array)).toEqual([0, 1, 3, 0, 3, 2]);
|
||||||
|
geometry.getAttribute("position").setX(0, -1.1);
|
||||||
|
expect(() => bridgeDungeonStairNavigation(geometry, [RAMP], 0.05))
|
||||||
|
.toThrow("reviewed navigation portal no longer matches the source GLB");
|
||||||
|
geometry.dispose();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import type { BufferGeometry } from "three";
|
||||||
|
import type { DungeonTraversalRamp, Vector3Tuple } from "./dungeonTypes";
|
||||||
|
|
||||||
|
// Only the stair relief is reshaped. Upper walls, ceilings and other floors
|
||||||
|
// remain untouched even where they share the staircase's horizontal footprint.
|
||||||
|
const MAX_STAIR_SURFACE_DELTA = 0.85;
|
||||||
|
const PORTAL_VERTEX_TOLERANCE = 0.002;
|
||||||
|
|
||||||
|
export function dungeonRampHeight(
|
||||||
|
ramp: DungeonTraversalRamp,
|
||||||
|
x: number,
|
||||||
|
z: number,
|
||||||
|
): number | null {
|
||||||
|
const dx = ramp.end[0] - ramp.start[0];
|
||||||
|
const dz = ramp.end[2] - ramp.start[2];
|
||||||
|
const length = Math.hypot(dx, dz);
|
||||||
|
if (
|
||||||
|
![...ramp.start, ...ramp.end, ramp.width].every(Number.isFinite)
|
||||||
|
|| length <= 0
|
||||||
|
|| ramp.width <= 0
|
||||||
|
) {
|
||||||
|
throw new Error(`Invalid dungeon traversal ramp: ${ramp.id}.`);
|
||||||
|
}
|
||||||
|
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 * 0.5) return null;
|
||||||
|
return ramp.start[1] + (ramp.end[1] - ramp.start[1]) * along / length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Flatten tread/riser relief onto a shared plane, without removing walls. */
|
||||||
|
export function smoothDungeonStairGeometry(
|
||||||
|
geometry: BufferGeometry,
|
||||||
|
ramps: readonly DungeonTraversalRamp[],
|
||||||
|
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) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stitch reviewed boundary edges, so routing can use filled triangles rather than vertex-only links. */
|
||||||
|
export function bridgeDungeonStairNavigation(
|
||||||
|
geometry: BufferGeometry,
|
||||||
|
ramps: readonly DungeonTraversalRamp[],
|
||||||
|
surfaceClearance: number,
|
||||||
|
): void {
|
||||||
|
const position = geometry.getAttribute("position");
|
||||||
|
const indices = geometry.index
|
||||||
|
? Array.from(geometry.index.array)
|
||||||
|
: Array.from({ length: position.count }, (_, vertex) => vertex);
|
||||||
|
const portalVertex = (ramp: DungeonTraversalRamp, point: Vector3Tuple): number => {
|
||||||
|
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(
|
||||||
|
position.getX(vertex) - point[0],
|
||||||
|
position.getY(vertex) - height - surfaceClearance,
|
||||||
|
position.getZ(vertex) - point[2],
|
||||||
|
) <= PORTAL_VERTEX_TOLERANCE) return vertex;
|
||||||
|
}
|
||||||
|
throw new Error(`${ramp.id}: reviewed navigation portal no longer matches the source GLB.`);
|
||||||
|
};
|
||||||
|
for (const ramp of ramps) {
|
||||||
|
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 ?? [])];
|
||||||
|
}
|
||||||
@@ -156,6 +156,24 @@ export interface DungeonNavigationLink {
|
|||||||
readonly authoredReason: string;
|
readonly authoredReason: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Reviewed package-space walking proxy; the rendered staircase is unchanged. */
|
||||||
|
export interface DungeonTraversalRamp {
|
||||||
|
readonly id: string;
|
||||||
|
readonly start: Vector3Tuple;
|
||||||
|
readonly end: Vector3Tuple;
|
||||||
|
readonly width: number;
|
||||||
|
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 {
|
export interface DungeonEncounterSpawnGroupDefinition {
|
||||||
readonly id: string;
|
readonly id: string;
|
||||||
readonly spawns: readonly StaticMobSpawnDefinition[];
|
readonly spawns: readonly StaticMobSpawnDefinition[];
|
||||||
@@ -209,6 +227,8 @@ export interface DungeonSourceProvenance {
|
|||||||
|
|
||||||
export interface DungeonValidationStatus {
|
export interface DungeonValidationStatus {
|
||||||
readonly status: "green" | "blocked" | "unvalidated";
|
readonly status: "green" | "blocked" | "unvalidated";
|
||||||
|
/** Navigation-only review state; permits routing without overstating encounter validation. */
|
||||||
|
readonly navigation?: "green" | "blocked" | "unvalidated";
|
||||||
readonly report?: string;
|
readonly report?: string;
|
||||||
readonly validatedAt?: string;
|
readonly validatedAt?: string;
|
||||||
readonly blockers: readonly string[];
|
readonly blockers: readonly string[];
|
||||||
@@ -239,6 +259,7 @@ export interface DungeonDefinition {
|
|||||||
readonly bosses: readonly DungeonBossObjective[];
|
readonly bosses: readonly DungeonBossObjective[];
|
||||||
readonly objectiveOrder: readonly DungeonBossObjective[];
|
readonly objectiveOrder: readonly DungeonBossObjective[];
|
||||||
readonly navigationLinks: readonly DungeonNavigationLink[];
|
readonly navigationLinks: readonly DungeonNavigationLink[];
|
||||||
|
readonly traversalRamps?: readonly DungeonTraversalRamp[];
|
||||||
/** Serializable dungeon-wide orchestration kept separate from render state. */
|
/** Serializable dungeon-wide orchestration kept separate from render state. */
|
||||||
readonly encounters?: readonly DungeonEncounterDefinition[];
|
readonly encounters?: readonly DungeonEncounterDefinition[];
|
||||||
/** Declarative groups activated by encounter actions; renderer ownership stays outside the reducer. */
|
/** Declarative groups activated by encounter actions; renderer ownership stays outside the reducer. */
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { useCombatStore } from "./combatStore";
|
||||||
|
import { registerCombatSpatialProvider, resetCombatSpatialProvider } from "./combatSpatial";
|
||||||
|
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
|
||||||
|
import { clearPartyRuntimeRegistry } from "./partyRuntimeRegistry";
|
||||||
|
import { usePartyStore } from "./partyStore";
|
||||||
|
import { advanceDungeonPartyCombat, resetPartyRuntimeClock } from "./partyRuntime";
|
||||||
|
import { registerPartyRuntimePosition, updatePartyRuntimeTargetVisibility } from "./partyRuntimeRegistry";
|
||||||
|
import { DEFAULT_ENEMY_ATTACK, type EnemyAttackDefinition } from "./enemyAttacks";
|
||||||
|
import { createMobPursuit } from "./mobPursuit";
|
||||||
|
import { subscribeCombatPresentation, type CombatPresentationEvent } from "./combatPresentation";
|
||||||
|
|
||||||
|
let visible = true;
|
||||||
|
const melee = { ...DEFAULT_ENEMY_ATTACK, id: "swing", range: 4, initialCooldownMs: [0, 0] as const };
|
||||||
|
const spell: EnemyAttackDefinition = { ...melee, id: "bolt", range: 30, school: "shadow", animation: "cast", castTimeMs: 1_000 };
|
||||||
|
function register(id = "enemy", attacks: readonly EnemyAttackDefinition[] = [melee]) {
|
||||||
|
setMobPosition(id, [2, 0, 0]);
|
||||||
|
useCombatStore.getState().registerMob(id, { name: id, attacks, maxHealth: 1_000, xpReward: 0 });
|
||||||
|
useCombatStore.getState().damageMob(id, 1, 1_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("dungeon wall combat authorization", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
resetCombatSpatialProvider(); clearMobRuntimeRegistry(); clearPartyRuntimeRegistry(); resetPartyRuntimeClock();
|
||||||
|
useCombatStore.getState().initializeCharacter({ classId: "priest", level: 20 });
|
||||||
|
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
|
||||||
|
usePartyStore.getState().initializeParty("walls", 20, useCombatStore.getState().health);
|
||||||
|
visible = true;
|
||||||
|
registerCombatSpatialProvider({ actor: () => null, hasLineOfSight: () => visible, projectGroundPoint: (point) => point });
|
||||||
|
});
|
||||||
|
afterEach(resetCombatSpatialProvider);
|
||||||
|
|
||||||
|
it("does not swing or consume an attack cooldown through a wall despite melee distance", () => {
|
||||||
|
register(); visible = false;
|
||||||
|
const health = useCombatStore.getState().health;
|
||||||
|
const events: CombatPresentationEvent[] = [];
|
||||||
|
const unsubscribe = subscribeCombatPresentation((message) => { if (message.kind === "event") events.push(message.event); });
|
||||||
|
expect(useCombatStore.getState().advanceMobCombat(2_000)).toBe(0);
|
||||||
|
expect(useCombatStore.getState().mobs.enemy).toMatchObject({ combatPhase: "chasing", attackRevision: 0 });
|
||||||
|
expect(useCombatStore.getState().health).toBe(health);
|
||||||
|
expect(events.filter((event) => event.sourceActorId === "enemy")).toHaveLength(0);
|
||||||
|
visible = true;
|
||||||
|
expect(useCombatStore.getState().advanceMobCombat(2_100)).toBeGreaterThan(0);
|
||||||
|
expect(useCombatStore.getState().mobs.enemy.combatPhase).toBe("attacking");
|
||||||
|
unsubscribe();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not start an obstructed cast and cancels a pending cast when sight is lost", () => {
|
||||||
|
register("enemy", [spell]); visible = false;
|
||||||
|
useCombatStore.getState().advanceMobCombat(1_000);
|
||||||
|
expect(useCombatStore.getState().mobs.enemy.activeCast).toBeNull();
|
||||||
|
visible = true;
|
||||||
|
useCombatStore.getState().advanceMobCombat(1_100);
|
||||||
|
expect(useCombatStore.getState().mobs.enemy.activeCast).not.toBeNull();
|
||||||
|
const health = useCombatStore.getState().health;
|
||||||
|
visible = false;
|
||||||
|
expect(useCombatStore.getState().advanceMobCombat(1_200)).toBe(0);
|
||||||
|
expect(useCombatStore.getState().mobs.enemy).toMatchObject({ activeCast: null, combatPhase: "chasing", attackRevision: 0 });
|
||||||
|
expect(useCombatStore.getState().health).toBe(health);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not let a self buff keep a wall-blocked melee mob in attack mode", () => {
|
||||||
|
register("enemy", [melee, { ...melee, id: "buff", target: "self", effect: { kind: "aura", aura: "enrage", durationMs: 10_000 } }]);
|
||||||
|
visible = false;
|
||||||
|
useCombatStore.getState().advanceMobCombat(2_000);
|
||||||
|
expect(useCombatStore.getState().mobs.enemy.combatPhase).toBe("chasing");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not proximity-pull a visible-distance mob through a wall", () => {
|
||||||
|
setMobPosition("idle", [2, 0, 0]);
|
||||||
|
useCombatStore.getState().registerMob("idle", { name: "idle", attacks: [melee] });
|
||||||
|
visible = false;
|
||||||
|
expect(useCombatStore.getState().engageNearbyMobs(1_000)).toBe(0);
|
||||||
|
visible = true;
|
||||||
|
expect(useCombatStore.getState().engageNearbyMobs(1_001)).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resets only the stuck living mob, clears its threat/cast, restores health, and delays repulls", () => {
|
||||||
|
register("enemy", [spell]); register("other");
|
||||||
|
useCombatStore.getState().advanceMobCombat(1_000);
|
||||||
|
const other = useCombatStore.getState().mobs.other;
|
||||||
|
expect(useCombatStore.getState().resetMobForNavigation("enemy", true, 2_000)).toBe(true);
|
||||||
|
expect(useCombatStore.getState().mobs.enemy).toMatchObject({ health: 1_000, engaged: false,
|
||||||
|
combatPhase: "idle", activeCast: null, targetActorId: null, threatByActor: {}, navigationEvadeUntil: 12_000,
|
||||||
|
dead: false, experienceGranted: false });
|
||||||
|
expect(useCombatStore.getState().mobs.other).toBe(other);
|
||||||
|
expect(useCombatStore.getState().engageNearbyMobs(11_999)).toBe(0);
|
||||||
|
expect(useCombatStore.getState().engageNearbyMobs(12_000)).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not recover or resurrect a dead mob", () => {
|
||||||
|
register();
|
||||||
|
useCombatStore.getState().damageMob("enemy", 10_000, 2_000);
|
||||||
|
expect(useCombatStore.getState().resetMobForNavigation("enemy", true, 3_000)).toBe(false);
|
||||||
|
expect(useCombatStore.getState().mobs.enemy.dead).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects damage against a navigation-reset mob during its evade cooldown", () => {
|
||||||
|
register();
|
||||||
|
useCombatStore.getState().resetMobForNavigation("enemy", true, 2_000);
|
||||||
|
expect(useCombatStore.getState().damageMob("enemy", 500, 3_000).amount).toBe(0);
|
||||||
|
expect(useCombatStore.getState().tauntMob("enemy", { actorId: "__player__", role: "healer" }, 3_000, 3_000)).toBe(false);
|
||||||
|
expect(useCombatStore.getState().mobs.enemy.health).toBe(1_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cancels an NPC teammate's hostile cast immediately on lost sight so it can pursue", () => {
|
||||||
|
register();
|
||||||
|
const member = usePartyStore.getState().members[0];
|
||||||
|
registerPartyRuntimePosition(member.id, [0, 0, 0]);
|
||||||
|
updatePartyRuntimeTargetVisibility(member.id, "enemy", true);
|
||||||
|
usePartyStore.setState((state) => ({ members: state.members.map((candidate) => candidate.id === member.id
|
||||||
|
? { ...candidate, activeCastTargetId: "enemy", activeCast: {
|
||||||
|
abilityId: "mage-frostbolt", school: "frost", mode: "cast", startedAt: 1_000,
|
||||||
|
completesAt: 5_000, originalDurationMs: 4_000, pushbackHits: 0,
|
||||||
|
origin: [0, 0, 0] as const, targetId: "enemy",
|
||||||
|
} } : candidate) }));
|
||||||
|
visible = false;
|
||||||
|
advanceDungeonPartyCombat(1_100);
|
||||||
|
expect(usePartyStore.getState().members[0]).toMatchObject({ activeCast: null, status: "advancing" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("nearby teleport recovery leaves mob health, threat, target, and other mobs unchanged", () => {
|
||||||
|
register(); register("other");
|
||||||
|
const before = useCombatStore.getState().mobs;
|
||||||
|
const navigator = createMobPursuit({ graph: () => null, hasLineOfSight: (from) => from[0] > 2,
|
||||||
|
directAllowed: () => false, routeAllowed: () => true, validateLanding: (point) => point });
|
||||||
|
const position: [number, number, number] = [0, 0, 0];
|
||||||
|
const sample = { id: "enemy", targetId: "tank", position, target: [4, 0, 0] as const,
|
||||||
|
home: [0, 0, 0] as const, phase: "chasing" as const, health: before.enemy.health,
|
||||||
|
engaged: true, active: true, casting: false, controlled: false, attackRange: 1.5,
|
||||||
|
leashRange: 40, maximumStep: 0, nowMs: 0 };
|
||||||
|
navigator.step(sample);
|
||||||
|
navigator.step({ ...sample, nowMs: 2_000 });
|
||||||
|
expect(navigator.step({ ...sample, nowMs: 5_000 })).toBe("teleported");
|
||||||
|
expect(position[0]).toBeGreaterThan(2);
|
||||||
|
expect(useCombatStore.getState().mobs.enemy).toBe(before.enemy);
|
||||||
|
expect(useCombatStore.getState().mobs.other).toBe(before.other);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
|
||||||
@@ -1,2 +1,2 @@
|
|||||||
// Generated by npm run dungeon:catalog. Do not edit.
|
// Generated by npm run dungeon:catalog. Do not edit.
|
||||||
export default "H4sIAAAAAAACCuy9a5PjNpY2+FcY+e7GzkRIKODgXhP7wa5y237b1fa6PO3d6HBMQCSUySlKzKGUWa7uqP++cQBSgu5USk63s9kfqp2ESNzOec4VB/+4WeR3fub+6ptFWc9vXrPRzaJ+aHJ/8/ofN7mb3bvydv7OzcupXyxvXt8UD/NbX8/H9+W9r8q5f9U96H5L/ntRz29GN37+WDb1fObnyx9c/sHd+sXN67/dLJr81a2b+Ve3fu4bt/TFq2n56H+o3CffvI3f+nZ2XzfLRfelPa/M3NwtlnUz+2Kx8Kvvhxeyf1t6fN81n7Kpq6qJyz/8+80vOKC8fpgvffPGLV1V3968PvrlHx/my3Lm2x93Y3F/9029vHtTN/5NPZuVuCSisAUrxERq4N4wm8ucWzpVuWNMazC8mEwnxvPN19+HVf6uzj/sH8jen66W5N5X1Woe/7hxi9zP4wbeEPLqO1eUHxbvfvh/virKZd28yqvSz5fj/KFp/Hz5yjXejSW8unfL/G789tXbL/9UVn7xJvzq1Xv8NikmOW7ifZ3fxW9+413lm3duPn6/rBt36191JDD+WDcfOjpYvArvjGOXBz/9+fPoJq8fPX7o5vXf/tHR1bcFrsadK+qPUze/HX/w/v5mdDNz99jCebKNi5vXGv9clssSaYsj6S7dsszf37uP88XNa0bt6Kap3ayc3yKNLG5eg1jTd7cbed34m8+jrTEgDXws58V4sazzD67wyTDE5jBkOgy2MwzLt0dBew6i8K6YlXO/SPpWR5YAYGcJ9O4S8J69f3RlVc5vx7l79M08GYPY2gazMQa6PQaQsD0G3XMIjft73Uz9fPyhcQ9VMgJ9jBBMj1Vg0HMIk8rlH6ZueVfPxoX39+k6mM1RqI110DvrQNnOKPpSwkNVuJmbr/vW9KwV4BSevAK383rmG3+bdm+3uhfHOZHrnalz1pcTH+Yf/HyMqF4lTBiY+/AQ+M7qC7k9BHEuFRb1x5QPGNhjBCB3yNCKnU3oy4yL3DWVX45nNcon33xKhmHsETziO3TIkeq2hqF6DuPvD9XUNY37sO4e6LHugfWAAybOYcamzj+MF/dlk1ADbG8F2xiF3mEIqdQOMMLZoyj8/fIuoQjgW3zBeDoMtUdEid3NkL1p4q6u6pmb5+lKbFODOYEMCFRPHcCyQWCsZqlw3N4Jm/Yvd6SD4DvQgF/o1f/MNe6hqOepaD46+x1UkLCDCkB7w8Ktn5aNH+d3bjFLxmCOIdOufsB2d6AvPxTY/WxTNO5wAmxwwg4qabnDCbLvBizu3HLpG1+M71xVJYwgxRYjbPAB39VTrHkyNE6qui7G04dm7lJGkAKODIHtwCLj9slDuPNVFUihQSuoWW6sxJa2tLEdbGcljHyyoF4svZs9uodqmXYvz1uFPXDQV1t7mBe+mdS3ae/qCCvsUVcVe7K6uqjcox/f+/nG4usjs4c9rLgjm3jf1XdN7paN+3vSu4Rjs7ent74vH07qpZuXuUv75sekst1VkdmTqX/m8zs3d03auziL6ox4skoQjcVx5SafmnK+vEsHIc8jPvN0AFj45Z3/8GEHBqU6xvxqjzrwZIsVPRfjZT2bbPR/jP7DGm/2z+yTlQH3kN/5uSvHefPpfgMBpTm2COK0vcx17zW4LVE7XoyXvmk2xYE5CoS7NqPYAUJ1jn44ntWNW6S6obJHBrBHHD1ZK/CL3N378bRBk/Whmd/VVeq+kOqYbN6FBm52N6TvUB6Wt64p/JYfR+pj8LCHMxk8XSi1I7gv53OXpxak1MfwoQ9I9V2F5Z0fz/2vDylTaHXMdtxBBuBPh0jsvs4fqs3+zbHJ99mCvoIxf6iCH6mejvcZDNIehelddxJ7sj8toHMcRz3fGII9ixK0frKWUjTuw/LONfV8iycUpWdJK2ufbMS7v/93XY3nvnmYpP2zY/i4Yzlp+lR6WO1CVd7eLefl/DYdBZxFDPvIsq9j6fZhXmx4MhQ95tjd7dzoJ/vzHssavTmIzGn/5sgW7Bhu9qls4O7mH9ydX47rqhh/KOe3RZ2Yr4od9efs6gycP1lnmNbNrQ8MWT+kepPicExl2CFGsaO8mr7Y2JSuwhEgSOZ3bnZfpv4EJemRxdjZEvnULbkvl2EZEBjS3o8RBN/lBmWebDyuuLLx08rny81lUEdVuN2BPBmil+X8g2+WPr8bL+7qj+hxXQ/DwBZGsXQYu5Spjo3ivqlzXzw0rhp3ccJd+mzqv/v5uPEuv0ud3+aZh1E3t/Vy6edjjAYlnl9tfusF+WV0U/hpOS+RIBYhVLcbMS73R+2W5bLCD79ftWR/3onnVdPbLuxb4PcNhmp/vXddODOv3GJR5psR5Xd1EQipWI7vPk2aEkG0KKfTMn+olp/+6prSzZdxtGFs87qZuWrjR+nTBa7PO7f4EGZT+Udf/ejmITLJzIjTX9plQNfK29UXvt34snt0ZeUmZVWGb7d/Bn3zvvELP8edwBn946aq8/a/9yzN8s7PcG7f3/s5RlarIvO/3vuibDly8TCbuQa7+BOKz2x519QPt3fZ1pdGWeGn3i2zcrnIVhSSNTWaR6PMzYsMdylb3vkMPVafMleVj57cjG6q2hXl/PadXyxCdPbmh8bfu6ac3253Qgj+/mGe37lm6YsvGu/+4sLo/7N7lu3OEAnrtqkf5rh+/4saWlDc3mkMZed1VTehwTFg9GZ0M/euiYHPKf4HKPp5dONmEwwpf4drsPGaMxNhUa8p50uMieNuUALweXRz52fl4v7ON3712uLDpzfdm0bb3KALJQ5u9ZxaOqF2+4tKfB4F/2uASlftjqSAXNti8z02uqmn04VfImnBCOhorH/5jAyx9CiJFuELD8v6YflDcK0Gnrv5ry/Z/4E5DLEF1xgf/zK6mVR+Xmz+EsIvQ0PyQ1cgBT36zd+K8NuubfXzMKB7HMrHsljeoZOe4vLFKXLUVO/xpfltVAjyer6sH5rw0XcgMqAye2NUxpjJmIAMhM6Amoxplr3nXGSaZYKqjIG6Gd284yYDbrM3jNqMGZ4xDRkok4Gg4bX3XIjMqoxbnWl98wsmDzhM/Air9VguHly15vX279HNtKx8S47xGbmtkDAemurm9c2r+IWYdrHK+njF+XgNZK823svvfP5h8TC7eX3Dc+GhsN4aj8kdzGGWh+fMc8nAGkWld0ZMCsM5ALM5dVRz55gwEzt1WiMfl3/3qClZo4USo6CIzG8r/wY59ea1sEAZRdzJ66oqIxh2U1w9GlNKN2e60XT+hPe9nsyb2mmucko9OGanU2FyVkxEMTHWT7ylTHI6KaxhGqbc6okSntlCc0clnRZa0WTeTFi1M2slJVco/XamyQ5Pk102TbY7TaWY0Uw6x11hnIKJ1UZSy5jTiikrpaKaG8oL4AYknzJGJznkwKYUitzKdJoKvS29pwmHpwmXTRN2p6klkyCm0kyYVs6zyUROClCF01IXPNe0AC35pNBGm9xKSZ2YOOnkNPecSjdJpgkUzBnT5IenyS+bJt+d5oQzp8QUBMicFrmUU80YCADgmtqpLQrpvLOSTqmSTkjtncmnjOqJnfrc+Y3dNOdMUxyeprhsmmJ3mmzCxNRZMQGTazE1lHqcCgVhhJlKJ7QRnGrqppO8mFg7meTeTycqtyafAJebvAlnTFMenqa8bJpyD9Eya8XEGS3ddKJzAANTyBVzEykMp5ZR6cE4J21hJsXEw2TKinwi1dS5nHmbTFNYdc5uqsPTVJdNU+1OE6ZTNoVcQ8GpLpRiVMmJd3o6pTlIjoNnjHmgXljFJoJNOIepoRrERFkmkmlKI86Zpj48TX3ZNPXuNIuJdJpScLnjuXWOmSnT1CvquZ0q4LkwTPpcWa8UCC+nArTPJ7YoOLd2YpJpKkHFGdM0h6dpLpum2SNQCjcpFOeCFgqUmSjlQDvnvAZKpZxMPZtA4b1yVjLwAnJq1BSmfGqUsCadJqfyHN60h6dpL5um3Z2mkdRQD1Om81wZ1IGcN/m04MZ5Psl9PtFScs+E1VL5KfWSCmONs1pw7aapQJESzpCb7LAWxC7TgtgeLchSmHBTeBB8Mply76a+8MKaYjIFPZ1QxVkhmKXFlOspsJxSWgg2RdUA2ESvp8mtNHSXN7Xi1u6d5mEtiF2mBbF9WhCYnJuJNCJXeWEceDBgmbXSeKMFc3SqCjmZOm+8EkJTnivMfi6o916kAsUado4WxA5rQewyLYjt0YJkrqy2aCzbiVK51tQwP9FeKw/TiWAKvPBMCeqBTimnwMFDLiaOWqF0IlCAcb4PaZU2Yu80D2tB7DItiO3RgizLvc5zqwstQckJ0IJZxaVSkrHCUmONBMscz3M3LYwXWio6zWmuuKdTnkCQ4EywXd0dkWv/dh5Wg9hlahDbowYp1GcnE4EKAlfFdAJcKZUXzhk+NZQZZZhT1nCwhcwdeGUFV26qYEqN1W69naD5HuZUmhu5d5qH1SB2mRrE9qhBdMINTDjaIwqM8xOaC6dyyp0UzDifg+dawMQXBUwMeJpP7JS7Qk/clE51qgYxq8+QKOywGsQuU4PYHjXIeJ/Twno6pdJapUHCxHCRWyWnHNUG6SdsQqcTZSbe5uDAyqgWCmkZX0+TK672TFMLS/cz52E1iF2mBrE9apA0uTLGS27YxMkcvMGJUiNdoXTuhQFRWA6QT50UQtFcSea9FULAhAkNa6JVRuyDWmNg/zQPq0HsMjWI7VGDtCmMUc4WxjIJmuYTwYQXlLuJ5LlxggHN7WSqp2BzkEI5qyR4KBSXOZvy1TQlpVbsantWM9gvOA+rQewyNYjtUYN4URjGclEowS0KzYk2fJLnclJoy6gFwRnlTuemKIxDP8qkkK6YFpBTPbVr7xA3iu/ZTa0o5/umCYfVILhMDYI9apCYUFc4aSUFLlC6cKWdlnzqcllI6Yw2zoIvKJdWTY2aiIILyQo9nUzB0MRE0caKM/QDOKwGwWVLine truncated
|
export default "H4sIAAAAAAACCuy9a5PjNpY2+FcY+e7GzkRIKODgXhP7wa5y237b1fa6PO3d6HBMQCSUySlKzKGUWa7uqP++cQBSgu5USk63s9kfqp2ESNzOec4VB/+4WeR3fub+6ptFWc9vXrPRzaJ+aHJ/8/ofN7mb3bvydv7OzcupXyxvXt8UD/NbX8/H9+W9r8q5f9U96H5L/ntRz29GN37+WDb1fObnyx9c/sHd+sXN67/dLJr81a2b+Ve3fu4bt/TFq2n56H+o3CffvI3f+nZ2XzfLRfelPa/M3NwtlnUz+2Kx8Kvvhxeyf1t6fN81n7Kpq6qJyz/8+80vOKC8fpgvffPGLV1V3968PvrlHx/my3Lm2x93Y3F/9029vHtTN/5NPZuVuCSisAUrxERq4N4wm8ucWzpVuWNMazC8mEwnxvPN19+HVf6uzj/sH8jen66W5N5X1Woe/7hxi9zP4wbeEPLqO1eUHxbvfvh/virKZd28yqvSz5fj/KFp/Hz5yjXejSW8unfL/G789tXbL/9UVn7xJvzq1Xv8NikmOW7ifZ3fxW9+413lm3duPn6/rBt36191JDD+WDcfOjpYvArvjGOXBz/9+fPoJq8fPX7o5vXf/tHR1bcFrsadK+qPUze/HX/w/v5mdDNz99jCebKNi5vXGv9clssSaYsj6S7dsszf37uP88XNa0bt6Kap3ayc3yKNLG5eg1jTd7cbed34m8+jrTEgDXws58V4sazzD67wyTDE5jBkOgy2MwzLt0dBew6i8K6YlXO/SPpWR5YAYGcJ9O4S8J69f3RlVc5vx7l79M08GYPY2gazMQa6PQaQsD0G3XMIjft73Uz9fPyhcQ9VMgJ9jBBMj1Vg0HMIk8rlH6ZueVfPxoX39+k6mM1RqI110DvrQNnOKPpSwkNVuJmbr/vW9KwV4BSevAK383rmG3+bdm+3uhfHOZHrnalz1pcTH+Yf/HyMqF4lTBiY+/AQ+M7qC7k9BHEuFRb1x5QPGNhjBCB3yNCKnU3oy4yL3DWVX45nNcon33xKhmHsETziO3TIkeq2hqF6DuPvD9XUNY37sO4e6LHugfWAAybOYcamzj+MF/dlk1ADbG8F2xiF3mEIqdQOMMLZoyj8/fIuoQjgW3zBeDoMtUdEid3NkL1p4q6u6pmb5+lKbFODOYEMCFRPHcCyQWCsZqlw3N4Jm/Yvd6SD4DvQgF/o1f/MNe6hqOepaD46+x1UkLCDCkB7w8Ktn5aNH+d3bjFLxmCOIdOufsB2d6AvPxTY/WxTNO5wAmxwwg4qabnDCbLvBizu3HLpG1+M71xVJYwgxRYjbPAB39VTrHkyNE6qui7G04dm7lJGkAKODIHtwCLj9slDuPNVFUihQSuoWW6sxJa2tLEdbGcljHyyoF4svZs9uodqmXYvz1uFPXDQV1t7mBe+mdS3ae/qCCvsUVcVe7K6uqjcox/f+/nG4usjs4c9rLgjm3jf1XdN7paN+3vSu4Rjs7ent74vH07qpZuXuUv75sekst1VkdmTqX/m8zs3d03auziL6ox4skoQjcVx5SafmnK+vEsHIc8jPvN0AFj45Z3/8GEHBqU6xvxqjzrwZIsVPRfjZT2bbPR/jP7DGm/2z+yTlQH3kN/5uSvHefPpfgMBpTm2COK0vcx17zW4LVE7XoyXvmk2xYE5CoS7NqPYAUJ1jn44ntWNW6S6obJHBrBHHD1ZK/CL3N378bRBk/Whmd/VVeq+kOqYbN6FBm52N6TvUB6Wt64p/JYfR+pj8LCHMxk8XSi1I7gv53OXpxak1MfwoQ9I9V2F5Z0fz/2vDylTaHXMdtxBBuBPh0jsvs4fqs3+zbHJ99mCvoIxf6iCH6mejvcZDNIehelddxJ7sj8toHMcRz3fGII9ixK0frKWUjTuw/LONfV8iycUpWdJK2ufbMS7v/93XY3nvnmYpP2zY/i4Yzlp+lR6WO1CVd7eLefl/DYdBZxFDPvIsq9j6fZhXmx4MhQ95tjd7dzoJ/vzHssavTmIzGn/5sgW7Bhu9qls4O7mH9ydX47rqhh/KOe3RZ2Yr4od9efs6gycP1lnmNbNrQ8MWT+kepPicExl2CFGsaO8mr7Y2JSuwhEgSOZ3bnZfpv4EJemRxdjZEvnULbkvl2EZEBjS3o8RBN/lBmWebDyuuLLx08rny81lUEdVuN2BPBmil+X8g2+WPr8bL+7qj+hxXQ/DwBZGsXQYu5Spjo3ivqlzXzw0rhp3ccJd+mzqv/v5uPEuv0ud3+aZh1E3t/Vy6edjjAYlnl9tfusF+WV0U/hpOS+RIBYhVLcbMS73R+2W5bLCD79ftWR/3onnVdPbLuxb4PcNhmp/vXddODOv3GJR5psR5Xd1EQipWI7vPk2aEkG0KKfTMn+olp/+6prSzZdxtGFs87qZuWrjR+nTBa7PO7f4EGZT+Udf/ejmITLJzIjTX9plQNfK29UXvt34snt0ZeUmZVWGb7d/Bn3zvvELP8edwBn946aq8/a/9yzN8s7PcG7f3/s5RlarIvO/3vuibDly8TCbuQa7+BOKz2x519QPt3fZ1pdGWeGn3i2zcrnIVhSSNTWaR6PMzYsMdylb3vkMPVafMleVj57cjG6q2hXl/PadXyxCdPbmh8bfu6ac3253Qgj+/mGe37lm6YsvGu/+4sLo/7N7lu3OEAnrtqkf5rh+/4saWlDc3mkMZed1VTehwTFg9GZ0M/euiYHPKf4HKPp5dONmEwwpf4drsPGaMxNhUa8p50uMieNuUALweXRz52fl4v7ON3712uLDpzfdm0bb3KALJQ5u9ZxaOqF2+4tKfB4F/2uASlftjqSAXNti8z02uqmn04VfImnBCOhorH/5jAyx9CiJFuELD8v6YflDcK0Gnrv5ry/Z/4E5DLEF1xgf/zK6mVR+Xmz+EsIvQ0PyQ1cgBT36zd+K8NuubfXzMKB7HMrHsljeoZOe4vLFKXLUVO/xpfltVAjyer6sH5rw0XcgMqAye2NUxpjJmIAMhM6Amoxplr3nXGSaZYKqjIG6Gd284yYDbrM3jNqMGZ4xDRkok4Gg4bX3XIjMqoxbnWl98wsmDzhM/Air9VguHly15vX279HNtKx8S47xGbmtkDAemurm9c2r+IWYdrHK+njF+XgNZK823svvfP5h8TC7eX3Dc+GhsN4aj8kdzGGWh+fMc8nAGkWld0ZMCsM5ALM5dVRz55gwEzt1WiMfl3/3qClZo4USo6CIzG8r/wY59ea1sEAZRdzJ66oqIxh2U1w9GlNKN2e60XT+hPe9nsyb2mmucko9OGanU2FyVkxEMTHWT7ylTHI6KaxhGqbc6okSntlCc0clnRZa0WTeTFi1M2slJVco/XamyQ5Pk102TbY7TaWY0Uw6x11hnIKJ1UZSy5jTiikrpaKaG8oL4AYknzJGJznkwKYUitzKdJoKvS29pwmHpwmXTRN2p6klkyCm0kyYVs6zyUROClCF01IXPNe0AC35pNBGm9xKSZ2YOOnkNPecSjdJpgkUzBnT5IenyS+bJt+d5oQzp8QUBMicFrmUU80YCADgmtqpLQrpvLOSTqmSTkjtncmnjOqJnfrc+Y3dNOdMUxyeprhsmmJ3mmzCxNRZMQGTazE1lHqcCgVhhJlKJ7QRnGrqppO8mFg7meTeTycqtyafAJebvAlnTFMenqa8bJpyD9Eya8XEGS3ddKJzAANTyBVzEykMp5ZR6cE4J21hJsXEw2TKinwi1dS5nHmbTFNYdc5uqsPTVJdNU+1OE6ZTNoVcQ8GpLpRiVMmJd3o6pTlIjoNnjHmgXljFJoJNOIepoRrERFkmkmlKI86Zpj48TX3ZNPXuNIuJdJpScLnjuXWOmSnT1CvquZ0q4LkwTPpcWa8UCC+nArTPJ7YoOLd2YpJpKkHFGdM0h6dpLpum2SNQCjcpFOeCFgqUmSjlQDvnvAZKpZxMPZtA4b1yVjLwAnJq1BSmfGqUsCadJqfyHN60h6dpL5um3Z2mkdRQD1Om81wZ1IGcN/m04MZ5Psl9PtFScs+E1VL5KfWSCmONs1pw7aapQJESzpCb7LAWxC7TgtgeLchSmHBTeBB8Mply76a+8MKaYjIFPZ1QxVkhmKXFlOspsJxSWgg2RdUA2ESvp8mtNHSXN7Xi1u6d5mEtiF2mBbF9WhCYnJuJNCJXeWEceDBgmbXSeKMFc3SqCjmZOm+8EkJTnivMfi6o916kAsUado4WxA5rQewyLYjt0YJkrqy2aCzbiVK51tQwP9FeKw/TiWAKvPBMCeqBTimnwMFDLiaOWqF0IlCAcb4PaZU2Yu80D2tB7DItiO3RgizLvc5zqwstQckJ0IJZxaVSkrHCUmONBMscz3M3LYwXWio6zWmuuKdTnkCQ4EywXd0dkWv/dh5Wg9hlahDbowYp1GcnE4EKAlfFdAJcKZUXzhk+NZQZZZhT1nCwhcwdeGUFV26qYEqN1W69naD5HuZUmhu5d5qH1SB2mRrE9qhBdMINTDjaIwqM8xOaC6dyyp0UzDifg+dawMQXBUwMeJpP7JS7Qk/clE51qgYxq8+QKOywGsQuU4PYHjXIeJ/Twno6pdJapUHCxHCRWyWnHNUG6SdsQqcTZSbe5uDAyqgWCmkZX0+TK672TFMLS/cz52E1iF2mBrE9apA0uTLGS27YxMkcvMGJUiNdoXTuhQFRWA6QT50UQtFcSea9FULAhAkNa6JVRuyDWmNg/zQPq0HsMjWI7VGDtCmMUc4WxjIJmuYTwYQXlLuJ5LlxggHN7WSqp2BzkEI5qyR4KBSXOZvy1TQlpVbsantWM9gvOA+rQewyNYjtUYN4URjGclEowS0KzYk2fJLnclJoy6gFwRnlTuemKIxDP8qkkK6YFpBTPbVr7xA3iu/ZTa0o5/umCYfVILhMDYI9apCYUFc4aSUFLlC6cKWdlnzqcllI6Yw2zoIvKJdWTY2aiIILyQo9nUzB0MRE0caKM/QDOKwGwWVLine truncated
|
||||||
@@ -1 +1 @@
|
|||||||
{"schemaVersion":1,"source":{"campaignManifest":"dungeon-pipeline/dungeon-campaign.json","environmentPackages":["src/game/generated/fivePlayerDungeonImports.json","src/game/generated/manastormAssetPackages.json (temporary fallback)"],"encounterCatalog":"src/game/generated/manastormRuntimeCatalog.json","azerothCoreCommit":"4d9d1d4b5723e819c5c390f6ca1177283dbfb8e3","azerothCoreSourceLock":"src/game/generated/azerothCoreSourceLock.json","spellCatalog":{"ascension":"../LadiksMPQEditor/client-current/area-52/patch-D/DBFilesClient/Spell.dbc","epoch":"../HealerMan-Storage/pipeline-work/dungeons/epoch-client/DBFilesClient/Spell.dbc"}},"coverage":[{"dungeonId":"shadowfang-keep","mapId":33,"encounters":7,"entities":31,"staticSpawns":109,"roamingPacks":24,"source":"azerothcore"},{"dungeonId":"stormwind-stockade","mapId":34,"encounters":5,"entities":11,"staticSpawns":93,"roamingPacks":0,"source":"azerothcore"},{"dungeonId":"deadmines","mapId":36,"encounters":7,"entities":22,"staticSpawns":179,"roamingPacks":23,"source":"azerothcore"},{"dungeonId":"wailing-caverns","mapId":43,"encounters":8,"entities":20,"staticSpawns":252,"roamingPacks":7,"source":"azerothcore"},{"dungeonId":"razorfen-kraul","mapId":47,"encounters":7,"entities":38,"staticSpawns":179,"roamingPacks":12,"source":"azerothcore"},{"dungeonId":"blackfathom-deeps","mapId":48,"encounters":6,"entities":27,"staticSpawns":201,"roamingPacks":10,"source":"azerothcore"},{"dungeonId":"uldaman","mapId":70,"encounters":7,"entities":38,"staticSpawns":302,"roamingPacks":12,"source":"azerothcore"},{"dungeonId":"gnomeregan","mapId":90,"encounters":4,"entities":31,"staticSpawns":371,"roamingPacks":31,"source":"azerothcore"},{"dungeonId":"sunken-temple","mapId":109,"encounters":4,"entities":33,"staticSpawns":245,"roamingPacks":4,"source":"azerothcore"},{"dungeonId":"razorfen-downs","mapId":129,"encounters":6,"entities":25,"staticSpawns":194,"roamingPacks":13,"source":"azerothcore"},{"dungeonId":"scarlet-monastery","mapId":189,"encounters":5,"entities":37,"staticSpawns":310,"roamingPacks":16,"source":"azerothcore"},{"dungeonId":"zulfarrak","mapId":209,"encounters":5,"entities":21,"staticSpawns":252,"roamingPacks":14,"source":"azerothcore"},{"dungeonId":"blackrock-spire","mapId":229,"encounters":15,"entities":78,"staticSpawns":566,"roamingPacks":22,"source":"azerothcore"},{"dungeonId":"blackrock-depths","mapId":230,"encounters":13,"entities":61,"staticSpawns":1040,"roamingPacks":15,"source":"azerothcore"},{"dungeonId":"scholomance","mapId":289,"encounters":8,"entities":38,"staticSpawns":327,"roamingPacks":15,"source":"azerothcore"},{"dungeonId":"stratholme","mapId":329,"encounters":9,"entities":50,"staticSpawns":431,"roamingPacks":29,"source":"azerothcore"},{"dungeonId":"maraudon","mapId":349,"encounters":8,"entities":33,"staticSpawns":525,"roamingPacks":20,"source":"azerothcore"},{"dungeonId":"ragefire-chasm","mapId":389,"encounters":4,"entities":11,"staticSpawns":127,"roamingPacks":4,"source":"azerothcore"},{"dungeonId":"dire-maul","mapId":429,"encounters":12,"entities":75,"staticSpawns":756,"roamingPacks":59,"source":"azerothcore"},{"dungeonId":"shattered-halls","mapId":540,"encounters":3,"entities":32,"staticSpawns":198,"roamingPacks":13,"source":"azerothcore"},{"dungeonId":"blood-furnace","mapId":542,"encounters":3,"entities":17,"staticSpawns":139,"roamingPacks":13,"source":"azerothcore"},{"dungeonId":"hellfire-ramparts","mapId":543,"encounters":2,"entities":12,"staticSpawns":85,"roamingPacks":12,"source":"azerothcore"},{"dungeonId":"steamvault","mapId":545,"encounters":3,"entities":17,"staticSpawns":127,"roamingPacks":17,"source":"azerothcore"},{"dungeonId":"underbog","mapId":546,"encounters":4,"entities":22,"staticSpawns":161,"roamingPacks":23,"source":"azerothcore"},{"dungeonId":"slave-pens","mapId":547,"encounters":3,"entities":21,"staticSpawns":122,"roamingPacks":32,"source":"azerothcore"},{"dungeonId":"arcatraz","mapId":552,"encounters":4,"entities":29,"staticSpawns":85,"roamingPacks":9,"source":"azerothcore"},{"dungeonId":"botanica","mapId":553,"encounters":5,"entities":29,"staticSpawns":171,"roamingPacks":13,"source":"azerothcore"},{"dungeonId":"mechanar","mapId":554,"encounters":3,"entities":17,"staticSpawns":84,"roamingPacks":14,"source":"azerothcore"},{"dungeonId":"shadow-labyrinth","mapId":555,"encounters":4,"entities":22,"staticSpawns":189,"roamingPacks":13,"source":"azerothcore"},{"dungeonId":"sethekk-halls","mapId":556,"encounters":2,"entities":16,"staticSpawns":104,"roamingPacks":24,"source":"azerothcore"},{"dungeonId":"mana-tombs","mapId":557,"encounters":3,"entities":14,"staticSpawns":119,"roamingPacks":15,"source":"azerothcore"},{"dungeonId":"auchenai-crypts","mapId":558,"encounters":2,"entities":14,"staticSpawns":93,"roamingPacks":37,"source":"azerothcore"},{"dungeonId":"magisters-terrace","mapId":585,"encounters":3,"entities":18,"staticSpawns":147,"roamingPacks":6,"source":"azerothcore"},{"dungeonId":"black-morass","mapId":269,"encounters":3,"entitiesLine truncated
|
{"schemaVersion":1,"source":{"campaignManifest":"dungeon-pipeline/dungeon-campaign.json","environmentPackages":["src/game/generated/fivePlayerDungeonImports.json","src/game/generated/manastormAssetPackages.json (temporary fallback)"],"encounterCatalog":"src/game/generated/manastormRuntimeCatalog.json","azerothCoreCommit":"4d9d1d4b5723e819c5c390f6ca1177283dbfb8e3","azerothCoreSourceLock":"src/game/generated/azerothCoreSourceLock.json","spellCatalog":{"ascension":"../LadiksMPQEditor/client-current/area-52/patch-D/DBFilesClient/Spell.dbc","epoch":"../HealerMan-Storage/pipeline-work/dungeons/epoch-client/DBFilesClient/Spell.dbc"}},"coverage":[{"dungeonId":"shadowfang-keep","mapId":33,"encounters":7,"entities":31,"staticSpawns":109,"roamingPacks":24,"source":"azerothcore"},{"dungeonId":"stormwind-stockade","mapId":34,"encounters":5,"entities":11,"staticSpawns":93,"roamingPacks":0,"source":"azerothcore"},{"dungeonId":"deadmines","mapId":36,"encounters":7,"entities":22,"staticSpawns":179,"roamingPacks":23,"source":"azerothcore"},{"dungeonId":"wailing-caverns","mapId":43,"encounters":8,"entities":20,"staticSpawns":252,"roamingPacks":7,"source":"azerothcore"},{"dungeonId":"razorfen-kraul","mapId":47,"encounters":7,"entities":38,"staticSpawns":179,"roamingPacks":12,"source":"azerothcore"},{"dungeonId":"blackfathom-deeps","mapId":48,"encounters":6,"entities":27,"staticSpawns":201,"roamingPacks":10,"source":"azerothcore"},{"dungeonId":"uldaman","mapId":70,"encounters":7,"entities":38,"staticSpawns":302,"roamingPacks":12,"source":"azerothcore"},{"dungeonId":"gnomeregan","mapId":90,"encounters":4,"entities":31,"staticSpawns":371,"roamingPacks":31,"source":"azerothcore"},{"dungeonId":"sunken-temple","mapId":109,"encounters":4,"entities":33,"staticSpawns":245,"roamingPacks":4,"source":"azerothcore"},{"dungeonId":"razorfen-downs","mapId":129,"encounters":6,"entities":25,"staticSpawns":194,"roamingPacks":13,"source":"azerothcore"},{"dungeonId":"scarlet-monastery","mapId":189,"encounters":5,"entities":37,"staticSpawns":310,"roamingPacks":16,"source":"azerothcore"},{"dungeonId":"zulfarrak","mapId":209,"encounters":5,"entities":21,"staticSpawns":252,"roamingPacks":14,"source":"azerothcore"},{"dungeonId":"blackrock-spire","mapId":229,"encounters":15,"entities":78,"staticSpawns":566,"roamingPacks":22,"source":"azerothcore"},{"dungeonId":"blackrock-depths","mapId":230,"encounters":13,"entities":61,"staticSpawns":1040,"roamingPacks":15,"source":"azerothcore"},{"dungeonId":"scholomance","mapId":289,"encounters":8,"entities":38,"staticSpawns":327,"roamingPacks":15,"source":"azerothcore"},{"dungeonId":"stratholme","mapId":329,"encounters":9,"entities":50,"staticSpawns":431,"roamingPacks":29,"source":"azerothcore"},{"dungeonId":"maraudon","mapId":349,"encounters":8,"entities":33,"staticSpawns":525,"roamingPacks":20,"source":"azerothcore"},{"dungeonId":"ragefire-chasm","mapId":389,"encounters":4,"entities":11,"staticSpawns":127,"roamingPacks":4,"source":"azerothcore"},{"dungeonId":"dire-maul","mapId":429,"encounters":12,"entities":75,"staticSpawns":756,"roamingPacks":59,"source":"azerothcore"},{"dungeonId":"shattered-halls","mapId":540,"encounters":3,"entities":32,"staticSpawns":198,"roamingPacks":13,"source":"azerothcore"},{"dungeonId":"blood-furnace","mapId":542,"encounters":3,"entities":17,"staticSpawns":139,"roamingPacks":13,"source":"azerothcore"},{"dungeonId":"hellfire-ramparts","mapId":543,"encounters":2,"entities":12,"staticSpawns":85,"roamingPacks":12,"source":"azerothcore"},{"dungeonId":"steamvault","mapId":545,"encounters":3,"entities":17,"staticSpawns":127,"roamingPacks":17,"source":"azerothcore"},{"dungeonId":"underbog","mapId":546,"encounters":4,"entities":22,"staticSpawns":161,"roamingPacks":23,"source":"azerothcore"},{"dungeonId":"slave-pens","mapId":547,"encounters":3,"entities":21,"staticSpawns":122,"roamingPacks":32,"source":"azerothcore"},{"dungeonId":"arcatraz","mapId":552,"encounters":4,"entities":29,"staticSpawns":85,"roamingPacks":9,"source":"azerothcore"},{"dungeonId":"botanica","mapId":553,"encounters":5,"entities":29,"staticSpawns":171,"roamingPacks":13,"source":"azerothcore"},{"dungeonId":"mechanar","mapId":554,"encounters":3,"entities":17,"staticSpawns":84,"roamingPacks":14,"source":"azerothcore"},{"dungeonId":"shadow-labyrinth","mapId":555,"encounters":4,"entities":22,"staticSpawns":189,"roamingPacks":13,"source":"azerothcore"},{"dungeonId":"sethekk-halls","mapId":556,"encounters":2,"entities":16,"staticSpawns":104,"roamingPacks":24,"source":"azerothcore"},{"dungeonId":"mana-tombs","mapId":557,"encounters":3,"entities":14,"staticSpawns":119,"roamingPacks":15,"source":"azerothcore"},{"dungeonId":"auchenai-crypts","mapId":558,"encounters":2,"entities":14,"staticSpawns":93,"roamingPacks":37,"source":"azerothcore"},{"dungeonId":"magisters-terrace","mapId":585,"encounters":3,"entities":18,"staticSpawns":147,"roamingPacks":6,"source":"azerothcore"},{"dungeonId":"black-morass","mapId":269,"encounters":3,"entitiesLine truncated
|
||||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,133 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { createMobPursuit, chooseMobPursuitLanding, type MobPursuitSample, type MobPursuitSpatial } from "./mobPursuit";
|
||||||
|
import type { PartyNavigationGraph } from "./partyNavigation";
|
||||||
|
|
||||||
|
function sample(patch: Partial<MobPursuitSample> = {}): MobPursuitSample {
|
||||||
|
return { id: "mob", targetId: "tank", position: [0, 0, 0], target: [4, 0, 0], home: [0, 0, 0],
|
||||||
|
phase: "chasing", health: 100, engaged: true, active: true, casting: false, controlled: false,
|
||||||
|
attackRange: 1.5, leashRange: 40, maximumStep: 0, nowMs: 0, ...patch };
|
||||||
|
}
|
||||||
|
|
||||||
|
function spatial(patch: Partial<MobPursuitSpatial> = {}): MobPursuitSpatial {
|
||||||
|
return { graph: () => null, hasLineOfSight: () => false, directAllowed: () => false,
|
||||||
|
routeAllowed: () => true, validateLanding: (point) => point, ...patch };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("wall-aware mob pursuit", () => {
|
||||||
|
it("moves toward a blocked target even when the old combat snapshot says attacking", () => {
|
||||||
|
const navigator = createMobPursuit(spatial({ directAllowed: () => true }));
|
||||||
|
const state = sample({ phase: "attacking", attackRange: 6, maximumStep: 0.2 });
|
||||||
|
expect(navigator.step(state)).toBe("none");
|
||||||
|
expect(state.position[0]).toBeCloseTo(0.2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("holds only when range and clear sight agree", () => {
|
||||||
|
const navigator = createMobPursuit(spatial({ hasLineOfSight: () => true, directAllowed: () => true }));
|
||||||
|
const state = sample({ attackRange: 6, maximumStep: 0.2 });
|
||||||
|
navigator.step(state);
|
||||||
|
expect(state.position).toEqual([0, 0, 0]);
|
||||||
|
expect(navigator.step({ ...state, nowMs: 10_000 })).toBe("none");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("routes around a wall through a doorway rather than cutting through it", () => {
|
||||||
|
const positions = [[-2, 0, 0], [-2, 0, 3], [2, 0, 3], [2, 0, 0]] as const;
|
||||||
|
const graph: PartyNavigationGraph = {
|
||||||
|
nodes: positions.map((position, id) => ({ id, position, neighbors: [id - 1, id + 1]
|
||||||
|
.filter((to) => to >= 0 && to < positions.length).map((to) => ({ to, cost: 4 })) })),
|
||||||
|
weldTolerance: 0.001, sourceVertexCount: 4, sourceTriangleCount: 0,
|
||||||
|
weldedNodeCount: 4, componentCount: 1, discardedNodeCount: 0,
|
||||||
|
};
|
||||||
|
const clear = (a: readonly number[], b: readonly number[]) => {
|
||||||
|
if (a[0] * b[0] >= 0) return true;
|
||||||
|
const t = -a[0] / (b[0] - a[0]);
|
||||||
|
return a[2] + (b[2] - a[2]) * t >= 2.5;
|
||||||
|
};
|
||||||
|
const navigator = createMobPursuit(spatial({ graph: () => graph, directAllowed: clear, hasLineOfSight: clear }));
|
||||||
|
const state = sample({ position: [-2, 0, 0], home: [-2, 0, 0], target: [2, 0, 0], maximumStep: 0.1, attackRange: 0.5 });
|
||||||
|
let reachedDoorway = false;
|
||||||
|
for (let frame = 0; frame < 120; frame += 1) {
|
||||||
|
const previous = [...state.position];
|
||||||
|
expect(navigator.step({ ...state, nowMs: frame * 50 })).toBe("none");
|
||||||
|
expect(clear(previous, state.position)).toBe(true);
|
||||||
|
if (state.position[2] >= 2.5) reachedDoorway = true;
|
||||||
|
}
|
||||||
|
expect(reachedDoorway).toBe(true);
|
||||||
|
expect(Math.hypot(state.position[0] - 2, state.position[2])).toBeLessThanOrEqual(0.6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("recovers a nearby stalled mob only after five seconds and preserves a ten-second cooldown", () => {
|
||||||
|
const navigator = createMobPursuit(spatial({ hasLineOfSight: (a) => a[0] > 2 }));
|
||||||
|
const state = sample();
|
||||||
|
navigator.step(state);
|
||||||
|
expect(navigator.step({ ...state, nowMs: 2_000 })).toBe("none");
|
||||||
|
expect(navigator.step({ ...state, nowMs: 4_999 })).toBe("none");
|
||||||
|
expect(navigator.step({ ...state, nowMs: 5_000 })).toBe("teleported");
|
||||||
|
expect(state.position[0]).toBeGreaterThan(2);
|
||||||
|
state.position[0] = 0;
|
||||||
|
navigator.step({ ...state, nowMs: 5_001 });
|
||||||
|
navigator.step({ ...state, nowMs: 7_001 });
|
||||||
|
expect(navigator.step({ ...state, nowMs: 14_999 })).toBe("none");
|
||||||
|
expect(navigator.step({ ...state, nowMs: 15_000 })).toBe("teleported");
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
{ active: false }, { casting: true }, { controlled: true }, { health: 0 }, { engaged: false },
|
||||||
|
])("does not recover intentional immobility %j", (exclusion) => {
|
||||||
|
const navigator = createMobPursuit(spatial());
|
||||||
|
const state = sample(exclusion);
|
||||||
|
navigator.step(state);
|
||||||
|
expect(navigator.step({ ...state, nowMs: 10_000 })).toBe("none");
|
||||||
|
expect(state.position).toEqual([0, 0, 0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
{ target: [12, 0, 0] as const }, { target: [4, 5, 0] as const }, { leashRange: 2 },
|
||||||
|
])("resets instead of teleporting to a distant, wrong-floor, or out-of-leash target %j", (patch) => {
|
||||||
|
const navigator = createMobPursuit(spatial());
|
||||||
|
const state = sample(patch);
|
||||||
|
navigator.step(state);
|
||||||
|
navigator.step({ ...state, nowMs: 2_000 });
|
||||||
|
expect(navigator.step({ ...state, nowMs: 5_000 })).toBe("reset");
|
||||||
|
expect(state.position).toEqual(state.home);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resets when every nearby target landing is blocked without applying an invalid home landing", () => {
|
||||||
|
const navigator = createMobPursuit(spatial({ validateLanding: () => null }));
|
||||||
|
const state = sample({ position: [1, 0, 0] });
|
||||||
|
navigator.step(state);
|
||||||
|
navigator.step({ ...state, nowMs: 2_000 });
|
||||||
|
expect(navigator.step({ ...state, nowMs: 5_000 })).toBe("reset");
|
||||||
|
expect(state.position).toEqual([1, 0, 0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("recovers a stalled return to validated home, never to the old target", () => {
|
||||||
|
const navigator = createMobPursuit(spatial());
|
||||||
|
const state = sample({ engaged: false, phase: "returning", position: [5, 0, 0] });
|
||||||
|
navigator.step(state);
|
||||||
|
navigator.step({ ...state, nowMs: 2_000 });
|
||||||
|
expect(navigator.step({ ...state, nowMs: 5_000 })).toBe("home");
|
||||||
|
expect(state.position).toEqual(state.home);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("bounds landing checks, rejects unsafe projections, and never blindly uses the target", () => {
|
||||||
|
let checks = 0;
|
||||||
|
const result = chooseMobPursuitLanding(sample(), spatial({
|
||||||
|
hasLineOfSight: () => true,
|
||||||
|
validateLanding: () => { checks += 1; return [20, 0, 0]; },
|
||||||
|
}));
|
||||||
|
expect(result).toBeNull();
|
||||||
|
expect(checks).toBe(16);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps different mobs' timers independent", () => {
|
||||||
|
const navigator = createMobPursuit(spatial({ hasLineOfSight: (a) => a[0] > 2 }));
|
||||||
|
const first = sample();
|
||||||
|
const second = sample({ id: "other", position: [0, 0, 0] });
|
||||||
|
navigator.step(first);
|
||||||
|
navigator.step({ ...second, nowMs: 4_000 });
|
||||||
|
navigator.step({ ...first, nowMs: 2_000 });
|
||||||
|
expect(navigator.step({ ...first, nowMs: 5_000 })).toBe("teleported");
|
||||||
|
expect(navigator.step({ ...second, nowMs: 5_000 })).toBe("none");
|
||||||
|
expect(second.position).toEqual([0, 0, 0]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import type { PartyNavigationGraph } from "./partyNavigation";
|
||||||
|
import { distanceSquared, moveMobToward, type MobAiPhase, type MobAiPosition, type MutableMobAiPosition } from "./mobAi";
|
||||||
|
import { emptyPartyRouteState, nextPartyRouteRecoveryWaypoint, nextPartyRouteWaypoint, partyRouteNeedsPlanning, planPartyRoute } from "./partyPathing";
|
||||||
|
import { createPartyStuckRecoveryState, markPartyStuckRecovered, updatePartyStuckRecovery, type PartyStuckRecoveryState } from "./partyStuckRecovery";
|
||||||
|
|
||||||
|
export const MOB_NEAR_RECOVERY_DISTANCE = 10;
|
||||||
|
const SAME_FLOOR_TOLERANCE = 1.25;
|
||||||
|
|
||||||
|
export interface MobPursuitSample {
|
||||||
|
readonly id: string;
|
||||||
|
readonly targetId: string | null;
|
||||||
|
readonly position: MutableMobAiPosition;
|
||||||
|
readonly target: MobAiPosition;
|
||||||
|
readonly home: MobAiPosition;
|
||||||
|
readonly phase: MobAiPhase;
|
||||||
|
readonly health: number;
|
||||||
|
readonly engaged: boolean;
|
||||||
|
readonly active: boolean;
|
||||||
|
readonly casting: boolean;
|
||||||
|
readonly controlled: boolean;
|
||||||
|
readonly attackRange: number;
|
||||||
|
readonly leashRange: number;
|
||||||
|
readonly maximumStep: number;
|
||||||
|
readonly nowMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MobPursuitSpatial {
|
||||||
|
readonly graph: () => PartyNavigationGraph | null;
|
||||||
|
readonly hasLineOfSight: (from: MobAiPosition, to: MobAiPosition) => boolean;
|
||||||
|
readonly directAllowed: (from: MobAiPosition, to: MobAiPosition) => boolean;
|
||||||
|
readonly routeAllowed: (from: MobAiPosition, to: MobAiPosition) => boolean;
|
||||||
|
readonly validateLanding: (point: MobAiPosition, anchor: MobAiPosition) => MobAiPosition | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Never blindly land inside the target, a wall, or an overlapping dungeon floor. */
|
||||||
|
export function chooseMobPursuitLanding(sample: MobPursuitSample, spatial: MobPursuitSpatial): MobAiPosition | null {
|
||||||
|
const { position, target, home } = sample;
|
||||||
|
if (distanceSquared(position, target) > MOB_NEAR_RECOVERY_DISTANCE ** 2
|
||||||
|
|| Math.abs(position[1] - target[1]) > SAME_FLOOR_TOLERANCE
|
||||||
|
|| distanceSquared(home, target) > sample.leashRange ** 2) return null;
|
||||||
|
const bearing = Math.atan2(position[0] - target[0], position[2] - target[2]);
|
||||||
|
const radius = Math.max(0.85, Math.min(2, sample.attackRange * 0.6));
|
||||||
|
for (let offset = 0; offset < 16; offset += 1) {
|
||||||
|
const angle = bearing + offset * Math.PI / 8;
|
||||||
|
const candidate: MobAiPosition = [target[0] + Math.sin(angle) * radius, target[1], target[2] + Math.cos(angle) * radius];
|
||||||
|
const landing = spatial.validateLanding(candidate, target);
|
||||||
|
if (landing?.every(Number.isFinite) && distanceSquared(landing, candidate) <= 0.85 ** 2
|
||||||
|
&& distanceSquared(landing, target) <= sample.attackRange ** 2
|
||||||
|
&& distanceSquared(landing, home) <= sample.leashRange ** 2
|
||||||
|
&& Math.abs(landing[1] - target[1]) <= SAME_FLOOR_TOLERANCE
|
||||||
|
&& spatial.hasLineOfSight(landing, target)) return [...landing];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shared imperative adapter for static spawns, roaming packs, and summoned enemies. */
|
||||||
|
export function createMobPursuit(spatial: MobPursuitSpatial) {
|
||||||
|
const states = new Map<string, {
|
||||||
|
route: ReturnType<typeof emptyPartyRouteState>;
|
||||||
|
watchdog: PartyStuckRecoveryState;
|
||||||
|
graph: PartyNavigationGraph | null;
|
||||||
|
}>();
|
||||||
|
const desired: MutableMobAiPosition = [0, 0, 0];
|
||||||
|
const next: MutableMobAiPosition = [0, 0, 0];
|
||||||
|
return {
|
||||||
|
clear: () => states.clear(),
|
||||||
|
step(sample: MobPursuitSample): "none" | "teleported" | "reset" | "home" {
|
||||||
|
const { position, target, home, nowMs } = sample;
|
||||||
|
if (![...position, ...target, ...home, nowMs, sample.attackRange, sample.leashRange, sample.maximumStep].every(Number.isFinite)) return "none";
|
||||||
|
let state = states.get(sample.id);
|
||||||
|
if (!state) {
|
||||||
|
state = { route: emptyPartyRouteState(), watchdog: createPartyStuckRecoveryState(position, nowMs), graph: spatial.graph() };
|
||||||
|
states.set(sample.id, state);
|
||||||
|
}
|
||||||
|
const returning = sample.phase === "returning";
|
||||||
|
const goal = returning ? home : target;
|
||||||
|
const visible = spatial.hasLineOfSight(position, target);
|
||||||
|
const inRange = visible && distanceSquared(position, target) <= sample.attackRange ** 2;
|
||||||
|
const movementExpected = returning ? distanceSquared(position, home) > 0.2 ** 2 : sample.engaged && !inRange;
|
||||||
|
const excluded = !sample.active || sample.health <= 0 || sample.controlled || sample.casting;
|
||||||
|
const goalKey = returning ? "return-home" : `target:${sample.targetId}`;
|
||||||
|
const graph = spatial.graph();
|
||||||
|
if (state.graph !== graph) {
|
||||||
|
state.graph = graph;
|
||||||
|
state.route = emptyPartyRouteState();
|
||||||
|
}
|
||||||
|
let waypoint: MobAiPosition | null = null;
|
||||||
|
let approvedRouteStep = false;
|
||||||
|
if (movementExpected && !excluded) {
|
||||||
|
if (spatial.directAllowed(position, goal)) waypoint = goal;
|
||||||
|
else {
|
||||||
|
if (partyRouteNeedsPlanning(state.route, goalKey, position, goal, nowMs, { replanIntervalMs: 350, blockedRetryMs: 800 })) {
|
||||||
|
state.route = planPartyRoute(graph, goalKey, position, goal, nowMs, {
|
||||||
|
maxOriginSnapDistance: 3, maxDestinationSnapDistance: 3,
|
||||||
|
directSegmentAllowed: spatial.directAllowed,
|
||||||
|
routeSegmentAllowed: graph?.triangles?.length ? spatial.routeAllowed : undefined,
|
||||||
|
destinationReached: (surface, destination) => distanceSquared(surface, destination)
|
||||||
|
<= (returning ? 0.2 : sample.attackRange) ** 2
|
||||||
|
&& (returning || spatial.hasLineOfSight(surface, destination)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (nextPartyRouteWaypoint(state.route, position, desired)) {
|
||||||
|
waypoint = desired;
|
||||||
|
approvedRouteStep = true;
|
||||||
|
} else if (nextPartyRouteRecoveryWaypoint(state.route, position, desired, spatial.directAllowed)) waypoint = desired;
|
||||||
|
}
|
||||||
|
if (waypoint) {
|
||||||
|
moveMobToward(position, waypoint, sample.maximumStep, next);
|
||||||
|
// Recast edges are already agent-radius eroded. A second full-radius
|
||||||
|
// sweep at boundary vertices can reject a valid stair corridor;
|
||||||
|
// use the same center/wall check as companions on approved edges.
|
||||||
|
// Off-surface connectors and direct shortcuts still need body sweeps.
|
||||||
|
if (spatial.directAllowed(position, next) || (approvedRouteStep && spatial.routeAllowed(position, next))) {
|
||||||
|
position[0] = next[0]; position[1] = next[1]; position[2] = next[2];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const recovery = updatePartyStuckRecovery(state.watchdog, {
|
||||||
|
position, waypoint, goalKey, movementExpected, nowMs, health: sample.health,
|
||||||
|
paused: !sample.active, stopped: false, casting: sample.casting, controlled: sample.controlled,
|
||||||
|
});
|
||||||
|
if (returning && !excluded && !movementExpected) return "home";
|
||||||
|
if (recovery !== "none") state.route = emptyPartyRouteState();
|
||||||
|
if (recovery !== "recover") return "none";
|
||||||
|
const landing = returning ? spatial.validateLanding(home, home) : chooseMobPursuitLanding(sample, spatial);
|
||||||
|
if (landing && (!returning || distanceSquared(landing, home) <= 0.85 ** 2)) {
|
||||||
|
position[0] = landing[0]; position[1] = landing[1]; position[2] = landing[2];
|
||||||
|
markPartyStuckRecovered(state.watchdog, position, nowMs);
|
||||||
|
return returning ? "home" : "teleported";
|
||||||
|
}
|
||||||
|
if (!returning) {
|
||||||
|
const homeLanding = spatial.validateLanding(home, home);
|
||||||
|
if (homeLanding?.every(Number.isFinite) && distanceSquared(homeLanding, home) <= 0.85 ** 2) {
|
||||||
|
position[0] = homeLanding[0]; position[1] = homeLanding[1]; position[2] = homeLanding[2];
|
||||||
|
}
|
||||||
|
markPartyStuckRecovered(state.watchdog, position, nowMs);
|
||||||
|
return "reset";
|
||||||
|
}
|
||||||
|
return "none";
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ import { abilitiesForClass, abilityAtLevel, resourceProfileForClass } from "./ab
|
|||||||
import { aiHintsForAbility } from "./abilityRules";
|
import { aiHintsForAbility } from "./abilityRules";
|
||||||
import { useCombatStore } from "./combatStore";
|
import { useCombatStore } from "./combatStore";
|
||||||
import { EMPTY_ITEM_STATS } from "./itemStats";
|
import { EMPTY_ITEM_STATS } from "./itemStats";
|
||||||
import { commitPartyAbilityUse, choosePartyAbility, partyAbilityIsReady } from "./partyAbilityAi";
|
import { commitPartyAbilityUse, choosePartyAbility, partyAbilityIsReady, partyAbilityAmount } from "./partyAbilityAi";
|
||||||
import { usePartyStore, type PartyMember } from "./partyStore";
|
import { usePartyStore, type PartyMember } from "./partyStore";
|
||||||
import { deriveCharacterStats } from "./wotlkStats";
|
import { deriveCharacterStats } from "./wotlkStats";
|
||||||
|
|
||||||
@@ -87,4 +87,19 @@ describe("catalog-driven party rotations", () => {
|
|||||||
.find((ability) => aiHintsForAbility(ability).categories.includes("heal") && ability.cost.amount > 0)!;
|
.find((ability) => aiHintsForAbility(ability).categories.includes("heal") && ability.cost.amount > 0)!;
|
||||||
expect(partyAbilityIsReady(member, heal, 2_000)).toBe(false);
|
expect(partyAbilityIsReady(member, heal, 2_000)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("preserves spell base damage when its bonus-power coefficient is zero", () => {
|
||||||
|
const member = { ...memberFor("warrior", "tank"), level: 5, gearStats: EMPTY_ITEM_STATS };
|
||||||
|
const ability = abilitiesForClass("warrior").find((candidate) => candidate.id === "warrior-heroic-strike")!;
|
||||||
|
expect(partyAbilityAmount(member, ability, {
|
||||||
|
kind: "damage", coefficient: 0, basePoints: 17, dieSides: 1, sourceLevel: 5,
|
||||||
|
})).toBe(17);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires a learned CoA tree entry before the NPC can cast it", () => {
|
||||||
|
const member = { ...memberFor("pyromancer", "damage"), talentRanks: {} };
|
||||||
|
const ability = abilitiesForClass(member.classId).find((candidate) => candidate.talentEntryId !== undefined && !candidate.passive)!;
|
||||||
|
expect(partyAbilityIsReady(member, ability, 2_000)).toBe(false);
|
||||||
|
expect(partyAbilityIsReady({ ...member, talentRanks: { [`coa-entry-${ability.talentEntryId}`]: 1 } }, ability, 2_000)).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
} from "./abilityCatalog";
|
} from "./abilityCatalog";
|
||||||
import { aiHintsForAbility, runeCostForAbility } from "./abilityRules";
|
import { aiHintsForAbility, runeCostForAbility } from "./abilityRules";
|
||||||
import { spendDeathKnightRunes } from "./combatResources";
|
import { spendDeathKnightRunes } from "./combatResources";
|
||||||
import type { MobCombatState } from "./combatStore";
|
import { equipmentAdjustedCombatAmount, type MobCombatState } from "./combatStore";
|
||||||
import type { PartyMember } from "./partyStore";
|
import type { PartyMember } from "./partyStore";
|
||||||
|
|
||||||
export interface PartyAbilityContext {
|
export interface PartyAbilityContext {
|
||||||
@@ -20,6 +20,7 @@ export interface PartyAbilityContext {
|
|||||||
readonly fallenAlly?: boolean;
|
readonly fallenAlly?: boolean;
|
||||||
readonly injuredAllies?: number;
|
readonly injuredAllies?: number;
|
||||||
readonly lowestHealthFraction?: number;
|
readonly lowestHealthFraction?: number;
|
||||||
|
readonly canUseAbility?: (ability: AbilityDefinition) => boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function currentResource(member: PartyMember, type: ResourceType): number {
|
function currentResource(member: PartyMember, type: ResourceType): number {
|
||||||
@@ -35,6 +36,7 @@ function abilityCosts(ability: AbilityDefinition): readonly { resource: Resource
|
|||||||
|
|
||||||
export function partyAbilityIsReady(member: PartyMember, ability: AbilityDefinition, now: number): boolean {
|
export function partyAbilityIsReady(member: PartyMember, ability: AbilityDefinition, now: number): boolean {
|
||||||
if (ability.passive || !isAbilityUnlocked(ability, member.level)) return false;
|
if (ability.passive || !isAbilityUnlocked(ability, member.level)) return false;
|
||||||
|
if (ability.talentEntryId !== undefined && !(member.talentRanks[`coa-entry-${ability.talentEntryId}`] ?? 0)) return false;
|
||||||
if (member.globalCooldownEndsAt > now || (member.cooldowns[ability.id] ?? 0) > now) return false;
|
if (member.globalCooldownEndsAt > now || (member.cooldowns[ability.id] ?? 0) > now) return false;
|
||||||
if ((member.schoolLockedUntil[ability.castRules?.school ?? "physical"] ?? 0) > now) return false;
|
if ((member.schoolLockedUntil[ability.castRules?.school ?? "physical"] ?? 0) > now) return false;
|
||||||
if (aiHintsForAbility(ability).categories.includes("taunt") && member.tauntReadyAt > now) return false;
|
if (aiHintsForAbility(ability).categories.includes("taunt") && member.tauntReadyAt > now) return false;
|
||||||
@@ -50,12 +52,18 @@ export function partyAbilityIsReady(member: PartyMember, ability: AbilityDefinit
|
|||||||
|
|
||||||
function candidateAbilities(member: PartyMember, now: number): readonly AbilityDefinition[] {
|
function candidateAbilities(member: PartyMember, now: number): readonly AbilityDefinition[] {
|
||||||
return abilitiesForClass(member.classId)
|
return abilitiesForClass(member.classId)
|
||||||
.filter((ability) => partyAbilityIsReady(member, abilityAtLevel(ability, member.level), now))
|
.map((ability) => abilityAtLevel(ability, member.level, ability.talentEntryId === undefined
|
||||||
.map((ability) => abilityAtLevel(ability, member.level));
|
? undefined : member.talentRanks[`coa-entry-${ability.talentEntryId}`] ?? 0))
|
||||||
|
.filter((ability) => partyAbilityIsReady(member, ability, now));
|
||||||
}
|
}
|
||||||
|
|
||||||
function scoreAbility(member: PartyMember, ability: AbilityDefinition, context: PartyAbilityContext): number {
|
function scoreAbility(member: PartyMember, ability: AbilityDefinition, context: PartyAbilityContext): number {
|
||||||
const categories = new Set(aiHintsForAbility(ability).categories);
|
const categories = new Set(aiHintsForAbility(ability).categories);
|
||||||
|
const damaging = categories.has("damage") || categories.has("execute");
|
||||||
|
if (categories.has("taunt") && (member.role !== "tank" || !context.targetIsLoose)) return 0;
|
||||||
|
if (!damaging && categories.has("interrupt") && !context.target?.activeCast) return 0;
|
||||||
|
const control = categories.has("control") || ability.effects.some((effect) => effect.kind === "apply-aura" && effect.control);
|
||||||
|
if (!damaging && control && !context.requiredControl) return 0;
|
||||||
let score = ability.ai?.priority ?? 0;
|
let score = ability.ai?.priority ?? 0;
|
||||||
if (member.role === "tank") {
|
if (member.role === "tank") {
|
||||||
if (context.target?.activeCast && categories.has("interrupt")) score += 1_000;
|
if (context.target?.activeCast && categories.has("interrupt")) score += 1_000;
|
||||||
@@ -89,6 +97,7 @@ export function choosePartyAbility(
|
|||||||
context: PartyAbilityContext,
|
context: PartyAbilityContext,
|
||||||
): AbilityDefinition | null {
|
): AbilityDefinition | null {
|
||||||
return candidateAbilities(member, context.now)
|
return candidateAbilities(member, context.now)
|
||||||
|
.filter((ability) => context.canUseAbility?.(ability) ?? true)
|
||||||
.map((ability) => ({ ability, score: scoreAbility(member, ability, context) }))
|
.map((ability) => ({ ability, score: scoreAbility(member, ability, context) }))
|
||||||
.filter((candidate) => candidate.score > 0)
|
.filter((candidate) => candidate.score > 0)
|
||||||
.sort((left, right) => right.score - left.score
|
.sort((left, right) => right.score - left.score
|
||||||
@@ -142,9 +151,11 @@ export function partyAbilityAmount(
|
|||||||
: effect.kind === "finisher-damage"
|
: effect.kind === "finisher-damage"
|
||||||
? effect.baseCoefficient + effect.perComboCoefficient * 5
|
? effect.baseCoefficient + effect.perComboCoefficient * 5
|
||||||
: 0;
|
: 0;
|
||||||
const power = member.level * 4
|
return equipmentAdjustedCombatAmount(
|
||||||
+ member.gearStats.spellPower
|
"coefficient" in effect ? effect : coefficient,
|
||||||
+ member.gearStats.attackPower * 0.35
|
member.level,
|
||||||
+ member.gearStats.rangedAttackPower * 0.35;
|
member.gearStats,
|
||||||
return Math.max(1, Math.round(Math.max(0.1, coefficient) * Math.max(8, power)));
|
member.classId,
|
||||||
|
ability,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -7,15 +7,11 @@ import {
|
|||||||
nextPartyBreadcrumbWaypoint,
|
nextPartyBreadcrumbWaypoint,
|
||||||
partyCombatMovementGoal,
|
partyCombatMovementGoal,
|
||||||
partyFormationPosition,
|
partyFormationPosition,
|
||||||
partyMadeStuckProgress,
|
|
||||||
partyMovementProfile,
|
partyMovementProfile,
|
||||||
partyNaturalFollowPosition,
|
partyNaturalFollowPosition,
|
||||||
partyPersonalizedCombatMovementGoal,
|
partyPersonalizedCombatMovementGoal,
|
||||||
partyPresentationY,
|
partyPresentationY,
|
||||||
partySeparationGoal,
|
|
||||||
partyShouldRecallForStuck,
|
|
||||||
partyShouldMoveToComfortTarget,
|
partyShouldMoveToComfortTarget,
|
||||||
PARTY_STUCK_RECALL_DISTANCE,
|
|
||||||
personalizedPartyCombatRangeBand,
|
personalizedPartyCombatRangeBand,
|
||||||
resetPartyBreadcrumbTrail,
|
resetPartyBreadcrumbTrail,
|
||||||
stepPartyPosition,
|
stepPartyPosition,
|
||||||
@@ -102,7 +98,7 @@ describe("party world movement", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses a stable combat bearing and separates overlapping allies", () => {
|
it("uses a stable combat bearing without forcing in-range actors to reposition", () => {
|
||||||
const out: [number, number, number] = [0, 0, 0];
|
const out: [number, number, number] = [0, 0, 0];
|
||||||
expect(partyPersonalizedCombatMovementGoal(
|
expect(partyPersonalizedCombatMovementGoal(
|
||||||
[20, 0, 0],
|
[20, 0, 0],
|
||||||
@@ -110,34 +106,20 @@ describe("party world movement", () => {
|
|||||||
true,
|
true,
|
||||||
{ minimum: 10, preferred: 14, maximum: 18 },
|
{ minimum: 10, preferred: 14, maximum: 18 },
|
||||||
Math.PI / 2,
|
Math.PI / 2,
|
||||||
false,
|
|
||||||
out,
|
out,
|
||||||
)).toBe("approach");
|
)).toBe("approach");
|
||||||
expect(out[0]).toBeCloseTo(14);
|
expect(out[0]).toBeCloseTo(14);
|
||||||
expect(out[1]).toBe(0);
|
expect(out[1]).toBe(0);
|
||||||
expect(out[2]).toBeCloseTo(0);
|
expect(out[2]).toBeCloseTo(0);
|
||||||
|
|
||||||
const separated: [number, number, number] = [0, 0, 0];
|
expect(partyPersonalizedCombatMovementGoal(
|
||||||
expect(partySeparationGoal(
|
|
||||||
[1, 0, 1],
|
[1, 0, 1],
|
||||||
[2, 0, 2],
|
[0, 0, 0],
|
||||||
[[1, 0, 1]],
|
true,
|
||||||
|
{ minimum: 0, preferred: 2.9, maximum: 3.15 },
|
||||||
Math.PI / 2,
|
Math.PI / 2,
|
||||||
separated,
|
out,
|
||||||
)).toBe(true);
|
)).toBe("hold");
|
||||||
expect(separated[0]).toBeGreaterThan(2);
|
|
||||||
expect(separated[2]).toBeCloseTo(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("recalls only distant allies that should be moving and stopped making progress", () => {
|
|
||||||
expect(partyMadeStuckProgress([0, 0, 0], [0.45, 4, 0])).toBe(true);
|
|
||||||
expect(partyMadeStuckProgress([0, 0, 0], [0.44, 0, 0])).toBe(false);
|
|
||||||
|
|
||||||
expect(PARTY_STUCK_RECALL_DISTANCE).toBe(80);
|
|
||||||
expect(partyShouldRecallForStuck([80, 0, 0], [0, 0, 0], true, 4_500, 1_000)).toBe(true);
|
|
||||||
expect(partyShouldRecallForStuck([79.9, 0, 0], [0, 0, 0], true, 4_500, 1_000)).toBe(false);
|
|
||||||
expect(partyShouldRecallForStuck([80, 0, 0], [0, 0, 0], false, 4_500, 1_000)).toBe(false);
|
|
||||||
expect(partyShouldRecallForStuck([80, 0, 0], [0, 0, 0], true, 4_499, 1_000)).toBe(false);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("makes melee approach a fleeing target and hold once in range", () => {
|
it("makes melee approach a fleeing target and hold once in range", () => {
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export interface PartyBreadcrumbCursor {
|
|||||||
breadcrumbId: number | null;
|
breadcrumbId: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PartyCombatMovementIntent = "hold" | "approach" | "retreat" | "reposition";
|
export type PartyCombatMovementIntent = "hold" | "approach" | "retreat";
|
||||||
|
|
||||||
export interface PartyMovementProfile {
|
export interface PartyMovementProfile {
|
||||||
readonly followDistanceJitter: number;
|
readonly followDistanceJitter: number;
|
||||||
@@ -86,45 +86,6 @@ export function partyMovementProfile(memberId: string, livingRank: number): Part
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Allow companions to operate across a full boss arena before the stuck
|
|
||||||
// watchdog treats their separation from the player as a reason to recall.
|
|
||||||
export const PARTY_STUCK_RECALL_DISTANCE = 80;
|
|
||||||
export const PARTY_STUCK_RECALL_DELAY_MS = 3_500;
|
|
||||||
export const PARTY_STUCK_PROGRESS_DISTANCE = 0.45;
|
|
||||||
|
|
||||||
/** Tests whether an expected move has made enough planar progress to reset the stuck timer. */
|
|
||||||
export function partyMadeStuckProgress(
|
|
||||||
previous: PartyWorldPosition,
|
|
||||||
current: PartyWorldPosition,
|
|
||||||
minimumDistance = PARTY_STUCK_PROGRESS_DISTANCE,
|
|
||||||
): boolean {
|
|
||||||
const minimum = Math.max(0, Number.isFinite(minimumDistance) ? minimumDistance : 0);
|
|
||||||
const distance = Math.hypot(current[0] - previous[0], current[2] - previous[2]);
|
|
||||||
return Number.isFinite(distance) && distance >= minimum;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A distant ally is stuck only while it should be moving and its progress
|
|
||||||
* timer has expired. Intentional ranged/melee holds never enter this state.
|
|
||||||
*/
|
|
||||||
export function partyShouldRecallForStuck(
|
|
||||||
current: PartyWorldPosition,
|
|
||||||
player: PartyWorldPosition,
|
|
||||||
movementExpected: boolean,
|
|
||||||
nowMs: number,
|
|
||||||
lastProgressAtMs: number,
|
|
||||||
recallDistance = PARTY_STUCK_RECALL_DISTANCE,
|
|
||||||
stuckDelayMs = PARTY_STUCK_RECALL_DELAY_MS,
|
|
||||||
): boolean {
|
|
||||||
if (!movementExpected || !Number.isFinite(nowMs) || !Number.isFinite(lastProgressAtMs)) return false;
|
|
||||||
const distance = Math.hypot(current[0] - player[0], current[2] - player[2]);
|
|
||||||
const minimumDistance = Math.max(0, Number.isFinite(recallDistance) ? recallDistance : 0);
|
|
||||||
const delay = Math.max(0, Number.isFinite(stuckDelayMs) ? stuckDelayMs : 0);
|
|
||||||
return Number.isFinite(distance)
|
|
||||||
&& distance >= minimumDistance
|
|
||||||
&& nowMs - lastProgressAtMs >= delay;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createPartyBreadcrumbTrail(): PartyBreadcrumbTrail {
|
export function createPartyBreadcrumbTrail(): PartyBreadcrumbTrail {
|
||||||
return { points: [], nextId: 0 };
|
return { points: [], nextId: 0 };
|
||||||
}
|
}
|
||||||
@@ -427,44 +388,6 @@ export function personalizedPartyCombatRangeBand(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Adds a bounded local repulsion so allies do not occupy the same point. */
|
|
||||||
export function partySeparationGoal(
|
|
||||||
current: PartyWorldPosition,
|
|
||||||
desired: PartyWorldPosition,
|
|
||||||
peers: readonly PartyWorldPosition[],
|
|
||||||
fallbackAngle: number,
|
|
||||||
out: MutablePartyWorldPosition,
|
|
||||||
separationDistance = 0.85,
|
|
||||||
maximumCorrection = 0.35,
|
|
||||||
): boolean {
|
|
||||||
let offsetX = 0;
|
|
||||||
let offsetZ = 0;
|
|
||||||
const safeSeparation = Math.max(EPSILON, separationDistance);
|
|
||||||
for (const peer of peers) {
|
|
||||||
const dx = current[0] - peer[0];
|
|
||||||
const dz = current[2] - peer[2];
|
|
||||||
const distance = Math.hypot(dx, dz);
|
|
||||||
if (!Number.isFinite(distance) || distance >= safeSeparation) continue;
|
|
||||||
const weight = (safeSeparation - distance) / safeSeparation;
|
|
||||||
if (distance <= EPSILON) {
|
|
||||||
offsetX += Math.sin(fallbackAngle) * weight;
|
|
||||||
offsetZ += Math.cos(fallbackAngle) * weight;
|
|
||||||
} else {
|
|
||||||
offsetX += dx / distance * weight;
|
|
||||||
offsetZ += dz / distance * weight;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const length = Math.hypot(offsetX, offsetZ);
|
|
||||||
out[0] = desired[0];
|
|
||||||
out[1] = desired[1];
|
|
||||||
out[2] = desired[2];
|
|
||||||
if (length <= EPSILON) return false;
|
|
||||||
const correction = Math.min(Math.max(0, maximumCorrection), length);
|
|
||||||
out[0] += offsetX / length * correction;
|
|
||||||
out[2] += offsetZ / length * correction;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Chooses an individual combat destination. Melee only closes distance;
|
* Chooses an individual combat destination. Melee only closes distance;
|
||||||
* ranged attackers also back out when a target collapses their preferred band.
|
* ranged attackers also back out when a target collapses their preferred band.
|
||||||
@@ -524,7 +447,6 @@ export function partyPersonalizedCombatMovementGoal(
|
|||||||
targetVisible: boolean,
|
targetVisible: boolean,
|
||||||
rangeBand: PartyCombatRangeLike,
|
rangeBand: PartyCombatRangeLike,
|
||||||
preferredBearing: number,
|
preferredBearing: number,
|
||||||
forceReposition: boolean,
|
|
||||||
out: MutablePartyWorldPosition,
|
out: MutablePartyWorldPosition,
|
||||||
): PartyCombatMovementIntent {
|
): PartyCombatMovementIntent {
|
||||||
const intent = partyCombatMovementGoal(
|
const intent = partyCombatMovementGoal(
|
||||||
@@ -538,10 +460,10 @@ export function partyPersonalizedCombatMovementGoal(
|
|||||||
Math.sin(preferredBearing),
|
Math.sin(preferredBearing),
|
||||||
Math.cos(preferredBearing),
|
Math.cos(preferredBearing),
|
||||||
);
|
);
|
||||||
if (!targetVisible || (intent === "hold" && !forceReposition)) return intent;
|
if (!targetVisible || intent === "hold") return intent;
|
||||||
|
|
||||||
out[0] = target[0] + Math.sin(preferredBearing) * rangeBand.preferred;
|
out[0] = target[0] + Math.sin(preferredBearing) * rangeBand.preferred;
|
||||||
out[1] = target[1];
|
out[1] = target[1];
|
||||||
out[2] = target[2] + Math.cos(preferredBearing) * rangeBand.preferred;
|
out[2] = target[2] + Math.cos(preferredBearing) * rangeBand.preferred;
|
||||||
return intent === "hold" ? "reposition" : intent;
|
return intent;
|
||||||
}
|
}
|
||||||
@@ -126,6 +126,10 @@ describe("party navigation graph", () => {
|
|||||||
|
|
||||||
expect(graph).toMatchObject({ componentCount: 1, discardedNodeCount: 0 });
|
expect(graph).toMatchObject({ componentCount: 1, discardedNodeCount: 0 });
|
||||||
expect(graph.triangles).toBeUndefined();
|
expect(graph.triangles).toBeUndefined();
|
||||||
|
expect(findNearestPartyNavigationSurface(graph, [0.25, 0.5, 0.25], 1)).toMatchObject({
|
||||||
|
triangleId: -1,
|
||||||
|
distance: expect.any(Number),
|
||||||
|
});
|
||||||
expect(findPartyNavigationPath(graph, [0, 0, 0], [11, 0, 10])).not.toBeNull();
|
expect(findPartyNavigationPath(graph, [0, 0, 0], [11, 0, 10])).not.toBeNull();
|
||||||
expect(findPartyNavigationPath(graph, [11, 0, 10], [0, 0, 0])).not.toBeNull();
|
expect(findPartyNavigationPath(graph, [11, 0, 10], [0, 0, 0])).not.toBeNull();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -570,7 +570,16 @@ export function findNearestPartyNavigationSurface(
|
|||||||
maxDistance = Number.POSITIVE_INFINITY,
|
maxDistance = Number.POSITIVE_INFINITY,
|
||||||
): PartyNavigationSurfaceMatch | null {
|
): PartyNavigationSurfaceMatch | null {
|
||||||
const triangles = graph.triangles;
|
const triangles = graph.triangles;
|
||||||
if (!triangles?.length) return null;
|
if (!triangles?.length) {
|
||||||
|
const nearest = findNearestPartyNavigationNode(graph, point, maxDistance);
|
||||||
|
return nearest
|
||||||
|
? Object.freeze({
|
||||||
|
position: nearest.node.position,
|
||||||
|
triangleId: -1,
|
||||||
|
distance: nearest.distance,
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
}
|
||||||
const target = pointComponents(point);
|
const target = pointComponents(point);
|
||||||
const limit = Number.isFinite(maxDistance) ? Math.max(0, maxDistance) : Number.POSITIVE_INFINITY;
|
const limit = Number.isFinite(maxDistance) ? Math.max(0, maxDistance) : Number.POSITIVE_INFINITY;
|
||||||
let nearestPosition: PartyNavigationPosition | null = null;
|
let nearestPosition: PartyNavigationPosition | null = null;
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
import { BufferGeometry, Float32BufferAttribute, Group, Mesh } from "three";
|
import {
|
||||||
|
BufferAttribute,
|
||||||
|
BufferGeometry,
|
||||||
|
Float32BufferAttribute,
|
||||||
|
Group,
|
||||||
|
Mesh,
|
||||||
|
} from "three";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { preparePartyNavigationGraph } from "./partyNavigationAssetCache";
|
import { preparePartyNavigationGraph } from "./partyNavigationAssetCache";
|
||||||
|
|
||||||
@@ -33,4 +39,26 @@ describe("party navigation asset cache", () => {
|
|||||||
}]);
|
}]);
|
||||||
expect(second).not.toBe(first);
|
expect(second).not.toBe(first);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("expands normalized quantized positions before baking the node transform", () => {
|
||||||
|
const geometry = new BufferGeometry();
|
||||||
|
geometry.setAttribute("position", new BufferAttribute(new Int16Array([
|
||||||
|
0, 0, 0,
|
||||||
|
32_767, 0, 0,
|
||||||
|
0, 0, 32_767,
|
||||||
|
]), 3, true));
|
||||||
|
const mesh = new Mesh(geometry);
|
||||||
|
mesh.position.set(10, 2, -5);
|
||||||
|
mesh.scale.setScalar(100);
|
||||||
|
const scene = new Group();
|
||||||
|
scene.add(mesh);
|
||||||
|
|
||||||
|
const graph = preparePartyNavigationGraph("/navigation/quantized-transform.glb", scene, []);
|
||||||
|
|
||||||
|
expect(graph.nodes.map((node) => node.position)).toEqual([
|
||||||
|
[10, 2, -5],
|
||||||
|
[110, 2, -5],
|
||||||
|
[10, 2, 95],
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { BufferGeometry, type Group, type Mesh } from "three";
|
import { BufferGeometry, Float32BufferAttribute, type Group, type Mesh } from "three";
|
||||||
import type { DungeonNavigationLink } from "./dungeonTypes";
|
import type { DungeonNavigationLink, DungeonTraversalRamp } from "./dungeonTypes";
|
||||||
|
import { bridgeDungeonStairNavigation, dungeonRampNavigationBridges, smoothDungeonStairGeometry } from "./dungeonTraversalRamps";
|
||||||
import {
|
import {
|
||||||
buildPartyNavigationGraph,
|
buildPartyNavigationGraph,
|
||||||
type PartyNavigationGraph,
|
type PartyNavigationGraph,
|
||||||
@@ -9,14 +10,18 @@ import { MAX_SYNCHRONOUS_PARTY_NAVIGATION_TRIANGLES } from "./partyNavigationPol
|
|||||||
const MAX_CACHED_NAVIGATION_GRAPHS = 8;
|
const MAX_CACHED_NAVIGATION_GRAPHS = 8;
|
||||||
const graphCache = new Map<string, PartyNavigationGraph>();
|
const graphCache = new Map<string, PartyNavigationGraph>();
|
||||||
|
|
||||||
function graphCacheKey(url: string, offMeshLinks: readonly DungeonNavigationLink[]): string {
|
function graphCacheKey(
|
||||||
|
url: string,
|
||||||
|
offMeshLinks: readonly DungeonNavigationLink[],
|
||||||
|
ramps: readonly DungeonTraversalRamp[],
|
||||||
|
): string {
|
||||||
const links = offMeshLinks.map((link) => [
|
const links = offMeshLinks.map((link) => [
|
||||||
link.id,
|
link.id,
|
||||||
link.start,
|
link.start,
|
||||||
link.end,
|
link.end,
|
||||||
link.bidirectional,
|
link.bidirectional,
|
||||||
]);
|
]);
|
||||||
return `${url}:${JSON.stringify(links)}`;
|
return `${url}:${JSON.stringify([links, ramps])}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function navigationGeometry(scene: Group): BufferGeometry {
|
function navigationGeometry(scene: Group): BufferGeometry {
|
||||||
@@ -33,6 +38,19 @@ function navigationGeometry(scene: Group): BufferGeometry {
|
|||||||
});
|
});
|
||||||
if (!sourceMesh) throw new Error("The party navigation GLB has no triangle geometry.");
|
if (!sourceMesh) throw new Error("The party navigation GLB has no triangle geometry.");
|
||||||
const geometry = (sourceMesh as Mesh).geometry.clone();
|
const geometry = (sourceMesh as Mesh).geometry.clone();
|
||||||
|
const position = geometry.getAttribute("position");
|
||||||
|
// BufferAttribute.applyMatrix4 writes back through the source component
|
||||||
|
// type. Quantized normalized integers therefore clamp world coordinates to
|
||||||
|
// [-1, 1] while baking their node transform. Expand them to floats first.
|
||||||
|
if (position.normalized) {
|
||||||
|
const expanded = new Float32Array(position.count * 3);
|
||||||
|
for (let index = 0; index < position.count; index += 1) {
|
||||||
|
expanded[index * 3] = position.getX(index);
|
||||||
|
expanded[index * 3 + 1] = position.getY(index);
|
||||||
|
expanded[index * 3 + 2] = position.getZ(index);
|
||||||
|
}
|
||||||
|
geometry.setAttribute("position", new Float32BufferAttribute(expanded, 3));
|
||||||
|
}
|
||||||
geometry.applyMatrix4((sourceMesh as Mesh).matrixWorld);
|
geometry.applyMatrix4((sourceMesh as Mesh).matrixWorld);
|
||||||
return geometry;
|
return geometry;
|
||||||
}
|
}
|
||||||
@@ -41,8 +59,9 @@ export function preparePartyNavigationGraph(
|
|||||||
url: string,
|
url: string,
|
||||||
scene: Group,
|
scene: Group,
|
||||||
offMeshLinks: readonly DungeonNavigationLink[],
|
offMeshLinks: readonly DungeonNavigationLink[],
|
||||||
|
ramps: readonly DungeonTraversalRamp[] = [],
|
||||||
): PartyNavigationGraph {
|
): PartyNavigationGraph {
|
||||||
const cacheKey = graphCacheKey(url, offMeshLinks);
|
const cacheKey = graphCacheKey(url, offMeshLinks, ramps);
|
||||||
const cached = graphCache.get(cacheKey);
|
const cached = graphCache.get(cacheKey);
|
||||||
if (cached) {
|
if (cached) {
|
||||||
graphCache.delete(cacheKey);
|
graphCache.delete(cacheKey);
|
||||||
@@ -53,9 +72,15 @@ export function preparePartyNavigationGraph(
|
|||||||
const geometry = navigationGeometry(scene);
|
const geometry = navigationGeometry(scene);
|
||||||
let graph: PartyNavigationGraph;
|
let graph: PartyNavigationGraph;
|
||||||
try {
|
try {
|
||||||
|
if (ramps.length) {
|
||||||
|
// Recast normally lifts its walkable surface slightly above collision.
|
||||||
|
smoothDungeonStairGeometry(geometry, ramps, 0.05);
|
||||||
|
bridgeDungeonStairNavigation(geometry, ramps, 0.05);
|
||||||
|
}
|
||||||
|
const bridgedLinks = new Set(ramps.flatMap((ramp) => dungeonRampNavigationBridges(ramp).map((bridge) => bridge.linkId)));
|
||||||
graph = buildPartyNavigationGraph(geometry, {
|
graph = buildPartyNavigationGraph(geometry, {
|
||||||
maxSourceTriangles: MAX_SYNCHRONOUS_PARTY_NAVIGATION_TRIANGLES,
|
maxSourceTriangles: MAX_SYNCHRONOUS_PARTY_NAVIGATION_TRIANGLES,
|
||||||
offMeshLinks,
|
offMeshLinks: offMeshLinks.filter((link) => !bridgedLinks.has(link.id)),
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
geometry.dispose();
|
geometry.dispose();
|
||||||
|
|||||||
@@ -102,7 +102,10 @@ describe("party navigation fleet safety", () => {
|
|||||||
|
|
||||||
if (shouldLoadPartyNavigation("dungeon", dungeon, null)) {
|
if (shouldLoadPartyNavigation("dungeon", dungeon, null)) {
|
||||||
enabled.push(dungeon.id);
|
enabled.push(dungeon.id);
|
||||||
expect(dungeon.validation.status, dungeon.id).toBe("green");
|
expect(
|
||||||
|
dungeon.validation.status === "green" || dungeon.validation.navigation === "green",
|
||||||
|
dungeon.id,
|
||||||
|
).toBe(true);
|
||||||
expect(actualTriangles, dungeon.id)
|
expect(actualTriangles, dungeon.id)
|
||||||
.toBeLessThanOrEqual(MAX_SYNCHRONOUS_PARTY_NAVIGATION_TRIANGLES);
|
.toBeLessThanOrEqual(MAX_SYNCHRONOUS_PARTY_NAVIGATION_TRIANGLES);
|
||||||
} else {
|
} else {
|
||||||
@@ -113,8 +116,11 @@ describe("party navigation fleet safety", () => {
|
|||||||
// Raksha Temple and The Origin now ship their full source-assembled
|
// Raksha Temple and The Origin now ship their full source-assembled
|
||||||
// navigation geometry, which correctly moves both above the synchronous
|
// navigation geometry, which correctly moves both above the synchronous
|
||||||
// graph-build ceiling and onto the breadcrumb fallback path.
|
// graph-build ceiling and onto the breadcrumb fallback path.
|
||||||
expect(enabled).toHaveLength(29);
|
// Stockades has a reviewed navigation override without claiming that its
|
||||||
expect(fallback).toHaveLength(55);
|
// broader encounter/reset validation is complete.
|
||||||
|
expect(enabled).toContain("stormwind-stockade");
|
||||||
|
expect(enabled).toHaveLength(30);
|
||||||
|
expect(fallback).toHaveLength(54);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("covers every Manastorm package and keeps actual synchronous geometry under budget", () => {
|
it("covers every Manastorm package and keeps actual synchronous geometry under budget", () => {
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ function manastormPackageWithNavigationTriangles(
|
|||||||
function dungeonWithNavigation(
|
function dungeonWithNavigation(
|
||||||
status: DungeonDefinition["validation"]["status"],
|
status: DungeonDefinition["validation"]["status"],
|
||||||
triangleCount: number,
|
triangleCount: number,
|
||||||
|
navigation?: DungeonDefinition["validation"]["navigation"],
|
||||||
): Pick<DungeonDefinition, "assets" | "validation"> {
|
): Pick<DungeonDefinition, "assets" | "validation"> {
|
||||||
return {
|
return {
|
||||||
assets: {
|
assets: {
|
||||||
@@ -32,7 +33,7 @@ function dungeonWithNavigation(
|
|||||||
collision: [],
|
collision: [],
|
||||||
navigation: { id: "navigation", fileName: "navigation.glb", triangleCount },
|
navigation: { id: "navigation", fileName: "navigation.glb", triangleCount },
|
||||||
},
|
},
|
||||||
validation: { status, blockers: [], warnings: [] },
|
validation: { status, navigation, blockers: [], warnings: [] },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,6 +62,14 @@ describe("party navigation loading policy", () => {
|
|||||||
)).toBe(false);
|
)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("loads an independently reviewed dungeon navigation graph", () => {
|
||||||
|
expect(shouldLoadPartyNavigation(
|
||||||
|
"dungeon",
|
||||||
|
dungeonWithNavigation("unvalidated", 1_000, "green"),
|
||||||
|
null,
|
||||||
|
)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it("protects Manastorm from oversized or unmeasured synchronous graphs", () => {
|
it("protects Manastorm from oversized or unmeasured synchronous graphs", () => {
|
||||||
const fallbackDungeon = requireDungeonDefinition("wailing-caverns");
|
const fallbackDungeon = requireDungeonDefinition("wailing-caverns");
|
||||||
|
|
||||||
|
|||||||
@@ -21,9 +21,13 @@ export function shouldLoadPartyNavigation(
|
|||||||
: dungeon.assets.navigation;
|
: dungeon.assets.navigation;
|
||||||
if (!navigation) return false;
|
if (!navigation) return false;
|
||||||
|
|
||||||
// Imported dungeon packs already use local/breadcrumb following until their
|
// Navigation can be reviewed independently from encounter/checkpoint content.
|
||||||
// routes are reviewed, so building their navigation graph provides no value.
|
// Do not make safe melee routing wait for unrelated dungeon validation work.
|
||||||
if (gameMode === "dungeon" && dungeon.validation.status !== "green") return false;
|
if (
|
||||||
|
gameMode === "dungeon"
|
||||||
|
&& dungeon.validation.status !== "green"
|
||||||
|
&& dungeon.validation.navigation !== "green"
|
||||||
|
) return false;
|
||||||
|
|
||||||
const triangleCount = navigation.triangleCount;
|
const triangleCount = navigation.triangleCount;
|
||||||
return typeof triangleCount === "number"
|
return typeof triangleCount === "number"
|
||||||
|
|||||||
@@ -92,6 +92,19 @@ describe("party path controller", () => {
|
|||||||
expect(route.blocked).toBe(true);
|
expect(route.blocked).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("accepts an in-range surface finish only when combat sight approves it", () => {
|
||||||
|
const options = { maxSnapDistance: 1, localFinishDistance: 1, directSegmentAllowed: () => false };
|
||||||
|
const visible = planPartyRoute(corridorGraph, "mob", [0, 0, 0], [2.5, 0, 2], 100, {
|
||||||
|
...options, destinationReached: (surface, target) => Math.hypot(surface[0] - target[0], surface[2] - target[2]) <= 1,
|
||||||
|
});
|
||||||
|
expect(visible.blocked).toBe(false);
|
||||||
|
expect(visible.waypoints.at(-1)).toEqual([2, 0, 2]);
|
||||||
|
const obstructed = planPartyRoute(corridorGraph, "mob", [0, 0, 0], [2.5, 0, 2], 100, {
|
||||||
|
...options, destinationReached: () => false,
|
||||||
|
});
|
||||||
|
expect(obstructed.blocked).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it("advances only one close-spaced waypoint at a corner", () => {
|
it("advances only one close-spaced waypoint at a corner", () => {
|
||||||
const route = {
|
const route = {
|
||||||
...emptyPartyRouteState(),
|
...emptyPartyRouteState(),
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ export interface PartyRouteOptions {
|
|||||||
start: PartyWorldPosition,
|
start: PartyWorldPosition,
|
||||||
end: PartyWorldPosition,
|
end: PartyWorldPosition,
|
||||||
) => boolean;
|
) => boolean;
|
||||||
|
/** Combat may finish on the retained surface only when range AND sight allow a hit. */
|
||||||
|
readonly destinationReached?: (surface: PartyWorldPosition, destination: PartyWorldPosition) => boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_MAX_SNAP_DISTANCE = 3;
|
const DEFAULT_MAX_SNAP_DISTANCE = 3;
|
||||||
@@ -117,7 +119,7 @@ export function planPartyRoute(
|
|||||||
) return blocked(waypoints);
|
) return blocked(waypoints);
|
||||||
const finishDistance = fullDistance(waypoints[waypoints.length - 1], destination);
|
const finishDistance = fullDistance(waypoints[waypoints.length - 1], destination);
|
||||||
if (finishDistance > localFinishDistance) return blocked(waypoints);
|
if (finishDistance > localFinishDistance) return blocked(waypoints);
|
||||||
if (finishDistance > reachedDistance * 0.5) {
|
if (finishDistance > reachedDistance * 0.5 && !options.destinationReached?.(waypoints[waypoints.length - 1], destination)) {
|
||||||
if (!options.directSegmentAllowed?.(waypoints[waypoints.length - 1], destination)) {
|
if (!options.directSegmentAllowed?.(waypoints[waypoints.length - 1], destination)) {
|
||||||
return blocked(waypoints);
|
return blocked(waypoints);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { partyRejoinMovement } from "./partyRejoin";
|
||||||
|
import { emptyPartyRouteState } from "./partyPathing";
|
||||||
|
import type { PartyNavigationGraph } from "./partyNavigation";
|
||||||
|
|
||||||
|
const graph: PartyNavigationGraph = {
|
||||||
|
nodes: [
|
||||||
|
{ id: 0, position: [0, 0, 0], neighbors: [{ to: 1, cost: 6 }] },
|
||||||
|
{ id: 1, position: [0, 0, 6], neighbors: [{ to: 0, cost: 6 }, { to: 2, cost: 2 }] },
|
||||||
|
{ id: 2, position: [2, 0, 6], neighbors: [{ to: 1, cost: 2 }, { to: 3, cost: 6 }] },
|
||||||
|
{ id: 3, position: [2, 0, 0], neighbors: [{ to: 2, cost: 6 }] },
|
||||||
|
],
|
||||||
|
weldTolerance: .001, sourceVertexCount: 4, sourceTriangleCount: 2,
|
||||||
|
weldedNodeCount: 4, componentCount: 1, discardedNodeCount: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("individual resurrection return navigation", () => {
|
||||||
|
it("finishes only when near the player on a clear traversable segment", () => {
|
||||||
|
expect(partyRejoinMovement("tank", [0, 0, 0], [2, 0, 0], 0, null, emptyPartyRouteState(),
|
||||||
|
{ directSegmentAllowed: () => true }).rejoined).toBe(true);
|
||||||
|
expect(partyRejoinMovement("tank", [0, 0, 0], [0, 5, 0], 0, null, emptyPartyRouteState(),
|
||||||
|
{ directSegmentAllowed: () => true }).rejoined).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("routes out of a room even when the player is close on the other side of a wall", () => {
|
||||||
|
const result = partyRejoinMovement("tank", [0, 0, 0], [2, 0, 0], 0, graph, emptyPartyRouteState(), {
|
||||||
|
directSegmentAllowed: (start, end) => start[0] === end[0] || (start[2] >= 5 && end[2] >= 5),
|
||||||
|
routeSegmentAllowed: () => true,
|
||||||
|
});
|
||||||
|
expect(result.rejoined).toBe(false);
|
||||||
|
expect(result.blocked).toBe(false);
|
||||||
|
expect(result.waypoint).toEqual([0, 0, 6]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("follows the current player position, not a fixed death location or boss", () => {
|
||||||
|
const result = partyRejoinMovement("tank", [0, 0, 0], [20, 0, 0], 0, null, emptyPartyRouteState(), {
|
||||||
|
directSegmentAllowed: () => true,
|
||||||
|
});
|
||||||
|
const moved = partyRejoinMovement("tank", [4, 0, 0], [20, 0, 8], 50, null, result.route, {
|
||||||
|
directSegmentAllowed: () => true,
|
||||||
|
});
|
||||||
|
expect(moved.waypoint).toEqual([20, 0, 8]);
|
||||||
|
expect(moved.rejoined).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports blocked navigation to the existing watchdog instead of walking through walls", () => {
|
||||||
|
const result = partyRejoinMovement("tank", [0, 0, 0], [20, 0, 0], 0, null, emptyPartyRouteState(), {
|
||||||
|
directSegmentAllowed: () => false,
|
||||||
|
});
|
||||||
|
expect(result.blocked).toBe(true);
|
||||||
|
expect(result.waypoint).toBeNull();
|
||||||
|
expect(result.rejoined).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { findNearestPartyNavigationSurface, type PartyNavigationGraph } from "./partyNavigation";
|
||||||
|
import { emptyPartyRouteState, nextPartyRouteRecoveryWaypoint, nextPartyRouteWaypoint, partyRouteNeedsPlanning, planPartyRoute, type PartyRouteOptions, type PartyRouteState } from "./partyPathing";
|
||||||
|
import type { PartyWorldPosition } from "./partyRuntimeRegistry";
|
||||||
|
|
||||||
|
export const PARTY_REJOIN_DISTANCE = 3;
|
||||||
|
|
||||||
|
/** Returning resurrected members target the player, not a distant boss or leader. */
|
||||||
|
export function partyRejoinMovement(
|
||||||
|
id: string, current: PartyWorldPosition, player: PartyWorldPosition, nowMs: number,
|
||||||
|
graph: PartyNavigationGraph | null, route: PartyRouteState,
|
||||||
|
options: Pick<PartyRouteOptions, "directSegmentAllowed" | "routeSegmentAllowed">,
|
||||||
|
) {
|
||||||
|
const reachedPlayer = (point: PartyWorldPosition) => Math.hypot(
|
||||||
|
point[0] - player[0], point[1] - player[1], point[2] - player[2],
|
||||||
|
) <= PARTY_REJOIN_DISTANCE && Boolean(options.directSegmentAllowed?.(point, player));
|
||||||
|
if (reachedPlayer(current)) return { rejoined: true, route: emptyPartyRouteState(), waypoint: null, blocked: false };
|
||||||
|
if (options.directSegmentAllowed?.(current, player)) {
|
||||||
|
return { rejoined: false, route: emptyPartyRouteState(), waypoint: player, blocked: false };
|
||||||
|
}
|
||||||
|
const destination = graph ? findNearestPartyNavigationSurface(graph, player, 5)?.position ?? player : player;
|
||||||
|
const goalKey = `member:${id}|resurrection-rejoin`;
|
||||||
|
if (partyRouteNeedsPlanning(route, goalKey, current, destination, nowMs, { replanIntervalMs: 350, blockedRetryMs: 800 })) {
|
||||||
|
route = planPartyRoute(graph, goalKey, current, destination, nowMs, {
|
||||||
|
...options, maxOriginSnapDistance: 6, maxDestinationSnapDistance: 5,
|
||||||
|
localFinishDistance: PARTY_REJOIN_DISTANCE,
|
||||||
|
destinationReached: reachedPlayer,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const waypoint: [number, number, number] = [0, 0, 0];
|
||||||
|
const hasWaypoint = nextPartyRouteWaypoint(route, current, waypoint)
|
||||||
|
|| nextPartyRouteRecoveryWaypoint(route, current, waypoint, options.directSegmentAllowed ?? (() => false));
|
||||||
|
return { rejoined: false, route, waypoint: hasWaypoint ? waypoint : null, blocked: route.blocked && !hasWaypoint };
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { PLAYER_AGGRO_ID } from "./aggro";
|
||||||
|
import { useCombatStore } from "./combatStore";
|
||||||
|
import { activateDungeonEncounterDefinitions, resetDungeonEncounters } from "./dungeonEncounterRuntime";
|
||||||
|
import { requireDungeonDefinition } from "./dungeonRegistry";
|
||||||
|
import { configureOnlineSessionRuntime } from "./onlineSessionRuntime";
|
||||||
|
import { clearPartyRuntimeRegistry, consumePartyRuntimeRejoin, getPartyRuntimePosition, partyRuntimeIsRejoining, registerPartyRuntimePosition } from "./partyRuntimeRegistry";
|
||||||
|
import { resurrectDeadPartyMembers } from "./partyResurrection";
|
||||||
|
import { derivePartySpawnSlots } from "./partySpawnSlots";
|
||||||
|
import { usePartyStore } from "./partyStore";
|
||||||
|
import { useGameStore } from "./store";
|
||||||
|
|
||||||
|
const dungeon = requireDungeonDefinition("stormwind-stockade");
|
||||||
|
|
||||||
|
function killMember(index: number) {
|
||||||
|
const member = usePartyStore.getState().members[index];
|
||||||
|
useCombatStore.getState().commitCompanionActors([{ ...member, health: 0, status: "down" }]);
|
||||||
|
return member.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("fallen NPC checkpoint resurrection", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
configureOnlineSessionRuntime({ active: false, authority: true, localAccountId: null });
|
||||||
|
clearPartyRuntimeRegistry();
|
||||||
|
useGameStore.getState().activateDungeon("stormwind-stockade");
|
||||||
|
activateDungeonEncounterDefinitions(dungeon.id, dungeon.encounters ?? []);
|
||||||
|
resetDungeonEncounters("dungeon", dungeon.id);
|
||||||
|
useCombatStore.getState().initializeCharacter({ classId: "priest", level: 16 });
|
||||||
|
useCombatStore.setState({ mobs: {} });
|
||||||
|
usePartyStore.getState().initializeParty("resurrection-party", 16, useCombatStore.getState().health);
|
||||||
|
useCombatStore.getState().synchronizeActors();
|
||||||
|
for (const member of usePartyStore.getState().members) registerPartyRuntimePosition(member.id, [99, 0, 99]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("restores only fallen members at the original entrance, not the current saved spawn", () => {
|
||||||
|
const deadId = killMember(0);
|
||||||
|
usePartyStore.getState().setCommand("stop");
|
||||||
|
const living = usePartyStore.getState().members.slice(1);
|
||||||
|
const livingActors = living.map((member) => useCombatStore.getState().actors[member.id]);
|
||||||
|
const gameBefore = useGameStore.getState();
|
||||||
|
const playerBefore = useCombatStore.getState().health;
|
||||||
|
useGameStore.setState({ activeSpawn: { footPosition: [80, 5, 80], yaw: 1 } });
|
||||||
|
expect(resurrectDeadPartyMembers("entrance", 1_000)).toBe(1);
|
||||||
|
expect(usePartyStore.getState().members[0]).toMatchObject({ health: usePartyStore.getState().members[0].maxHealth, status: "advancing" });
|
||||||
|
expect(getPartyRuntimePosition(deadId)).toEqual(derivePartySpawnSlots(dungeon.entrance, 5)[1].footPosition);
|
||||||
|
expect(partyRuntimeIsRejoining(deadId)).toBe(true);
|
||||||
|
expect(consumePartyRuntimeRejoin(deadId)).not.toBeNull();
|
||||||
|
expect(consumePartyRuntimeRejoin(deadId)).toBeNull();
|
||||||
|
expect(partyRuntimeIsRejoining(deadId)).toBe(true);
|
||||||
|
expect(usePartyStore.getState().members.slice(1)).toEqual(living);
|
||||||
|
expect(living.map((member) => useCombatStore.getState().actors[member.id])).toEqual(livingActors);
|
||||||
|
expect(usePartyStore.getState().command).toBe("stop");
|
||||||
|
expect(useCombatStore.getState().health).toBe(playerBefore);
|
||||||
|
expect(useGameStore.getState().playerPosition).toEqual(gameBefore.playerPosition);
|
||||||
|
expect(useGameStore.getState().resetRevision).toBe(gameBefore.resetRevision);
|
||||||
|
expect(useCombatStore.getState().combatEvents.at(-1)).toMatchObject({ kind: "resurrection", targetActorId: deadId });
|
||||||
|
expect(resurrectDeadPartyMembers("entrance", 1_001)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("restores all fallen members at a killed boss without resetting any living encounter", () => {
|
||||||
|
const boss = dungeon.bosses[0];
|
||||||
|
useCombatStore.getState().registerMob(boss.id, { name: boss.name, maxHealth: 1, boss: true });
|
||||||
|
useCombatStore.getState().damageMob(boss.id, 1, 100);
|
||||||
|
const mobsBefore = useCombatStore.getState().mobs;
|
||||||
|
const ids = usePartyStore.getState().members.map((_, index) => killMember(index));
|
||||||
|
expect(resurrectDeadPartyMembers(`boss:${boss.id}`, 1_000)).toBe(ids.length);
|
||||||
|
const slots = derivePartySpawnSlots({ footPosition: boss.position, yaw: dungeon.entrance.yaw }, ids.length + 1);
|
||||||
|
ids.forEach((id, index) => expect(getPartyRuntimePosition(id)).toEqual(slots[index + 1].footPosition));
|
||||||
|
expect(useCombatStore.getState().mobs).toBe(mobsBefore);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("enforces boss combat at action time and remains usable during trash combat", () => {
|
||||||
|
const id = killMember(0);
|
||||||
|
useCombatStore.getState().registerMob("active-boss", { name: "Boss", maxHealth: 100, boss: true });
|
||||||
|
useCombatStore.getState().damageMob("active-boss", 1, 100);
|
||||||
|
const before = useCombatStore.getState();
|
||||||
|
expect(resurrectDeadPartyMembers("entrance", 1_000)).toBe(0);
|
||||||
|
expect(useCombatStore.getState()).toBe(before);
|
||||||
|
expect(partyRuntimeIsRejoining(id)).toBe(false);
|
||||||
|
useCombatStore.getState().damageMob("active-boss", 99, 101);
|
||||||
|
useCombatStore.getState().registerMob("trash", { name: "Trash", maxHealth: 100 });
|
||||||
|
useCombatStore.getState().damageMob("trash", 1, 102);
|
||||||
|
expect(resurrectDeadPartyMembers("entrance", 1_001)).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects locked, stale, or arbitrary checkpoint IDs without any mutation", () => {
|
||||||
|
killMember(0);
|
||||||
|
const before = useCombatStore.getState();
|
||||||
|
for (const id of [`boss:${dungeon.bosses[0].id}`, "boss:other-dungeon", "20,30,40"]) {
|
||||||
|
expect(resurrectDeadPartyMembers(id, 1_000)).toBe(0);
|
||||||
|
expect(useCombatStore.getState()).toBe(before);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cannot spend or bypass Manastorm revival charges or run on a non-authority client", () => {
|
||||||
|
killMember(0);
|
||||||
|
useGameStore.setState({ gameMode: "manastorm" });
|
||||||
|
expect(resurrectDeadPartyMembers()).toBe(0);
|
||||||
|
useGameStore.setState({ gameMode: "dungeon" });
|
||||||
|
configureOnlineSessionRuntime({ active: true, authority: false, localAccountId: "guest" });
|
||||||
|
expect(resurrectDeadPartyMembers()).toBe(0);
|
||||||
|
configureOnlineSessionRuntime({ active: false, authority: true, localAccountId: null });
|
||||||
|
expect(resurrectDeadPartyMembers("entrance", NaN)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not resurrect the dead player with the NPC button", () => {
|
||||||
|
killMember(0);
|
||||||
|
useCombatStore.getState().damagePlayer(useCombatStore.getState().maxHealth * 10);
|
||||||
|
expect(resurrectDeadPartyMembers("entrance", 1_000)).toBe(1);
|
||||||
|
expect(useCombatStore.getState().health).toBe(0);
|
||||||
|
expect(useCombatStore.getState().combatEvents.at(-1)?.targetActorId).not.toBe(PLAYER_AGGRO_ID);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes lingering effects only on the resurrected member, leaving living allies' HoTs intact", () => {
|
||||||
|
const members = usePartyStore.getState().members;
|
||||||
|
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
|
||||||
|
for (const member of members) registerPartyRuntimePosition(member.id, [0, 0, 0]);
|
||||||
|
useCombatStore.getState().synchronizeActors();
|
||||||
|
const renew = useCombatStore.getState().abilities.find((ability) => ability.name === "Renew")!;
|
||||||
|
usePartyStore.getState().selectMember(members[0].id);
|
||||||
|
expect(useCombatStore.getState().castAbility(renew.id, undefined, 1_000)).toMatchObject({ ok: true });
|
||||||
|
usePartyStore.getState().selectMember(members[1].id);
|
||||||
|
expect(useCombatStore.getState().castAbility(renew.id, undefined, 5_000).ok).toBe(true);
|
||||||
|
const livingEffects = useCombatStore.getState().effects.filter((effect) => effect.targetId === members[1].id);
|
||||||
|
expect(livingEffects.length).toBeGreaterThan(0);
|
||||||
|
killMember(0);
|
||||||
|
expect(resurrectDeadPartyMembers("entrance", 6_000)).toBe(1);
|
||||||
|
expect(useCombatStore.getState().effects.some((effect) => effect.targetId === members[0].id)).toBe(false);
|
||||||
|
expect(useCombatStore.getState().effects.filter((effect) => effect.targetId === members[1].id)).toEqual(livingEffects);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { PLAYER_AGGRO_ID } from "./aggro";
|
||||||
|
import { useCombatStore, type MobCombatState } from "./combatStore";
|
||||||
|
import { onlineSessionOwnsWorldSimulation } from "./onlineSessionRuntime";
|
||||||
|
import { beginPartyRuntimeRejoin } from "./partyRuntimeRegistry";
|
||||||
|
import { derivePartySpawnSlots } from "./partySpawnSlots";
|
||||||
|
import { usePartyStore } from "./partyStore";
|
||||||
|
import { useGameStore } from "./store";
|
||||||
|
import { resolveResurrectionCheckpoint } from "./resurrectionCheckpoints";
|
||||||
|
|
||||||
|
export function partyResurrectionBlockedByBoss(mobs: Readonly<Record<string, MobCombatState>>): boolean {
|
||||||
|
return Object.values(mobs).some((mob) => mob.boss && mob.engaged && !mob.dead && !mob.despawned);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Normal-dungeon recovery only; never bypass Manastorm charges or online authority. */
|
||||||
|
export function resurrectDeadPartyMembers(checkpointId = "entrance", now = Date.now()): number {
|
||||||
|
const game = useGameStore.getState();
|
||||||
|
const combat = useCombatStore.getState();
|
||||||
|
if (game.gameMode !== "dungeon" || !Number.isFinite(now) || !onlineSessionOwnsWorldSimulation()
|
||||||
|
|| partyResurrectionBlockedByBoss(combat.mobs)) return 0;
|
||||||
|
const checkpoint = resolveResurrectionCheckpoint(checkpointId);
|
||||||
|
if (!checkpoint) return 0;
|
||||||
|
const party = usePartyStore.getState();
|
||||||
|
const fallen = party.members.filter((member) => (combat.actors[member.id]?.health ?? member.health) <= 0);
|
||||||
|
if (!fallen.length) return 0;
|
||||||
|
const ids = new Set(fallen.map((member) => member.id));
|
||||||
|
const slots = derivePartySpawnSlots(checkpoint.spawn, party.members.length + 1);
|
||||||
|
useCombatStore.setState({ effects: combat.effects.filter((effect) => !effect.targetId || !ids.has(effect.targetId)) });
|
||||||
|
let restoredCount = 0;
|
||||||
|
for (const member of fallen) {
|
||||||
|
if (useCombatStore.getState().reviveActor(member.id, 1, PLAYER_AGGRO_ID, "party-checkpoint-resurrection", now) <= 0) continue;
|
||||||
|
const index = party.members.findIndex((candidate) => candidate.id === member.id);
|
||||||
|
beginPartyRuntimeRejoin(member.id, (slots[index + 1] ?? slots[0]).footPosition);
|
||||||
|
usePartyStore.getState().updateMemberCombat(member.id, {
|
||||||
|
status: "advancing", activeCast: null, activeCastTargetId: null, controlledUntil: 0, controlMechanic: null,
|
||||||
|
});
|
||||||
|
restoredCount += 1;
|
||||||
|
}
|
||||||
|
return restoredCount;
|
||||||
|
}
|
||||||
@@ -6,6 +6,8 @@ import {
|
|||||||
registerPartyRuntimePosition,
|
registerPartyRuntimePosition,
|
||||||
updatePartyRuntimeAttackCorridor,
|
updatePartyRuntimeAttackCorridor,
|
||||||
updatePartyRuntimeTargetVisibility,
|
updatePartyRuntimeTargetVisibility,
|
||||||
|
beginPartyRuntimeRejoin,
|
||||||
|
finishPartyRuntimeRejoin,
|
||||||
} from "./partyRuntimeRegistry";
|
} from "./partyRuntimeRegistry";
|
||||||
import {
|
import {
|
||||||
activePartyTargetId,
|
activePartyTargetId,
|
||||||
@@ -47,6 +49,36 @@ describe("party combat runtime", () => {
|
|||||||
expect(activePartyTargetId(mobs, null)).toBe("pack:a");
|
expect(activePartyTargetId(mobs, null)).toBe("pack:a");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("lets a resurrected tank rejoin independently instead of taunting or casting from its checkpoint", () => {
|
||||||
|
registerEngagedMob();
|
||||||
|
const tank = usePartyStore.getState().members.find((member) => member.role === "tank")!;
|
||||||
|
for (const member of usePartyStore.getState().members) registerPartyRuntimePosition(member.id, [0, 0, 0]);
|
||||||
|
beginPartyRuntimeRejoin(tank.id, [1_000, 0, 0]);
|
||||||
|
const beforeHealth = useCombatStore.getState().mobs["pack:a"].health;
|
||||||
|
advanceDungeonPartyCombat(1_000);
|
||||||
|
expect(usePartyStore.getState().members.find((member) => member.id === tank.id))
|
||||||
|
.toMatchObject({ status: "advancing", activeCast: null, activeCastTargetId: null });
|
||||||
|
expect(usePartyStore.getState().memberTargetIds[tank.id]).toBeUndefined();
|
||||||
|
expect(useCombatStore.getState().mobs["pack:a"].forcedTarget?.actorId).not.toBe(tank.id);
|
||||||
|
expect(useCombatStore.getState().mobs["pack:a"].health).toBeLessThan(beforeHealth);
|
||||||
|
finishPartyRuntimeRejoin(tank.id);
|
||||||
|
registerPartyRuntimePosition(tank.id, [0, 0, 0]);
|
||||||
|
advanceDungeonPartyCombat(2_000);
|
||||||
|
expect(usePartyStore.getState().memberTargetIds[tank.id]).toBe("pack:a");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps returning teammates advancing during Stop, then resumes the party order when they arrive", () => {
|
||||||
|
const tank = usePartyStore.getState().members.find((member) => member.role === "tank")!;
|
||||||
|
usePartyStore.getState().setCommand("stop");
|
||||||
|
beginPartyRuntimeRejoin(tank.id, [0, 0, 0]);
|
||||||
|
advanceDungeonPartyCombat(1_000);
|
||||||
|
expect(usePartyStore.getState().members.find((member) => member.id === tank.id)?.status).toBe("advancing");
|
||||||
|
finishPartyRuntimeRejoin(tank.id);
|
||||||
|
advanceDungeonPartyCombat(2_000);
|
||||||
|
expect(usePartyStore.getState().command).toBe("stop");
|
||||||
|
expect(usePartyStore.getState().members.find((member) => member.id === tank.id)?.status).toBe("holding");
|
||||||
|
});
|
||||||
|
|
||||||
it("has the tank taunt the active mob while all damage allies attack it", () => {
|
it("has the tank taunt the active mob while all damage allies attack it", () => {
|
||||||
registerEngagedMob();
|
registerEngagedMob();
|
||||||
for (const member of usePartyStore.getState().members) {
|
for (const member of usePartyStore.getState().members) {
|
||||||
|
|||||||
+131
-17
@@ -21,12 +21,14 @@ import { requireDungeonDefinition } from "./dungeonRegistry";
|
|||||||
import { getMobPosition } from "./mobRuntimeRegistry";
|
import { getMobPosition } from "./mobRuntimeRegistry";
|
||||||
import {
|
import {
|
||||||
getPartyRuntimePosition,
|
getPartyRuntimePosition,
|
||||||
|
partyRuntimeIsRejoining,
|
||||||
partyRuntimeCenter,
|
partyRuntimeCenter,
|
||||||
partyRuntimePositionAlongAttackCorridor,
|
partyRuntimePositionAlongAttackCorridor,
|
||||||
partyRuntimeTargetVisible,
|
partyRuntimeTargetVisible,
|
||||||
updatePartyRuntimePosition,
|
updatePartyRuntimePosition,
|
||||||
} from "./partyRuntimeRegistry";
|
} from "./partyRuntimeRegistry";
|
||||||
import { usePartyStore, type PartyCommand, type PartyMember } from "./partyStore";
|
import { usePartyStore, type PartyCommand, type PartyMember } from "./partyStore";
|
||||||
|
import { baseClassFor, classById } from "../app/characterCatalog";
|
||||||
import { useGameStore } from "./store";
|
import { useGameStore } from "./store";
|
||||||
import { useManastormStore } from "./manastormStore";
|
import { useManastormStore } from "./manastormStore";
|
||||||
import { activeDungeonCompletedObjectiveIds } from "./dungeonEncounterRuntime";
|
import { activeDungeonCompletedObjectiveIds } from "./dungeonEncounterRuntime";
|
||||||
@@ -53,6 +55,7 @@ import {
|
|||||||
} from "./partyAbilityAi";
|
} from "./partyAbilityAi";
|
||||||
import { normalizeActorResourcePool } from "./combatActors";
|
import { normalizeActorResourcePool } from "./combatActors";
|
||||||
import { advanceCombatResourcePool } from "./combatResources";
|
import { advanceCombatResourcePool } from "./combatResources";
|
||||||
|
import { channelAmountEffects } from "./combatChannelAmounts";
|
||||||
import {
|
import {
|
||||||
combatHasLineOfSight,
|
combatHasLineOfSight,
|
||||||
combatSpatialActor,
|
combatSpatialActor,
|
||||||
@@ -68,6 +71,7 @@ const TANK_TAUNT_COOLDOWN_MS = 8_000;
|
|||||||
const TANK_TAUNT_DURATION_MS = 3_000;
|
const TANK_TAUNT_DURATION_MS = 3_000;
|
||||||
let lastAdvanceAt = 0;
|
let lastAdvanceAt = 0;
|
||||||
let nextRegenAt = 0;
|
let nextRegenAt = 0;
|
||||||
|
const nextWeaponAttackAt = new Map<string, number>();
|
||||||
|
|
||||||
export interface PartyPresentationIdentity {
|
export interface PartyPresentationIdentity {
|
||||||
readonly spellId: number;
|
readonly spellId: number;
|
||||||
@@ -140,6 +144,7 @@ export function activePartyBossObjectives(): readonly PartyBossObjective[] {
|
|||||||
export function resetPartyRuntimeClock(): void {
|
export function resetPartyRuntimeClock(): void {
|
||||||
lastAdvanceAt = 0;
|
lastAdvanceAt = 0;
|
||||||
nextRegenAt = 0;
|
nextRegenAt = 0;
|
||||||
|
nextWeaponAttackAt.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function activePartyTargetId(
|
export function activePartyTargetId(
|
||||||
@@ -166,7 +171,7 @@ export function partyAttackDamage(
|
|||||||
specialization: member.specialization ?? "",
|
specialization: member.specialization ?? "",
|
||||||
role: member.role,
|
role: member.role,
|
||||||
})
|
})
|
||||||
? classId === "hunter" ? "ranged" : "spell"
|
? baseClassFor(classId) === "hunter" ? "ranged" : "spell"
|
||||||
: "melee";
|
: "melee";
|
||||||
const weapon = kind === "ranged" ? gear.rangedWeapon : kind === "melee" ? gear.meleeWeapon : null;
|
const weapon = kind === "ranged" ? gear.rangedWeapon : kind === "melee" ? gear.meleeWeapon : null;
|
||||||
const equipmentPower = kind === "spell"
|
const equipmentPower = kind === "spell"
|
||||||
@@ -192,15 +197,30 @@ export function partyAttackDamage(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function partyUsesRangedAttacks(
|
export function partyUsesRangedAttacks(
|
||||||
member: Pick<PartyMember, "classId" | "specialization" | "role">,
|
member: Pick<PartyMember, "classId" | "specialization" | "role"> & Partial<Pick<PartyMember, "level" | "talentRanks">>,
|
||||||
): boolean {
|
): boolean {
|
||||||
if (member.role === "tank") return false;
|
if (member.role === "tank") return false;
|
||||||
if (member.role === "healer") return true;
|
if (member.role === "healer") return true;
|
||||||
|
const definition = classById(member.classId);
|
||||||
|
if (definition.mode !== "classic") {
|
||||||
|
return (baseClassFor(member.classId) === "hunter"
|
||||||
|
|| ["intellect", "wisdom", "spirit"].includes(definition.primaryStat))
|
||||||
|
&& partyCatalogAttackRange(member) > 5;
|
||||||
|
}
|
||||||
return ["hunter", "mage", "priest", "warlock"].includes(member.classId)
|
return ["hunter", "mage", "priest", "warlock"].includes(member.classId)
|
||||||
|| member.specialization === "Elemental"
|
|| member.specialization === "Elemental"
|
||||||
|| member.specialization === "Balance";
|
|| member.specialization === "Balance";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function partyCatalogAttackRange(member: Pick<PartyMember, "classId"> & Partial<Pick<PartyMember, "level" | "talentRanks">>): number {
|
||||||
|
return Math.max(0, ...abilitiesForClass(member.classId)
|
||||||
|
.filter((ability) => !ability.passive && ability.unlockLevel <= (member.level ?? 80)
|
||||||
|
&& (ability.talentEntryId === undefined || (member.talentRanks?.[`coa-entry-${ability.talentEntryId}`] ?? 0) > 0))
|
||||||
|
.map((ability) => abilityAtLevel(ability, member.level ?? 80))
|
||||||
|
.filter((ability) => ability.effects.some((effect) => ["damage", "dot", "finisher-damage", "execute"].includes(effect.kind)))
|
||||||
|
.map((ability) => ability.range.max));
|
||||||
|
}
|
||||||
|
|
||||||
export function partyAttackRange(member: Pick<PartyMember, "classId" | "specialization" | "role">): number {
|
export function partyAttackRange(member: Pick<PartyMember, "classId" | "specialization" | "role">): number {
|
||||||
if (member.role === "tank") return 3.8;
|
if (member.role === "tank") return 3.8;
|
||||||
if (partyUsesRangedAttacks(member)) return 19;
|
if (partyUsesRangedAttacks(member)) return 19;
|
||||||
@@ -235,9 +255,18 @@ const MELEE_DAMAGE_COMBAT_RANGE_BAND: PartyCombatRangeBand = Object.freeze({
|
|||||||
* target instead of treating the tank's position as their own.
|
* target instead of treating the tank's position as their own.
|
||||||
*/
|
*/
|
||||||
export function partyCombatRangeBand(
|
export function partyCombatRangeBand(
|
||||||
member: Pick<PartyMember, "classId" | "specialization" | "role">,
|
member: Pick<PartyMember, "classId" | "specialization" | "role"> & Partial<Pick<PartyMember, "level" | "talentRanks">>,
|
||||||
): PartyCombatRangeBand {
|
): PartyCombatRangeBand {
|
||||||
if (partyUsesRangedAttacks(member)) return RANGED_COMBAT_RANGE_BAND;
|
if (partyUsesRangedAttacks(member)) {
|
||||||
|
if (classById(member.classId).mode !== "classic") {
|
||||||
|
const attackRange = partyCatalogAttackRange(member);
|
||||||
|
if (attackRange <= 5) return RANGED_COMBAT_RANGE_BAND;
|
||||||
|
const maximum = Math.min(RANGED_COMBAT_RANGE_BAND.maximum, attackRange - 0.1);
|
||||||
|
const preferred = Math.min(RANGED_COMBAT_RANGE_BAND.preferred, maximum - 0.5);
|
||||||
|
return { minimum: Math.min(RANGED_COMBAT_RANGE_BAND.minimum, preferred - 0.5), preferred, maximum };
|
||||||
|
}
|
||||||
|
return RANGED_COMBAT_RANGE_BAND;
|
||||||
|
}
|
||||||
return member.role === "tank" ? TANK_COMBAT_RANGE_BAND : MELEE_DAMAGE_COMBAT_RANGE_BAND;
|
return member.role === "tank" ? TANK_COMBAT_RANGE_BAND : MELEE_DAMAGE_COMBAT_RANGE_BAND;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -598,7 +627,7 @@ function memberSwingMs(member: PartyMember): number {
|
|||||||
ratingPercent(
|
ratingPercent(
|
||||||
member.gearStats.hasteRating,
|
member.gearStats.hasteRating,
|
||||||
partyUsesRangedAttacks(member)
|
partyUsesRangedAttacks(member)
|
||||||
? member.classId === "hunter" ? "rangedHaste" : "spellHaste"
|
? baseClassFor(member.classId) === "hunter" ? "rangedHaste" : "spellHaste"
|
||||||
: "meleeHaste",
|
: "meleeHaste",
|
||||||
member.level,
|
member.level,
|
||||||
member.classId,
|
member.classId,
|
||||||
@@ -611,7 +640,7 @@ function emitPartyAbility(
|
|||||||
member: PartyMember,
|
member: PartyMember,
|
||||||
ability: AbilityDefinition,
|
ability: AbilityDefinition,
|
||||||
targetId: string,
|
targetId: string,
|
||||||
phase: "cast-start" | "release" | "impact",
|
phase: "cast-start" | "cast-cancel" | "release" | "impact",
|
||||||
now: number,
|
now: number,
|
||||||
instantCast = false,
|
instantCast = false,
|
||||||
): void {
|
): void {
|
||||||
@@ -638,6 +667,33 @@ function emitPartyAbility(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Weapon swings continue during the GCD and supply Rage for paid attacks. */
|
||||||
|
function advancePartyWeaponAttack(member: PartyMember, targetId: string, now: number): PartyMember {
|
||||||
|
if (partyUsesRangedAttacks(member) && baseClassFor(member.classId) !== "hunter") return member;
|
||||||
|
const origin = getPartyRuntimePosition(member.id) ?? useCombatStore.getState().playerPosition;
|
||||||
|
const targetPosition = getMobPosition(targetId);
|
||||||
|
const spatial = combatSpatialActor(member.id);
|
||||||
|
if (!targetPosition || !partyRuntimeTargetVisible(member.id, targetId)
|
||||||
|
|| distanceSquared(origin, targetPosition) > partyAttackRange(member) ** 2
|
||||||
|
|| Math.abs(origin[1] - targetPosition[1]) > 8
|
||||||
|
|| !combatHasLineOfSight(origin, targetPosition)
|
||||||
|
|| (spatial && !isPositionInFacingArc(spatial, targetPosition, Math.PI))
|
||||||
|
|| now < (nextWeaponAttackAt.get(member.id) ?? 0)) return member;
|
||||||
|
nextWeaponAttackAt.set(member.id, now + memberSwingMs(member));
|
||||||
|
const abilityId = "party-auto-attack";
|
||||||
|
const result = useCombatStore.getState().damageMob(targetId, partyAttackDamage(member), now,
|
||||||
|
{ actorId: member.id, role: member.role, abilityId }, "physical");
|
||||||
|
const identity = partyAttackPresentation(member);
|
||||||
|
for (const phase of ["release", "impact"] as const) {
|
||||||
|
emitCombatPresentation({ occurredAt: now, phase, actorGroup: "party", sourceActorId: member.id,
|
||||||
|
targetActorId: targetId, abilityId, name: "Auto Attack", source: "fallback", school: "physical",
|
||||||
|
delivery: baseClassFor(member.classId) === "hunter" ? "projectile" : identity.delivery, origin, targetPosition, instantCast: true });
|
||||||
|
}
|
||||||
|
if (member.resourceType !== "rage" || result.amount <= 0) return member;
|
||||||
|
const resource = Math.min(member.maxResource, member.resource + 6);
|
||||||
|
return { ...member, resource, resourcePools: { ...member.resourcePools, rage: resource } };
|
||||||
|
}
|
||||||
|
|
||||||
function partyFriendlyPosition(actorId: string): MobVector3 | null {
|
function partyFriendlyPosition(actorId: string): MobVector3 | null {
|
||||||
if (actorId === PLAYER_AGGRO_ID) return useCombatStore.getState().playerPosition;
|
if (actorId === PLAYER_AGGRO_ID) return useCombatStore.getState().playerPosition;
|
||||||
return getPartyRuntimePosition(actorId);
|
return getPartyRuntimePosition(actorId);
|
||||||
@@ -660,8 +716,11 @@ function partyAbilityTargetIsValid(
|
|||||||
if (!hostile && (!targetActor?.alive || targetActor.health <= 0)
|
if (!hostile && (!targetActor?.alive || targetActor.health <= 0)
|
||||||
&& !ability.effects.some((effect) => effect.kind === "resurrection")) return false;
|
&& !ability.effects.some((effect) => effect.kind === "resurrection")) return false;
|
||||||
const distance = Math.sqrt(distanceSquared(origin, targetPosition));
|
const distance = Math.sqrt(distanceSquared(origin, targetPosition));
|
||||||
if (distance < ability.range.min || distance > ability.range.max || Math.abs(origin[1] - targetPosition[1]) > 8) return false;
|
|
||||||
const targeting = targetingRulesForAbility(ability);
|
const targeting = targetingRulesForAbility(ability);
|
||||||
|
const maximumRange = hostile && ability.target === "self"
|
||||||
|
? targeting.radius ?? ability.radius ?? ability.range.max
|
||||||
|
: ability.range.max;
|
||||||
|
if (distance < ability.range.min || distance > maximumRange || Math.abs(origin[1] - targetPosition[1]) > 8) return false;
|
||||||
if (targeting.requiresLineOfSight && !combatHasLineOfSight(origin, targetPosition)) return false;
|
if (targeting.requiresLineOfSight && !combatHasLineOfSight(origin, targetPosition)) return false;
|
||||||
if (hostile && targeting.requiresFacing) {
|
if (hostile && targeting.requiresFacing) {
|
||||||
const sourceSpatial = combatSpatialActor(member.id);
|
const sourceSpatial = combatSpatialActor(member.id);
|
||||||
@@ -705,7 +764,7 @@ function partyHostileTargets(
|
|||||||
.filter((mob) => {
|
.filter((mob) => {
|
||||||
const position = getMobPosition(mob.id);
|
const position = getMobPosition(mob.id);
|
||||||
return !mob.dead && Boolean(position
|
return !mob.dead && Boolean(position
|
||||||
&& distanceSquared(origin, position) <= ability.range.max ** 2
|
&& distanceSquared(origin, position) <= (targeting.radius ?? ability.radius ?? ability.range.max) ** 2
|
||||||
&& combatHasLineOfSight(origin, position)
|
&& combatHasLineOfSight(origin, position)
|
||||||
&& (!memberSpatial || isPositionInFacingArc(memberSpatial, position, targeting.attackArcRadians ?? Math.PI / 2)));
|
&& (!memberSpatial || isPositionInFacingArc(memberSpatial, position, targeting.attackArcRadians ?? Math.PI / 2)));
|
||||||
})
|
})
|
||||||
@@ -714,12 +773,13 @@ function partyHostileTargets(
|
|||||||
}
|
}
|
||||||
if (targeting.shape === "circle" || targeting.shape === "ground" || ability.radius !== undefined) {
|
if (targeting.shape === "circle" || targeting.shape === "ground" || ability.radius !== undefined) {
|
||||||
const radius = targeting.radius ?? ability.radius ?? 0;
|
const radius = targeting.radius ?? ability.radius ?? 0;
|
||||||
|
const center = ability.target === "self" ? origin : primaryPosition;
|
||||||
return Object.values(combat.mobs)
|
return Object.values(combat.mobs)
|
||||||
.filter((mob) => {
|
.filter((mob) => {
|
||||||
const position = getMobPosition(mob.id);
|
const position = getMobPosition(mob.id);
|
||||||
return !mob.dead && Boolean(position
|
return !mob.dead && Boolean(position
|
||||||
&& distanceSquared(primaryPosition, position) <= radius * radius
|
&& distanceSquared(center, position) <= radius * radius
|
||||||
&& combatHasLineOfSight(primaryPosition, position));
|
&& combatHasLineOfSight(center, position));
|
||||||
})
|
})
|
||||||
.map((mob) => mob.id)
|
.map((mob) => mob.id)
|
||||||
.sort();
|
.sort();
|
||||||
@@ -855,7 +915,7 @@ function executePartyAbility(
|
|||||||
? Boolean(useCombatStore.getState().createActorAreaEffect(
|
? Boolean(useCombatStore.getState().createActorAreaEffect(
|
||||||
member.id,
|
member.id,
|
||||||
ability,
|
ability,
|
||||||
getMobPosition(targetId) ?? partyFriendlyPosition(targetId) ?? origin,
|
ability.target === "self" ? origin : getMobPosition(targetId) ?? partyFriendlyPosition(targetId) ?? origin,
|
||||||
now,
|
now,
|
||||||
))
|
))
|
||||||
: false;
|
: false;
|
||||||
@@ -1058,7 +1118,7 @@ export function advanceDungeonPartyCombat(now = Date.now()): void {
|
|||||||
));
|
));
|
||||||
let members: readonly PartyMember[] = authoritativeMembers;
|
let members: readonly PartyMember[] = authoritativeMembers;
|
||||||
const partyOrigin = partyRuntimeCenter(
|
const partyOrigin = partyRuntimeCenter(
|
||||||
members.map((member) => member.id),
|
members.filter((member) => !partyRuntimeIsRejoining(member.id)).map((member) => member.id),
|
||||||
combat.playerPosition,
|
combat.playerPosition,
|
||||||
);
|
);
|
||||||
let targetId = partyCommandTargetId(
|
let targetId = partyCommandTargetId(
|
||||||
@@ -1122,6 +1182,10 @@ export function advanceDungeonPartyCombat(now = Date.now()): void {
|
|||||||
if (member.status !== "down") nextMembers[index] = { ...member, status: "down" };
|
if (member.status !== "down") nextMembers[index] = { ...member, status: "down" };
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (partyRuntimeIsRejoining(member.id)) {
|
||||||
|
nextMembers[index] = { ...member, status: "advancing", activeCast: null, activeCastTargetId: null };
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (member.controlledUntil > now) {
|
if (member.controlledUntil > now) {
|
||||||
nextMembers[index] = member.status === "holding"
|
nextMembers[index] = member.status === "holding"
|
||||||
? member
|
? member
|
||||||
@@ -1138,6 +1202,7 @@ export function advanceDungeonPartyCombat(now = Date.now()): void {
|
|||||||
member = activeMember;
|
member = activeMember;
|
||||||
const currentCombat = useCombatStore.getState();
|
const currentCombat = useCombatStore.getState();
|
||||||
if (member.activeCast) {
|
if (member.activeCast) {
|
||||||
|
const activeCast = member.activeCast;
|
||||||
const castingAbility = abilityById(member.activeCast.abilityId);
|
const castingAbility = abilityById(member.activeCast.abilityId);
|
||||||
const castTargetId = member.activeCastTargetId;
|
const castTargetId = member.activeCastTargetId;
|
||||||
if (!castingAbility || !castTargetId) {
|
if (!castingAbility || !castTargetId) {
|
||||||
@@ -1148,7 +1213,41 @@ export function advanceDungeonPartyCombat(now = Date.now()): void {
|
|||||||
const desiredStatus = hostileCast
|
const desiredStatus = hostileCast
|
||||||
? member.role === "tank" ? "guarding" : "attacking"
|
? member.role === "tank" ? "guarding" : "attacking"
|
||||||
: "healing";
|
: "healing";
|
||||||
if (now < member.activeCast.completesAt) {
|
const hostilePosition = hostileCast ? getMobPosition(castTargetId) : null;
|
||||||
|
if (hostileCast && (!hostilePosition || !combatHasLineOfSight(
|
||||||
|
getPartyRuntimePosition(member.id) ?? currentCombat.playerPosition, hostilePosition,
|
||||||
|
))) {
|
||||||
|
emitPartyAbility(member, castingAbility, castTargetId, "cast-cancel", now);
|
||||||
|
memberTargetIds[member.id] = castTargetId;
|
||||||
|
nextMembers[index] = { ...member, activeCast: null, activeCastTargetId: null,
|
||||||
|
status: "advancing", nextActionAt: now + 250 };
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (member.activeCast.mode === "channel") {
|
||||||
|
const cast = member.activeCast;
|
||||||
|
if (!partyAbilityTargetIsValid(member, castingAbility, castTargetId, cast.origin)) {
|
||||||
|
emitPartyAbility(member, castingAbility, castTargetId, "cast-cancel", now);
|
||||||
|
nextMembers[index] = { ...member, activeCast: null, activeCastTargetId: null, status: "advancing", nextActionAt: now + 250 };
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const interval = Math.max(1, cast.originalDurationMs / 5);
|
||||||
|
let completed = cast.channelTicksCompleted ?? 0;
|
||||||
|
while (completed < 5 && cast.startedAt + (completed + 1) * interval <= now) {
|
||||||
|
const effects: AbilityEffect[] = channelAmountEffects(castingAbility, 5).map(({ effect, multiplier }) => ({
|
||||||
|
kind: effect.kind === "heal" || effect.kind === "hot" ? "heal" : "damage",
|
||||||
|
coefficient: 0, basePoints: Math.round(partyAbilityAmount(member, castingAbility, effect) * multiplier),
|
||||||
|
bonusPowerCoefficient: 0, sourceLevel: member.level,
|
||||||
|
...(effect.kind === "heal" && effect.percentMaxHealth !== undefined
|
||||||
|
? { percentMaxHealth: effect.percentMaxHealth * multiplier } : {}),
|
||||||
|
}));
|
||||||
|
const execution = executePartyAbility(member, { ...castingAbility, effects }, castTargetId,
|
||||||
|
cast.startedAt + (completed + 1) * interval, nextMembers, 0, true);
|
||||||
|
member = execution.member;
|
||||||
|
completed += 1;
|
||||||
|
}
|
||||||
|
member = { ...member, activeCast: { ...cast, channelTicksCompleted: completed } };
|
||||||
|
}
|
||||||
|
if (now < activeCast.completesAt) {
|
||||||
nextMembers[index] = member.status === desiredStatus ? member : { ...member, status: desiredStatus };
|
nextMembers[index] = member.status === desiredStatus ? member : { ...member, status: desiredStatus };
|
||||||
if (hostileCast) memberTargetIds[member.id] = castTargetId;
|
if (hostileCast) memberTargetIds[member.id] = castTargetId;
|
||||||
continue;
|
continue;
|
||||||
@@ -1158,12 +1257,14 @@ export function advanceDungeonPartyCombat(now = Date.now()): void {
|
|||||||
completedMember,
|
completedMember,
|
||||||
castingAbility,
|
castingAbility,
|
||||||
castTargetId,
|
castTargetId,
|
||||||
member.activeCast.origin,
|
activeCast.origin,
|
||||||
);
|
);
|
||||||
if (valid) {
|
if (valid) {
|
||||||
const execution = executePartyAbility(
|
const execution = executePartyAbility(
|
||||||
completedMember,
|
completedMember,
|
||||||
castingAbility,
|
activeCast.mode === "channel"
|
||||||
|
? { ...castingAbility, effects: castingAbility.effects.filter((effect) => !["damage", "dot", "heal", "hot"].includes(effect.kind)) }
|
||||||
|
: castingAbility,
|
||||||
castTargetId,
|
castTargetId,
|
||||||
now,
|
now,
|
||||||
nextMembers,
|
nextMembers,
|
||||||
@@ -1269,7 +1370,11 @@ export function advanceDungeonPartyCombat(now = Date.now()): void {
|
|||||||
const targetVisible = Boolean(
|
const targetVisible = Boolean(
|
||||||
targetPosition && partyRuntimeTargetVisible(member.id, memberTargetId)
|
targetPosition && partyRuntimeTargetVisible(member.id, memberTargetId)
|
||||||
);
|
);
|
||||||
let runtimeMember = member;
|
let runtimeMember = targetVisible ? advancePartyWeaponAttack(member, memberTargetId, now) : member;
|
||||||
|
if (useCombatStore.getState().mobs[memberTargetId]?.dead) {
|
||||||
|
nextMembers[index] = { ...runtimeMember, status: desiredStatus };
|
||||||
|
continue;
|
||||||
|
}
|
||||||
const targetMob = currentCombat.mobs[memberTargetId];
|
const targetMob = currentCombat.mobs[memberTargetId];
|
||||||
if (now < member.nextActionAt) {
|
if (now < member.nextActionAt) {
|
||||||
if (runtimeMember.status !== desiredStatus) {
|
if (runtimeMember.status !== desiredStatus) {
|
||||||
@@ -1282,9 +1387,18 @@ export function advanceDungeonPartyCombat(now = Date.now()): void {
|
|||||||
now,
|
now,
|
||||||
target: targetMob ?? null,
|
target: targetMob ?? null,
|
||||||
targetIsLoose: member.role === "tank" && targetMob?.targetActorId !== member.id,
|
targetIsLoose: member.role === "tank" && targetMob?.targetActorId !== member.id,
|
||||||
|
canUseAbility: (ability) => partyAbilityTargetIsValid(runtimeMember, ability, memberTargetId)
|
||||||
|
// Let maintained effects tick instead of resetting their first tick on every GCD.
|
||||||
|
&& !currentCombat.effects.some((effect) => effect.sourceActorId === member.id
|
||||||
|
&& effect.sourceAbilityId === ability.id && effect.targetId === memberTargetId && effect.remainingTicks > 0)
|
||||||
|
&& !ability.effects.some((effect) => effect.kind === "apply-aura"
|
||||||
|
&& currentCombat.auras.some((aura) => aura.sourceId === member.id
|
||||||
|
&& aura.definition.id === effect.aura.id && aura.targetId === memberTargetId)),
|
||||||
});
|
});
|
||||||
if (!selectedAbility) {
|
if (!selectedAbility) {
|
||||||
nextMembers[index] = { ...runtimeMember, status: desiredStatus, nextActionAt: now + 250 };
|
const inAttackRange = targetVisible && targetPosition
|
||||||
|
&& distanceSquared(memberPosition, targetPosition) <= partyAttackRange(member) ** 2;
|
||||||
|
nextMembers[index] = { ...runtimeMember, status: inAttackRange ? desiredStatus : "advancing", nextActionAt: now + 250 };
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const inRange = targetVisible && partyAbilityTargetIsValid(runtimeMember, selectedAbility, memberTargetId);
|
const inRange = targetVisible && partyAbilityTargetIsValid(runtimeMember, selectedAbility, memberTargetId);
|
||||||
|
|||||||
@@ -11,11 +11,41 @@ import {
|
|||||||
updatePartyRuntimeTargetVisibility,
|
updatePartyRuntimeTargetVisibility,
|
||||||
updatePartyRuntimeAttackCorridor,
|
updatePartyRuntimeAttackCorridor,
|
||||||
updatePartyRuntimePosition,
|
updatePartyRuntimePosition,
|
||||||
|
beginPartyRuntimeRejoin,
|
||||||
|
consumePartyRuntimeRejoin,
|
||||||
|
partyRuntimeIsRejoining,
|
||||||
|
finishPartyRuntimeRejoin,
|
||||||
} from "./partyRuntimeRegistry";
|
} from "./partyRuntimeRegistry";
|
||||||
|
|
||||||
describe("party runtime position registry", () => {
|
describe("party runtime position registry", () => {
|
||||||
beforeEach(clearPartyRuntimeRegistry);
|
beforeEach(clearPartyRuntimeRegistry);
|
||||||
|
|
||||||
|
it("consumes a copied relocation once and keeps the individual return task until completed", () => {
|
||||||
|
const origin: [number, number, number] = [1, 2, 3];
|
||||||
|
beginPartyRuntimeRejoin("tank", origin);
|
||||||
|
origin[0] = 999;
|
||||||
|
const result = consumePartyRuntimeRejoin("tank")! as [number, number, number];
|
||||||
|
expect(result).toEqual([1, 2, 3]);
|
||||||
|
result[0] = 888;
|
||||||
|
expect(getPartyRuntimePosition("tank")).toEqual([1, 2, 3]);
|
||||||
|
expect(consumePartyRuntimeRejoin("tank")).toBeNull();
|
||||||
|
expect(partyRuntimeIsRejoining("tank")).toBe(true);
|
||||||
|
expect(partyRuntimeIsRejoining("healer")).toBe(false);
|
||||||
|
finishPartyRuntimeRejoin("tank");
|
||||||
|
expect(partyRuntimeIsRejoining("tank")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears pending resurrection tasks on actor unmount and dungeon switch", () => {
|
||||||
|
const unregister = registerPartyRuntimePosition("tank", [0, 0, 0]);
|
||||||
|
beginPartyRuntimeRejoin("tank", [1, 0, 0]);
|
||||||
|
unregister();
|
||||||
|
expect(partyRuntimeIsRejoining("tank")).toBe(false);
|
||||||
|
beginPartyRuntimeRejoin("tank", [1, 0, 0]);
|
||||||
|
clearPartyRuntimeRegistry();
|
||||||
|
expect(consumePartyRuntimeRejoin("tank")).toBeNull();
|
||||||
|
expect(partyRuntimeIsRejoining("tank")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it("tracks world actors without exposing mutable registry storage", () => {
|
it("tracks world actors without exposing mutable registry storage", () => {
|
||||||
const unregister = registerPartyRuntimePosition("tank", [1, 2, 3]);
|
const unregister = registerPartyRuntimePosition("tank", [1, 2, 3]);
|
||||||
updatePartyRuntimePosition("tank", 4, 5, 6);
|
updatePartyRuntimePosition("tank", 4, 5, 6);
|
||||||
|
|||||||
@@ -3,6 +3,29 @@ export type MutablePartyWorldPosition = [x: number, y: number, z: number];
|
|||||||
|
|
||||||
const positions = new Map<string, MutablePartyWorldPosition>();
|
const positions = new Map<string, MutablePartyWorldPosition>();
|
||||||
const targetVisibility = new Map<string, { targetId: string | null; visible: boolean }>();
|
const targetVisibility = new Map<string, { targetId: string | null; visible: boolean }>();
|
||||||
|
const rejoins = new Map<string, { position: PartyWorldPosition; pending: boolean }>();
|
||||||
|
|
||||||
|
/** A one-shot scene relocation followed by an individual return-to-player task. */
|
||||||
|
export function beginPartyRuntimeRejoin(id: string, position: PartyWorldPosition): void {
|
||||||
|
rejoins.set(id, { position: [...position], pending: true });
|
||||||
|
updatePartyRuntimePosition(id, position[0], position[1], position[2]);
|
||||||
|
targetVisibility.delete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function consumePartyRuntimeRejoin(id: string): PartyWorldPosition | null {
|
||||||
|
const rejoin = rejoins.get(id);
|
||||||
|
if (!rejoin?.pending) return null;
|
||||||
|
rejoin.pending = false;
|
||||||
|
return [...rejoin.position];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function partyRuntimeIsRejoining(id: string): boolean {
|
||||||
|
return rejoins.has(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function finishPartyRuntimeRejoin(id: string): void {
|
||||||
|
rejoins.delete(id);
|
||||||
|
}
|
||||||
let attackCorridor: {
|
let attackCorridor: {
|
||||||
readonly origin: MutablePartyWorldPosition;
|
readonly origin: MutablePartyWorldPosition;
|
||||||
readonly waypoints: readonly PartyWorldPosition[];
|
readonly waypoints: readonly PartyWorldPosition[];
|
||||||
@@ -21,6 +44,7 @@ export function registerPartyRuntimePosition(
|
|||||||
return () => {
|
return () => {
|
||||||
positions.delete(id);
|
positions.delete(id);
|
||||||
targetVisibility.delete(id);
|
targetVisibility.delete(id);
|
||||||
|
rejoins.delete(id);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,6 +135,7 @@ export function updatePartyRuntimePosition(
|
|||||||
export function removePartyRuntimePosition(id: string): void {
|
export function removePartyRuntimePosition(id: string): void {
|
||||||
positions.delete(id);
|
positions.delete(id);
|
||||||
targetVisibility.delete(id);
|
targetVisibility.delete(id);
|
||||||
|
rejoins.delete(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getPartyRuntimePosition(id: string): PartyWorldPosition | null {
|
export function getPartyRuntimePosition(id: string): PartyWorldPosition | null {
|
||||||
@@ -166,5 +191,6 @@ export function partyRuntimeCenter(
|
|||||||
export function clearPartyRuntimeRegistry(): void {
|
export function clearPartyRuntimeRegistry(): void {
|
||||||
positions.clear();
|
positions.clear();
|
||||||
targetVisibility.clear();
|
targetVisibility.clear();
|
||||||
|
rejoins.clear();
|
||||||
attackCorridor = null;
|
attackCorridor = null;
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { beforeEach, describe, expect, it } from "vitest";
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
APPEARANCE_KEYS,
|
APPEARANCE_KEYS,
|
||||||
|
ROM_CLASS_IDS,
|
||||||
classesForRace,
|
classesForRace,
|
||||||
raceById,
|
raceById,
|
||||||
supportedGenders,
|
supportedGenders,
|
||||||
@@ -80,6 +81,51 @@ describe("dungeon party composition", () => {
|
|||||||
expect(first.every((member) => member.level === 18 && member.health === member.maxHealth)).toBe(true);
|
expect(first.every((member) => member.level === 18 && member.health === member.maxHealth)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("syncs every RoM companion class through entry and level changes without WoW talents", () => {
|
||||||
|
const seenClasses = new Set<string>();
|
||||||
|
for (const role of ["tank", "healer", "damage"] as const) {
|
||||||
|
for (let seed = 0; seed < 20; seed += 1) {
|
||||||
|
usePartyStore.getState().initializeParty(`rom-level-${seed}`, 1, 100, role, 5, "rom");
|
||||||
|
const identities = usePartyStore.getState().members.map(({ id, classId, raceId, gender, appearance }) => ({
|
||||||
|
id, classId, raceId, gender, appearance,
|
||||||
|
}));
|
||||||
|
for (const level of [2, 10, 70, 1]) {
|
||||||
|
usePartyStore.getState().syncLevel(level);
|
||||||
|
const members = usePartyStore.getState().members;
|
||||||
|
expect(members.map(({ id, classId, raceId, gender, appearance }) => ({
|
||||||
|
id, classId, raceId, gender, appearance,
|
||||||
|
}))).toEqual(identities);
|
||||||
|
for (const member of members) {
|
||||||
|
seenClasses.add(member.classId);
|
||||||
|
expect(member.level).toBe(level);
|
||||||
|
expect(member.talentRanks).toEqual({});
|
||||||
|
expect(Object.isFrozen(member.talentRanks)).toBe(true);
|
||||||
|
expect(member.health).toBe(member.maxHealth);
|
||||||
|
expect(member.resourcePools[member.resourceType]).toBe(member.resource);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect([...seenClasses].sort()).toEqual([...ROM_CLASS_IDS].sort());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves fallen RoM companions and living health/resource ratios on level sync", () => {
|
||||||
|
usePartyStore.getState().initializeParty("rom-level-ratios", 10, 100, "damage", 5, "rom");
|
||||||
|
const [fallen, healer] = usePartyStore.getState().members;
|
||||||
|
usePartyStore.getState().updateMemberCombat(fallen.id, { health: 0, status: "down" });
|
||||||
|
usePartyStore.getState().updateMemberCombat(healer.id, {
|
||||||
|
health: Math.round(healer.maxHealth * 0.5),
|
||||||
|
resource: Math.round(healer.maxResource * 0.25),
|
||||||
|
});
|
||||||
|
const before = usePartyStore.getState().members[1];
|
||||||
|
usePartyStore.getState().syncLevel(11);
|
||||||
|
const [syncedFallen, syncedHealer] = usePartyStore.getState().members;
|
||||||
|
expect(syncedFallen).toMatchObject({ health: 0, status: "down", level: 11, talentRanks: {} });
|
||||||
|
expect(syncedHealer.health).toBe(Math.round(before.health / before.maxHealth * syncedHealer.maxHealth));
|
||||||
|
expect(syncedHealer.resource).toBe(Math.round(before.resource / before.maxResource * syncedHealer.maxResource));
|
||||||
|
expect(syncedHealer.resourcePools.mana).toBe(syncedHealer.resource);
|
||||||
|
});
|
||||||
|
|
||||||
it("supports solo through five-person Manastorm party sizes without changing the dungeon default", () => {
|
it("supports solo through five-person Manastorm party sizes without changing the dungeon default", () => {
|
||||||
expect(createDungeonParty("sizes", 18)).toHaveLength(4);
|
expect(createDungeonParty("sizes", 18)).toHaveLength(4);
|
||||||
for (const size of [1, 2, 3, 4, 5] as const) {
|
for (const size of [1, 2, 3, 4, 5] as const) {
|
||||||
|
|||||||
@@ -496,7 +496,9 @@ export const usePartyStore = create<PartyState>((set, get) => ({
|
|||||||
resource,
|
resource,
|
||||||
maxResource,
|
maxResource,
|
||||||
resourcePools: Object.freeze({ ...member.resourcePools, [member.resourceType]: resource }),
|
resourcePools: Object.freeze({ ...member.resourcePools, [member.resourceType]: resource }),
|
||||||
talentRanks: runtimePartyTalentRanksForLevel(member.classId, member.specialization, safeLevel),
|
talentRanks: isRomClassId(member.classId)
|
||||||
|
? Object.freeze({})
|
||||||
|
: runtimePartyTalentRanksForLevel(member.classId, member.specialization, safeLevel),
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,181 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
applyPartyStuckRecoveryLanding,
|
||||||
|
choosePartyStuckRecoveryPosition,
|
||||||
|
createPartyStuckRecoveryState,
|
||||||
|
markPartyStuckRecovered,
|
||||||
|
resetPartyStuckRecoveryProgress,
|
||||||
|
updatePartyStuckRecovery,
|
||||||
|
type PartyStuckRecoverySample,
|
||||||
|
} from "./partyStuckRecovery";
|
||||||
|
import type { MutablePartyWorldPosition, PartyWorldPosition } from "./partyRuntimeRegistry";
|
||||||
|
|
||||||
|
const origin: PartyWorldPosition = [0, 0, 0];
|
||||||
|
const sample = (nowMs: number, overrides: Partial<PartyStuckRecoverySample> = {}): PartyStuckRecoverySample => ({
|
||||||
|
position: origin, waypoint: [10, 0, 0], goalKey: "follow:leader", movementExpected: true,
|
||||||
|
health: 100, paused: false, stopped: false, casting: false, controlled: false, nowMs, ...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("per-teammate stuck navigation", () => {
|
||||||
|
it("replans at two seconds and recovers at five, without a player-distance gate", () => {
|
||||||
|
const state = createPartyStuckRecoveryState(origin, 0);
|
||||||
|
expect(updatePartyStuckRecovery(state, sample(0))).toBe("none");
|
||||||
|
expect(updatePartyStuckRecovery(state, sample(1_999))).toBe("none");
|
||||||
|
expect(updatePartyStuckRecovery(state, sample(2_000))).toBe("replan");
|
||||||
|
expect(updatePartyStuckRecovery(state, sample(2_500))).toBe("none");
|
||||||
|
expect(updatePartyStuckRecovery(state, sample(4_999))).toBe("none");
|
||||||
|
expect(updatePartyStuckRecovery(state, sample(5_000))).toBe("recover");
|
||||||
|
expect(updatePartyStuckRecovery(state, sample(5_100))).toBe("none");
|
||||||
|
expect(updatePartyStuckRecovery(state, sample(6_000))).toBe("recover");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not restart the timer when a replan changes waypoints or yields no path", () => {
|
||||||
|
const state = createPartyStuckRecoveryState(origin, 0);
|
||||||
|
updatePartyStuckRecovery(state, sample(0));
|
||||||
|
expect(updatePartyStuckRecovery(state, sample(2_000))).toBe("replan");
|
||||||
|
updatePartyStuckRecovery(state, sample(3_000, { waypoint: [0, 0, 10] }));
|
||||||
|
updatePartyStuckRecovery(state, sample(4_000, { waypoint: null }));
|
||||||
|
expect(updatePartyStuckRecovery(state, sample(5_000, { waypoint: null }))).toBe("recover");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cancels back-and-forth doorway shuffling instead of treating it as repeated progress", () => {
|
||||||
|
const state = createPartyStuckRecoveryState(origin, 0);
|
||||||
|
updatePartyStuckRecovery(state, sample(0));
|
||||||
|
updatePartyStuckRecovery(state, sample(500, { position: [0.6, 0, 0] }));
|
||||||
|
const actions: string[] = [];
|
||||||
|
for (let tick = 1; tick <= 10; tick += 1) {
|
||||||
|
actions.push(updatePartyStuckRecovery(state, sample(500 + tick * 500, {
|
||||||
|
position: tick % 2 ? origin : [0.6, 0, 0],
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
expect(actions).toContain("replan");
|
||||||
|
expect(actions.at(-1)).toBe("recover");
|
||||||
|
expect(state.lastProgressAtMs).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("counts movement toward route waypoints even when the route goes away from the player", () => {
|
||||||
|
const state = createPartyStuckRecoveryState(origin, 0);
|
||||||
|
updatePartyStuckRecovery(state, sample(0));
|
||||||
|
for (let tick = 1; tick <= 12; tick += 1) {
|
||||||
|
expect(updatePartyStuckRecovery(state, sample(tick * 500, { position: [tick * 0.5, 0, 0] }))).toBe("none");
|
||||||
|
}
|
||||||
|
expect(state.lastProgressAtMs).toBe(6_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not mistake target movement or sideways motion for useful actor progress", () => {
|
||||||
|
const state = createPartyStuckRecoveryState(origin, 0);
|
||||||
|
updatePartyStuckRecovery(state, sample(0));
|
||||||
|
for (let tick = 1; tick <= 5; tick += 1) {
|
||||||
|
updatePartyStuckRecovery(state, sample(tick * 1_000, { position: [0, 0, tick % 2], waypoint: [10 + tick, 0, 0] }));
|
||||||
|
}
|
||||||
|
expect(state.lastProgressAtMs).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("advances through dense waypoint chains and corners without false recovery", () => {
|
||||||
|
const state = createPartyStuckRecoveryState(origin, 0);
|
||||||
|
updatePartyStuckRecovery(state, sample(0, { waypoint: [0.08, 0, 0] }));
|
||||||
|
for (let frame = 1; frame <= 400; frame += 1) {
|
||||||
|
const point = (step: number): PartyWorldPosition => step <= 200
|
||||||
|
? [step * 0.08, 0, 0] : [16, 0, (step - 200) * 0.08];
|
||||||
|
expect(updatePartyStuckRecovery(state, sample(frame * 1000 / 60, {
|
||||||
|
position: point(frame), waypoint: point(frame + 1),
|
||||||
|
}))).toBe("none");
|
||||||
|
}
|
||||||
|
expect(state.lastProgressAtMs).toBeGreaterThan(6_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
{ movementExpected: false }, { health: 0 }, { paused: true }, { stopped: true }, { casting: true }, { controlled: true },
|
||||||
|
])("excludes intentional immobility %j and starts a fresh timer afterward", (excluded) => {
|
||||||
|
const state = createPartyStuckRecoveryState(origin, 0);
|
||||||
|
updatePartyStuckRecovery(state, sample(0));
|
||||||
|
expect(updatePartyStuckRecovery(state, sample(10_000, excluded))).toBe("none");
|
||||||
|
expect(updatePartyStuckRecovery(state, sample(11_000))).toBe("none");
|
||||||
|
expect(updatePartyStuckRecovery(state, sample(12_999))).toBe("none");
|
||||||
|
expect(updatePartyStuckRecovery(state, sample(13_000))).toBe("replan");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a ten-second teleport cooldown across pauses, holds, and goal changes", () => {
|
||||||
|
const state = createPartyStuckRecoveryState(origin, 0);
|
||||||
|
markPartyStuckRecovered(state, origin, 5_000);
|
||||||
|
updatePartyStuckRecovery(state, sample(5_100));
|
||||||
|
expect(updatePartyStuckRecovery(state, sample(7_100))).toBe("replan");
|
||||||
|
expect(updatePartyStuckRecovery(state, sample(10_100))).toBe("none");
|
||||||
|
resetPartyStuckRecoveryProgress(state);
|
||||||
|
updatePartyStuckRecovery(state, sample(10_200, { goalKey: "mob:new-target" }));
|
||||||
|
expect(updatePartyStuckRecovery(state, sample(12_200, { goalKey: "mob:new-target" }))).toBe("replan");
|
||||||
|
expect(updatePartyStuckRecovery(state, sample(14_999, { goalKey: "mob:new-target" }))).toBe("none");
|
||||||
|
expect(updatePartyStuckRecovery(state, sample(15_200, { goalKey: "mob:new-target" }))).toBe("recover");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps independent teammate timers", () => {
|
||||||
|
const stuck = createPartyStuckRecoveryState(origin, 0);
|
||||||
|
const moving = createPartyStuckRecoveryState(origin, 0);
|
||||||
|
updatePartyStuckRecovery(stuck, sample(0));
|
||||||
|
updatePartyStuckRecovery(moving, sample(0));
|
||||||
|
updatePartyStuckRecovery(stuck, sample(2_000));
|
||||||
|
for (let tick = 1; tick <= 10; tick += 1) updatePartyStuckRecovery(moving, sample(tick * 500, { position: [tick * 0.5, 0, 0] }));
|
||||||
|
expect(updatePartyStuckRecovery(stuck, sample(5_000))).toBe("recover");
|
||||||
|
expect(updatePartyStuckRecovery(moving, sample(5_001, { position: [5, 0, 0] }))).toBe("none");
|
||||||
|
expect(moving.cooldownUntilMs).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("moves only the affected teammate and starts only its teleport cooldown", () => {
|
||||||
|
const positions = new Map<string, MutablePartyWorldPosition>([
|
||||||
|
["tank", [0, 0, 0]], ["rogue", [2, 0, 1]], ["mage", [3, 0, 2]],
|
||||||
|
]);
|
||||||
|
const states = new Map([...positions].map(([id, position]) => [id, createPartyStuckRecoveryState(position, 0)]));
|
||||||
|
expect(applyPartyStuckRecoveryLanding(positions, "rogue", [10, 0, 0], states.get("rogue")!, 5_000)).toBe(true);
|
||||||
|
expect(positions.get("rogue")).toEqual([10, 0, 0]);
|
||||||
|
expect(positions.get("tank")).toEqual([0, 0, 0]);
|
||||||
|
expect(positions.get("mage")).toEqual([3, 0, 2]);
|
||||||
|
expect(states.get("rogue")!.cooldownUntilMs).toBe(15_000);
|
||||||
|
expect(states.get("tank")!.cooldownUntilMs).toBe(0);
|
||||||
|
expect(states.get("mage")!.cooldownUntilMs).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not apply missing or malformed landings", () => {
|
||||||
|
const positions = new Map<string, MutablePartyWorldPosition>([["tank", [0, 0, 0]]]);
|
||||||
|
const state = createPartyStuckRecoveryState(origin, 0);
|
||||||
|
expect(applyPartyStuckRecoveryLanding(positions, "missing", [10, 0, 0], state, 5_000)).toBe(false);
|
||||||
|
expect(applyPartyStuckRecoveryLanding(positions, "tank", [NaN, 0, 0], state, 5_000)).toBe(false);
|
||||||
|
expect(positions.get("tank")).toEqual(origin);
|
||||||
|
expect(state.cooldownUntilMs).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles malformed samples without poisoning later progress", () => {
|
||||||
|
const state = createPartyStuckRecoveryState(origin, 0);
|
||||||
|
expect(updatePartyStuckRecovery(state, sample(NaN))).toBe("none");
|
||||||
|
expect(updatePartyStuckRecovery(state, sample(1, { position: [NaN, 0, 0] }))).toBe("none");
|
||||||
|
expect(updatePartyStuckRecovery(state, sample(10_000))).toBe("none");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("safe stuck-recovery landings", () => {
|
||||||
|
const player: PartyWorldPosition = [10, 0, 0];
|
||||||
|
const choose = (trailPoints: readonly PartyWorldPosition[],
|
||||||
|
validate: (point: PartyWorldPosition) => PartyWorldPosition | null = (point) => point) => choosePartyStuckRecoveryPosition({
|
||||||
|
current: origin, player, groupAnchor: player, trailPoints, validate,
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers a visited trail point near the group over returning to the player", () => {
|
||||||
|
expect(choose([[2, 0, 0], [9, 0, 0]])).toEqual({ position: [9, 0, 0], source: "trail" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips wrong floors, distant points, current position, and blocked/unsafe landings", () => {
|
||||||
|
const result = choose([[0.2, 0, 0], [10, 5, 0], [50, 0, 0], [9, 0, 0], [8, 0, 0]],
|
||||||
|
(point) => point[0] === 9 ? null : point);
|
||||||
|
expect(result).toEqual({ position: [8, 0, 0], source: "trail" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to a validated player point when no safe local trail exists", () => {
|
||||||
|
expect(choose([], (point) => point)).toEqual({ position: player, source: "player" });
|
||||||
|
expect(choose([[9, 0, 0]], (point) => point[0] === 10 ? point : null)).toEqual({ position: player, source: "player" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never blindly teleports to an invalid player point or a far-away projected room", () => {
|
||||||
|
expect(choose([], () => null)).toBeNull();
|
||||||
|
expect(choose([[9, 0, 0]], () => [10, 0, 5])).toBeNull();
|
||||||
|
expect(choose([], () => [10, -5, 0])).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
import type { MutablePartyWorldPosition, PartyWorldPosition } from "./partyRuntimeRegistry";
|
||||||
|
|
||||||
|
export const PARTY_STUCK_REPLAN_MS = 2_000;
|
||||||
|
export const PARTY_STUCK_RECOVER_MS = 5_000;
|
||||||
|
export const PARTY_STUCK_RECOVERY_COOLDOWN_MS = 10_000;
|
||||||
|
const PROGRESS_DISTANCE = 0.45;
|
||||||
|
const RECOVERY_RETRY_MS = 1_000;
|
||||||
|
const MAX_TRAIL_GROUP_DISTANCE = 12;
|
||||||
|
const MAX_TRAIL_HEIGHT_DIFFERENCE = 1.25;
|
||||||
|
const MIN_RECOVERY_DISTANCE = 1;
|
||||||
|
|
||||||
|
export interface PartyStuckRecoveryState {
|
||||||
|
goalKey: string | null;
|
||||||
|
readonly previousPosition: MutablePartyWorldPosition;
|
||||||
|
waypoint: MutablePartyWorldPosition | null;
|
||||||
|
netProgress: number;
|
||||||
|
lastProgressAtMs: number;
|
||||||
|
replanned: boolean;
|
||||||
|
cooldownUntilMs: number;
|
||||||
|
retryAtMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PartyStuckRecoverySample {
|
||||||
|
readonly position: PartyWorldPosition;
|
||||||
|
readonly waypoint: PartyWorldPosition | null;
|
||||||
|
readonly goalKey: string;
|
||||||
|
readonly movementExpected: boolean;
|
||||||
|
readonly health: number;
|
||||||
|
readonly paused: boolean;
|
||||||
|
readonly stopped: boolean;
|
||||||
|
readonly casting: boolean;
|
||||||
|
readonly controlled: boolean;
|
||||||
|
readonly nowMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function distance(a: PartyWorldPosition, b: PartyWorldPosition): number {
|
||||||
|
return Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function finitePosition(position: PartyWorldPosition): boolean {
|
||||||
|
return position.every(Number.isFinite);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPartyStuckRecoveryState(position: PartyWorldPosition, nowMs: number): PartyStuckRecoveryState {
|
||||||
|
return {
|
||||||
|
goalKey: null,
|
||||||
|
previousPosition: [...position],
|
||||||
|
waypoint: null,
|
||||||
|
netProgress: 0,
|
||||||
|
lastProgressAtMs: nowMs,
|
||||||
|
replanned: false,
|
||||||
|
cooldownUntilMs: 0,
|
||||||
|
retryAtMs: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clear navigation timing without letting pauses/holds bypass a teleport cooldown. */
|
||||||
|
export function resetPartyStuckRecoveryProgress(state: PartyStuckRecoveryState): void {
|
||||||
|
state.goalKey = null;
|
||||||
|
state.waypoint = null;
|
||||||
|
state.netProgress = 0;
|
||||||
|
state.replanned = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Signed progress toward the previous waypoint cancels doorway back-and-forth. */
|
||||||
|
export function updatePartyStuckRecovery(
|
||||||
|
state: PartyStuckRecoveryState,
|
||||||
|
sample: PartyStuckRecoverySample,
|
||||||
|
): "none" | "replan" | "recover" {
|
||||||
|
const { position, waypoint, nowMs } = sample;
|
||||||
|
if (!Number.isFinite(nowMs) || !finitePosition(position) || (waypoint && !finitePosition(waypoint))) {
|
||||||
|
resetPartyStuckRecoveryProgress(state);
|
||||||
|
return "none";
|
||||||
|
}
|
||||||
|
const excluded = !sample.movementExpected || sample.health <= 0 || sample.paused
|
||||||
|
|| sample.stopped || sample.casting || sample.controlled;
|
||||||
|
if (excluded || state.goalKey !== sample.goalKey || nowMs < state.lastProgressAtMs) {
|
||||||
|
resetPartyStuckRecoveryProgress(state);
|
||||||
|
state.lastProgressAtMs = nowMs;
|
||||||
|
if (!excluded) state.goalKey = sample.goalKey;
|
||||||
|
} else if (state.waypoint) {
|
||||||
|
// Target/waypoint movement itself is not actor progress. Count only the
|
||||||
|
// actor's displacement relative to the last sampled route waypoint.
|
||||||
|
// Once the old waypoint was reached, movement belongs to the next leg.
|
||||||
|
// Otherwise dense waypoint chains would count every forward step as
|
||||||
|
// moving away from the just-completed point.
|
||||||
|
const progressWaypoint = waypoint && distance(state.previousPosition, state.waypoint) <= 0.06
|
||||||
|
&& distance(waypoint, state.waypoint) > 0.04 ? waypoint : state.waypoint;
|
||||||
|
state.netProgress += distance(state.previousPosition, progressWaypoint) - distance(position, progressWaypoint);
|
||||||
|
if (state.netProgress >= PROGRESS_DISTANCE) {
|
||||||
|
state.netProgress = 0;
|
||||||
|
state.lastProgressAtMs = nowMs;
|
||||||
|
state.replanned = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state.previousPosition[0] = position[0];
|
||||||
|
state.previousPosition[1] = position[1];
|
||||||
|
state.previousPosition[2] = position[2];
|
||||||
|
if (waypoint && !excluded) {
|
||||||
|
state.waypoint ??= [0, 0, 0];
|
||||||
|
state.waypoint[0] = waypoint[0];
|
||||||
|
state.waypoint[1] = waypoint[1];
|
||||||
|
state.waypoint[2] = waypoint[2];
|
||||||
|
} else state.waypoint = null;
|
||||||
|
if (excluded) return "none";
|
||||||
|
|
||||||
|
const elapsed = nowMs - state.lastProgressAtMs;
|
||||||
|
if (!state.replanned && elapsed >= PARTY_STUCK_REPLAN_MS) {
|
||||||
|
state.replanned = true;
|
||||||
|
return "replan";
|
||||||
|
}
|
||||||
|
if (elapsed >= PARTY_STUCK_RECOVER_MS && nowMs >= state.cooldownUntilMs && nowMs >= state.retryAtMs) {
|
||||||
|
state.retryAtMs = nowMs + RECOVERY_RETRY_MS;
|
||||||
|
return "recover";
|
||||||
|
}
|
||||||
|
return "none";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function markPartyStuckRecovered(state: PartyStuckRecoveryState, position: PartyWorldPosition, nowMs: number): void {
|
||||||
|
resetPartyStuckRecoveryProgress(state);
|
||||||
|
state.previousPosition[0] = position[0];
|
||||||
|
state.previousPosition[1] = position[1];
|
||||||
|
state.previousPosition[2] = position[2];
|
||||||
|
state.lastProgressAtMs = nowMs;
|
||||||
|
state.cooldownUntilMs = nowMs + PARTY_STUCK_RECOVERY_COOLDOWN_MS;
|
||||||
|
state.retryAtMs = state.cooldownUntilMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Position-only recovery: no squad recall, healing, threat reset, or resurrection. */
|
||||||
|
export function applyPartyStuckRecoveryLanding(
|
||||||
|
positions: ReadonlyMap<string, MutablePartyWorldPosition>,
|
||||||
|
memberId: string,
|
||||||
|
landing: PartyWorldPosition,
|
||||||
|
state: PartyStuckRecoveryState,
|
||||||
|
nowMs: number,
|
||||||
|
): boolean {
|
||||||
|
const current = positions.get(memberId);
|
||||||
|
if (!current || !finitePosition(landing) || !Number.isFinite(nowMs)) return false;
|
||||||
|
current[0] = landing[0];
|
||||||
|
current[1] = landing[1];
|
||||||
|
current[2] = landing[2];
|
||||||
|
markPartyStuckRecovered(state, current, nowMs);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Only visited trail positions are local candidates; never jump to an arbitrary nearest room. */
|
||||||
|
export function choosePartyStuckRecoveryPosition({
|
||||||
|
current, player, groupAnchor, trailPoints, validate,
|
||||||
|
}: {
|
||||||
|
readonly current: PartyWorldPosition;
|
||||||
|
readonly player: PartyWorldPosition;
|
||||||
|
readonly groupAnchor: PartyWorldPosition;
|
||||||
|
readonly trailPoints: readonly PartyWorldPosition[];
|
||||||
|
readonly validate: (position: PartyWorldPosition, anchor: PartyWorldPosition) => PartyWorldPosition | null;
|
||||||
|
}): { readonly position: PartyWorldPosition; readonly source: "trail" | "player" } | null {
|
||||||
|
if (!finitePosition(current) || !finitePosition(player) || !finitePosition(groupAnchor)) return null;
|
||||||
|
const candidates = trailPoints.filter((point) => finitePosition(point)
|
||||||
|
&& distance(point, current) >= MIN_RECOVERY_DISTANCE
|
||||||
|
&& distance(point, groupAnchor) <= MAX_TRAIL_GROUP_DISTANCE
|
||||||
|
&& Math.abs(point[1] - groupAnchor[1]) <= MAX_TRAIL_HEIGHT_DIFFERENCE)
|
||||||
|
.sort((a, b) => distance(a, groupAnchor) - distance(b, groupAnchor));
|
||||||
|
// Bound expensive path/physics checks even when a looping trail has many samples.
|
||||||
|
for (const candidate of candidates.slice(0, 16)) {
|
||||||
|
const position = validate(candidate, groupAnchor);
|
||||||
|
if (position && finitePosition(position) && distance(position, current) >= MIN_RECOVERY_DISTANCE
|
||||||
|
&& distance(position, candidate) <= 0.85
|
||||||
|
&& Math.abs(position[1] - groupAnchor[1]) <= MAX_TRAIL_HEIGHT_DIFFERENCE) {
|
||||||
|
return { position: [...position], source: "trail" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const position = validate(player, player);
|
||||||
|
return position && finitePosition(position) && distance(position, player) <= 0.85
|
||||||
|
? { position: [...position], source: "player" }
|
||||||
|
: null;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -145,7 +145,7 @@ describe("player movement", () => {
|
|||||||
expect(translation.y).toBeLessThan(PLAYER_CENTER_HEIGHT + 0.1);
|
expect(translation.y).toBeLessThan(PLAYER_CENTER_HEIGHT + 0.1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("walks back up the real Stockades entrance stairs without a jump impulse", () => {
|
it("walks back up the Stockades interior stairs without a jump impulse", () => {
|
||||||
const world = new RAPIER.World({ x: 0, y: -18, z: 0 });
|
const world = new RAPIER.World({ x: 0, y: -18, z: 0 });
|
||||||
const dungeonBody = world.createRigidBody(RAPIER.RigidBodyDesc.fixed());
|
const dungeonBody = world.createRigidBody(RAPIER.RigidBodyDesc.fixed());
|
||||||
world.createCollider(
|
world.createCollider(
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { useCombatStore } from "./combatStore";
|
||||||
|
import { activateDungeonEncounterDefinitions, activeDungeonCompletedObjectiveIds, advanceDungeonEncounters, dispatchActiveDungeonEntityEvent, resetDungeonEncounters } from "./dungeonEncounterRuntime";
|
||||||
|
import { requireDungeonDefinition } from "./dungeonRegistry";
|
||||||
|
import { respawnDefeatedPlayer } from "./deathRespawn";
|
||||||
|
import { availableResurrectionCheckpoints, dungeonResurrectionCheckpoints } from "./resurrectionCheckpoints";
|
||||||
|
import { useGameStore } from "./store";
|
||||||
|
|
||||||
|
const dungeon = requireDungeonDefinition("stormwind-stockade");
|
||||||
|
|
||||||
|
describe("boss resurrection checkpoints", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
useGameStore.getState().activateDungeon("stormwind-stockade");
|
||||||
|
activateDungeonEncounterDefinitions(dungeon.id, dungeon.encounters ?? []);
|
||||||
|
resetDungeonEncounters("dungeon", dungeon.id);
|
||||||
|
useCombatStore.getState().initializeCharacter({ classId: "priest", level: 16 });
|
||||||
|
useCombatStore.setState({ mobs: {} });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("always offers the entrance and unlocks every killed boss, including optional bosses", () => {
|
||||||
|
expect(dungeonResurrectionCheckpoints(dungeon, {})).toHaveLength(1);
|
||||||
|
const result = dungeonResurrectionCheckpoints(dungeon, {}, dungeon.bosses.map((boss) => boss.id));
|
||||||
|
expect(result.map((checkpoint) => checkpoint.id)).toEqual(["entrance", ...dungeon.bosses.map((boss) => `boss:${boss.id}`)]);
|
||||||
|
dungeon.bosses.forEach((boss, index) => expect(result[index + 1].spawn.footPosition).toEqual(boss.position));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses durable encounter completion after hydration when no corpse is registered", () => {
|
||||||
|
const boss = dungeon.bosses[0];
|
||||||
|
dispatchActiveDungeonEntityEvent({ kind: "entity-death", entityId: boss.id, now: 100 });
|
||||||
|
advanceDungeonEncounters(dungeon.id, { kind: "tick", now: 100, facts: { deadEntityIds: [boss.id], registeredEntityIds: [boss.id] } });
|
||||||
|
expect(activeDungeonCompletedObjectiveIds(dungeon.id)).toContain(boss.id);
|
||||||
|
expect(availableResurrectionCheckpoints().map((checkpoint) => checkpoint.id)).toContain(`boss:${boss.id}`);
|
||||||
|
resetDungeonEncounters("boss-wipe", dungeon.id);
|
||||||
|
expect(availableResurrectionCheckpoints().map((checkpoint) => checkpoint.id)).toContain(`boss:${boss.id}`);
|
||||||
|
resetDungeonEncounters("dungeon", dungeon.id);
|
||||||
|
expect(availableResurrectionCheckpoints()).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not award a checkpoint for an alive or script-despawned boss", () => {
|
||||||
|
const boss = dungeon.bosses[0];
|
||||||
|
useCombatStore.getState().registerMob(boss.id, { name: boss.name, maxHealth: 100, boss: true });
|
||||||
|
expect(availableResurrectionCheckpoints()).toHaveLength(1);
|
||||||
|
const mob = useCombatStore.getState().mobs[boss.id];
|
||||||
|
useCombatStore.setState({ mobs: { [boss.id]: { ...mob, dead: true, health: 0, despawned: true } } });
|
||||||
|
expect(availableResurrectionCheckpoints()).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the defeated player and party reset signal to the selected boss, preserving the kill", () => {
|
||||||
|
const boss = dungeon.bosses[0];
|
||||||
|
useCombatStore.getState().registerMob(boss.id, { name: boss.name, maxHealth: 1, boss: true });
|
||||||
|
useCombatStore.getState().damageMob(boss.id, 1, 100);
|
||||||
|
useCombatStore.getState().damagePlayer(useCombatStore.getState().maxHealth * 10);
|
||||||
|
const before = useGameStore.getState().resetRevision;
|
||||||
|
expect(respawnDefeatedPlayer(`boss:${boss.id}`)).toBe(true);
|
||||||
|
expect(useGameStore.getState().playerPosition).toEqual(boss.position);
|
||||||
|
expect(useGameStore.getState().resetRevision).toBe(before + 1);
|
||||||
|
expect(useCombatStore.getState().playerPosition).toEqual(boss.position);
|
||||||
|
expect(useCombatStore.getState().health).toBe(useCombatStore.getState().maxHealth);
|
||||||
|
expect(useCombatStore.getState().mobs[boss.id].dead).toBe(true);
|
||||||
|
expect(availableResurrectionCheckpoints().map((checkpoint) => checkpoint.id)).toContain(`boss:${boss.id}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lets a defeated player explicitly choose the entrance instead of a previously saved boss spawn", () => {
|
||||||
|
useGameStore.setState({ activeSpawn: { footPosition: dungeon.bosses[0].position, yaw: 1 } });
|
||||||
|
useCombatStore.getState().damagePlayer(useCombatStore.getState().maxHealth * 10);
|
||||||
|
expect(respawnDefeatedPlayer("entrance")).toBe(true);
|
||||||
|
expect(useGameStore.getState().playerPosition).toEqual(dungeon.entrance.footPosition);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects locked boss choices and cannot teleport a living player", () => {
|
||||||
|
const before = useGameStore.getState();
|
||||||
|
expect(respawnDefeatedPlayer("entrance")).toBe(false);
|
||||||
|
useCombatStore.getState().damagePlayer(useCombatStore.getState().maxHealth * 10);
|
||||||
|
expect(respawnDefeatedPlayer(`boss:${dungeon.bosses[0].id}`)).toBe(false);
|
||||||
|
expect(useCombatStore.getState().health).toBe(0);
|
||||||
|
expect(useGameStore.getState()).toBe(before);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { useCombatStore, type MobCombatState } from "./combatStore";
|
||||||
|
import { activeDungeonCompletedObjectiveIds } from "./dungeonEncounterRuntime";
|
||||||
|
import { requireDungeonDefinition } from "./dungeonRegistry";
|
||||||
|
import type { DungeonDefinition, WorldSpawn } from "./dungeonTypes";
|
||||||
|
import { useGameStore } from "./store";
|
||||||
|
|
||||||
|
export interface ResurrectionCheckpoint {
|
||||||
|
readonly id: string;
|
||||||
|
readonly name: string;
|
||||||
|
readonly spawn: WorldSpawn;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Authored boss positions only: never accept a corpse position or arbitrary UI coordinates. */
|
||||||
|
export function dungeonResurrectionCheckpoints(
|
||||||
|
dungeon: DungeonDefinition,
|
||||||
|
mobs: Readonly<Record<string, MobCombatState>>,
|
||||||
|
completedObjectiveIds: readonly string[] = [],
|
||||||
|
): ResurrectionCheckpoint[] {
|
||||||
|
const completed = new Set(completedObjectiveIds);
|
||||||
|
return [
|
||||||
|
{ id: "entrance", name: "Dungeon entrance", spawn: dungeon.entrance },
|
||||||
|
...dungeon.bosses.filter((boss) => completed.has(boss.id)
|
||||||
|
|| (mobs[boss.id]?.dead && !mobs[boss.id]?.despawned)).map((boss) => ({
|
||||||
|
id: `boss:${boss.id}`, name: boss.name,
|
||||||
|
spawn: { footPosition: boss.position, yaw: dungeon.entrance.yaw },
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function availableResurrectionCheckpoints(): ResurrectionCheckpoint[] {
|
||||||
|
const game = useGameStore.getState();
|
||||||
|
return dungeonResurrectionCheckpoints(requireDungeonDefinition(game.activeDungeonId),
|
||||||
|
useCombatStore.getState().mobs, activeDungeonCompletedObjectiveIds(game.activeDungeonId));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveResurrectionCheckpoint(id: string): ResurrectionCheckpoint | null {
|
||||||
|
if (useGameStore.getState().gameMode !== "dungeon") return null;
|
||||||
|
return availableResurrectionCheckpoints().find((checkpoint) => checkpoint.id === id) ?? null;
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
Loaded 100 of 153 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user