forked from phenom/healer-man
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b417b7c3c3 | ||
|
|
361734bc41 | ||
|
|
03a4e3f51c |
No files matched your search
@@ -2348,3 +2348,417 @@ Vite's existing large-chunk advisory remains informational.
|
||||
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.
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "healer-man",
|
||||
"version": "0.1.35",
|
||||
"version": "0.1.38",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "healer-man",
|
||||
"version": "0.1.35",
|
||||
"version": "0.1.38",
|
||||
"dependencies": {
|
||||
"@capacitor/android": "8.4.1",
|
||||
"@capacitor/core": "8.4.1",
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "healer-man",
|
||||
"private": true,
|
||||
"version": "0.1.35",
|
||||
"version": "0.1.38",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"versionName": "0.1.35",
|
||||
"versionCode": 1035
|
||||
"versionName": "0.1.38",
|
||||
"versionCode": 1038
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { inflateSync } from "node:zlib";
|
||||
import sharp from "sharp";
|
||||
import { vocationGrowth } from "./vocation-growth.mjs";
|
||||
|
||||
const scriptDirectory = path.dirname(fileURLToPath(import.meta.url));
|
||||
const projectRoot = path.resolve(scriptDirectory, "../..");
|
||||
@@ -38,17 +39,6 @@ const RACE_MATRIX = {
|
||||
"rom-dwarf": [1, 3, 4, 5, 9, 10],
|
||||
};
|
||||
|
||||
function vocationGrowth(classIndex) {
|
||||
const recordSize = 816;
|
||||
const offset = DATA_START + classIndex * recordSize + 8;
|
||||
const values = Array.from({ length: 21 }, (_, index) => cleanFloat(float32(buffers.vocationTable, offset + index * 4)));
|
||||
const keys = ["strength", "stamina", "intellect", "wisdom", "agility", "health", "mana"];
|
||||
return Object.fromEntries(keys.map((key, index) => [key, {
|
||||
levelOne: values[index],
|
||||
flatGrowth: values[index + 7],
|
||||
percentGrowth: values[index + 14],
|
||||
}]));
|
||||
}
|
||||
const DATA_START = 140;
|
||||
const LEARN_RECORD_SIZE = 12184;
|
||||
const LEARN_ENTRY_SIZE = 24;
|
||||
@@ -578,7 +568,7 @@ const catalog = {
|
||||
schemaVersion: 1,
|
||||
source: { rootHint: "RuneWaker source snapshot", hashes: sourceHashes },
|
||||
canonicalClasses: CLASS_IDS.map((id, index) => ({ id, sourceClassId: index + 1, name: CLASS_NAMES[index], resource: RESOURCES[index] })),
|
||||
baseStatGrowth: Object.fromEntries(CLASS_IDS.map((id, index) => [id, vocationGrowth(index + 1)])),
|
||||
baseStatGrowth: Object.fromEntries(CLASS_IDS.map((id, index) => [id, vocationGrowth(buffers.vocationTable, index + 1)])),
|
||||
raceMatrix: Object.fromEntries(Object.entries(RACE_MATRIX).map(([raceId, classes]) => [raceId, classes.map((index) => CLASS_IDS[index - 1])])),
|
||||
eliteLevels: ELITE_LEVELS,
|
||||
skills,
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
const DATA_START = 140;
|
||||
const RECORD_SIZE = 408;
|
||||
const STAT_KEYS = ["strength", "stamina", "intellect", "wisdom", "agility", "health", "mana"];
|
||||
|
||||
/** Players use FixArgTableID = 1; each vocation has two adjacent source records. */
|
||||
export function vocationGrowth(buffer, classIndex) {
|
||||
const recordId = 301000 + classIndex * 2 + 1;
|
||||
const offset = DATA_START + (classIndex * 2 + 1) * RECORD_SIZE;
|
||||
if (offset + RECORD_SIZE > buffer.length
|
||||
|| buffer.readInt32LE(offset) !== recordId || buffer.readInt32LE(offset + 4) !== recordId) {
|
||||
throw new Error(`Player vocation ${classIndex} record sentinel drifted.`);
|
||||
}
|
||||
const values = Array.from({ length: 21 }, (_, index) => {
|
||||
const value = buffer.readFloatLE(offset + 8 + index * 4);
|
||||
if (!Number.isFinite(value)) throw new Error(`Player vocation ${classIndex} has invalid growth.`);
|
||||
return Number(value.toFixed(5));
|
||||
});
|
||||
return Object.fromEntries(STAT_KEYS.map((key, index) => [key, {
|
||||
levelOne: values[index],
|
||||
flatGrowth: values[index + 7],
|
||||
percentGrowth: values[index + 14],
|
||||
}]));
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { vocationGrowth } from "./vocation-growth.mjs";
|
||||
|
||||
test("reads the player table instead of the adjacent alternate growth table", () => {
|
||||
const buffer = Buffer.alloc(140 + 22 * 408);
|
||||
const offset = 140 + 21 * 408;
|
||||
buffer.writeInt32LE(301021, offset);
|
||||
buffer.writeInt32LE(301021, offset + 4);
|
||||
const values = [14, 13, 5, 6, 12, 60, 20, 5, 4.5, 1.9, 2.1, 4.25, 2, 2, ...Array(7).fill(0.02)];
|
||||
values.forEach((value, index) => buffer.writeFloatLE(value, offset + 8 + index * 4));
|
||||
buffer.writeFloatLE(2.8, offset - 408 + 8 + 14 * 4);
|
||||
assert.deepEqual(vocationGrowth(buffer, 10).strength, { levelOne: 14, flatGrowth: 5, percentGrowth: 0.02 });
|
||||
buffer.writeInt32LE(301020, offset);
|
||||
assert.throws(() => vocationGrowth(buffer, 10), /sentinel drifted/);
|
||||
});
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from "./manastorm-config.mjs";
|
||||
import { arenaRatingBand, calculateArenaRating, clampArenaRating } from "./arena-rules.mjs";
|
||||
import { arenaRoleForSpecialization } from "./arena-specializations.mjs";
|
||||
import { createDungeonReportRepository } from "./dungeon-reports.mjs";
|
||||
|
||||
const scrypt = promisify(scryptCallback);
|
||||
|
||||
@@ -895,6 +896,7 @@ export function openGameDatabase(options = {}) {
|
||||
|
||||
return {
|
||||
databasePath,
|
||||
...createDungeonReportRepository(database, { isGameMaster, ErrorType: GameDatabaseError }),
|
||||
register,
|
||||
login,
|
||||
authenticate,
|
||||
|
||||
@@ -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,7 @@ export function createGameServer(options = {}) {
|
||||
const onlineSessions = createOnlineSessionManager();
|
||||
const arenaService = createArenaService({ database: gameDatabase, autoPump: enableShelvedModes });
|
||||
const allowAuthRequest = createAuthLimiter();
|
||||
const allowReportRequest = createAuthLimiter();
|
||||
gameDatabase.pruneExpiredSessions();
|
||||
|
||||
const server = createServer(async (request, response) => {
|
||||
@@ -239,6 +240,30 @@ export function createGameServer(options = {}) {
|
||||
json(response, 200, { account: gameDatabase.authenticate(bearerToken(request)) });
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/api/dungeon-reports" && request.method === "GET") {
|
||||
json(response, 200, gameDatabase.listDungeonReports({
|
||||
status: url.searchParams.get("status"),
|
||||
dungeonId: url.searchParams.get("dungeon"),
|
||||
}));
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/api/dungeon-reports" && request.method === "POST") {
|
||||
const token = bearerToken(request);
|
||||
const account = token ? gameDatabase.authenticate(token) : null;
|
||||
if (!allowReportRequest(account?.id ?? request.socket.remoteAddress)) {
|
||||
throw new GameDatabaseError("Too many reports. Please wait a minute and try again.", 429, "rate_limited");
|
||||
}
|
||||
const body = await readJson(request, 8 * 1024);
|
||||
json(response, 201, { report: gameDatabase.submitDungeonReport(account, body) });
|
||||
return;
|
||||
}
|
||||
const reportMatch = /^\/api\/dungeon-reports\/([^/]+)$/.exec(url.pathname);
|
||||
if (reportMatch && request.method === "PATCH") {
|
||||
const account = gameDatabase.authenticate(bearerToken(request));
|
||||
const body = await readJson(request, 8 * 1024);
|
||||
json(response, 200, { report: gameDatabase.updateDungeonReport(account, reportMatch[1], body) });
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/api/cloud-save" && request.method === "GET") {
|
||||
const account = gameDatabase.authenticate(bearerToken(request));
|
||||
json(response, 200, gameDatabase.getCloudSave(account.id));
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"commentMaxLength": 500,
|
||||
"statuses": ["Submitted", "In-Progress", "Testing Fixes", "Closed"]
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import "./ui/uiLayout.css";
|
||||
import { DualDisplayFrame } from "./components/DualDisplayFrame";
|
||||
import { installInput, releasePointerLock } from "./game/inputManager";
|
||||
import { CombatBridge } from "./game/CombatBridge";
|
||||
import { connectDpsMeter } from "./game/dpsMeterStore";
|
||||
import { useCombatStore } from "./game/combatStore";
|
||||
import { releaseDungeonGltfCache } from "./game/dungeonAssetCache";
|
||||
import { clearDungeonRuntimeCaches } from "./game/dungeonRuntimeReset";
|
||||
@@ -93,6 +94,7 @@ function InputBridge() {
|
||||
}
|
||||
|
||||
export default function GameRuntime() {
|
||||
useEffect(connectDpsMeter, []);
|
||||
const editingControls = useGameStore((state) => state.overlay === "keybinds" || state.overlay === "ui-layout");
|
||||
useEffect(() => () => {
|
||||
const activeDungeon = requireDungeonDefinition(useGameStore.getState().activeDungeonId);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -14,6 +14,16 @@ import {
|
||||
} from "./coaLiveCatalog";
|
||||
|
||||
const abilities = Object.values(COA_ABILITIES_BY_CLASS).flat();
|
||||
|
||||
it("keeps Drakkari Barrier friendly and its retaliation in the damage-taken proc", () => {
|
||||
const barrier = COA_ABILITIES_BY_CLASS["witch-doctor"].find((ability) => ability.name === "Drakkari Barrier")!;
|
||||
expect(barrier.target).toBe("friendly");
|
||||
for (const rank of barrier.ranks ?? []) {
|
||||
expect(rank.effects.some((effect) => effect.kind === "damage")).toBe(false);
|
||||
expect(rank.effects.some((effect) => effect.kind === "apply-aura"
|
||||
&& effect.aura.procs?.some((proc) => proc.triggers === "damage-taken" && proc.action.kind === "damage"))).toBe(true);
|
||||
}
|
||||
});
|
||||
const ranksFor = (ability: AbilityDefinition): readonly AbilityRankDefinition[] => ability.ranks ?? [{
|
||||
rank: 1,
|
||||
spellId: ability.dbcSpellId,
|
||||
|
||||
@@ -987,7 +987,8 @@ function effectsForSpell(
|
||||
}
|
||||
|
||||
const hasHealing = effects.some((effect) => ["heal", "hot"].includes(effect.kind));
|
||||
const hasDamage = effects.some((effect) => ["damage", "dot"].includes(effect.kind));
|
||||
const hasDamage = effects.some((effect) => ["damage", "dot"].includes(effect.kind)
|
||||
|| (effect.kind === "apply-aura" && effect.aura.procs?.some((proc) => proc.action.kind === "damage")));
|
||||
const describesHealing = /\b(?:heal(?:s|ing|ed)?|restores? .*health|mends?|rejuvenat\w*)\b/i.test(description);
|
||||
const describesDamage = /\b(?:deal(?:s|ing)?|inflict(?:s|ing)?|caus(?:e|es|ing)|damag(?:e|es|ing))\b[^.]*\bdamage\b/i.test(description);
|
||||
if (!hasHealing && describesHealing) effects.unshift({ kind: "heal", coefficient: 1 });
|
||||
|
||||
@@ -33,6 +33,7 @@ export interface CombatActorCastState {
|
||||
/** Simulation-space origin used for movement cancellation and completion validation. */
|
||||
readonly origin?: CombatActorPosition;
|
||||
readonly targetId?: string | null;
|
||||
readonly channelTicksCompleted?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { abilitiesForClass } from "./abilityCatalog";
|
||||
import { channelAmountEffects } from "./combatChannelAmounts";
|
||||
|
||||
describe("channel amount expansion", () => {
|
||||
it("spreads a normal channel's full amount across its pulses", () => {
|
||||
const ability = abilitiesForClass("mage")[0];
|
||||
const effects = channelAmountEffects({ ...ability, effects: [
|
||||
{ kind: "damage", coefficient: 1, basePoints: 100 },
|
||||
{ kind: "dot", coefficient: 1, ticks: 5, intervalMs: 1_000, tag: "channel" },
|
||||
] }, 5);
|
||||
expect(effects.map(({ multiplier }) => multiplier)).toEqual([0.2, 1]);
|
||||
});
|
||||
|
||||
it("uses the authored periodic payload for Radiant Flame", () => {
|
||||
const ability = abilitiesForClass("sun-cleric").find((candidate) => candidate.name === "Radiant Flame")!;
|
||||
const amounts = channelAmountEffects(ability, 5);
|
||||
expect(amounts).toHaveLength(1);
|
||||
expect(amounts[0]).toMatchObject({ effect: { kind: "damage", basePoints: 1 }, multiplier: 1 });
|
||||
const conditional = { ...ability, effects: ability.effects.map((effect) => effect.kind === "apply-aura"
|
||||
? { ...effect, aura: { ...effect.aura, procs: effect.aura.procs?.map((proc) => ({ ...proc, chance: 0.5 })) } }
|
||||
: effect) };
|
||||
expect(channelAmountEffects(conditional, 5)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { triggeredAbilityBySpellId, type AbilityDefinition, type AbilityEffect } from "./abilityCatalog";
|
||||
|
||||
type ChannelAmountEffect = Extract<AbilityEffect, { kind: "damage" | "dot" | "heal" | "hot" }>;
|
||||
|
||||
/** Direct channel amounts span the cast; periodic trigger payloads fire each pulse. */
|
||||
export function channelAmountEffects(ability: AbilityDefinition, totalTicks: number): readonly {
|
||||
effect: ChannelAmountEffect;
|
||||
multiplier: number;
|
||||
}[] {
|
||||
const ticks = Math.max(1, totalTicks);
|
||||
const amounts: { effect: ChannelAmountEffect; multiplier: number }[] = [];
|
||||
for (const effect of ability.effects) {
|
||||
if (effect.kind === "damage" || effect.kind === "heal" || effect.kind === "dot" || effect.kind === "hot") {
|
||||
amounts.push({ effect, multiplier: (effect.kind === "dot" || effect.kind === "hot" ? effect.ticks : 1) / ticks });
|
||||
} else if (effect.kind === "apply-aura") {
|
||||
for (const proc of effect.aura.procs ?? []) {
|
||||
const triggers = Array.isArray(proc.triggers) ? proc.triggers : [proc.triggers];
|
||||
const action = proc.action;
|
||||
if (!triggers.includes("periodic") || proc.chance !== 1 || proc.internalCooldownMs || proc.charges !== undefined
|
||||
|| action.kind !== "custom"
|
||||
|| !["wow335-trigger-spell", "rom-trigger-skill"].includes(action.id)
|
||||
|| typeof action.data?.spellId !== "number") continue;
|
||||
const payload = triggeredAbilityBySpellId(action.data.spellId);
|
||||
for (const payloadEffect of payload?.effects ?? []) {
|
||||
if (payloadEffect.kind === "damage" || payloadEffect.kind === "heal") {
|
||||
amounts.push({ effect: payloadEffect, multiplier: 1 });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return amounts;
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { PartyNavigationGraph } from "./partyNavigation";
|
||||
|
||||
export type CombatSpatialPosition = readonly [x: number, y: number, z: number];
|
||||
|
||||
export interface CombatSpatialActorSnapshot {
|
||||
@@ -8,6 +10,8 @@ export interface CombatSpatialActorSnapshot {
|
||||
}
|
||||
|
||||
export interface CombatSpatialProvider {
|
||||
/** The already-loaded, reviewed dungeon surface shared by actor navigation. */
|
||||
readonly navigationGraph?: () => PartyNavigationGraph | null;
|
||||
readonly actor: (actorId: string) => CombatSpatialActorSnapshot | null;
|
||||
readonly hasLineOfSight: (
|
||||
source: CombatSpatialPosition,
|
||||
@@ -45,6 +49,10 @@ export function combatHasLineOfSight(
|
||||
return provider?.hasLineOfSight(source, target) ?? true;
|
||||
}
|
||||
|
||||
export function combatNavigationGraph(): PartyNavigationGraph | null {
|
||||
return provider?.navigationGraph?.() ?? null;
|
||||
}
|
||||
|
||||
export function projectCombatGroundPoint(
|
||||
requested: CombatSpatialPosition,
|
||||
maximumDistance: number,
|
||||
|
||||
+203
-86
@@ -129,6 +129,7 @@ import {
|
||||
} from "./combatAuras";
|
||||
import { rollBossLoot } from "./lootCatalog";
|
||||
import { adaptRunewakerEnemyDamage } from "./runewakerCombatFormula";
|
||||
import { channelAmountEffects } from "./combatChannelAmounts";
|
||||
import { normalizeInventory, type InventoryItem } from "./lootTypes";
|
||||
import {
|
||||
PLAYER_EQUIPMENT_OWNER_ID,
|
||||
@@ -372,6 +373,7 @@ export interface MobCombatState {
|
||||
readonly lastDamageAt: number;
|
||||
readonly engaged: boolean;
|
||||
readonly combatPhase: "idle" | "chasing" | "attacking" | "returning";
|
||||
readonly navigationEvadeUntil?: number;
|
||||
readonly homePosition: CombatPosition | null;
|
||||
readonly nextAttackAt: number;
|
||||
readonly abilityReadyAt: Readonly<Record<string, number>>;
|
||||
@@ -720,9 +722,12 @@ export interface CombatState {
|
||||
damageSummon: (id: string, amount: number) => number;
|
||||
healPlayer: (amount: number) => number;
|
||||
revivePlayer: (percentMaxHealth?: number) => number;
|
||||
/** Checkpoint recovery of the player only; keeps the party and current pull intact. */
|
||||
respawnPlayerOnly: (position: CombatPosition, now?: number) => boolean;
|
||||
cancelCast: (message?: string) => boolean;
|
||||
engageNearbyMobs: (now?: number, playerPosition?: CombatPosition) => number;
|
||||
advanceMobCombat: (now?: number, playerPosition?: CombatPosition) => number;
|
||||
resetMobForNavigation: (id: string, atHome: boolean, now?: number) => boolean;
|
||||
tick: (deltaSeconds: number, now?: number) => void;
|
||||
resetEncounter: (options?: {
|
||||
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 rangedPhysicalKeyword = /\b(?:shot|arrow|bow|gun|bullet|javelin)\b/.test(searchable);
|
||||
if (healing) return "spell";
|
||||
if (ability.range.max > 4 && (rangedPhysicalKeyword || baseClassFor(classId) === "hunter")) return "ranged";
|
||||
if (ability.range.max > 5 && (rangedPhysicalKeyword || baseClassFor(classId) === "hunter")) return "ranged";
|
||||
if (["warrior", "rogue", "death-knight"].includes(baseClassFor(classId)) || physicalKeyword) return "melee";
|
||||
return "spell";
|
||||
}
|
||||
@@ -1200,7 +1205,7 @@ export function abilityDamageSchool(classId: ClassId, ability: AbilityDefinition
|
||||
return "arcane";
|
||||
}
|
||||
|
||||
function equipmentAdjustedCombatAmount(
|
||||
export function equipmentAdjustedCombatAmount(
|
||||
scaling: number | (AbilityAmountScaling & { readonly coefficient: number }),
|
||||
level: number,
|
||||
gearStats: ItemStats,
|
||||
@@ -2173,10 +2178,7 @@ function executeTriggeredAuraProcsInWork(
|
||||
const maximum = work.maxResourcePools[resource] ?? defaultResourceMaximum(resource);
|
||||
work.resourcePools[resource] = clamp((work.resourcePools[resource] ?? 0) + action.amount, 0, maximum);
|
||||
} else if (action.kind === "heal" && actionTarget === PLAYER_AURA_ENTITY_ID) {
|
||||
const healed = Math.min(Math.max(0, action.amount), work.maxHealth - work.health);
|
||||
work.health += healed;
|
||||
work.progression.health = work.health;
|
||||
work.totalHealing += healed;
|
||||
work.totalHealing += healPlayerInWork(work, action.amount);
|
||||
} else if (action.kind === "heal" && actionTarget && !work.mobs[actionTarget]) {
|
||||
work.totalHealing += healCompanionActorInWork(work, actionTarget, Math.max(0, action.amount));
|
||||
} else if (action.kind === "damage" && actionTarget && work.mobs[actionTarget]) {
|
||||
@@ -2377,7 +2379,8 @@ function grantMobExperience(
|
||||
work.secondaryClassId,
|
||||
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.maxHealth = progression.maxHealth;
|
||||
work.maxResource = talentAdjustedMaxResource(
|
||||
@@ -2418,6 +2421,7 @@ function engageMobGroup(
|
||||
let changed = false;
|
||||
for (const [id, mob] of Object.entries(mobs)) {
|
||||
if (mob.dead || mob.despawned || mobGroupId(id) !== groupId) continue;
|
||||
if (mob.combatPhase === "returning" || (mob.navigationEvadeUntil ?? 0) > now) continue;
|
||||
const threatByActor = groupWasEngaged
|
||||
? mob.threatByActor
|
||||
: seedActorThreat(mob.threatByActor, sourceActorId);
|
||||
@@ -2459,7 +2463,7 @@ function nearestAggroActorId(
|
||||
let nearestDistanceSquared = aggroRange * aggroRange;
|
||||
if (state.health > 0) {
|
||||
const playerDistanceSquared = distanceSquared(mobPosition, state.playerPosition);
|
||||
if (playerDistanceSquared <= nearestDistanceSquared) {
|
||||
if (playerDistanceSquared <= nearestDistanceSquared && combatHasLineOfSight(mobPosition, state.playerPosition)) {
|
||||
nearestId = PLAYER_AGGRO_ID;
|
||||
nearestDistanceSquared = playerDistanceSquared;
|
||||
}
|
||||
@@ -2469,7 +2473,7 @@ function nearestAggroActorId(
|
||||
const position = getPartyRuntimePosition(member.id);
|
||||
if (!position) continue;
|
||||
const memberDistanceSquared = distanceSquared(mobPosition, position);
|
||||
if (memberDistanceSquared < nearestDistanceSquared) {
|
||||
if (memberDistanceSquared < nearestDistanceSquared && combatHasLineOfSight(mobPosition, position)) {
|
||||
nearestId = member.id;
|
||||
nearestDistanceSquared = memberDistanceSquared;
|
||||
}
|
||||
@@ -2479,7 +2483,7 @@ function nearestAggroActorId(
|
||||
const position = getPartyRuntimePosition(actor.id);
|
||||
if (!position) continue;
|
||||
const actorDistanceSquared = distanceSquared(mobPosition, position);
|
||||
if (actorDistanceSquared < nearestDistanceSquared) {
|
||||
if (actorDistanceSquared < nearestDistanceSquared && combatHasLineOfSight(mobPosition, position)) {
|
||||
nearestId = actor.id;
|
||||
nearestDistanceSquared = actorDistanceSquared;
|
||||
}
|
||||
@@ -2487,7 +2491,7 @@ function nearestAggroActorId(
|
||||
for (const summon of state.summons) {
|
||||
if (summon.health <= 0) continue;
|
||||
const summonDistanceSquared = distanceSquared(mobPosition, summon.position);
|
||||
if (summonDistanceSquared < nearestDistanceSquared) {
|
||||
if (summonDistanceSquared < nearestDistanceSquared && combatHasLineOfSight(mobPosition, summon.position)) {
|
||||
nearestId = summon.id;
|
||||
nearestDistanceSquared = summonDistanceSquared;
|
||||
}
|
||||
@@ -2533,7 +2537,9 @@ function damageMobInWork(
|
||||
combatResult?: CombatResult,
|
||||
): DamageResult {
|
||||
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(
|
||||
amount,
|
||||
mob,
|
||||
@@ -2678,7 +2684,7 @@ function resolveAndDamageMobInWork(
|
||||
rules: CombatResultRules = {},
|
||||
): DamageResult {
|
||||
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 };
|
||||
}
|
||||
const sourceAuras = aurasForEntity(work.auras, source.actorId);
|
||||
@@ -2872,10 +2878,7 @@ function executeTriggeredSpellInWork(
|
||||
"taken",
|
||||
));
|
||||
if (recipient === PLAYER_AURA_ENTITY_ID) {
|
||||
const healed = Math.min(amount, work.maxHealth - work.health);
|
||||
work.health += healed;
|
||||
work.progression.health = work.health;
|
||||
work.totalHealing += healed;
|
||||
work.totalHealing += healPlayerInWork(work, amount);
|
||||
} else {
|
||||
const healed = healCompanionActorInWork(work, recipient, amount);
|
||||
work.totalHealing += healed;
|
||||
@@ -3183,6 +3186,18 @@ function applyAuraHealthTransitionInWork(
|
||||
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 {
|
||||
const actor = ensureCompanionActorInWork(work, id);
|
||||
const requested = Math.max(0, Math.round(Number.isFinite(amount) ? amount : 0));
|
||||
@@ -4695,7 +4710,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
? Math.min(resolution.rawAmount, friendlySummon.maxHealth - friendlySummon.health)
|
||||
: friendlyOnline
|
||||
? Math.min(resolution.rawAmount, friendlyOnline.maxHealth - friendlyOnline.health)
|
||||
: resolution.effectiveAmount;
|
||||
: healPlayerInWork(work, resolution.rawAmount);
|
||||
if (friendlySummon && healed > 0) {
|
||||
work.summons = work.summons.map((summon) => summon.id === healingTargetId
|
||||
? { ...summon, health: summon.health + healed }
|
||||
@@ -4705,9 +4720,6 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
} else if (friendlyOnline && healed > 0) {
|
||||
const actor = work.actors[healingTargetId] ?? friendlyOnline;
|
||||
work.actors[healingTargetId] = { ...actor, health: actor.health + healed, alive: true };
|
||||
} else if (!friendlyTarget) {
|
||||
work.health += healed;
|
||||
work.progression.health = work.health;
|
||||
}
|
||||
recordCombatEventInWork(work, {
|
||||
occurredAt: now,
|
||||
@@ -4739,10 +4751,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
&& aura.definition.utilityTags?.includes("beacon-of-light")
|
||||
));
|
||||
if (beacon?.targetId === PLAYER_AURA_ENTITY_ID) {
|
||||
const beaconHealing = Math.min(directHealing, work.maxHealth - work.health);
|
||||
work.health += beaconHealing;
|
||||
work.progression.health = work.health;
|
||||
work.totalHealing += beaconHealing;
|
||||
work.totalHealing += healPlayerInWork(work, directHealing);
|
||||
} else if (beacon) {
|
||||
work.totalHealing += healCompanionActorInWork(work, beacon.targetId, directHealing);
|
||||
}
|
||||
@@ -5326,10 +5335,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
work.resource = clamp(work.resource + restored, 0, work.maxResource);
|
||||
work.resourcePools[activeResourceType] = work.resource;
|
||||
} 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.health += healed;
|
||||
work.progression.health = work.health;
|
||||
work.totalHealing += healed;
|
||||
work.totalHealing += healPlayerInWork(work, Math.max(1, Math.round(work.maxHealth * 0.25)));
|
||||
}
|
||||
} else if (effect.action === "portal") {
|
||||
summonSequence += 1;
|
||||
@@ -5358,13 +5364,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
aura.targetId === PLAYER_AURA_ENTITY_ID
|
||||
&& aura.definition.utilityTags?.includes("rom-revival-charge")
|
||||
));
|
||||
const restored = Math.min(
|
||||
work.maxHealth - work.health,
|
||||
Math.round(work.maxHealth * 0.05 * (revival?.stacks ?? 0)),
|
||||
);
|
||||
work.health += restored;
|
||||
work.progression.health = work.health;
|
||||
work.totalHealing += restored;
|
||||
work.totalHealing += healPlayerInWork(work, Math.round(work.maxHealth * 0.05 * (revival?.stacks ?? 0)));
|
||||
if (revival) work.auras = work.auras.filter((aura) => aura.instanceId !== revival.instanceId);
|
||||
}
|
||||
}
|
||||
@@ -5407,9 +5407,8 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
|
||||
// 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")) {
|
||||
const healed = Math.min(talentAdjustedAmount(Math.round(work.maxHealth * 0.02), modifiers, "healing-percent", ability), work.maxHealth - work.health);
|
||||
work.health += healed;
|
||||
work.progression.health = work.health;
|
||||
const healed = healPlayerInWork(work,
|
||||
talentAdjustedAmount(Math.round(work.maxHealth * 0.02), modifiers, "healing-percent", ability));
|
||||
work.totalHealing += healed;
|
||||
addHealingThreatInWork(work, castThreatSource, healed);
|
||||
}
|
||||
@@ -5656,7 +5655,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
|
||||
tauntMob: (id, source, durationMs, now = combatNow()) => {
|
||||
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 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;
|
||||
@@ -5676,6 +5675,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
));
|
||||
for (const [mobId, mob] of Object.entries(mobs)) {
|
||||
if (mob.dead || mobGroupId(mobId) !== groupId) continue;
|
||||
if ((mob.navigationEvadeUntil ?? 0) > now) continue;
|
||||
const seeded = groupWasEngaged
|
||||
? mob.threatByActor
|
||||
: seedActorThreat(mob.threatByActor, source.actorId);
|
||||
@@ -5868,10 +5868,15 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
if (id === PLAYER_AURA_ENTITY_ID) {
|
||||
const restored = state.health > 0 ? 0 : Math.max(1, Math.round(state.maxHealth * fraction));
|
||||
if (restored <= 0) return 0;
|
||||
const playerActor = state.actors[id];
|
||||
set({
|
||||
health: restored,
|
||||
controlledUntil: 0,
|
||||
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 }),
|
||||
combatEvents: recordCombatEvent(state.combatEvents, {
|
||||
occurredAt: now,
|
||||
@@ -6095,16 +6100,16 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
const state = get();
|
||||
if (!state.actors[sourceActorId]) 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 sourceMember = usePartyStore.getState().members.find((member) => member.id === sourceActorId);
|
||||
const amount = sourceMember
|
||||
? Math.max(1, Math.round((effect.basePoints ?? 0) + Math.max(0.1, effect.coefficient) * (
|
||||
sourceMember.level * 4
|
||||
+ sourceMember.gearStats.spellPower
|
||||
+ sourceMember.gearStats.attackPower * 0.35
|
||||
+ sourceMember.gearStats.rangedAttackPower * 0.35
|
||||
)))
|
||||
? equipmentAdjustedCombatAmount(effect, sourceMember.level, sourceMember.gearStats, sourceMember.classId, ability)
|
||||
: Math.max(1, Math.round((effect.basePoints ?? 0) + Math.max(0.1, effect.coefficient) * sourceActor.level * 4));
|
||||
const next = state.effects.filter((entry) => !(
|
||||
entry.kind === effect.kind
|
||||
@@ -6396,16 +6401,65 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
const restored = Math.max(1, Math.round(
|
||||
state.maxHealth * Math.max(0.01, Math.min(1, percentMaxHealth)),
|
||||
));
|
||||
const playerActor = state.actors[PLAYER_AURA_ENTITY_ID];
|
||||
set({
|
||||
health: restored,
|
||||
controlledUntil: 0,
|
||||
controlMechanic: null,
|
||||
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 }),
|
||||
});
|
||||
return restored;
|
||||
},
|
||||
|
||||
respawnPlayerOnly: (position, now = combatNow()) => {
|
||||
const state = get();
|
||||
if (state.health > 0 || state.maxHealth <= 0 || !Number.isFinite(now)
|
||||
|| position.length !== 3 || !position.every(Number.isFinite)) return false;
|
||||
const auras = syncPassiveAuras(state.auras.filter((aura) => (
|
||||
aura.targetId !== PLAYER_AURA_ENTITY_ID || aura.definition.disposition === "buff"
|
||||
)), state.abilities, state.level, state.talentRanks, now);
|
||||
const maxHealth = auraAdjustedPlayerMaxHealth(state.classId, state.level, state.talentModifiers,
|
||||
state.gearStats, state.raceId, state.secondaryClassId, auras);
|
||||
const patch: Partial<CombatState> = {
|
||||
health: maxHealth,
|
||||
maxHealth,
|
||||
resource: initialResourceForMaximum(state.classId, state.maxResource),
|
||||
resourcePools: Object.fromEntries(Object.entries(state.maxResourcePools).map(([type, maximum]) => [
|
||||
type, type === resourceProfileForClass(state.classId).type
|
||||
? initialResourceForMaximum(state.classId, maximum ?? state.maxResource) : 0,
|
||||
])),
|
||||
controlledUntil: 0,
|
||||
controlMechanic: null,
|
||||
schoolLockedUntil: {},
|
||||
shield: 0,
|
||||
shieldExpiresAt: 0,
|
||||
activeCast: null,
|
||||
pendingGroundTarget: null,
|
||||
autoAttackTargetId: null,
|
||||
nextAutoAttackAt: 0,
|
||||
comboPoints: 0,
|
||||
comboTargetId: null,
|
||||
playerPosition: [...position],
|
||||
playerAnimationEvent: nextAnimationEvent("ability-cancel"),
|
||||
effects: state.effects.filter((effect) => effect.targetId !== null && effect.targetId !== PLAYER_AURA_ENTITY_ID),
|
||||
auras,
|
||||
combatEvents: recordCombatEvent(state.combatEvents, {
|
||||
occurredAt: now, kind: "resurrection", sourceActorId: PLAYER_AURA_ENTITY_ID,
|
||||
targetActorId: PLAYER_AURA_ENTITY_ID, abilityId: "player-checkpoint-resurrection",
|
||||
rawAmount: maxHealth, effectiveAmount: maxHealth, overheal: 0, absorbed: 0, critical: false,
|
||||
}),
|
||||
feedback: nextFeedback("success", "You have revived. Your party is still fighting; use Recall only if you want to regroup."),
|
||||
};
|
||||
// Publish only the player snapshot; never replace or mirror companion/enemy actors.
|
||||
const player = combatActorSnapshots({ ...state, ...patch })[PLAYER_AURA_ENTITY_ID];
|
||||
set({ ...patch, actors: { ...state.actors, [PLAYER_AURA_ENTITY_ID]: player } });
|
||||
return true;
|
||||
},
|
||||
|
||||
cancelCast: (message = "Casting interrupted.") => {
|
||||
const cast = get().activeCast;
|
||||
if (!cast) return false;
|
||||
@@ -6441,7 +6495,8 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
let engagedGroups = 0;
|
||||
for (const id of Object.keys(mobs)) {
|
||||
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);
|
||||
if (!position) continue;
|
||||
const actorId = nearestAggroActorId(runtimeState, position, mob.aggroRange);
|
||||
@@ -6452,6 +6507,38 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
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) => {
|
||||
const state = get();
|
||||
const runtimeState = playerPosition
|
||||
@@ -6527,6 +6614,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
const dy = position[1] - targetPosition[1];
|
||||
const dz = position[2] - targetPosition[2];
|
||||
const distanceSquared = dx * dx + dy * dy + dz * dz;
|
||||
const targetVisible = combatHasLineOfSight(position, targetPosition);
|
||||
const abilityReadyAt = Object.keys(mob.abilityReadyAt).length
|
||||
? { ...mob.abilityReadyAt }
|
||||
: { ...initialEnemyAbilityReadyAt(mob, now) };
|
||||
@@ -6537,6 +6625,20 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
let enemyActiveCast = cannotAttack || (silenced && mob.activeCast?.school !== "physical")
|
||||
? null
|
||||
: 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 selectedAttackIndex = -1;
|
||||
let selectedAttack: EnemyAttackDefinition | null = null;
|
||||
@@ -6551,7 +6653,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
const attack = mob.attacks[index];
|
||||
const attackKind = enemyAttackKind(attack);
|
||||
const inAbilityRange = !enemyAbilityNeedsHostileRange(attack)
|
||||
|| distanceSquared <= attack.range * attack.range;
|
||||
|| (targetVisible && distanceSquared <= attack.range * attack.range);
|
||||
if (
|
||||
inAbilityRange
|
||||
&& !(silenced && attackKind === "spell")
|
||||
@@ -6567,10 +6669,24 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
}
|
||||
}
|
||||
}
|
||||
const inRange = mob.attacks.some((attack) => (
|
||||
!enemyAbilityNeedsHostileRange(attack)
|
||||
|| distanceSquared <= attack.range * attack.range
|
||||
));
|
||||
// A self buff/heal must not pin an otherwise blocked attacker in place.
|
||||
const hostileAttacks = mob.attacks.filter(enemyAbilityNeedsHostileRange);
|
||||
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";
|
||||
let nextAttackAt = Math.min(...Object.values(abilityReadyAt), Number.POSITIVE_INFINITY);
|
||||
let attackRevision = mob.attackRevision;
|
||||
@@ -7510,7 +7626,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
combatEvents: [...state.combatEvents],
|
||||
actors: { ...state.actors },
|
||||
cooldowns: { ...state.cooldowns },
|
||||
health: Math.min(
|
||||
health: state.health <= 0 ? 0 : Math.min(
|
||||
state.maxHealth,
|
||||
state.health + resolveAuraStatValue(
|
||||
state.gearStats.healthPer5 / 5 + state.gearStats.spirit * 0.01,
|
||||
@@ -7639,10 +7755,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) {
|
||||
// Check before due ticks/procs, not only after applying their health changes.
|
||||
if (!timedEffectTargetAlive(effect)) continue;
|
||||
let remainingTicks = effect.remainingTicks;
|
||||
let nextTickAt = effect.nextTickAt;
|
||||
while (remainingTicks > 0 && nextTickAt <= now) {
|
||||
while (remainingTicks > 0 && nextTickAt <= now && timedEffectTargetAlive(effect)) {
|
||||
const ability = abilityById(effect.sourceAbilityId);
|
||||
const baseAmount = effect.fixedAmount ?? (ability
|
||||
? equipmentAdjustedCombatAmount(
|
||||
@@ -7696,10 +7826,10 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
"taken",
|
||||
));
|
||||
if (effect.targetId) {
|
||||
const target = work.actors[effect.targetId]
|
||||
?? ensureCompanionActorInWork(work, effect.targetId)
|
||||
?? (work.summons.some((summon) => summon.id === effect.targetId)
|
||||
? combatActorFromSummon(work.summons.find((summon) => summon.id === effect.targetId)!, progression.level, work.auras)
|
||||
const target = ensureCompanionActorInWork(work, hotTargetId)
|
||||
?? work.actors[hotTargetId]
|
||||
?? (work.summons.some((summon) => summon.id === hotTargetId)
|
||||
? combatActorFromSummon(work.summons.find((summon) => summon.id === hotTargetId)!, progression.level, work.auras)
|
||||
: null);
|
||||
if (target?.alive) partyHotTicks.push({
|
||||
memberId: target.id,
|
||||
@@ -7708,9 +7838,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
sourceAbilityId: effect.sourceAbilityId,
|
||||
});
|
||||
} else {
|
||||
const healed = Math.min(modifiedHealing, work.maxHealth - work.health);
|
||||
work.health += healed;
|
||||
work.progression.health = work.health;
|
||||
const healed = healPlayerInWork(work, modifiedHealing);
|
||||
work.totalHealing += healed;
|
||||
addHealingThreatInWork(
|
||||
work,
|
||||
@@ -7755,12 +7883,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
remainingTicks -= 1;
|
||||
nextTickAt += effect.intervalMs;
|
||||
}
|
||||
const targetAlive = effect.kind === "dot"
|
||||
? 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) {
|
||||
if (remainingTicks > 0 && timedEffectTargetAlive(effect)) {
|
||||
work.effects.push(
|
||||
remainingTicks === effect.remainingTicks && nextTickAt === effect.nextTickAt
|
||||
? effect
|
||||
@@ -7816,13 +7939,10 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
|| Math.sqrt(distanceSquared(area.position, position)) > area.radius
|
||||
|| !combatHasLineOfSight(area.position, position)) continue;
|
||||
const member = partyMembers.find((candidate) => candidate.id === friendlyId);
|
||||
if (member ? member.health <= 0 : work.health <= 0) continue;
|
||||
const healed = member
|
||||
? healCompanionActorInWork(work, friendlyId, amount)
|
||||
: Math.min(amount, work.maxHealth - work.health);
|
||||
if (!member) {
|
||||
work.health += healed;
|
||||
work.progression.health = work.health;
|
||||
}
|
||||
: healPlayerInWork(work, amount);
|
||||
work.totalHealing += healed;
|
||||
recordCombatEventInWork(work, {
|
||||
occurredAt: nextTickAt,
|
||||
@@ -7873,12 +7993,8 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
const target = injured[0];
|
||||
if (target) {
|
||||
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);
|
||||
if (target.id === PLAYER_AURA_ENTITY_ID) {
|
||||
work.health += healed;
|
||||
work.progression.health = work.health;
|
||||
}
|
||||
work.totalHealing += healed;
|
||||
addHealingThreatInWork(work, { actorId: summon.ownerActorId, role: "healer", abilityId: summon.sourceAbilityId }, healed);
|
||||
recordCombatEventInWork(work, {
|
||||
@@ -8078,30 +8194,31 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
: "Channel interrupted: target unavailable.");
|
||||
return;
|
||||
}
|
||||
const damageAmount = ability.effects.reduce(
|
||||
(total, effect) => total + (effect.kind === "damage" || effect.kind === "dot"
|
||||
const channelEffects = channelAmountEffects(ability, cast.totalTicks);
|
||||
const damageAmount = channelEffects.reduce(
|
||||
(total, { effect, multiplier }) => total + (effect.kind === "damage" || effect.kind === "dot"
|
||||
? equipmentAdjustedCombatAmount(
|
||||
effect,
|
||||
get().level,
|
||||
get().gearStats,
|
||||
get().classId,
|
||||
ability,
|
||||
) * (effect.kind === "dot" ? effect.ticks : 1)
|
||||
) * multiplier
|
||||
: 0),
|
||||
0,
|
||||
) / cast.totalTicks;
|
||||
const healingAmount = ability.effects.reduce(
|
||||
(total, effect) => total + (effect.kind === "heal" || effect.kind === "hot"
|
||||
);
|
||||
const healingAmount = channelEffects.reduce(
|
||||
(total, { effect, multiplier }) => total + (effect.kind === "heal" || effect.kind === "hot"
|
||||
? equipmentAdjustedCombatAmount(
|
||||
effect,
|
||||
get().level,
|
||||
get().gearStats,
|
||||
get().classId,
|
||||
ability,
|
||||
) * (effect.kind === "hot" ? effect.ticks : 1)
|
||||
) * multiplier
|
||||
: 0),
|
||||
0,
|
||||
) / cast.totalTicks;
|
||||
);
|
||||
if (damageAmount > 0) {
|
||||
const channelCenter = channelHostile ? targetPosition ?? cast.origin : get().playerPosition;
|
||||
const channelTargets = ability.radius === undefined
|
||||
|
||||
@@ -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 { useGameStore } from "./store";
|
||||
import { resolveResurrectionCheckpoint } from "./resurrectionCheckpoints";
|
||||
import { usePartyStore, type PartyMember } from "./partyStore";
|
||||
import {
|
||||
activeDungeonCompletedObjectiveIds,
|
||||
resetDungeonEncounters,
|
||||
} from "./dungeonEncounterRuntime";
|
||||
|
||||
/**
|
||||
* Atomically returns a defeated player to the saved run spawn and resets the
|
||||
* current pull. Defeated enemies and persisted dungeon progress remain clear,
|
||||
* matching a wipe instead of silently starting a brand-new dungeon.
|
||||
*/
|
||||
export function respawnDefeatedPlayer(): boolean {
|
||||
export function partyFightStillActive(
|
||||
members: readonly Pick<PartyMember, "id" | "health">[],
|
||||
mobs: Readonly<Record<string, MobCombatState>>,
|
||||
actors: Readonly<Record<string, Pick<CombatActorState, "health">>> = {},
|
||||
): boolean {
|
||||
return members.some((member) => (actors[member.id]?.health ?? member.health) > 0)
|
||||
&& Object.values(mobs).some((mob) => mob.engaged && !mob.dead && !mob.despawned);
|
||||
}
|
||||
|
||||
/** Recover only the player during an ongoing party fight; otherwise retain wipe recovery. */
|
||||
export function respawnDefeatedPlayer(checkpointId?: string): boolean {
|
||||
const combat = useCombatStore.getState();
|
||||
if (combat.health > 0) return false;
|
||||
const game = useGameStore.getState();
|
||||
if (game.gameMode !== "dungeon") return false;
|
||||
const checkpoint = checkpointId === undefined ? null : resolveResurrectionCheckpoint(checkpointId);
|
||||
if (checkpointId !== undefined && !checkpoint) return false;
|
||||
|
||||
if (partyFightStillActive(usePartyStore.getState().members, combat.mobs, combat.actors)) {
|
||||
const spawn = checkpoint?.spawn ?? game.activeSpawn;
|
||||
if (!combat.respawnPlayerOnly(spawn.footPosition)) return false;
|
||||
// Relocation does not fire the resetRevision watched by PartyBridge/PartyPopulation.
|
||||
useGameStore.getState().relocatePlayer(spawn, true);
|
||||
useGameStore.getState().closeOverlay();
|
||||
return true;
|
||||
}
|
||||
|
||||
const dungeonId = useGameStore.getState().activeDungeonId;
|
||||
const completedObjectiveIds = new Set(activeDungeonCompletedObjectiveIds(dungeonId));
|
||||
@@ -31,6 +52,8 @@ export function respawnDefeatedPlayer(): boolean {
|
||||
if (typeof document !== "undefined" && document.pointerLockElement) {
|
||||
document.exitPointerLock();
|
||||
}
|
||||
// Select the saved return spawn without relocating first or resetting twice.
|
||||
if (checkpoint) useGameStore.setState({ activeSpawn: checkpoint.spawn });
|
||||
useGameStore.getState().resetAfterDefeat();
|
||||
resetDungeonEncounters("boss-wipe", dungeonId);
|
||||
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";
|
||||
import { DUNGEON_MANIFEST } from "./dungeonManifest";
|
||||
import { RUNEWAKER_DUNGEON_DEFINITIONS } from "./generated/runewakerDungeonDefinitions.generated";
|
||||
import { compareDungeonReleaseOrder } from "./dungeonReleaseStage";
|
||||
import availability from "./generated/dungeonAvailability.json";
|
||||
|
||||
describe("dungeon catalog", () => {
|
||||
it("lists the complete generated campaign with authored Wailing Caverns first", () => {
|
||||
it("lists the complete generated campaign with Stormwind Stockades in Beta first", () => {
|
||||
expect(DUNGEON_CATALOG).toHaveLength(84);
|
||||
expect(DUNGEON_CATALOG[0]).toMatchObject({
|
||||
id: DUNGEON_MANIFEST.id,
|
||||
id: "stormwind-stockade",
|
||||
releaseStage: "beta",
|
||||
available: true,
|
||||
});
|
||||
expect(dungeonById(DUNGEON_MANIFEST.id)).toMatchObject({
|
||||
title: DUNGEON_MANIFEST.title,
|
||||
mapId: DUNGEON_MANIFEST.mapId,
|
||||
entranceName: DUNGEON_MANIFEST.entrance.name,
|
||||
available: true,
|
||||
releaseStage: "alpha",
|
||||
});
|
||||
expect(DUNGEON_CATALOG.find((dungeon) => dungeon.id === "deadmines")).toMatchObject({
|
||||
id: "deadmines",
|
||||
@@ -33,10 +39,10 @@ describe("dungeon catalog", () => {
|
||||
|
||||
it("separates WoW and RoM dungeons with all RuneWaker imports under RoM", () => {
|
||||
expect(DUNGEON_CATEGORIES.map((category) => category.id)).toEqual(["wow", "rom"]);
|
||||
expect(dungeonsByCategory("rom").map((dungeon) => dungeon.id)).toEqual([
|
||||
expect(dungeonsByCategory("rom").map((dungeon) => dungeon.id).sort()).toEqual([
|
||||
"forsaken-abbey",
|
||||
...RUNEWAKER_DUNGEON_DEFINITIONS.map((definition) => definition.id),
|
||||
]);
|
||||
].sort());
|
||||
expect(dungeonsByCategory("wow")).not.toContainEqual(
|
||||
expect.objectContaining({ id: "forsaken-abbey" }),
|
||||
);
|
||||
@@ -46,7 +52,7 @@ describe("dungeon catalog", () => {
|
||||
it("keeps stable unique ids and resolves only catalogued dungeons", () => {
|
||||
const ids = DUNGEON_CATALOG.map((dungeon) => dungeon.id);
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
expect(dungeonById(DUNGEON_MANIFEST.id)).toBe(DUNGEON_CATALOG[0]);
|
||||
expect(dungeonById(DUNGEON_MANIFEST.id)).toBe(DUNGEON_CATALOG.find((dungeon) => dungeon.id === DUNGEON_MANIFEST.id));
|
||||
expect(dungeonById("missing-dungeon")).toBeNull();
|
||||
});
|
||||
|
||||
@@ -57,4 +63,30 @@ describe("dungeon catalog", () => {
|
||||
expect(dungeon.summary.trim()).not.toBe("");
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps only Stockades in Beta and alphabetizes each release group", () => {
|
||||
expect(DUNGEON_CATALOG.filter((dungeon) => dungeon.releaseStage === "beta").map((dungeon) => dungeon.id))
|
||||
.toEqual(["stormwind-stockade"]);
|
||||
expect(DUNGEON_CATALOG.filter((dungeon) => dungeon.id !== "stormwind-stockade").every((dungeon) => dungeon.releaseStage === "alpha"))
|
||||
.toBe(true);
|
||||
for (const category of DUNGEON_CATEGORIES) {
|
||||
const dungeons = dungeonsByCategory(category.id);
|
||||
expect(dungeons).toEqual([...dungeons].sort(compareDungeonReleaseOrder));
|
||||
}
|
||||
const stages = [
|
||||
{ title: "Zeta", releaseStage: "alpha" as const },
|
||||
{ title: "Beta Z", releaseStage: "beta" as const },
|
||||
{ title: "Released Z", releaseStage: "full-release" as const },
|
||||
{ title: "Alpha", releaseStage: "alpha" as const },
|
||||
{ title: "Beta A", releaseStage: "beta" as const },
|
||||
{ title: "Released A", releaseStage: "full-release" as const },
|
||||
];
|
||||
expect(stages.sort(compareDungeonReleaseOrder).map((entry) => entry.title))
|
||||
.toEqual(["Released A", "Released Z", "Beta A", "Beta Z", "Alpha", "Zeta"]);
|
||||
});
|
||||
|
||||
it("shares the complete dungeon identity list used to validate reports on the server", () => {
|
||||
expect(DUNGEON_CATALOG.map((dungeon) => dungeon.id).sort())
|
||||
.toEqual(["forsaken-abbey", ...Object.keys(availability.enabled)].sort());
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { DUNGEON_DEFINITIONS, dungeonCanEnter, type DungeonId } from "./dungeonRegistry";
|
||||
import { contentCategoryById, type ContentCategoryId } from "../app/contentCategories";
|
||||
import { compareDungeonReleaseOrder, dungeonReleaseStage, type DungeonReleaseStage } from "./dungeonReleaseStage";
|
||||
|
||||
const DUNGEON_CATEGORY_IDS = ["wow", "rom"] as const satisfies readonly ContentCategoryId[];
|
||||
export type DungeonCategoryId = (typeof DUNGEON_CATEGORY_IDS)[number];
|
||||
@@ -27,10 +28,11 @@ export interface DungeonCatalogEntry {
|
||||
readonly theme: string;
|
||||
readonly summary: string;
|
||||
readonly available: boolean;
|
||||
readonly releaseStage: DungeonReleaseStage;
|
||||
readonly availabilityReason?: string;
|
||||
}
|
||||
|
||||
/** Stable browser ordering is the registry order; identity is never duplicated. */
|
||||
/** Release readiness first, then alphabetical title within each release stage. */
|
||||
export const DUNGEON_CATALOG: readonly DungeonCatalogEntry[] = Object.freeze(
|
||||
DUNGEON_DEFINITIONS.map<DungeonCatalogEntry>((definition) => ({
|
||||
id: definition.id as DungeonId,
|
||||
@@ -42,8 +44,9 @@ export const DUNGEON_CATALOG: readonly DungeonCatalogEntry[] = Object.freeze(
|
||||
theme: definition.presentation.theme,
|
||||
summary: definition.presentation.summary,
|
||||
available: dungeonCanEnter(definition.id),
|
||||
releaseStage: dungeonReleaseStage(definition.id),
|
||||
availabilityReason: "availabilityReason" in definition ? definition.availabilityReason : undefined,
|
||||
})),
|
||||
})).sort(compareDungeonReleaseOrder),
|
||||
);
|
||||
|
||||
export type { DungeonId } from "./dungeonRegistry";
|
||||
|
||||
@@ -0,0 +1,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" });
|
||||
}
|
||||
@@ -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
|
||||
@@ -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 { useCombatStore } from "./combatStore";
|
||||
import { EMPTY_ITEM_STATS } from "./itemStats";
|
||||
import { commitPartyAbilityUse, choosePartyAbility, partyAbilityIsReady } from "./partyAbilityAi";
|
||||
import { commitPartyAbilityUse, choosePartyAbility, partyAbilityIsReady, partyAbilityAmount } from "./partyAbilityAi";
|
||||
import { usePartyStore, type PartyMember } from "./partyStore";
|
||||
import { deriveCharacterStats } from "./wotlkStats";
|
||||
|
||||
@@ -87,4 +87,19 @@ describe("catalog-driven party rotations", () => {
|
||||
.find((ability) => aiHintsForAbility(ability).categories.includes("heal") && ability.cost.amount > 0)!;
|
||||
expect(partyAbilityIsReady(member, heal, 2_000)).toBe(false);
|
||||
});
|
||||
|
||||
it("preserves spell base damage when its bonus-power coefficient is zero", () => {
|
||||
const member = { ...memberFor("warrior", "tank"), level: 5, gearStats: EMPTY_ITEM_STATS };
|
||||
const ability = abilitiesForClass("warrior").find((candidate) => candidate.id === "warrior-heroic-strike")!;
|
||||
expect(partyAbilityAmount(member, ability, {
|
||||
kind: "damage", coefficient: 0, basePoints: 17, dieSides: 1, sourceLevel: 5,
|
||||
})).toBe(17);
|
||||
});
|
||||
|
||||
it("requires a learned CoA tree entry before the NPC can cast it", () => {
|
||||
const member = { ...memberFor("pyromancer", "damage"), talentRanks: {} };
|
||||
const ability = abilitiesForClass(member.classId).find((candidate) => candidate.talentEntryId !== undefined && !candidate.passive)!;
|
||||
expect(partyAbilityIsReady(member, ability, 2_000)).toBe(false);
|
||||
expect(partyAbilityIsReady({ ...member, talentRanks: { [`coa-entry-${ability.talentEntryId}`]: 1 } }, ability, 2_000)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
} from "./abilityCatalog";
|
||||
import { aiHintsForAbility, runeCostForAbility } from "./abilityRules";
|
||||
import { spendDeathKnightRunes } from "./combatResources";
|
||||
import type { MobCombatState } from "./combatStore";
|
||||
import { equipmentAdjustedCombatAmount, type MobCombatState } from "./combatStore";
|
||||
import type { PartyMember } from "./partyStore";
|
||||
|
||||
export interface PartyAbilityContext {
|
||||
@@ -20,6 +20,7 @@ export interface PartyAbilityContext {
|
||||
readonly fallenAlly?: boolean;
|
||||
readonly injuredAllies?: number;
|
||||
readonly lowestHealthFraction?: number;
|
||||
readonly canUseAbility?: (ability: AbilityDefinition) => boolean;
|
||||
}
|
||||
|
||||
function currentResource(member: PartyMember, type: ResourceType): number {
|
||||
@@ -35,6 +36,7 @@ function abilityCosts(ability: AbilityDefinition): readonly { resource: Resource
|
||||
|
||||
export function partyAbilityIsReady(member: PartyMember, ability: AbilityDefinition, now: number): boolean {
|
||||
if (ability.passive || !isAbilityUnlocked(ability, member.level)) return false;
|
||||
if (ability.talentEntryId !== undefined && !(member.talentRanks[`coa-entry-${ability.talentEntryId}`] ?? 0)) return false;
|
||||
if (member.globalCooldownEndsAt > now || (member.cooldowns[ability.id] ?? 0) > now) return false;
|
||||
if ((member.schoolLockedUntil[ability.castRules?.school ?? "physical"] ?? 0) > now) return false;
|
||||
if (aiHintsForAbility(ability).categories.includes("taunt") && member.tauntReadyAt > now) return false;
|
||||
@@ -50,12 +52,18 @@ export function partyAbilityIsReady(member: PartyMember, ability: AbilityDefinit
|
||||
|
||||
function candidateAbilities(member: PartyMember, now: number): readonly AbilityDefinition[] {
|
||||
return abilitiesForClass(member.classId)
|
||||
.filter((ability) => partyAbilityIsReady(member, abilityAtLevel(ability, member.level), now))
|
||||
.map((ability) => abilityAtLevel(ability, member.level));
|
||||
.map((ability) => abilityAtLevel(ability, member.level, ability.talentEntryId === undefined
|
||||
? undefined : member.talentRanks[`coa-entry-${ability.talentEntryId}`] ?? 0))
|
||||
.filter((ability) => partyAbilityIsReady(member, ability, now));
|
||||
}
|
||||
|
||||
function scoreAbility(member: PartyMember, ability: AbilityDefinition, context: PartyAbilityContext): number {
|
||||
const categories = new Set(aiHintsForAbility(ability).categories);
|
||||
const damaging = categories.has("damage") || categories.has("execute");
|
||||
if (categories.has("taunt") && (member.role !== "tank" || !context.targetIsLoose)) return 0;
|
||||
if (!damaging && categories.has("interrupt") && !context.target?.activeCast) return 0;
|
||||
const control = categories.has("control") || ability.effects.some((effect) => effect.kind === "apply-aura" && effect.control);
|
||||
if (!damaging && control && !context.requiredControl) return 0;
|
||||
let score = ability.ai?.priority ?? 0;
|
||||
if (member.role === "tank") {
|
||||
if (context.target?.activeCast && categories.has("interrupt")) score += 1_000;
|
||||
@@ -89,6 +97,7 @@ export function choosePartyAbility(
|
||||
context: PartyAbilityContext,
|
||||
): AbilityDefinition | null {
|
||||
return candidateAbilities(member, context.now)
|
||||
.filter((ability) => context.canUseAbility?.(ability) ?? true)
|
||||
.map((ability) => ({ ability, score: scoreAbility(member, ability, context) }))
|
||||
.filter((candidate) => candidate.score > 0)
|
||||
.sort((left, right) => right.score - left.score
|
||||
@@ -142,9 +151,11 @@ export function partyAbilityAmount(
|
||||
: effect.kind === "finisher-damage"
|
||||
? effect.baseCoefficient + effect.perComboCoefficient * 5
|
||||
: 0;
|
||||
const power = member.level * 4
|
||||
+ member.gearStats.spellPower
|
||||
+ member.gearStats.attackPower * 0.35
|
||||
+ member.gearStats.rangedAttackPower * 0.35;
|
||||
return Math.max(1, Math.round(Math.max(0.1, coefficient) * Math.max(8, power)));
|
||||
return equipmentAdjustedCombatAmount(
|
||||
"coefficient" in effect ? effect : coefficient,
|
||||
member.level,
|
||||
member.gearStats,
|
||||
member.classId,
|
||||
ability,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createDungeonParty, usePartyStore, type PartyMember } from "./partyStore";
|
||||
import { useCombatStore } from "./combatStore";
|
||||
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
|
||||
import { clearPartyRuntimeRegistry, registerPartyRuntimePosition, updatePartyRuntimeTargetVisibility } from "./partyRuntimeRegistry";
|
||||
import { advanceDungeonPartyCombat, partyUsesRangedAttacks, resetPartyRuntimeClock } from "./partyRuntime";
|
||||
import { subscribeCombatPresentation } from "./combatPresentation";
|
||||
import { abilitiesForClass } from "./abilityCatalog";
|
||||
|
||||
function partyTemplates(level: number): PartyMember[] {
|
||||
const templates = new Map<string, PartyMember>();
|
||||
for (let seed = 0; seed < 100; seed += 1) {
|
||||
for (const role of ["healer", "damage"] as const) {
|
||||
for (const member of createDungeonParty(`damage-audit-${seed}`, level, role)) {
|
||||
templates.set(`${member.classId}:${member.specialization}`, member);
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...templates.values()];
|
||||
}
|
||||
|
||||
describe("NPC sustained damage audit", () => {
|
||||
it("lets Garrick swing immediately with no Rage, and keeps swinging during the GCD", () => {
|
||||
clearMobRuntimeRegistry();
|
||||
clearPartyRuntimeRegistry();
|
||||
resetPartyRuntimeClock();
|
||||
useCombatStore.getState().initializeCharacter({ classId: "priest", level: 3 });
|
||||
const garrick = partyTemplates(3).find((member) => member.name === "Garrick")!;
|
||||
usePartyStore.getState().initializeParty("short-pull", 3, useCombatStore.getState().health);
|
||||
usePartyStore.setState({ members: [{ ...garrick, globalCooldownEndsAt: 10_000 }] });
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
registerPartyRuntimePosition(garrick.id, [0, 0, 0]);
|
||||
useCombatStore.getState().registerMob("short-pull", { name: "Training target", maxHealth: 1_000, xpReward: 0 });
|
||||
setMobPosition("short-pull", [2, 0, 0]);
|
||||
useCombatStore.getState().damageMob("short-pull", 1, 100);
|
||||
updatePartyRuntimeTargetVisibility(garrick.id, "short-pull", true);
|
||||
advanceDungeonPartyCombat(1_000);
|
||||
const swings = () => useCombatStore.getState().combatEvents.filter((event) => event.sourceActorId === garrick.id && event.kind === "damage");
|
||||
expect(swings()).toHaveLength(1);
|
||||
expect(swings()[0]).toMatchObject({ abilityId: "party-auto-attack" });
|
||||
expect(swings()[0].effectiveAmount).toBeGreaterThan(1);
|
||||
expect(usePartyStore.getState().members[0].resource).toBe(6);
|
||||
advanceDungeonPartyCombat(1_100);
|
||||
expect(swings()).toHaveLength(1);
|
||||
advanceDungeonPartyCombat(2_800);
|
||||
expect(swings()).toHaveLength(2);
|
||||
expect(usePartyStore.getState().members[0].resource).toBe(12);
|
||||
usePartyStore.getState().setCommand("stop");
|
||||
advanceDungeonPartyCombat(5_000);
|
||||
expect(swings()).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("casts Thunder Clap around the tank rather than rejecting its self range or centering it on the enemy", () => {
|
||||
clearMobRuntimeRegistry();
|
||||
clearPartyRuntimeRegistry();
|
||||
resetPartyRuntimeClock();
|
||||
useCombatStore.getState().initializeCharacter({ classId: "priest", level: 5 });
|
||||
const garrick = partyTemplates(5).find((member) => member.name === "Garrick")!;
|
||||
usePartyStore.getState().initializeParty("area-pull", 5, useCombatStore.getState().health);
|
||||
usePartyStore.setState({ members: [{ ...garrick, resource: 100, resourcePools: { rage: 100 },
|
||||
cooldowns: Object.fromEntries(abilitiesForClass("warrior").filter((ability) => ability.id !== "warrior-thunder-clap")
|
||||
.map((ability) => [ability.id, 60_000])) }] });
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
registerPartyRuntimePosition(garrick.id, [0, 0, 0]);
|
||||
for (const [id, x] of [["area:a", 7], ["area:b", -7], ["area:c", 10]] as const) {
|
||||
useCombatStore.getState().registerMob(id, { name: id, maxHealth: 1_000, xpReward: 0 });
|
||||
setMobPosition(id, [x, 0, 0]);
|
||||
updatePartyRuntimeTargetVisibility(garrick.id, id, true);
|
||||
}
|
||||
useCombatStore.getState().damageMob("area:a", 1, 100);
|
||||
updatePartyRuntimeTargetVisibility(garrick.id, "area:a", true);
|
||||
advanceDungeonPartyCombat(1_000);
|
||||
const damage = useCombatStore.getState().combatEvents.filter((event) => event.sourceActorId === garrick.id && event.kind === "damage");
|
||||
expect(damage.map((event) => event.targetActorId).sort()).toEqual(["area:a", "area:b"]);
|
||||
expect(damage.every((event) => event.abilityId === "warrior-thunder-clap" && event.effectiveAmount > 0)).toBe(true);
|
||||
expect(useCombatStore.getState().mobs["area:c"].health).toBe(1_000);
|
||||
});
|
||||
|
||||
for (const level of [1, 3, 5, 20, 80]) {
|
||||
it(`executes damaging abilities for every party template at level ${level}`, () => {
|
||||
const templates = partyTemplates(level);
|
||||
expect(templates).toHaveLength(26);
|
||||
for (const member of templates) {
|
||||
clearMobRuntimeRegistry();
|
||||
clearPartyRuntimeRegistry();
|
||||
resetPartyRuntimeClock();
|
||||
useCombatStore.getState().initializeCharacter({ classId: "priest", level });
|
||||
usePartyStore.getState().initializeParty("damage-audit", level, useCombatStore.getState().health);
|
||||
usePartyStore.setState({ members: [member] });
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
registerPartyRuntimePosition(member.id, partyUsesRangedAttacks(member) ? [-12, 0, 0] : [0, 0, 0]);
|
||||
useCombatStore.getState().registerMob("audit", { name: "Training target", maxHealth: 100_000, xpReward: 0 });
|
||||
setMobPosition("audit", [2, 0, 0]);
|
||||
useCombatStore.getState().damageMob("audit", 1, 100);
|
||||
updatePartyRuntimeTargetVisibility(member.id, "audit", true);
|
||||
const releases: string[] = [];
|
||||
const unsubscribe = subscribeCombatPresentation((message) => {
|
||||
if (message.kind === "event" && message.event.sourceActorId === member.id && message.event.phase === "release") {
|
||||
releases.push(message.event.name);
|
||||
}
|
||||
});
|
||||
for (let now = 1_000; now <= 31_000; now += 100) {
|
||||
useCombatStore.getState().tick(0.1, now);
|
||||
advanceDungeonPartyCombat(now);
|
||||
}
|
||||
unsubscribe();
|
||||
const damageEvents = useCombatStore.getState().combatEvents.filter((event) => event.kind === "damage" && event.sourceActorId === member.id);
|
||||
const damage = damageEvents
|
||||
.reduce((total, event) => total + event.effectiveAmount, 0);
|
||||
const description = `${member.name}, ${member.classId}:${member.specialization}, level ${level}`;
|
||||
expect(damage, description).toBeGreaterThan(1);
|
||||
expect(releases.filter((name) => name !== "Auto Attack").length, description).toBeGreaterThan(0);
|
||||
expect(damageEvents.some((event) => event.abilityId !== "party-auto-attack" && event.effectiveAmount > 0), description).toBe(true);
|
||||
const finalMember = usePartyStore.getState().members[0];
|
||||
expect(finalMember.resource, description).toBeGreaterThanOrEqual(0);
|
||||
expect(finalMember.resource, description).toBeLessThanOrEqual(finalMember.maxResource);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -7,14 +7,11 @@ import {
|
||||
nextPartyBreadcrumbWaypoint,
|
||||
partyCombatMovementGoal,
|
||||
partyFormationPosition,
|
||||
partyMadeStuckProgress,
|
||||
partyMovementProfile,
|
||||
partyNaturalFollowPosition,
|
||||
partyPersonalizedCombatMovementGoal,
|
||||
partyPresentationY,
|
||||
partyShouldRecallForStuck,
|
||||
partyShouldMoveToComfortTarget,
|
||||
PARTY_STUCK_RECALL_DISTANCE,
|
||||
personalizedPartyCombatRangeBand,
|
||||
resetPartyBreadcrumbTrail,
|
||||
stepPartyPosition,
|
||||
@@ -125,17 +122,6 @@ describe("party world movement", () => {
|
||||
)).toBe("hold");
|
||||
});
|
||||
|
||||
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", () => {
|
||||
const out: [number, number, number] = [0, 0, 0];
|
||||
expect(partyCombatMovementGoal(
|
||||
|
||||
@@ -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 {
|
||||
return { points: [], nextId: 0 };
|
||||
}
|
||||
|
||||
@@ -92,6 +92,19 @@ describe("party path controller", () => {
|
||||
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", () => {
|
||||
const route = {
|
||||
...emptyPartyRouteState(),
|
||||
|
||||
@@ -34,6 +34,8 @@ export interface PartyRouteOptions {
|
||||
start: PartyWorldPosition,
|
||||
end: PartyWorldPosition,
|
||||
) => 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;
|
||||
@@ -117,7 +119,7 @@ export function planPartyRoute(
|
||||
) return blocked(waypoints);
|
||||
const finishDistance = fullDistance(waypoints[waypoints.length - 1], destination);
|
||||
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)) {
|
||||
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,
|
||||
updatePartyRuntimeAttackCorridor,
|
||||
updatePartyRuntimeTargetVisibility,
|
||||
beginPartyRuntimeRejoin,
|
||||
finishPartyRuntimeRejoin,
|
||||
} from "./partyRuntimeRegistry";
|
||||
import {
|
||||
activePartyTargetId,
|
||||
@@ -47,6 +49,36 @@ describe("party combat runtime", () => {
|
||||
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", () => {
|
||||
registerEngagedMob();
|
||||
for (const member of usePartyStore.getState().members) {
|
||||
|
||||
+131
-17
@@ -21,12 +21,14 @@ import { requireDungeonDefinition } from "./dungeonRegistry";
|
||||
import { getMobPosition } from "./mobRuntimeRegistry";
|
||||
import {
|
||||
getPartyRuntimePosition,
|
||||
partyRuntimeIsRejoining,
|
||||
partyRuntimeCenter,
|
||||
partyRuntimePositionAlongAttackCorridor,
|
||||
partyRuntimeTargetVisible,
|
||||
updatePartyRuntimePosition,
|
||||
} from "./partyRuntimeRegistry";
|
||||
import { usePartyStore, type PartyCommand, type PartyMember } from "./partyStore";
|
||||
import { baseClassFor, classById } from "../app/characterCatalog";
|
||||
import { useGameStore } from "./store";
|
||||
import { useManastormStore } from "./manastormStore";
|
||||
import { activeDungeonCompletedObjectiveIds } from "./dungeonEncounterRuntime";
|
||||
@@ -53,6 +55,7 @@ import {
|
||||
} from "./partyAbilityAi";
|
||||
import { normalizeActorResourcePool } from "./combatActors";
|
||||
import { advanceCombatResourcePool } from "./combatResources";
|
||||
import { channelAmountEffects } from "./combatChannelAmounts";
|
||||
import {
|
||||
combatHasLineOfSight,
|
||||
combatSpatialActor,
|
||||
@@ -68,6 +71,7 @@ const TANK_TAUNT_COOLDOWN_MS = 8_000;
|
||||
const TANK_TAUNT_DURATION_MS = 3_000;
|
||||
let lastAdvanceAt = 0;
|
||||
let nextRegenAt = 0;
|
||||
const nextWeaponAttackAt = new Map<string, number>();
|
||||
|
||||
export interface PartyPresentationIdentity {
|
||||
readonly spellId: number;
|
||||
@@ -140,6 +144,7 @@ export function activePartyBossObjectives(): readonly PartyBossObjective[] {
|
||||
export function resetPartyRuntimeClock(): void {
|
||||
lastAdvanceAt = 0;
|
||||
nextRegenAt = 0;
|
||||
nextWeaponAttackAt.clear();
|
||||
}
|
||||
|
||||
export function activePartyTargetId(
|
||||
@@ -166,7 +171,7 @@ export function partyAttackDamage(
|
||||
specialization: member.specialization ?? "",
|
||||
role: member.role,
|
||||
})
|
||||
? classId === "hunter" ? "ranged" : "spell"
|
||||
? baseClassFor(classId) === "hunter" ? "ranged" : "spell"
|
||||
: "melee";
|
||||
const weapon = kind === "ranged" ? gear.rangedWeapon : kind === "melee" ? gear.meleeWeapon : null;
|
||||
const equipmentPower = kind === "spell"
|
||||
@@ -192,15 +197,30 @@ export function partyAttackDamage(
|
||||
}
|
||||
|
||||
export function partyUsesRangedAttacks(
|
||||
member: Pick<PartyMember, "classId" | "specialization" | "role">,
|
||||
member: Pick<PartyMember, "classId" | "specialization" | "role"> & Partial<Pick<PartyMember, "level" | "talentRanks">>,
|
||||
): boolean {
|
||||
if (member.role === "tank") return false;
|
||||
if (member.role === "healer") return true;
|
||||
const definition = classById(member.classId);
|
||||
if (definition.mode !== "classic") {
|
||||
return (baseClassFor(member.classId) === "hunter"
|
||||
|| ["intellect", "wisdom", "spirit"].includes(definition.primaryStat))
|
||||
&& partyCatalogAttackRange(member) > 5;
|
||||
}
|
||||
return ["hunter", "mage", "priest", "warlock"].includes(member.classId)
|
||||
|| member.specialization === "Elemental"
|
||||
|| member.specialization === "Balance";
|
||||
}
|
||||
|
||||
function partyCatalogAttackRange(member: Pick<PartyMember, "classId"> & Partial<Pick<PartyMember, "level" | "talentRanks">>): number {
|
||||
return Math.max(0, ...abilitiesForClass(member.classId)
|
||||
.filter((ability) => !ability.passive && ability.unlockLevel <= (member.level ?? 80)
|
||||
&& (ability.talentEntryId === undefined || (member.talentRanks?.[`coa-entry-${ability.talentEntryId}`] ?? 0) > 0))
|
||||
.map((ability) => abilityAtLevel(ability, member.level ?? 80))
|
||||
.filter((ability) => ability.effects.some((effect) => ["damage", "dot", "finisher-damage", "execute"].includes(effect.kind)))
|
||||
.map((ability) => ability.range.max));
|
||||
}
|
||||
|
||||
export function partyAttackRange(member: Pick<PartyMember, "classId" | "specialization" | "role">): number {
|
||||
if (member.role === "tank") return 3.8;
|
||||
if (partyUsesRangedAttacks(member)) return 19;
|
||||
@@ -235,9 +255,18 @@ const MELEE_DAMAGE_COMBAT_RANGE_BAND: PartyCombatRangeBand = Object.freeze({
|
||||
* target instead of treating the tank's position as their own.
|
||||
*/
|
||||
export function partyCombatRangeBand(
|
||||
member: Pick<PartyMember, "classId" | "specialization" | "role">,
|
||||
member: Pick<PartyMember, "classId" | "specialization" | "role"> & Partial<Pick<PartyMember, "level" | "talentRanks">>,
|
||||
): PartyCombatRangeBand {
|
||||
if (partyUsesRangedAttacks(member)) return RANGED_COMBAT_RANGE_BAND;
|
||||
if (partyUsesRangedAttacks(member)) {
|
||||
if (classById(member.classId).mode !== "classic") {
|
||||
const attackRange = partyCatalogAttackRange(member);
|
||||
if (attackRange <= 5) return RANGED_COMBAT_RANGE_BAND;
|
||||
const maximum = Math.min(RANGED_COMBAT_RANGE_BAND.maximum, attackRange - 0.1);
|
||||
const preferred = Math.min(RANGED_COMBAT_RANGE_BAND.preferred, maximum - 0.5);
|
||||
return { minimum: Math.min(RANGED_COMBAT_RANGE_BAND.minimum, preferred - 0.5), preferred, maximum };
|
||||
}
|
||||
return RANGED_COMBAT_RANGE_BAND;
|
||||
}
|
||||
return member.role === "tank" ? TANK_COMBAT_RANGE_BAND : MELEE_DAMAGE_COMBAT_RANGE_BAND;
|
||||
}
|
||||
|
||||
@@ -598,7 +627,7 @@ function memberSwingMs(member: PartyMember): number {
|
||||
ratingPercent(
|
||||
member.gearStats.hasteRating,
|
||||
partyUsesRangedAttacks(member)
|
||||
? member.classId === "hunter" ? "rangedHaste" : "spellHaste"
|
||||
? baseClassFor(member.classId) === "hunter" ? "rangedHaste" : "spellHaste"
|
||||
: "meleeHaste",
|
||||
member.level,
|
||||
member.classId,
|
||||
@@ -611,7 +640,7 @@ function emitPartyAbility(
|
||||
member: PartyMember,
|
||||
ability: AbilityDefinition,
|
||||
targetId: string,
|
||||
phase: "cast-start" | "release" | "impact",
|
||||
phase: "cast-start" | "cast-cancel" | "release" | "impact",
|
||||
now: number,
|
||||
instantCast = false,
|
||||
): 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 {
|
||||
if (actorId === PLAYER_AGGRO_ID) return useCombatStore.getState().playerPosition;
|
||||
return getPartyRuntimePosition(actorId);
|
||||
@@ -660,8 +716,11 @@ function partyAbilityTargetIsValid(
|
||||
if (!hostile && (!targetActor?.alive || targetActor.health <= 0)
|
||||
&& !ability.effects.some((effect) => effect.kind === "resurrection")) return false;
|
||||
const distance = Math.sqrt(distanceSquared(origin, targetPosition));
|
||||
if (distance < ability.range.min || distance > ability.range.max || Math.abs(origin[1] - targetPosition[1]) > 8) return false;
|
||||
const targeting = targetingRulesForAbility(ability);
|
||||
const maximumRange = hostile && ability.target === "self"
|
||||
? targeting.radius ?? ability.radius ?? ability.range.max
|
||||
: ability.range.max;
|
||||
if (distance < ability.range.min || distance > maximumRange || Math.abs(origin[1] - targetPosition[1]) > 8) return false;
|
||||
if (targeting.requiresLineOfSight && !combatHasLineOfSight(origin, targetPosition)) return false;
|
||||
if (hostile && targeting.requiresFacing) {
|
||||
const sourceSpatial = combatSpatialActor(member.id);
|
||||
@@ -705,7 +764,7 @@ function partyHostileTargets(
|
||||
.filter((mob) => {
|
||||
const position = getMobPosition(mob.id);
|
||||
return !mob.dead && Boolean(position
|
||||
&& distanceSquared(origin, position) <= ability.range.max ** 2
|
||||
&& distanceSquared(origin, position) <= (targeting.radius ?? ability.radius ?? ability.range.max) ** 2
|
||||
&& combatHasLineOfSight(origin, position)
|
||||
&& (!memberSpatial || isPositionInFacingArc(memberSpatial, position, targeting.attackArcRadians ?? Math.PI / 2)));
|
||||
})
|
||||
@@ -714,12 +773,13 @@ function partyHostileTargets(
|
||||
}
|
||||
if (targeting.shape === "circle" || targeting.shape === "ground" || ability.radius !== undefined) {
|
||||
const radius = targeting.radius ?? ability.radius ?? 0;
|
||||
const center = ability.target === "self" ? origin : primaryPosition;
|
||||
return Object.values(combat.mobs)
|
||||
.filter((mob) => {
|
||||
const position = getMobPosition(mob.id);
|
||||
return !mob.dead && Boolean(position
|
||||
&& distanceSquared(primaryPosition, position) <= radius * radius
|
||||
&& combatHasLineOfSight(primaryPosition, position));
|
||||
&& distanceSquared(center, position) <= radius * radius
|
||||
&& combatHasLineOfSight(center, position));
|
||||
})
|
||||
.map((mob) => mob.id)
|
||||
.sort();
|
||||
@@ -855,7 +915,7 @@ function executePartyAbility(
|
||||
? Boolean(useCombatStore.getState().createActorAreaEffect(
|
||||
member.id,
|
||||
ability,
|
||||
getMobPosition(targetId) ?? partyFriendlyPosition(targetId) ?? origin,
|
||||
ability.target === "self" ? origin : getMobPosition(targetId) ?? partyFriendlyPosition(targetId) ?? origin,
|
||||
now,
|
||||
))
|
||||
: false;
|
||||
@@ -1058,7 +1118,7 @@ export function advanceDungeonPartyCombat(now = Date.now()): void {
|
||||
));
|
||||
let members: readonly PartyMember[] = authoritativeMembers;
|
||||
const partyOrigin = partyRuntimeCenter(
|
||||
members.map((member) => member.id),
|
||||
members.filter((member) => !partyRuntimeIsRejoining(member.id)).map((member) => member.id),
|
||||
combat.playerPosition,
|
||||
);
|
||||
let targetId = partyCommandTargetId(
|
||||
@@ -1122,6 +1182,10 @@ export function advanceDungeonPartyCombat(now = Date.now()): void {
|
||||
if (member.status !== "down") nextMembers[index] = { ...member, status: "down" };
|
||||
continue;
|
||||
}
|
||||
if (partyRuntimeIsRejoining(member.id)) {
|
||||
nextMembers[index] = { ...member, status: "advancing", activeCast: null, activeCastTargetId: null };
|
||||
continue;
|
||||
}
|
||||
if (member.controlledUntil > now) {
|
||||
nextMembers[index] = member.status === "holding"
|
||||
? member
|
||||
@@ -1138,6 +1202,7 @@ export function advanceDungeonPartyCombat(now = Date.now()): void {
|
||||
member = activeMember;
|
||||
const currentCombat = useCombatStore.getState();
|
||||
if (member.activeCast) {
|
||||
const activeCast = member.activeCast;
|
||||
const castingAbility = abilityById(member.activeCast.abilityId);
|
||||
const castTargetId = member.activeCastTargetId;
|
||||
if (!castingAbility || !castTargetId) {
|
||||
@@ -1148,7 +1213,41 @@ export function advanceDungeonPartyCombat(now = Date.now()): void {
|
||||
const desiredStatus = hostileCast
|
||||
? member.role === "tank" ? "guarding" : "attacking"
|
||||
: "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 };
|
||||
if (hostileCast) memberTargetIds[member.id] = castTargetId;
|
||||
continue;
|
||||
@@ -1158,12 +1257,14 @@ export function advanceDungeonPartyCombat(now = Date.now()): void {
|
||||
completedMember,
|
||||
castingAbility,
|
||||
castTargetId,
|
||||
member.activeCast.origin,
|
||||
activeCast.origin,
|
||||
);
|
||||
if (valid) {
|
||||
const execution = executePartyAbility(
|
||||
completedMember,
|
||||
castingAbility,
|
||||
activeCast.mode === "channel"
|
||||
? { ...castingAbility, effects: castingAbility.effects.filter((effect) => !["damage", "dot", "heal", "hot"].includes(effect.kind)) }
|
||||
: castingAbility,
|
||||
castTargetId,
|
||||
now,
|
||||
nextMembers,
|
||||
@@ -1269,7 +1370,11 @@ export function advanceDungeonPartyCombat(now = Date.now()): void {
|
||||
const targetVisible = Boolean(
|
||||
targetPosition && partyRuntimeTargetVisible(member.id, memberTargetId)
|
||||
);
|
||||
let runtimeMember = member;
|
||||
let runtimeMember = targetVisible ? advancePartyWeaponAttack(member, memberTargetId, now) : member;
|
||||
if (useCombatStore.getState().mobs[memberTargetId]?.dead) {
|
||||
nextMembers[index] = { ...runtimeMember, status: desiredStatus };
|
||||
continue;
|
||||
}
|
||||
const targetMob = currentCombat.mobs[memberTargetId];
|
||||
if (now < member.nextActionAt) {
|
||||
if (runtimeMember.status !== desiredStatus) {
|
||||
@@ -1282,9 +1387,18 @@ export function advanceDungeonPartyCombat(now = Date.now()): void {
|
||||
now,
|
||||
target: targetMob ?? null,
|
||||
targetIsLoose: member.role === "tank" && targetMob?.targetActorId !== member.id,
|
||||
canUseAbility: (ability) => partyAbilityTargetIsValid(runtimeMember, ability, memberTargetId)
|
||||
// Let maintained effects tick instead of resetting their first tick on every GCD.
|
||||
&& !currentCombat.effects.some((effect) => effect.sourceActorId === member.id
|
||||
&& effect.sourceAbilityId === ability.id && effect.targetId === memberTargetId && effect.remainingTicks > 0)
|
||||
&& !ability.effects.some((effect) => effect.kind === "apply-aura"
|
||||
&& currentCombat.auras.some((aura) => aura.sourceId === member.id
|
||||
&& aura.definition.id === effect.aura.id && aura.targetId === memberTargetId)),
|
||||
});
|
||||
if (!selectedAbility) {
|
||||
nextMembers[index] = { ...runtimeMember, status: desiredStatus, nextActionAt: now + 250 };
|
||||
const inAttackRange = targetVisible && targetPosition
|
||||
&& distanceSquared(memberPosition, targetPosition) <= partyAttackRange(member) ** 2;
|
||||
nextMembers[index] = { ...runtimeMember, status: inAttackRange ? desiredStatus : "advancing", nextActionAt: now + 250 };
|
||||
continue;
|
||||
}
|
||||
const inRange = targetVisible && partyAbilityTargetIsValid(runtimeMember, selectedAbility, memberTargetId);
|
||||
|
||||
@@ -11,11 +11,41 @@ import {
|
||||
updatePartyRuntimeTargetVisibility,
|
||||
updatePartyRuntimeAttackCorridor,
|
||||
updatePartyRuntimePosition,
|
||||
beginPartyRuntimeRejoin,
|
||||
consumePartyRuntimeRejoin,
|
||||
partyRuntimeIsRejoining,
|
||||
finishPartyRuntimeRejoin,
|
||||
} from "./partyRuntimeRegistry";
|
||||
|
||||
describe("party runtime position registry", () => {
|
||||
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", () => {
|
||||
const unregister = registerPartyRuntimePosition("tank", [1, 2, 3]);
|
||||
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 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: {
|
||||
readonly origin: MutablePartyWorldPosition;
|
||||
readonly waypoints: readonly PartyWorldPosition[];
|
||||
@@ -21,6 +44,7 @@ export function registerPartyRuntimePosition(
|
||||
return () => {
|
||||
positions.delete(id);
|
||||
targetVisibility.delete(id);
|
||||
rejoins.delete(id);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -111,6 +135,7 @@ export function updatePartyRuntimePosition(
|
||||
export function removePartyRuntimePosition(id: string): void {
|
||||
positions.delete(id);
|
||||
targetVisibility.delete(id);
|
||||
rejoins.delete(id);
|
||||
}
|
||||
|
||||
export function getPartyRuntimePosition(id: string): PartyWorldPosition | null {
|
||||
@@ -166,5 +191,6 @@ export function partyRuntimeCenter(
|
||||
export function clearPartyRuntimeRegistry(): void {
|
||||
positions.clear();
|
||||
targetVisibility.clear();
|
||||
rejoins.clear();
|
||||
attackCorridor = null;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
APPEARANCE_KEYS,
|
||||
ROM_CLASS_IDS,
|
||||
classesForRace,
|
||||
raceById,
|
||||
supportedGenders,
|
||||
@@ -80,6 +81,51 @@ describe("dungeon party composition", () => {
|
||||
expect(first.every((member) => member.level === 18 && member.health === member.maxHealth)).toBe(true);
|
||||
});
|
||||
|
||||
it("syncs every RoM companion class through entry and level changes without WoW talents", () => {
|
||||
const seenClasses = new Set<string>();
|
||||
for (const role of ["tank", "healer", "damage"] as const) {
|
||||
for (let seed = 0; seed < 20; seed += 1) {
|
||||
usePartyStore.getState().initializeParty(`rom-level-${seed}`, 1, 100, role, 5, "rom");
|
||||
const identities = usePartyStore.getState().members.map(({ id, classId, raceId, gender, appearance }) => ({
|
||||
id, classId, raceId, gender, appearance,
|
||||
}));
|
||||
for (const level of [2, 10, 70, 1]) {
|
||||
usePartyStore.getState().syncLevel(level);
|
||||
const members = usePartyStore.getState().members;
|
||||
expect(members.map(({ id, classId, raceId, gender, appearance }) => ({
|
||||
id, classId, raceId, gender, appearance,
|
||||
}))).toEqual(identities);
|
||||
for (const member of members) {
|
||||
seenClasses.add(member.classId);
|
||||
expect(member.level).toBe(level);
|
||||
expect(member.talentRanks).toEqual({});
|
||||
expect(Object.isFrozen(member.talentRanks)).toBe(true);
|
||||
expect(member.health).toBe(member.maxHealth);
|
||||
expect(member.resourcePools[member.resourceType]).toBe(member.resource);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
expect([...seenClasses].sort()).toEqual([...ROM_CLASS_IDS].sort());
|
||||
});
|
||||
|
||||
it("preserves fallen RoM companions and living health/resource ratios on level sync", () => {
|
||||
usePartyStore.getState().initializeParty("rom-level-ratios", 10, 100, "damage", 5, "rom");
|
||||
const [fallen, healer] = usePartyStore.getState().members;
|
||||
usePartyStore.getState().updateMemberCombat(fallen.id, { health: 0, status: "down" });
|
||||
usePartyStore.getState().updateMemberCombat(healer.id, {
|
||||
health: Math.round(healer.maxHealth * 0.5),
|
||||
resource: Math.round(healer.maxResource * 0.25),
|
||||
});
|
||||
const before = usePartyStore.getState().members[1];
|
||||
usePartyStore.getState().syncLevel(11);
|
||||
const [syncedFallen, syncedHealer] = usePartyStore.getState().members;
|
||||
expect(syncedFallen).toMatchObject({ health: 0, status: "down", level: 11, talentRanks: {} });
|
||||
expect(syncedHealer.health).toBe(Math.round(before.health / before.maxHealth * syncedHealer.maxHealth));
|
||||
expect(syncedHealer.resource).toBe(Math.round(before.resource / before.maxResource * syncedHealer.maxResource));
|
||||
expect(syncedHealer.resourcePools.mana).toBe(syncedHealer.resource);
|
||||
});
|
||||
|
||||
it("supports solo through five-person Manastorm party sizes without changing the dungeon default", () => {
|
||||
expect(createDungeonParty("sizes", 18)).toHaveLength(4);
|
||||
for (const size of [1, 2, 3, 4, 5] as const) {
|
||||
|
||||
@@ -496,7 +496,9 @@ export const usePartyStore = create<PartyState>((set, get) => ({
|
||||
resource,
|
||||
maxResource,
|
||||
resourcePools: Object.freeze({ ...member.resourcePools, [member.resourceType]: resource }),
|
||||
talentRanks: runtimePartyTalentRanksForLevel(member.classId, member.specialization, safeLevel),
|
||||
talentRanks: isRomClassId(member.classId)
|
||||
? Object.freeze({})
|
||||
: runtimePartyTalentRanksForLevel(member.classId, member.specialization, safeLevel),
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -0,0 +1,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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
{"schemaVersion":1,"source":{"rootHint":"RuneWaker source snapshot","hashes":{"learnMagic":"6148199bcd1484ba1a122a6db5e2b3c2f2a161402bfe67fc67eb53f55fc6cfa3","magicCollect":"4aae19865cb07b30f99945aa7319a0096db244ff5e5b4b5d902fc6889474f501","magicObject":"52af13a2d8cfa9a66d4caa2d3c38832edc232f058995a64ef8135a6b82e43691","imageObject":"dd5ffcfd74b253dcecaa2f41a931a634dd45ef5f7c13e849d539ddc47fdee1f9","vocationTable":"f06cdaf60c54415dcbea47e9fa0e77b7527035a2aec73d3289370dc39751e76c","strings":"0c20d43660cb2ee042a78f7a8c18b4e104a263d54dd8c4bc98a54155e8adb3b0","eliteLua":"ee9e337548abd7fc555d85bf2f96813dfd77416c3e65d4cadf74a80838da1486","roleAttr":"8143a90ce826bf3152551a4508d212f186012cbc054e505e426551cb62369c1f","objectStruct":"708f2d087f2654b97d149ab7124d9cdffc915cf8bf18108feff3f1a45f124c20","interfaceFdb":"51bbb5a9d6a19a254b302000d3dd6e3a796f4f759015f3352f576a1055496e30"}},"canonicalClasses":[{"id":"rom-warrior","sourceClassId":1,"name":"Warrior","resource":"rage"},{"id":"rom-scout","sourceClassId":2,"name":"Scout","resource":"focus"},{"id":"rom-rogue","sourceClassId":3,"name":"Rogue","resource":"energy"},{"id":"rom-mage","sourceClassId":4,"name":"Mage","resource":"mana"},{"id":"rom-priest","sourceClassId":5,"name":"Priest","resource":"mana"},{"id":"rom-knight","sourceClassId":6,"name":"Knight","resource":"mana"},{"id":"rom-warden","sourceClassId":7,"name":"Warden","resource":"mana"},{"id":"rom-druid","sourceClassId":8,"name":"Druid","resource":"mana"},{"id":"rom-warlock","sourceClassId":9,"name":"Warlock","resource":"focus"},{"id":"rom-champion","sourceClassId":10,"name":"Champion","resource":"rage"}],"baseStatGrowth":{"rom-warrior":{"strength":{"levelOne":14,"flatGrowth":5,"percentGrowth":0.02},"stamina":{"levelOne":13,"flatGrowth":4.5,"percentGrowth":0.02},"intellect":{"levelOne":5,"flatGrowth":1.9,"percentGrowth":0.02},"wisdom":{"levelOne":14,"flatGrowth":4,"percentGrowth":0.02},"agility":{"levelOne":12,"flatGrowth":4.25,"percentGrowth":0.02},"health":{"levelOne":60,"flatGrowth":2,"percentGrowth":0.02},"mana":{"levelOne":20,"flatGrowth":2,"percentGrowth":0.02}},"rom-scout":{"strength":{"levelOne":11,"flatGrowth":3.85,"percentGrowth":0.02},"stamina":{"levelOne":10,"flatGrowth":3.65,"percentGrowth":0.02},"intellect":{"levelOne":10,"flatGrowth":3.6,"percentGrowth":0.02},"wisdom":{"levelOne":14,"flatGrowth":4,"percentGrowth":0.02},"agility":{"levelOne":13,"flatGrowth":4.5,"percentGrowth":0.02},"health":{"levelOne":60,"flatGrowth":2,"percentGrowth":0.02},"mana":{"levelOne":20,"flatGrowth":2,"percentGrowth":0.02}},"rom-rogue":{"strength":{"levelOne":12,"flatGrowth":4.15,"percentGrowth":0.02},"stamina":{"levelOne":11,"flatGrowth":3.9,"percentGrowth":0.02},"intellect":{"levelOne":7,"flatGrowth":2.6,"percentGrowth":0.02},"wisdom":{"levelOne":14,"flatGrowth":4,"percentGrowth":0.02},"agility":{"levelOne":14,"flatGrowth":5,"percentGrowth":0.02},"health":{"levelOne":60,"flatGrowth":2,"percentGrowth":0.02},"mana":{"levelOne":20,"flatGrowth":2,"percentGrowth":0.02}},"rom-mage":{"strength":{"levelOne":5,"flatGrowth":1.9,"percentGrowth":0.02},"stamina":{"levelOne":9,"flatGrowth":3.4,"percentGrowth":0.02},"intellect":{"levelOne":14,"flatGrowth":5,"percentGrowth":0.02},"wisdom":{"levelOne":14,"flatGrowth":5,"percentGrowth":0.02},"agility":{"levelOne":10,"flatGrowth":3.5,"percentGrowth":0.02},"health":{"levelOne":60,"flatGrowth":2,"percentGrowth":0.02},"mana":{"levelOne":20,"flatGrowth":2,"percentGrowth":0.02}},"rom-priest":{"strength":{"levelOne":7,"flatGrowth":2.5,"percentGrowth":0.02},"stamina":{"levelOne":10,"flatGrowth":3.6,"percentGrowth":0.02},"intellect":{"levelOne":13,"flatGrowth":4.5,"percentGrowth":0.02},"wisdom":{"levelOne":14,"flatGrowth":5,"percentGrowth":0.02},"agility":{"levelOne":10,"flatGrowth":3.5,"percentGrowth":0.02},"health":{"levelOne":60,"flatGrowth":2,"percentGrowth":0.02},"mana":{"levelOne":20,"flatGrowth":2,"percentGrowth":0.02}},"rom-knight":{"strength":{"levelOne":13,"flatGrowth":4.5,"percentGrowth":0.02},"stamina":{"levelOne":14,"flatGrowth":5,"percentGrowth":0.02},"intellect":{"levelOne":11,"flatGrowth":3.75,"percentGrowth":0.02},"wisdom":{"levelOne":14,"flatGrowth":4,"percentGrowth":0.02},"agility":{"levelOne":11,"flatGrowth":3.9,"percentGrowth":0.02},"health":{"levelOne":60,"flatGrowth":2,"percentGrowth":0.02},"mana":{"levelOne":20,"flatGrowth":2,"percentGrowth":0.02}},"rom-warden":{"strength":{"levelOne":9,"flatGrowth":3.25,"percentGrowth":0.02},"stamina":{"levelOne":9,"flatGrowth":3.4,"percentGrowth":0.02},"intellect":{"levelOne":12,"flatGrowth":4.25,"percentGrowth":0.02},"wisdom":{"levelOne":13,"flatGrowth":4.5,"percentGrowth":0.02},"agility":{"levelOne":10,"flatGrowth":3.5,"percentGrowth":0.02},"health":{"levelOne":60,"flatGrowth":2,"percentGrowth":0.02},"mana":{"levelOne":20,"flatGrowth":2,"percentGrowth":0.02}},"rom-druid":{"strength":{"levelOne":12,"flatGrowth":4.35,"percentGrowth":0.02},"stamina":{"levelOne":12,"flatGrowth":4.15,"percentGrowth":0.02},"intellect":{"levelOne":11,"flatGLine truncated
|
||||
{"schemaVersion":1,"source":{"rootHint":"RuneWaker source snapshot","hashes":{"learnMagic":"6148199bcd1484ba1a122a6db5e2b3c2f2a161402bfe67fc67eb53f55fc6cfa3","magicCollect":"4aae19865cb07b30f99945aa7319a0096db244ff5e5b4b5d902fc6889474f501","magicObject":"52af13a2d8cfa9a66d4caa2d3c38832edc232f058995a64ef8135a6b82e43691","imageObject":"dd5ffcfd74b253dcecaa2f41a931a634dd45ef5f7c13e849d539ddc47fdee1f9","vocationTable":"f06cdaf60c54415dcbea47e9fa0e77b7527035a2aec73d3289370dc39751e76c","strings":"0c20d43660cb2ee042a78f7a8c18b4e104a263d54dd8c4bc98a54155e8adb3b0","eliteLua":"ee9e337548abd7fc555d85bf2f96813dfd77416c3e65d4cadf74a80838da1486","roleAttr":"8143a90ce826bf3152551a4508d212f186012cbc054e505e426551cb62369c1f","objectStruct":"708f2d087f2654b97d149ab7124d9cdffc915cf8bf18108feff3f1a45f124c20","interfaceFdb":"51bbb5a9d6a19a254b302000d3dd6e3a796f4f759015f3352f576a1055496e30"}},"canonicalClasses":[{"id":"rom-warrior","sourceClassId":1,"name":"Warrior","resource":"rage"},{"id":"rom-scout","sourceClassId":2,"name":"Scout","resource":"focus"},{"id":"rom-rogue","sourceClassId":3,"name":"Rogue","resource":"energy"},{"id":"rom-mage","sourceClassId":4,"name":"Mage","resource":"mana"},{"id":"rom-priest","sourceClassId":5,"name":"Priest","resource":"mana"},{"id":"rom-knight","sourceClassId":6,"name":"Knight","resource":"mana"},{"id":"rom-warden","sourceClassId":7,"name":"Warden","resource":"mana"},{"id":"rom-druid","sourceClassId":8,"name":"Druid","resource":"mana"},{"id":"rom-warlock","sourceClassId":9,"name":"Warlock","resource":"focus"},{"id":"rom-champion","sourceClassId":10,"name":"Champion","resource":"rage"}],"baseStatGrowth":{"rom-warrior":{"strength":{"levelOne":14,"flatGrowth":5,"percentGrowth":0.02},"stamina":{"levelOne":13,"flatGrowth":4.5,"percentGrowth":0.02},"intellect":{"levelOne":5,"flatGrowth":1.9,"percentGrowth":0.02},"wisdom":{"levelOne":6,"flatGrowth":2.1,"percentGrowth":0.02},"agility":{"levelOne":12,"flatGrowth":4.25,"percentGrowth":0.02},"health":{"levelOne":60,"flatGrowth":2,"percentGrowth":0.02},"mana":{"levelOne":20,"flatGrowth":2,"percentGrowth":0.02}},"rom-scout":{"strength":{"levelOne":11,"flatGrowth":3.85,"percentGrowth":0.02},"stamina":{"levelOne":10,"flatGrowth":3.65,"percentGrowth":0.02},"intellect":{"levelOne":10,"flatGrowth":3.6,"percentGrowth":0.02},"wisdom":{"levelOne":9,"flatGrowth":3.4,"percentGrowth":0.02},"agility":{"levelOne":13,"flatGrowth":4.5,"percentGrowth":0.02},"health":{"levelOne":60,"flatGrowth":2,"percentGrowth":0.02},"mana":{"levelOne":20,"flatGrowth":2,"percentGrowth":0.02}},"rom-rogue":{"strength":{"levelOne":12,"flatGrowth":4.15,"percentGrowth":0.02},"stamina":{"levelOne":11,"flatGrowth":3.9,"percentGrowth":0.02},"intellect":{"levelOne":7,"flatGrowth":2.6,"percentGrowth":0.02},"wisdom":{"levelOne":7,"flatGrowth":2.4,"percentGrowth":0.02},"agility":{"levelOne":14,"flatGrowth":5,"percentGrowth":0.02},"health":{"levelOne":60,"flatGrowth":2,"percentGrowth":0.02},"mana":{"levelOne":20,"flatGrowth":2,"percentGrowth":0.02}},"rom-mage":{"strength":{"levelOne":5,"flatGrowth":1.9,"percentGrowth":0.02},"stamina":{"levelOne":9,"flatGrowth":3.4,"percentGrowth":0.02},"intellect":{"levelOne":14,"flatGrowth":5,"percentGrowth":0.02},"wisdom":{"levelOne":12,"flatGrowth":4.25,"percentGrowth":0.02},"agility":{"levelOne":10,"flatGrowth":3.5,"percentGrowth":0.02},"health":{"levelOne":60,"flatGrowth":2,"percentGrowth":0.02},"mana":{"levelOne":20,"flatGrowth":2,"percentGrowth":0.02}},"rom-priest":{"strength":{"levelOne":7,"flatGrowth":2.5,"percentGrowth":0.02},"stamina":{"levelOne":10,"flatGrowth":3.6,"percentGrowth":0.02},"intellect":{"levelOne":13,"flatGrowth":4.5,"percentGrowth":0.02},"wisdom":{"levelOne":14,"flatGrowth":5,"percentGrowth":0.02},"agility":{"levelOne":10,"flatGrowth":3.5,"percentGrowth":0.02},"health":{"levelOne":60,"flatGrowth":2,"percentGrowth":0.02},"mana":{"levelOne":20,"flatGrowth":2,"percentGrowth":0.02}},"rom-knight":{"strength":{"levelOne":13,"flatGrowth":4.5,"percentGrowth":0.02},"stamina":{"levelOne":14,"flatGrowth":5,"percentGrowth":0.02},"intellect":{"levelOne":11,"flatGrowth":3.75,"percentGrowth":0.02},"wisdom":{"levelOne":11,"flatGrowth":4.1,"percentGrowth":0.02},"agility":{"levelOne":11,"flatGrowth":3.9,"percentGrowth":0.02},"health":{"levelOne":60,"flatGrowth":2,"percentGrowth":0.02},"mana":{"levelOne":20,"flatGrowth":2,"percentGrowth":0.02}},"rom-warden":{"strength":{"levelOne":14,"flatGrowth":5,"percentGrowth":0.02},"stamina":{"levelOne":12,"flatGrowth":4.2,"percentGrowth":0.02},"intellect":{"levelOne":10,"flatGrowth":3.8,"percentGrowth":0.02},"wisdom":{"levelOne":10,"flatGrowth":4,"percentGrowth":0.02},"agility":{"levelOne":10,"flatGrowth":3.5,"percentGrowth":0.02},"health":{"levelOne":60,"flatGrowth":2,"percentGrowth":0.02},"mana":{"levelOne":20,"flatGrowth":2,"percentGrowth":0.02}},"rom-druid":{"strength":{"levelOne":7,"flatGrowth":2.5,"percentGrowth":0.02},"stamina":{"levelOne":11,"flatGrowth":3.7,"percentGrowth":0.02},"intellect":{"levelOne":13,"flatLine truncated
|
||||
@@ -122,7 +122,7 @@ describe("generated RuneWaker player catalog", () => {
|
||||
103 * MANA_POOL_MULTIPLIER,
|
||||
);
|
||||
expect(deriveCharacterStats("rom-druid", 1, EMPTY_ITEM_STATS, "rom-elf").mana).toBe(
|
||||
86 * MANA_POOL_MULTIPLIER,
|
||||
98 * MANA_POOL_MULTIPLIER,
|
||||
);
|
||||
expect(generated.skills.find((ability) => ability.id === "rom-priest-general-1")?.costs).toEqual([
|
||||
{ resource: "mana", amount: 12, percentage: false },
|
||||
@@ -131,4 +131,17 @@ describe("generated RuneWaker player catalog", () => {
|
||||
{ resource: "mana", amount: 12, percentage: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses player growth tables for every released RoM class without exponential stat explosions", () => {
|
||||
for (const entry of generated.canonicalClasses) {
|
||||
const classId = entry.id as keyof typeof generated.baseStatGrowth;
|
||||
const growth = generated.baseStatGrowth[classId];
|
||||
expect(growth.strength.percentGrowth, classId).toBe(0.02);
|
||||
const stats = romNativeClassBaseStats(classId, 70);
|
||||
expect(stats.health, classId).toBeLessThan(10_000);
|
||||
expect(stats.strength, classId).toBeLessThan(2_000);
|
||||
}
|
||||
expect(generated.baseStatGrowth["rom-champion"].strength).toEqual({ levelOne: 14, flatGrowth: 5, percentGrowth: 0.02 });
|
||||
expect(generated.baseStatGrowth["rom-warlock"].intellect).toEqual({ levelOne: 14, flatGrowth: 5, percentGrowth: 0.02 });
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,7 @@ import { NodeIO } from "@gltf-transform/core";
|
||||
import RAPIER from "@dimforge/rapier3d-compat";
|
||||
import { ALL_EXTENSIONS } from "@gltf-transform/extensions";
|
||||
import { MeshoptDecoder } from "meshoptimizer";
|
||||
import { BufferAttribute, BufferGeometry, Group, Mesh } from "three";
|
||||
import { BufferAttribute, BufferGeometry, Group, Mesh, Ray, Vector3 } from "three";
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
import { requireDungeonDefinition } from "./dungeonRegistry";
|
||||
import {
|
||||
@@ -13,13 +13,17 @@ import {
|
||||
} from "./partyNavigation";
|
||||
import { preparePartyNavigationGraph } from "./partyNavigationAssetCache";
|
||||
import { shouldLoadPartyNavigation } from "./partyNavigationPolicy";
|
||||
import { planPartyRoute } from "./partyPathing";
|
||||
import { emptyPartyRouteState, planPartyRoute } from "./partyPathing";
|
||||
import { partyRejoinMovement } from "./partyRejoin";
|
||||
import { partyCombatMovementGoal, stepPartyPosition } from "./partyMovement";
|
||||
import { derivePartySpawnSlots } from "./partySpawnSlots";
|
||||
import { collisionTrimeshes, prepareCollision } from "../scene/productionDungeonCollision";
|
||||
import { createPartyDirectSegmentValidator, createPartyRouteSegmentValidator } from "../scene/partyTraversal";
|
||||
import { collisionTrimeshes, collisionLineOfSightTrimeshes, prepareCollision } from "../scene/productionDungeonCollision";
|
||||
import { DUNGEON_PHYSICAL_COLLISION_GROUPS, DUNGEON_SIGHT_BLOCKER_COLLISION_GROUPS, hasDungeonLineOfSight } from "../scene/dungeonLineOfSight";
|
||||
import { createPartyDirectSegmentValidator, createPartyRouteSegmentValidator, createPartyRecoveryPointValidator } from "../scene/partyTraversal";
|
||||
import { configurePlayerCharacterController, PLAYER_CHARACTER_CONTROLLER_OFFSET } from "./playerMovement";
|
||||
import { ACTOR_COLLISION_GROUPS, ACTOR_TERRAIN_QUERY_GROUPS } from "../scene/actorCollision";
|
||||
import { choosePartyStuckRecoveryPosition } from "./partyStuckRecovery";
|
||||
import { createMobPursuit } from "./mobPursuit";
|
||||
|
||||
const STOCKADES_NAVIGATION = "public/assets/game/manastorm/34-stormwindjail/navigation.glb";
|
||||
const TANK_RANGE = Object.freeze({ minimum: 0, preferred: 2.9, maximum: 3.15 });
|
||||
@@ -83,13 +87,20 @@ beforeAll(async () => {
|
||||
}
|
||||
});
|
||||
|
||||
function collisionWorld(useRamps = true): RAPIER.World {
|
||||
function collisionWorld(useRamps = true, includeSight = false): RAPIER.World {
|
||||
const world = new RAPIER.World({ x: 0, y: -18, z: 0 });
|
||||
const prepared = prepareCollision(sourceCollision, useRamps ? stockades.traversalRamps : []);
|
||||
for (const trimesh of collisionTrimeshes(prepared)) {
|
||||
world.createCollider(RAPIER.ColliderDesc.trimesh(
|
||||
Float32Array.from(trimesh.vertices), Uint32Array.from(trimesh.indices),
|
||||
));
|
||||
).setCollisionGroups(DUNGEON_PHYSICAL_COLLISION_GROUPS));
|
||||
}
|
||||
if (includeSight) {
|
||||
for (const trimesh of collisionLineOfSightTrimeshes(prepared)) {
|
||||
world.createCollider(RAPIER.ColliderDesc.trimesh(
|
||||
Float32Array.from(trimesh.vertices), Uint32Array.from(trimesh.indices),
|
||||
).setCollisionGroups(DUNGEON_SIGHT_BLOCKER_COLLISION_GROUPS).setSensor(true));
|
||||
}
|
||||
}
|
||||
for (const child of prepared.children) (child as Mesh).geometry.dispose();
|
||||
world.step();
|
||||
@@ -101,6 +112,127 @@ function distance(left: readonly number[], right: readonly number[]): number {
|
||||
}
|
||||
|
||||
describe("Stormwind Stockade party navigation", () => {
|
||||
it("returns resurrected teammates from the entrance and every boss checkpoint through approved shipping corridors", () => {
|
||||
const world = collisionWorld(true, true);
|
||||
try {
|
||||
const directSegmentAllowed = createPartyDirectSegmentValidator(RAPIER, world);
|
||||
const routeSegmentAllowed = createPartyRouteSegmentValidator(RAPIER, world);
|
||||
const checkpoints = [stockades.entrance, ...stockades.bosses.map((boss) => ({ footPosition: boss.position, yaw: stockades.entrance.yaw }))];
|
||||
for (const checkpoint of checkpoints) {
|
||||
const player = findNearestPartyNavigationSurface(graph, stockades.entrance.footPosition, 6)!.position;
|
||||
for (const slot of derivePartySpawnSlots(checkpoint, 5).slice(1)) {
|
||||
const origin = findNearestPartyNavigationSurface(graph, slot.footPosition, 6)?.position;
|
||||
expect(origin, JSON.stringify(slot)).toBeDefined();
|
||||
let result = partyRejoinMovement("returning-member", origin!, player, 0, graph, emptyPartyRouteState(), {
|
||||
directSegmentAllowed, routeSegmentAllowed,
|
||||
});
|
||||
expect(result.blocked, JSON.stringify({ checkpoint, origin, waypoints: result.route.waypoints })).toBe(false);
|
||||
expect(result.rejoined || result.waypoint !== null).toBe(true);
|
||||
for (let index = 1; index < result.route.waypoints.length; index += 1) {
|
||||
expect(routeSegmentAllowed(result.route.waypoints[index - 1], result.route.waypoints[index])).toBe(true);
|
||||
}
|
||||
const current: [number, number, number] = [...origin!];
|
||||
const next: [number, number, number] = [0, 0, 0];
|
||||
for (let frame = 1; frame <= 4_000 && !result.rejoined; frame += 1) {
|
||||
if (result.waypoint) {
|
||||
stepPartyPosition(current, result.waypoint, .175, next);
|
||||
current[0] = next[0]; current[1] = next[1]; current[2] = next[2];
|
||||
}
|
||||
result = partyRejoinMovement("returning-member", current, player, frame * 50, graph, result.route, {
|
||||
directSegmentAllowed, routeSegmentAllowed,
|
||||
});
|
||||
}
|
||||
expect(result.rejoined, JSON.stringify({ checkpoint, origin, current, result })).toBe(true);
|
||||
}
|
||||
}
|
||||
} finally { world.free(); }
|
||||
}, 60_000);
|
||||
|
||||
it("blocks combat sight across shipping room walls in both directions while preserving clear stair sight", () => {
|
||||
const world = collisionWorld(true, true);
|
||||
try {
|
||||
const prepared = prepareCollision(sourceCollision, stockades.traversalRamps);
|
||||
let checkedWalls = 0;
|
||||
for (const mesh of collisionLineOfSightTrimeshes(prepared)) {
|
||||
for (let offset = 0; offset < mesh.indices.length && checkedWalls < 12; offset += 3) {
|
||||
const a = Number(mesh.indices[offset]) * 3;
|
||||
const b = Number(mesh.indices[offset + 1]) * 3;
|
||||
const c = Number(mesh.indices[offset + 2]) * 3;
|
||||
const v = mesh.vertices;
|
||||
const ab = [Number(v[b]) - Number(v[a]), Number(v[b + 1]) - Number(v[a + 1]), Number(v[b + 2]) - Number(v[a + 2])];
|
||||
const ac = [Number(v[c]) - Number(v[a]), Number(v[c + 1]) - Number(v[a + 1]), Number(v[c + 2]) - Number(v[a + 2])];
|
||||
const normalX = ab[1] * ac[2] - ab[2] * ac[1];
|
||||
const normalZ = ab[0] * ac[1] - ab[1] * ac[0];
|
||||
const length = Math.hypot(normalX, normalZ);
|
||||
if (length < 0.1) continue;
|
||||
const center = [0, 1, 2].map((axis) => (Number(v[a + axis]) + Number(v[b + axis]) + Number(v[c + axis])) / 3);
|
||||
const first = findNearestPartyNavigationSurface(graph,
|
||||
[center[0] + normalX / length, center[1] - 1.05, center[2] + normalZ / length], 2.5)?.position;
|
||||
const second = findNearestPartyNavigationSurface(graph,
|
||||
[center[0] - normalX / length, center[1] - 1.05, center[2] - normalZ / length], 2.5)?.position;
|
||||
if (!first || !second || Math.abs(first[1] - second[1]) > 0.4 || distance(first, second) < 1.5
|
||||
|| Math.abs((first[1] + second[1]) / 2 + 1.05 - center[1]) > 0.15
|
||||
|| Math.hypot(first[0] - center[0], first[2] - center[2]) > 1.3
|
||||
|| Math.hypot(second[0] - center[0], second[2] - center[2]) > 1.3) continue;
|
||||
const sideA = (first[0] - center[0]) * normalX + (first[2] - center[2]) * normalZ;
|
||||
const sideB = (second[0] - center[0]) * normalX + (second[2] - center[2]) * normalZ;
|
||||
if (sideA * sideB >= 0) continue;
|
||||
// Nearby decorative faces/door-frame triangles are not room walls
|
||||
// unless the actors' chest ray actually crosses this source face.
|
||||
const origin = new Vector3(first[0], first[1] + 1.05, first[2]);
|
||||
const destination = new Vector3(second[0], second[1] + 1.05, second[2]);
|
||||
const ray = new Ray(origin, destination.clone().sub(origin).normalize());
|
||||
const intersection = ray.intersectTriangle(
|
||||
new Vector3(Number(v[a]), Number(v[a + 1]), Number(v[a + 2])),
|
||||
new Vector3(Number(v[b]), Number(v[b + 1]), Number(v[b + 2])),
|
||||
new Vector3(Number(v[c]), Number(v[c + 1]), Number(v[c + 2])), false, new Vector3(),
|
||||
);
|
||||
if (!intersection || intersection.distanceTo(origin) >= destination.distanceTo(origin)) continue;
|
||||
expect(hasDungeonLineOfSight(RAPIER, world, first, second), JSON.stringify({ center, first, second })).toBe(false);
|
||||
expect(hasDungeonLineOfSight(RAPIER, world, second, first), JSON.stringify({ center, first, second })).toBe(false);
|
||||
checkedWalls += 1;
|
||||
}
|
||||
}
|
||||
for (const child of prepared.children) (child as Mesh).geometry.dispose();
|
||||
expect(checkedWalls).toBeGreaterThanOrEqual(8);
|
||||
for (const ramp of stockades.traversalRamps ?? []) {
|
||||
expect(hasDungeonLineOfSight(RAPIER, world, ramp.start, ramp.end)).toBe(true);
|
||||
expect(hasDungeonLineOfSight(RAPIER, world, ramp.end, ramp.start)).toBe(true);
|
||||
}
|
||||
} finally { world.free(); }
|
||||
}, 20_000);
|
||||
|
||||
it("moves pursuing mobs up and down all shipping ramps without a false teleport or reset", () => {
|
||||
const world = collisionWorld(true, true);
|
||||
try {
|
||||
const directAllowed = createPartyDirectSegmentValidator(RAPIER, world);
|
||||
const routeAllowed = createPartyRouteSegmentValidator(RAPIER, world);
|
||||
const navigator = createMobPursuit({ graph: () => graph, directAllowed, routeAllowed,
|
||||
hasLineOfSight: (a, b) => hasDungeonLineOfSight(RAPIER, world, a, b),
|
||||
validateLanding: createPartyRecoveryPointValidator(RAPIER, world, graph) });
|
||||
for (const ramp of stockades.traversalRamps ?? []) {
|
||||
for (const [start, target] of [[ramp.start, ramp.end], [ramp.end, ramp.start]]) {
|
||||
const initialRoute = planPartyRoute(graph, "probe", start, target, 0, {
|
||||
maxOriginSnapDistance: 3, maxDestinationSnapDistance: 3,
|
||||
directSegmentAllowed: directAllowed, routeSegmentAllowed: routeAllowed,
|
||||
destinationReached: (surface, destination) => distance(surface, destination) <= 1.5
|
||||
&& hasDungeonLineOfSight(RAPIER, world, surface, destination),
|
||||
});
|
||||
expect(initialRoute.blocked, JSON.stringify({ ramp: ramp.id, waypoints: initialRoute.waypoints })).toBe(false);
|
||||
const position: [number, number, number] = [...start];
|
||||
for (let frame = 0; frame < 500 && distance(position, target) > 1.5; frame += 1) {
|
||||
const before = [...position];
|
||||
expect(navigator.step({ id: `${ramp.id}:${start[1]}`, targetId: "target", position,
|
||||
target, home: start, phase: "chasing", health: 100, engaged: true, active: true,
|
||||
casting: false, controlled: false, attackRange: 1.5, leashRange: 40,
|
||||
maximumStep: 0.175, nowMs: frame * 50 }), JSON.stringify({ ramp: ramp.id, start, target, before, frame })).toBe("none");
|
||||
}
|
||||
expect(distance(position, target)).toBeLessThanOrEqual(1.5);
|
||||
}
|
||||
}
|
||||
} finally { world.free(); }
|
||||
});
|
||||
|
||||
it("uses the reviewed warm indoor lighting profile", () => {
|
||||
expect(stockades.presentation).toMatchObject({
|
||||
background: "#171815",
|
||||
@@ -310,6 +442,42 @@ describe("Stormwind Stockade party navigation", () => {
|
||||
world.free();
|
||||
});
|
||||
|
||||
it("finds grounded recovery landings on all three stair ramps", () => {
|
||||
const world = collisionWorld();
|
||||
const allowed = createPartyRecoveryPointValidator(RAPIER, world, graph);
|
||||
for (const ramp of stockades.traversalRamps!) {
|
||||
const point = (t: number): [number, number, number] => [
|
||||
ramp.start[0] + (ramp.end[0] - ramp.start[0]) * t,
|
||||
ramp.start[1] + (ramp.end[1] - ramp.start[1]) * t + 0.05,
|
||||
ramp.start[2] + (ramp.end[2] - ramp.start[2]) * t,
|
||||
];
|
||||
expect(allowed(point(0.5), point(0.52)), ramp.id).not.toBeNull();
|
||||
}
|
||||
world.free();
|
||||
});
|
||||
|
||||
it("recovers from boss-room approaches onto a validated visited trail near the group", () => {
|
||||
const world = collisionWorld();
|
||||
const allowed = createPartyRecoveryPointValidator(RAPIER, world, graph);
|
||||
const routeAllowed = createPartyRouteSegmentValidator(RAPIER, world);
|
||||
const entrance = findNearestPartyNavigationSurface(graph, stockades.entrance.footPosition, 6)!.position;
|
||||
for (const objective of stockades.objectiveOrder) {
|
||||
const current = findNearestPartyNavigationSurface(graph, objective.position, 15)!.position;
|
||||
const path = findPartyNavigationPath(graph, entrance, current, { routeSegmentAllowed: routeAllowed })!;
|
||||
const trail = path.slice(1).map((point, index): [number, number, number] => [
|
||||
(path[index][0] + point[0]) / 2, (path[index][1] + point[1]) / 2, (path[index][2] + point[2]) / 2,
|
||||
]).filter((point) => distance(point, current) >= 2 && distance(point, current) <= 12
|
||||
&& Math.abs(point[1] - current[1]) <= 1.25 && allowed(point, point));
|
||||
expect(trail.length, objective.name).toBeGreaterThan(0);
|
||||
const anchor = trail.at(-1)!;
|
||||
const landing = choosePartyStuckRecoveryPosition({ current, player: entrance, groupAnchor: anchor,
|
||||
trailPoints: trail, validate: allowed });
|
||||
expect(landing?.source, objective.name).toBe("trail");
|
||||
expect(allowed(landing!.position, anchor), objective.name).not.toBeNull();
|
||||
}
|
||||
world.free();
|
||||
});
|
||||
|
||||
it("walks the player capsule down and back up every ramp without jumping", () => {
|
||||
for (const ramp of stockades.traversalRamps!) {
|
||||
const world = collisionWorld();
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@ import type { GmReviewTarget } from "./gmContentReview";
|
||||
export type AssetStatus = "checking" | "loading" | "ready" | "proxy" | "error";
|
||||
export type InputMode = "keyboard" | "gamepad";
|
||||
export type GameMode = "dungeon" | "manastorm";
|
||||
export type GameOverlay = "pause" | "party" | "inventory" | "map" | "boss-guide" | "spellbook" | "talents" | "bindings" | "options" | "ui-layout" | "keybinds" | "gm" | null;
|
||||
export type GameOverlay = "pause" | "report-issue" | "party" | "inventory" | "map" | "boss-guide" | "spellbook" | "talents" | "bindings" | "options" | "ui-layout" | "keybinds" | "gm" | null;
|
||||
|
||||
interface PlayerSnapshot {
|
||||
position: Vector3Tuple;
|
||||
|
||||
@@ -1,10 +1,31 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
actionKeyForEvent, bindDesktopSlot, defaultUiLayout, desktopSlotForKey,
|
||||
exportUiPreferences, importUiPreferences, normalizeActionKey, normalizeUiPreferences,
|
||||
exportUiPreferences, importUiPreferences, normalizeActionKey, normalizeUiPreferences, resolveDpsMeterDisplay,
|
||||
} from "./uiLayout";
|
||||
|
||||
describe("personal UI layouts", () => {
|
||||
it("defaults DPS to PC lower-left flow and the Thor companion, with saved overrides", () => {
|
||||
const ui = normalizeUiPreferences(null);
|
||||
expect(ui.current.pc.elements.dps).toEqual({ visible: true, position: null, width: null });
|
||||
expect(resolveDpsMeterDisplay(ui.current.pc, false)).toBe("main");
|
||||
expect(resolveDpsMeterDisplay(ui.current.mobile, true)).toBe("companion");
|
||||
expect(resolveDpsMeterDisplay(ui.current.pc, true)).toBe("companion");
|
||||
ui.current.mobile.dpsMeterDisplay = "main";
|
||||
ui.current.mobile.elements.dps = { visible: false, position: { x: .6, y: .7 }, width: 300 };
|
||||
expect(importUiPreferences(exportUiPreferences(ui))).toEqual(ui);
|
||||
expect(resolveDpsMeterDisplay(ui.current.mobile, true)).toBe("main");
|
||||
});
|
||||
it("imports older UI templates without a DPS meter", () => {
|
||||
const exported = JSON.parse(exportUiPreferences(normalizeUiPreferences(null)));
|
||||
for (const device of ["pc", "mobile"]) {
|
||||
delete exported.current[device].elements.dps;
|
||||
delete exported.current[device].dpsMeterDisplay;
|
||||
}
|
||||
expect(importUiPreferences(JSON.stringify(exported)).current.pc.elements.dps.visible).toBe(true);
|
||||
exported.current.pc.dpsMeterDisplay = "invalid";
|
||||
expect(() => importUiPreferences(JSON.stringify(exported))).toThrow("DPS meter display");
|
||||
});
|
||||
it("migrates old accounts to independent Mobile and PC layouts with exactly six empty slots", () => {
|
||||
const ui = normalizeUiPreferences(undefined);
|
||||
expect(ui.slots.mobile).toEqual([null, null, null]);
|
||||
|
||||
+13
-2
@@ -7,7 +7,7 @@ export const BAR_COLUMNS = [1, 2, 3, 4, 6, 12] as const;
|
||||
export const HUD_ELEMENTS = {
|
||||
party: "Party frames", summons: "Pet and summon frames", minimap: "Minimap",
|
||||
player: "Player status", timeline: "Encounter timeline", target: "Enemy target",
|
||||
friendly: "Friendly target", threat: "Threat meter", cast: "Cast bar",
|
||||
friendly: "Friendly target", threat: "Threat meter", dps: "DPS meter", cast: "Cast bar",
|
||||
hints: "Control hints", actions: "Controller action bar", experience: "Experience bar",
|
||||
reticle: "Crosshair", loot: "Loot notifications", interaction: "Interaction prompt",
|
||||
manastorm: "Manastorm status", completion: "Dungeon completion", assets: "Asset notice",
|
||||
@@ -25,6 +25,7 @@ export interface DesktopActionSlot { abilityId: string | null; key: string | nul
|
||||
export interface DesktopActionBar { columns: number; slots: DesktopActionSlot[] }
|
||||
export interface UiLayout {
|
||||
elements: Record<HudElementId, HudElementLayout>;
|
||||
dpsMeterDisplay: "auto" | "main" | "companion";
|
||||
wowBars: boolean;
|
||||
barCount: number;
|
||||
bars: DesktopActionBar[];
|
||||
@@ -42,6 +43,7 @@ export function defaultUiLayout(): UiLayout {
|
||||
elements: Object.fromEntries(Object.keys(HUD_ELEMENTS).map((id) => [id, {
|
||||
visible: true, position: null, width: null,
|
||||
}])) as UiLayout["elements"],
|
||||
dpsMeterDisplay: "auto",
|
||||
wowBars: false,
|
||||
barCount: 1,
|
||||
bars: Array.from({ length: 5 }, (_, bar) => ({
|
||||
@@ -106,7 +108,13 @@ export function normalizeUiLayout(value: unknown): UiLayout {
|
||||
}),
|
||||
};
|
||||
});
|
||||
return { ...defaults, wowBars: source.wowBars === true, barCount: Math.floor(bounded(source.barCount, 1, 1, 5)) };
|
||||
return { ...defaults,
|
||||
dpsMeterDisplay: source.dpsMeterDisplay === "main" || source.dpsMeterDisplay === "companion" ? source.dpsMeterDisplay : "auto",
|
||||
wowBars: source.wowBars === true, barCount: Math.floor(bounded(source.barCount, 1, 1, 5)) };
|
||||
}
|
||||
|
||||
export function resolveDpsMeterDisplay(layout: UiLayout, dualScreen: boolean): "main" | "companion" {
|
||||
return layout.dpsMeterDisplay === "auto" ? dualScreen ? "companion" : "main" : layout.dpsMeterDisplay;
|
||||
}
|
||||
export function normalizeUiPreferences(value: unknown): UiPreferences {
|
||||
const source = record(value);
|
||||
@@ -146,9 +154,12 @@ export function importUiPreferences(text: string): UiPreferences {
|
||||
if (source.format !== "healer-man-ui" || source.version !== 1) throw new Error("Unsupported UI file format or version.");
|
||||
const validateLayout = (value: unknown) => {
|
||||
const layout = record(value);
|
||||
if (layout.dpsMeterDisplay !== undefined && !["auto", "main", "companion"].includes(String(layout.dpsMeterDisplay))) throw new Error("Invalid DPS meter display.");
|
||||
if (typeof layout.wowBars !== "boolean" || !Number.isInteger(layout.barCount) || Number(layout.barCount) < 1 || Number(layout.barCount) > 5
|
||||
|| !Array.isArray(layout.bars) || layout.bars.length !== 5) throw new Error("Invalid action bar configuration.");
|
||||
for (const id of Object.keys(HUD_ELEMENTS)) {
|
||||
// Version-one exports made before the DPS meter inherit its defaults.
|
||||
if (id === "dps" && record(layout.elements)[id] === undefined) continue;
|
||||
const element = record(record(layout.elements)[id]);
|
||||
const position = record(element.position);
|
||||
if (typeof element.visible !== "boolean" || (element.position !== null && [position.x, position.y].some((v) => typeof v !== "number" || !Number.isFinite(v) || v < 0 || v > 1))
|
||||
|
||||
@@ -6,6 +6,7 @@ import { startControllerInput } from "./input/controller";
|
||||
import { currentDisplayLayout } from "./platform/displayLayout";
|
||||
import { initializeContentSystem } from "./content/contentManager";
|
||||
import "./styles.css";
|
||||
import "./ui/dungeonReports.css";
|
||||
|
||||
const BottomDisplayApp = lazy(() => import("./platform/BottomDisplayApp").then((module) => ({ default: module.BottomDisplayApp })));
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@ import { useShellStore } from "../app/shellStore";
|
||||
import { emitControllerToken, setExternalControllerMovement } from "../input/controller";
|
||||
import { currentCompanionSnapshot, executeCompanionCommand, type DualScreenMessage } from "./dualScreenSync";
|
||||
import { createRateLimitedPublisher } from "./rateLimitedPublisher";
|
||||
import { useUiLayoutStore } from "../game/uiLayoutStore";
|
||||
import { useDpsMeterStore } from "../game/dpsMeterStore";
|
||||
|
||||
export function connectAuthoritativeDualScreen(
|
||||
channel: BroadcastChannel,
|
||||
@@ -30,6 +32,12 @@ export function connectAuthoritativeDualScreen(
|
||||
const unsubscribeParty = usePartyStore.subscribe(() => {
|
||||
if (companionReady) gamePublisher.request();
|
||||
});
|
||||
const unsubscribeUi = useUiLayoutStore.subscribe(() => {
|
||||
if (companionReady) gamePublisher.request();
|
||||
});
|
||||
const unsubscribeDps = useDpsMeterStore.subscribe(() => {
|
||||
if (companionReady) gamePublisher.request();
|
||||
});
|
||||
channel.onmessage = (event: MessageEvent<DualScreenMessage>) => {
|
||||
if (event.data.type === "companion-ready") {
|
||||
companionReady = true;
|
||||
@@ -52,7 +60,8 @@ export function connectAuthoritativeDualScreen(
|
||||
unsubscribeGame();
|
||||
unsubscribeCombat();
|
||||
unsubscribeParty();
|
||||
unsubscribeUi();
|
||||
unsubscribeDps();
|
||||
gamePublisher.dispose();
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { connectAuthoritativeDualScreen } from "./authoritativeDualScreenRuntime";
|
||||
import { useUiLayoutStore } from "../game/uiLayoutStore";
|
||||
import { useDpsMeterStore } from "../game/dpsMeterStore";
|
||||
import { emptyDpsMeter } from "../game/dpsMeter";
|
||||
import type { DualScreenMessage } from "./dualScreenSync";
|
||||
|
||||
describe("dedicated companion DPS synchronization", () => {
|
||||
afterEach(() => {
|
||||
useUiLayoutStore.getState().finish(false);
|
||||
useDpsMeterStore.setState(emptyDpsMeter(), true);
|
||||
vi.unstubAllGlobals();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("publishes meter ticks and layout drafts, then unsubscribes on disconnect", () => {
|
||||
vi.useFakeTimers(); vi.setSystemTime(1_000);
|
||||
vi.stubGlobal("window", { setTimeout, clearTimeout, location: { search: "?display=top" } });
|
||||
const postMessage = vi.fn<(message: DualScreenMessage) => void>();
|
||||
const channel = { postMessage, onmessage: null } as unknown as BroadcastChannel;
|
||||
const disconnect = connectAuthoritativeDualScreen(channel, { data: { type: "companion-ready" } } as MessageEvent<DualScreenMessage>);
|
||||
try {
|
||||
useUiLayoutStore.getState().begin();
|
||||
useUiLayoutStore.getState().updateDraft((layout) => ({ ...layout, dpsMeterDisplay: "companion", elements: {
|
||||
...layout.elements, dps: { visible: false, position: { x: .4, y: .6 }, width: 300 },
|
||||
} }));
|
||||
vi.advanceTimersByTime(100);
|
||||
let latest = postMessage.mock.lastCall?.[0];
|
||||
expect(latest?.type).toBe("app-state");
|
||||
if (latest?.type !== "app-state") throw new Error("Missing companion snapshot");
|
||||
expect(latest.snapshot.game.dpsMeter).toMatchObject({ display: "companion", layout: {
|
||||
visible: false, position: { x: .4, y: .6 }, width: 300,
|
||||
} });
|
||||
|
||||
useDpsMeterStore.setState({ startedAt: 1_000, endedAt: null, updatedAt: 3_000, elapsedMs: 2_000,
|
||||
damage: { test: { id: "test", name: "Test", color: "#fff", total: 200 } } }, true);
|
||||
vi.advanceTimersByTime(100);
|
||||
latest = postMessage.mock.lastCall?.[0];
|
||||
if (latest?.type !== "app-state") throw new Error("Missing DPS snapshot");
|
||||
expect(latest.snapshot.game.dpsMeter.values).toMatchObject({ totalDamage: 200, totalDps: 100, durationSeconds: 2 });
|
||||
} finally { disconnect(); }
|
||||
postMessage.mockClear();
|
||||
useUiLayoutStore.getState().finish(false);
|
||||
useDpsMeterStore.setState(emptyDpsMeter(), true);
|
||||
vi.advanceTimersByTime(100);
|
||||
expect(postMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -26,6 +26,12 @@ import { abilityAtLevel, resourceProfileForClass, type AbilityTarget } from "../
|
||||
import type { DungeonId } from "../game/dungeonCatalog";
|
||||
import { useManastormStore } from "../game/manastormStore";
|
||||
import { resolveStagePresentation } from "../game/manastormStagePresentation";
|
||||
import { currentDpsMeterSnapshot } from "../game/dpsMeterStore";
|
||||
import type { DpsMeterSnapshot } from "../game/dpsMeter";
|
||||
import { activeUiLayout, useUiLayoutStore } from "../game/uiLayoutStore";
|
||||
import { normalizeHudPosition } from "../game/hudLayout";
|
||||
import { resolveDpsMeterDisplay, type HudElementLayout } from "../game/uiLayout";
|
||||
import { currentDisplayLayout } from "./displayLayout";
|
||||
|
||||
export { createDualScreenChannel } from "./dualScreenChannel";
|
||||
|
||||
@@ -89,6 +95,12 @@ export interface CompanionPartyObjectiveSnapshot {
|
||||
}
|
||||
|
||||
export interface CompanionGameSnapshot {
|
||||
dpsMeter: {
|
||||
values: DpsMeterSnapshot;
|
||||
layout: HudElementLayout;
|
||||
display: "main" | "companion";
|
||||
editing: boolean;
|
||||
};
|
||||
gameMode: GameMode;
|
||||
dungeonId: string;
|
||||
dungeonTitle: string;
|
||||
@@ -159,7 +171,8 @@ export type CompanionCommand =
|
||||
| { name: "cyclePartyMember"; direction: 1 | -1 }
|
||||
| { name: "setPartyCommand"; command: PartyCommand }
|
||||
| { name: "recallParty" }
|
||||
| { name: "resetAtEntrance" };
|
||||
| { name: "resetAtEntrance" }
|
||||
| { name: "editDpsMeter"; update: Partial<HudElementLayout> };
|
||||
|
||||
export type DualScreenMessage =
|
||||
| { type: "companion-ready" }
|
||||
@@ -180,6 +193,7 @@ export function currentCompanionSnapshot(): CompanionSnapshot {
|
||||
const combat = useCombatStore.getState();
|
||||
const party = usePartyStore.getState();
|
||||
const manastorm = useManastormStore.getState();
|
||||
const uiLayout = activeUiLayout();
|
||||
const dungeon = requireDungeonDefinition(game.activeDungeonId);
|
||||
const stagePresentation = resolveStagePresentation(game.gameMode, manastorm.currentEncounter, {
|
||||
title: dungeon.title,
|
||||
@@ -251,6 +265,12 @@ export function currentCompanionSnapshot(): CompanionSnapshot {
|
||||
draft: shell.draft,
|
||||
notice: shell.notice,
|
||||
game: {
|
||||
dpsMeter: {
|
||||
values: currentDpsMeterSnapshot(),
|
||||
layout: uiLayout.elements.dps,
|
||||
display: resolveDpsMeterDisplay(uiLayout, typeof window !== "undefined" && currentDisplayLayout() !== "single"),
|
||||
editing: game.overlay === "ui-layout",
|
||||
},
|
||||
gameMode: game.gameMode,
|
||||
dungeonId: dungeon.id,
|
||||
dungeonTitle: stagePresentation.title,
|
||||
@@ -322,6 +342,17 @@ export function executeCompanionCommand(command: CompanionCommand): void {
|
||||
const game = useGameStore.getState();
|
||||
const combat = useCombatStore.getState();
|
||||
switch (command.name) {
|
||||
case "editDpsMeter": {
|
||||
if (game.overlay !== "ui-layout" || !useUiLayoutStore.getState().draft) break;
|
||||
const update: Partial<HudElementLayout> = {};
|
||||
if (command.update.position) update.position = normalizeHudPosition(command.update.position, { x: .5, y: .5 });
|
||||
if (typeof command.update.width === "number" && Number.isFinite(command.update.width)) update.width = Math.round(Math.max(160, Math.min(600, command.update.width)));
|
||||
useUiLayoutStore.getState().updateDraft((layout) => ({ ...layout, elements: {
|
||||
...layout.elements, dps: { ...layout.elements.dps, ...update },
|
||||
} }));
|
||||
useUiLayoutStore.setState({ selectedElement: "dps" });
|
||||
break;
|
||||
}
|
||||
case "selectCharacter": shell.selectCharacter(command.characterId); break;
|
||||
case "openCreator": shell.openCreator(); break;
|
||||
case "cancelCreator": shell.cancelCreator(); break;
|
||||
|
||||
+43
-34
@@ -27,7 +27,7 @@ import {
|
||||
sampleMobAnimation,
|
||||
} from "../game/mobAnimation";
|
||||
import type { MobAnimationState } from "../game/mobAnimation";
|
||||
import { useCombatStore } from "../game/combatStore";
|
||||
import { useCombatStore, type MobCombatState } from "../game/combatStore";
|
||||
import { PRIMARY_MOUSE_BUTTON } from "../game/inputManager";
|
||||
import { TimedEffectStrip } from "../ui/TimedEffectStrip";
|
||||
import { AuraStrip } from "../ui/AuraStrip";
|
||||
@@ -76,9 +76,10 @@ import {
|
||||
resolveMobAnimationInterval,
|
||||
} from "./mobAnimationCadence";
|
||||
import { useGameGLTF } from "./useGameGLTF";
|
||||
import { onlineSynchronizedMobTransform, requestOnlineInteraction } from "../game/onlineSessionRuntime";
|
||||
import { onlineSessionOwnsWorldSimulation, onlineSynchronizedMobTransform, requestOnlineInteraction } from "../game/onlineSessionRuntime";
|
||||
import { isMobAggroRangeVisible } from "./mobAggroRangeVisibility";
|
||||
import { DungeonOccludedHtml } from "./DungeonOccludedHtml";
|
||||
import { useMobNavigation } from "./mobNavigation";
|
||||
|
||||
const MOVEMENT_LOCKING_MOB_STATUSES: ReadonlySet<string> = new Set([
|
||||
"stun", "root", "sleep", "knockdown", "freeze", "charm", "confuse",
|
||||
@@ -119,8 +120,32 @@ function mobTargetPosition(
|
||||
playerPosition: MobVector3,
|
||||
): MobVector3 {
|
||||
if (!targetActorId || targetActorId === PLAYER_AGGRO_ID) return playerPosition;
|
||||
const summon = useCombatStore.getState().summons.find((candidate) => candidate.id === targetActorId);
|
||||
if (summon) return summon.position;
|
||||
return getPartyRuntimePosition(targetActorId) ?? playerPosition;
|
||||
}
|
||||
|
||||
function navigateMob(
|
||||
navigator: ReturnType<typeof useMobNavigation>, mob: MobCombatState,
|
||||
position: MutableMobVector3, target: MobVector3, home: MobVector3,
|
||||
maximumStep: number, active: boolean, nowMs: number, controlled = false,
|
||||
): void {
|
||||
const game = useGameStore.getState();
|
||||
const hostileRanges = mob.attacks.filter((attack) => attack.target === "primary"
|
||||
|| attack.target === "random-party" || attack.target === "nearby-party").map((attack) => attack.range);
|
||||
const result = navigator.step({
|
||||
id: mob.id, targetId: mob.targetActorId, position, target, home,
|
||||
phase: mob.combatPhase, health: mob.health, engaged: mob.engaged,
|
||||
active: active && onlineSessionOwnsWorldSimulation() && !game.paused && !game.mapOpen && !game.companionOpen,
|
||||
casting: mob.activeCast !== null, controlled,
|
||||
attackRange: hostileRanges.length ? Math.max(...hostileRanges) : mob.attackRange,
|
||||
leashRange: mob.leashRange, maximumStep, nowMs,
|
||||
});
|
||||
if (result === "reset" || result === "home") {
|
||||
useCombatStore.getState().resetMobForNavigation(mob.id,
|
||||
result === "home" || Math.hypot(position[0] - home[0], position[1] - home[1], position[2] - home[2]) <= 0.85, nowMs);
|
||||
}
|
||||
}
|
||||
const PROXIMITY_CHECK_INTERVAL = 0.3;
|
||||
const AGGRO_RANGE_VISIBILITY_CHECK_INTERVAL = 0.25;
|
||||
let preparedCreatureTemplates = new WeakMap<Object3D, Object3D>();
|
||||
@@ -771,23 +796,6 @@ function isPlayerOrPartyNear(points: readonly MobVector3[], maxDistance: number)
|
||||
});
|
||||
}
|
||||
|
||||
function stepToward(
|
||||
current: MutableMobVector3,
|
||||
target: MobVector3,
|
||||
maximumStep: number,
|
||||
): number {
|
||||
const dx = target[0] - current[0];
|
||||
const dy = target[1] - current[1];
|
||||
const dz = target[2] - current[2];
|
||||
const distance = Math.hypot(dx, dy, dz);
|
||||
if (distance <= 0.001) return 0;
|
||||
const step = Math.min(Math.max(0, maximumStep), distance);
|
||||
current[0] += dx / distance * step;
|
||||
current[1] += dy / distance * step;
|
||||
current[2] += dz / distance * step;
|
||||
return distance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Activates content when the player or an autonomous party member approaches.
|
||||
* The stores are read
|
||||
@@ -1159,6 +1167,7 @@ function RoamingPack({
|
||||
useOriginalModels,
|
||||
allowedRuntimeIds,
|
||||
}: RoamingPackProps) {
|
||||
const navigator = useMobNavigation();
|
||||
const path = useMemo(() => createLoopedPath(definition.waypoints), [definition.waypoints]);
|
||||
const distanceRef = useRef(definition.startDistance ?? 0);
|
||||
const { scratchPositions, scratchForwards, chasePositions } = useMemo(() => {
|
||||
@@ -1277,7 +1286,7 @@ function RoamingPack({
|
||||
const maximumStep = Math.min(delta, 0.05)
|
||||
* mob!.moveSpeed
|
||||
* Math.max(0.1, 1 - slow);
|
||||
if (!movementLocked && feared) {
|
||||
if (active && !movementLocked && feared) {
|
||||
stepFearedMobAway(
|
||||
chasePosition,
|
||||
targetPosition,
|
||||
@@ -1286,19 +1295,16 @@ function RoamingPack({
|
||||
mob!.leashRange,
|
||||
chasePosition,
|
||||
);
|
||||
} else if (!movementLocked && mob!.combatPhase === "chasing") {
|
||||
stepToward(
|
||||
chasePosition,
|
||||
targetPosition,
|
||||
maximumStep,
|
||||
);
|
||||
}
|
||||
navigateMob(navigator, mob!, chasePosition, targetPosition,
|
||||
mob!.homePosition ?? patrolPosition, maximumStep, active, now, movementLocked || feared);
|
||||
position = chasePosition;
|
||||
const facingSign = feared && !movementLocked ? -1 : 1;
|
||||
forward[0] = (targetPosition[0] - position[0]) * facingSign;
|
||||
forward[2] = (targetPosition[2] - position[2]) * facingSign;
|
||||
} else if (mob?.combatPhase === "returning") {
|
||||
stepToward(chasePosition, patrolPosition, Math.min(delta, 0.05) * mob.moveSpeed * 1.25);
|
||||
navigateMob(navigator, mob, chasePosition, targetPosition,
|
||||
mob.homePosition ?? patrolPosition, Math.min(delta, 0.05) * mob.moveSpeed * 1.25, active, now);
|
||||
position = chasePosition;
|
||||
} else {
|
||||
chasePosition[0] = patrolPosition[0];
|
||||
@@ -1360,6 +1366,7 @@ interface StaticPopulationProps {
|
||||
}
|
||||
|
||||
function StaticPopulationMember({ spawn, entities, active, showLabels, useOriginalModels }: StaticPopulationProps) {
|
||||
const navigator = useMobNavigation();
|
||||
const entity = entities[spawn.entityId];
|
||||
const groupRef = useRef<Group>(null);
|
||||
const positionRef = useRef<MutableMobVector3>([...spawn.position]);
|
||||
@@ -1398,7 +1405,7 @@ function StaticPopulationMember({ spawn, entities, active, showLabels, useOrigin
|
||||
feared = mob.statuses.some((status) => status.endsAt > now && status.kind === "fear");
|
||||
const slow = mob.statuses.find((status) => status.endsAt > now && status.kind === "slow")?.magnitude ?? 0;
|
||||
const maximumStep = Math.min(delta, 0.05) * mob.moveSpeed * Math.max(0.1, 1 - slow);
|
||||
if (!movementLocked && feared) {
|
||||
if (active && !movementLocked && feared) {
|
||||
stepFearedMobAway(
|
||||
position,
|
||||
targetPosition,
|
||||
@@ -1407,11 +1414,12 @@ function StaticPopulationMember({ spawn, entities, active, showLabels, useOrigin
|
||||
mob.leashRange,
|
||||
position,
|
||||
);
|
||||
} else if (!movementLocked && mob.combatPhase === "chasing") {
|
||||
stepToward(position, targetPosition, maximumStep);
|
||||
}
|
||||
navigateMob(navigator, mob, position, targetPosition, mob.homePosition ?? spawn.position,
|
||||
maximumStep, active, now, movementLocked || feared);
|
||||
} else if (mob?.combatPhase === "returning") {
|
||||
stepToward(position, spawn.position, Math.min(delta, 0.05) * mob.moveSpeed * 1.25);
|
||||
navigateMob(navigator, mob, position, targetPosition, mob.homePosition ?? spawn.position,
|
||||
Math.min(delta, 0.05) * mob.moveSpeed * 1.25, active, now);
|
||||
} else {
|
||||
position[0] = spawn.position[0];
|
||||
position[1] = spawn.position[1];
|
||||
@@ -1500,6 +1508,7 @@ function SummonedPopulationMember({
|
||||
readonly active: boolean;
|
||||
readonly useOriginalModels: boolean;
|
||||
}) {
|
||||
const navigator = useMobNavigation();
|
||||
const groupRef = useRef<Group>(null);
|
||||
const initial = getMobRuntimeTransform(instanceId)?.position ?? [0, 0, 0];
|
||||
const positionRef = useRef<MutableMobVector3>([...initial]);
|
||||
@@ -1515,7 +1524,7 @@ function SummonedPopulationMember({
|
||||
const feared = mob.statuses.some((status) => status.endsAt > now && status.kind === "fear");
|
||||
const slow = mob.statuses.find((status) => status.endsAt > now && status.kind === "slow")?.magnitude ?? 0;
|
||||
const maximumStep = Math.min(delta, 0.05) * mob.moveSpeed * Math.max(0.1, 1 - slow);
|
||||
if (!movementLocked && feared) {
|
||||
if (active && !movementLocked && feared) {
|
||||
stepFearedMobAway(
|
||||
position,
|
||||
targetPosition,
|
||||
@@ -1524,9 +1533,9 @@ function SummonedPopulationMember({
|
||||
mob.leashRange,
|
||||
position,
|
||||
);
|
||||
} else if (!movementLocked && mob.combatPhase === "chasing") {
|
||||
stepToward(position, targetPosition, maximumStep);
|
||||
}
|
||||
navigateMob(navigator, mob, position, targetPosition, mob.homePosition ?? initial,
|
||||
maximumStep, active, now, movementLocked || feared);
|
||||
const facingSign = feared && !movementLocked ? -1 : 1;
|
||||
const yaw = Math.atan2(
|
||||
(targetPosition[0] - position[0]) * facingSign,
|
||||
|
||||
+136
-44
@@ -33,12 +33,10 @@ import {
|
||||
createPartyBreadcrumbTrail,
|
||||
nextPartyBreadcrumbFollowWaypoint,
|
||||
partyFormationPosition,
|
||||
partyMadeStuckProgress,
|
||||
partyMovementProfile,
|
||||
partyNaturalFollowPosition,
|
||||
partyPersonalizedCombatMovementGoal,
|
||||
partyPresentationY,
|
||||
partyShouldRecallForStuck,
|
||||
partyShouldMoveToComfortTarget,
|
||||
personalizedPartyCombatRangeBand,
|
||||
resetPartyBreadcrumbTrail,
|
||||
@@ -47,6 +45,15 @@ import {
|
||||
type PartyBreadcrumbCursor,
|
||||
type PartyMovementProfile,
|
||||
} from "../game/partyMovement";
|
||||
import {
|
||||
applyPartyStuckRecoveryLanding,
|
||||
choosePartyStuckRecoveryPosition,
|
||||
createPartyStuckRecoveryState,
|
||||
markPartyStuckRecovered,
|
||||
resetPartyStuckRecoveryProgress,
|
||||
updatePartyStuckRecovery,
|
||||
type PartyStuckRecoveryState,
|
||||
} from "../game/partyStuckRecovery";
|
||||
import {
|
||||
findNearestPartyNavigationSurface,
|
||||
type PartyNavigationGraph,
|
||||
@@ -65,10 +72,14 @@ import {
|
||||
type PartyRouteState,
|
||||
} from "../game/partyPathing";
|
||||
import { partyCombatRangeBand } from "../game/partyRuntime";
|
||||
import { partyRejoinMovement } from "../game/partyRejoin";
|
||||
import { useWailingEncounterStore } from "../game/wailingCavernsEncounter";
|
||||
import {
|
||||
clearPartyRuntimeAttackCorridor,
|
||||
consumePartyRuntimeRejoin,
|
||||
finishPartyRuntimeRejoin,
|
||||
getPartyRuntimePosition,
|
||||
partyRuntimeIsRejoining,
|
||||
registerPartyRuntimePosition,
|
||||
updatePartyRuntimeAttackCorridor,
|
||||
updatePartyRuntimePosition,
|
||||
@@ -87,6 +98,7 @@ import { ACTOR_TERRAIN_QUERY_GROUPS } from "./actorCollision";
|
||||
import {
|
||||
createPartyDirectSegmentValidator,
|
||||
createPartyRouteSegmentValidator,
|
||||
createPartyRecoveryPointValidator,
|
||||
PARTY_FLOOR_RAY_DEPTH,
|
||||
PARTY_FLOOR_RAY_HEIGHT,
|
||||
} from "./partyTraversal";
|
||||
@@ -112,11 +124,6 @@ interface PartyAvatarMetrics {
|
||||
readonly hitCenterY: number;
|
||||
}
|
||||
|
||||
interface PartyStuckProgress {
|
||||
readonly position: MutablePartyWorldPosition;
|
||||
lastProgressAt: number;
|
||||
}
|
||||
|
||||
interface PartyCombatAnchor {
|
||||
readonly targetId: string;
|
||||
readonly bearing: number;
|
||||
@@ -317,6 +324,10 @@ function PartyActors({
|
||||
() => createPartyRouteSegmentValidator(rapier, world),
|
||||
[rapier, world],
|
||||
);
|
||||
const recoveryPointAllowed = useMemo(
|
||||
() => createPartyRecoveryPointValidator(rapier, world, navigationGraph),
|
||||
[rapier, world, navigationGraph],
|
||||
);
|
||||
// Authored-link graphs use their eroded Recast node edges directly. Their
|
||||
// boundary vertices are already agent-radius safe, while a center ray can
|
||||
// falsely read the stair floor as a wall across a short vertical seam.
|
||||
@@ -344,7 +355,7 @@ function PartyActors({
|
||||
const lastLeaderWaypointRef = useRef<MutablePartyWorldPosition | null>(null);
|
||||
const lastCommandRef = useRef(usePartyStore.getState().command);
|
||||
const lastRecallRevisionRef = useRef(usePartyStore.getState().recallRevision);
|
||||
const stuckProgressRef = useRef(new Map<string, PartyStuckProgress>());
|
||||
const stuckProgressRef = useRef(new Map<string, PartyStuckRecoveryState>());
|
||||
const movementProfilesRef = useRef(new Map<string, PartyMovementProfile>());
|
||||
const directFollowMovingRef = useRef(new Map<string, boolean>());
|
||||
const combatAnchorsRef = useRef(new Map<string, PartyCombatAnchor>());
|
||||
@@ -381,6 +392,7 @@ function PartyActors({
|
||||
return { id: actorId, position: mobPosition, yaw };
|
||||
},
|
||||
hasLineOfSight: hasStaticLineOfSight,
|
||||
navigationGraph: () => navigationGraphRef.current,
|
||||
projectGroundPoint: (requested, maximumDistance, requestedOrigin) => {
|
||||
const origin = requestedOrigin ?? useGameStore.getState().playerPosition;
|
||||
const dx = requested[0] - origin[0];
|
||||
@@ -411,6 +423,7 @@ function PartyActors({
|
||||
: null;
|
||||
const recallPosition = navigationRecall?.position ?? player;
|
||||
party.members.forEach((member, index) => {
|
||||
finishPartyRuntimeRejoin(member.id);
|
||||
const position = positionsRef.current.get(member.id) ?? initialMemberPosition(index);
|
||||
// Attach a recalled squad to the reviewed surface just as initial
|
||||
// spawns do. An exact visual-floor point can sit between Recast stair
|
||||
@@ -425,10 +438,9 @@ function PartyActors({
|
||||
if (movingSignal) movingSignal.current = false;
|
||||
const body = bodyRefs.current[index];
|
||||
if (body) body.position.y = 0;
|
||||
stuckProgressRef.current.set(member.id, {
|
||||
position: [position[0], position[1], position[2]],
|
||||
lastProgressAt: now,
|
||||
});
|
||||
const progress = stuckProgressRef.current.get(member.id) ?? createPartyStuckRecoveryState(position, now);
|
||||
markPartyStuckRecovered(progress, position, now);
|
||||
stuckProgressRef.current.set(member.id, progress);
|
||||
});
|
||||
const firstLiving = party.members.find((member) => member.health > 0);
|
||||
resetPartyBreadcrumbTrail(playerTrailRef.current, player);
|
||||
@@ -511,6 +523,7 @@ function PartyActors({
|
||||
members.forEach((member, index) => {
|
||||
const position = positionsRef.current.get(member.id) ?? initialMemberPosition(index);
|
||||
const slot = spawnSlots[index + 1] ?? spawnSlots[0];
|
||||
finishPartyRuntimeRejoin(member.id);
|
||||
position[0] = slot.footPosition[0];
|
||||
position[1] = slot.footPosition[1];
|
||||
position[2] = slot.footPosition[2];
|
||||
@@ -569,7 +582,7 @@ function PartyActors({
|
||||
|
||||
// Time spent in a pause/menu must not count toward the stuck watchdog.
|
||||
useEffect(() => {
|
||||
stuckProgressRef.current.clear();
|
||||
for (const state of stuckProgressRef.current.values()) resetPartyStuckRecoveryProgress(state);
|
||||
}, [active]);
|
||||
|
||||
useEffect(() => clearPartyRuntimeAttackCorridor, []);
|
||||
@@ -597,6 +610,30 @@ function PartyActors({
|
||||
const playerNavigation = game.playerNavigationPosition;
|
||||
appendPartyBreadcrumb(playerTrailRef.current, playerNavigation);
|
||||
const routeNow = clock.elapsedTime * 1_000;
|
||||
const combatNow = Date.now();
|
||||
for (const member of party.members) {
|
||||
if (member.health <= 0) {
|
||||
finishPartyRuntimeRejoin(member.id);
|
||||
continue;
|
||||
}
|
||||
const checkpoint = consumePartyRuntimeRejoin(member.id);
|
||||
if (!checkpoint) continue;
|
||||
const landing = navigationGraph
|
||||
? findNearestPartyNavigationSurface(navigationGraph, checkpoint, 6)?.position ?? checkpoint : checkpoint;
|
||||
const current = positionsRef.current.get(member.id) ?? [...landing];
|
||||
current[0] = landing[0]; current[1] = landing[1]; current[2] = landing[2];
|
||||
positionsRef.current.set(member.id, current);
|
||||
updatePartyRuntimePosition(member.id, current[0], current[1], current[2]);
|
||||
combatRoutesRef.current.delete(member.id);
|
||||
combatAnchorsRef.current.delete(member.id);
|
||||
followerCursorsRef.current.delete(member.id);
|
||||
directFollowMovingRef.current.delete(member.id);
|
||||
stuckProgressRef.current.delete(member.id);
|
||||
updatePartyRuntimeTargetVisibility(member.id, null, false);
|
||||
const animationSignal = animationEventSignalsRef.current.get(member.id);
|
||||
animationRevisionRef.current += 1;
|
||||
if (animationSignal) animationSignal.current = { revision: animationRevisionRef.current, kind: "ability-cancel" };
|
||||
}
|
||||
const playerTrail = playerTrailRef.current.points;
|
||||
const latestPlayerTrailPoint = playerTrail[playerTrail.length - 1];
|
||||
const previousPlayerTrailPoint = playerTrail[playerTrail.length - 2];
|
||||
@@ -631,7 +668,7 @@ function PartyActors({
|
||||
lastLeaderWaypointRef.current = null;
|
||||
}
|
||||
const objective = objectives.find((candidate) => candidate.id === party.objectiveBossId) ?? null;
|
||||
const leaderIndex = party.members.findIndex((member) => member.health > 0);
|
||||
const leaderIndex = party.members.findIndex((member) => member.health > 0 && !partyRuntimeIsRejoining(member.id));
|
||||
const safeDelta = Math.min(delta, 0.05);
|
||||
const desired: MutablePartyWorldPosition = [0, 0, 0];
|
||||
const nextPosition: MutablePartyWorldPosition = [0, 0, 0];
|
||||
@@ -889,6 +926,13 @@ function PartyActors({
|
||||
localFinishDistance: leaderLocalFinishDistance,
|
||||
directSegmentAllowed,
|
||||
routeSegmentAllowed: navigationRouteSegmentAllowed,
|
||||
destinationReached: targetPosition ? (surface) => {
|
||||
const band = personalizedPartyCombatRangeBand(partyCombatRangeBand(leaderMember),
|
||||
movementProfileFor(leaderMember.id, leaderIndex));
|
||||
const distance = fullDistance(surface, targetPosition);
|
||||
return distance >= band.minimum && distance <= band.maximum
|
||||
&& hasStaticLineOfSight(surface, targetPosition);
|
||||
} : undefined,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -953,7 +997,6 @@ function PartyActors({
|
||||
: navigationFailed || Boolean(navigationGraph && routeRef.current.blocked)
|
||||
),
|
||||
);
|
||||
let autoRecallRequested = false;
|
||||
|
||||
for (let index = 0; index < party.members.length; index += 1) {
|
||||
const member = party.members[index];
|
||||
@@ -980,7 +1023,8 @@ function PartyActors({
|
||||
}
|
||||
lastHealthRef.current.set(member.id, member.health);
|
||||
if (!actor || !current) continue;
|
||||
const memberTargetId = party.memberTargetIds[member.id] ?? party.activeMobId;
|
||||
const rejoining = partyRuntimeIsRejoining(member.id);
|
||||
const memberTargetId = rejoining ? null : party.memberTargetIds[member.id] ?? party.activeMobId;
|
||||
const memberTargetPosition = memberTargetId ? getMobPosition(memberTargetId) : null;
|
||||
const canSeeActiveTarget = Boolean(
|
||||
memberTargetId
|
||||
@@ -998,6 +1042,26 @@ function PartyActors({
|
||||
if (member.health <= 0) {
|
||||
const signal = movingSignalsRef.current.get(member.id);
|
||||
if (signal) signal.current = false;
|
||||
} else if (member.activeCast || member.controlledUntil > combatNow) {
|
||||
// Casting and crowd control are intentional immobility, not stuck navigation.
|
||||
} else if (rejoining) {
|
||||
const intent = partyRejoinMovement(member.id, current, playerNavigation, routeNow,
|
||||
navigationGraph, combatRoutesRef.current.get(member.id) ?? emptyPartyRouteState(),
|
||||
{ directSegmentAllowed, routeSegmentAllowed: navigationRouteSegmentAllowed });
|
||||
combatRoutesRef.current.set(member.id, intent.route);
|
||||
if (intent.rejoined) {
|
||||
finishPartyRuntimeRejoin(member.id);
|
||||
combatRoutesRef.current.delete(member.id);
|
||||
followerCursorsRef.current.delete(member.id);
|
||||
} else {
|
||||
movementExpected = true;
|
||||
frameRouteBlocked ||= intent.blocked;
|
||||
if (intent.waypoint) {
|
||||
desired[0] = intent.waypoint[0]; desired[1] = intent.waypoint[1]; desired[2] = intent.waypoint[2];
|
||||
hasDesired = true;
|
||||
faceX = desired[0] - current[0]; faceZ = desired[2] - current[2];
|
||||
}
|
||||
}
|
||||
} else if (party.command === "stop") {
|
||||
// Holding is represented by retaining the current world-space tuple.
|
||||
} else if (index === leaderIndex) {
|
||||
@@ -1069,6 +1133,11 @@ function PartyActors({
|
||||
blockedRetryMs: 800,
|
||||
directSegmentAllowed,
|
||||
routeSegmentAllowed: navigationRouteSegmentAllowed,
|
||||
destinationReached: (surface) => {
|
||||
const distance = fullDistance(surface, memberTargetPosition);
|
||||
return distance >= rangeBand.minimum && distance <= rangeBand.maximum
|
||||
&& hasStaticLineOfSight(surface, memberTargetPosition);
|
||||
},
|
||||
},
|
||||
);
|
||||
combatRoutesRef.current.set(member.id, route);
|
||||
@@ -1233,7 +1302,7 @@ function PartyActors({
|
||||
const movementProfile = movementProfileFor(member.id, index);
|
||||
|
||||
const currentPlanarDistance = planarDistance(current, playerNavigation);
|
||||
if (member.health > 0 && party.command !== "stop" && hasDesired) {
|
||||
if (member.health > 0 && (party.command !== "stop" || rejoining) && hasDesired) {
|
||||
const speed = (currentPlanarDistance > 8 ? PARTY_CATCH_UP_SPEED : PARTY_MOVE_SPEED)
|
||||
* movementProfile.speedMultiplier;
|
||||
const moved = stepPartyPosition(current, desired, speed * safeDelta, nextPosition);
|
||||
@@ -1263,30 +1332,59 @@ function PartyActors({
|
||||
|
||||
let stuckProgress = stuckProgressRef.current.get(member.id);
|
||||
if (!stuckProgress) {
|
||||
stuckProgress = {
|
||||
position: [current[0], current[1], current[2]],
|
||||
lastProgressAt: routeNow,
|
||||
};
|
||||
stuckProgress = createPartyStuckRecoveryState(current, routeNow);
|
||||
stuckProgressRef.current.set(member.id, stuckProgress);
|
||||
}
|
||||
if (member.health <= 0 || party.command === "stop" || !movementExpected) {
|
||||
stuckProgress.position[0] = current[0];
|
||||
stuckProgress.position[1] = current[1];
|
||||
stuckProgress.position[2] = current[2];
|
||||
stuckProgress.lastProgressAt = routeNow;
|
||||
} else if (partyMadeStuckProgress(stuckProgress.position, current)) {
|
||||
stuckProgress.position[0] = current[0];
|
||||
stuckProgress.position[1] = current[1];
|
||||
stuckProgress.position[2] = current[2];
|
||||
stuckProgress.lastProgressAt = routeNow;
|
||||
} else if (partyShouldRecallForStuck(
|
||||
current,
|
||||
playerNavigation,
|
||||
const recoveryGoal = rejoining ? "resurrection-rejoin:player" : memberTargetPosition ? `mob:${memberTargetId}`
|
||||
: index === leaderIndex ? `objective:${party.objectiveBossId}` : `follow:${leaderMember?.id}`;
|
||||
const recovery = updatePartyStuckRecovery(stuckProgress, {
|
||||
position: current,
|
||||
waypoint: hasDesired ? desired : null,
|
||||
goalKey: `${party.command}|${recoveryGoal}`,
|
||||
movementExpected,
|
||||
routeNow,
|
||||
stuckProgress.lastProgressAt,
|
||||
)) {
|
||||
autoRecallRequested = true;
|
||||
health: member.health,
|
||||
paused: !active || game.paused || game.mapOpen || game.companionOpen,
|
||||
stopped: party.command === "stop" && !rejoining,
|
||||
casting: member.activeCast !== null,
|
||||
controlled: member.controlledUntil > combatNow,
|
||||
nowMs: routeNow,
|
||||
});
|
||||
if (recovery !== "none") {
|
||||
// Invalidate only this actor's route. Keep its watchdog running across
|
||||
// replans so an endlessly blocked connector cannot restart the timer.
|
||||
combatRoutesRef.current.delete(member.id);
|
||||
followerCursorsRef.current.delete(member.id);
|
||||
directFollowMovingRef.current.delete(member.id);
|
||||
if (index === leaderIndex) {
|
||||
routeRef.current = emptyPartyRouteState();
|
||||
leaderPlayerCursorRef.current.breadcrumbId = null;
|
||||
lastLeaderWaypointRef.current = null;
|
||||
}
|
||||
if (recovery === "recover") {
|
||||
const landing = choosePartyStuckRecoveryPosition({
|
||||
current,
|
||||
player: playerNavigation,
|
||||
groupAnchor: rejoining || index === leaderIndex ? playerNavigation : leaderPosition ?? playerNavigation,
|
||||
trailPoints: [...leaderTrailRef.current.points, ...playerTrailRef.current.points].map((point) => point.position),
|
||||
validate: recoveryPointAllowed,
|
||||
});
|
||||
if (landing && applyPartyStuckRecoveryLanding(positionsRef.current, member.id, landing.position, stuckProgress, routeNow)) {
|
||||
updatePartyRuntimePosition(member.id, current[0], current[1], current[2]);
|
||||
actor.position.set(current[0], presentationY(current), current[2]);
|
||||
const movingSignal = movingSignalsRef.current.get(member.id);
|
||||
if (movingSignal) movingSignal.current = false;
|
||||
shouldMove = false;
|
||||
combatAnchorsRef.current.delete(member.id);
|
||||
updatePartyRuntimeTargetVisibility(member.id, memberTargetId, Boolean(memberTargetPosition
|
||||
&& hasStaticLineOfSight(current, memberTargetPosition)));
|
||||
if (index === leaderIndex) {
|
||||
// Do not record a teleport as a straight, walkable breadcrumb segment.
|
||||
resetPartyBreadcrumbTrail(leaderTrailRef.current, current);
|
||||
followerCursorsRef.current.clear();
|
||||
clearPartyRuntimeAttackCorridor();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (Math.hypot(faceX, faceZ) > 0.001) {
|
||||
@@ -1305,12 +1403,6 @@ function PartyActors({
|
||||
body.rotation.x = 0;
|
||||
}
|
||||
}
|
||||
if (autoRecallRequested) {
|
||||
party.recallParty();
|
||||
lastRecallRevisionRef.current = usePartyStore.getState().recallRevision;
|
||||
teleportPartyToPlayer(playerNavigation, routeNow);
|
||||
return;
|
||||
}
|
||||
party.setRouteBlocked(frameRouteBlocked);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { useRapier } from "@react-three/rapier";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { combatNavigationGraph } from "../game/combatSpatial";
|
||||
import { createMobPursuit } from "../game/mobPursuit";
|
||||
import { registerDungeonRuntimeReset } from "../game/dungeonRuntimeReset";
|
||||
import { hasDungeonLineOfSight } from "./dungeonLineOfSight";
|
||||
import { createPartyDirectSegmentValidator, createPartyRecoveryPointValidator, createPartyRouteSegmentValidator } from "./partyTraversal";
|
||||
|
||||
/** Uses terrain only; actors remain non-blocking and the graph is loaded only once. */
|
||||
export function useMobNavigation() {
|
||||
const { rapier, world } = useRapier();
|
||||
const navigator = useMemo(() => {
|
||||
let graph = combatNavigationGraph();
|
||||
let landing = createPartyRecoveryPointValidator(rapier, world, graph);
|
||||
return createMobPursuit({
|
||||
graph: combatNavigationGraph,
|
||||
hasLineOfSight: (from, to) => hasDungeonLineOfSight(rapier, world, from, to),
|
||||
directAllowed: createPartyDirectSegmentValidator(rapier, world),
|
||||
routeAllowed: createPartyRouteSegmentValidator(rapier, world),
|
||||
validateLanding: (point, anchor) => {
|
||||
const currentGraph = combatNavigationGraph();
|
||||
if (graph !== currentGraph) {
|
||||
graph = currentGraph;
|
||||
landing = createPartyRecoveryPointValidator(rapier, world, graph);
|
||||
}
|
||||
return landing(point, anchor);
|
||||
},
|
||||
});
|
||||
}, [rapier, world]);
|
||||
useEffect(() => registerDungeonRuntimeReset(navigator.clear), [navigator]);
|
||||
return navigator;
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import RAPIER from "@dimforge/rapier3d-compat";
|
||||
import { BufferAttribute, BufferGeometry } from "three";
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
import { buildPartyNavigationGraph } from "../game/partyNavigation";
|
||||
import { ACTOR_COLLISION_GROUPS } from "./actorCollision";
|
||||
import { DUNGEON_PHYSICAL_COLLISION_GROUPS, DUNGEON_WALL_COLLISION_GROUPS } from "./dungeonLineOfSight";
|
||||
import { createPartyRecoveryPointValidator } from "./partyTraversal";
|
||||
|
||||
beforeAll(async () => { await RAPIER.init(); });
|
||||
|
||||
function floorWorld() {
|
||||
const world = new RAPIER.World({ x: 0, y: 0, z: 0 });
|
||||
world.createCollider(RAPIER.ColliderDesc.cuboid(5, 0.1, 5).setTranslation(0, -0.1, 0)
|
||||
.setCollisionGroups(DUNGEON_PHYSICAL_COLLISION_GROUPS));
|
||||
return world;
|
||||
}
|
||||
|
||||
function floorGraph() {
|
||||
const geometry = new BufferGeometry();
|
||||
geometry.setAttribute("position", new BufferAttribute(new Float32Array([
|
||||
-4, 0.05, -4, 4, 0.05, -4, -4, 0.05, 4, 4, 0.05, 4,
|
||||
]), 3));
|
||||
geometry.setIndex([0, 2, 1, 1, 2, 3]);
|
||||
const graph = buildPartyNavigationGraph(geometry);
|
||||
geometry.dispose();
|
||||
return graph;
|
||||
}
|
||||
|
||||
describe("safe recovery terrain", () => {
|
||||
it("accepts walkable, unobstructed landings connected to the group", () => {
|
||||
const world = floorWorld();
|
||||
world.step();
|
||||
const allowed = createPartyRecoveryPointValidator(RAPIER, world, floorGraph());
|
||||
expect(allowed([-2, 0.05, 0], [2, 0.05, 0])).not.toBeNull();
|
||||
world.free();
|
||||
});
|
||||
|
||||
it("rejects a landing inside a wall and a route cut off by a wall", () => {
|
||||
const world = floorWorld();
|
||||
world.createCollider(RAPIER.ColliderDesc.cuboid(0.1, 2, 5).setTranslation(0, 2, 0)
|
||||
.setCollisionGroups(DUNGEON_WALL_COLLISION_GROUPS));
|
||||
world.step();
|
||||
const allowed = createPartyRecoveryPointValidator(RAPIER, world, floorGraph());
|
||||
expect(allowed([0, 0.05, 0], [-2, 0.05, 0])).toBeNull();
|
||||
expect(allowed([-2, 0.05, 0], [2, 0.05, 0])).toBeNull();
|
||||
expect(allowed([-2, 0.05, 0], [-3, 0.05, 0])).not.toBeNull();
|
||||
world.free();
|
||||
});
|
||||
|
||||
it("rejects low ceilings, missing floors, and points on the wrong vertical level", () => {
|
||||
const world = floorWorld();
|
||||
world.createCollider(RAPIER.ColliderDesc.cuboid(0.5, 0.1, 0.5).setTranslation(-2, 1.4, 0)
|
||||
.setCollisionGroups(DUNGEON_PHYSICAL_COLLISION_GROUPS));
|
||||
world.step();
|
||||
const allowed = createPartyRecoveryPointValidator(RAPIER, world, floorGraph());
|
||||
expect(allowed([-2, 0.05, 0], [2, 0.05, 0])).toBeNull();
|
||||
expect(allowed([0, 5, 0], [2, 0.05, 0])).toBeNull();
|
||||
const noFloor = new RAPIER.World({ x: 0, y: 0, z: 0 });
|
||||
noFloor.step();
|
||||
expect(createPartyRecoveryPointValidator(RAPIER, noFloor, floorGraph())([0, 0.05, 0], [0, 0.05, 0])).toBeNull();
|
||||
noFloor.free();
|
||||
world.free();
|
||||
});
|
||||
|
||||
it("allows a grounded player fallback without a navigation asset and ignores actor occupancy", () => {
|
||||
const world = floorWorld();
|
||||
const body = world.createRigidBody(RAPIER.RigidBodyDesc.kinematicPositionBased().setTranslation(0, 0.84, 0));
|
||||
world.createCollider(RAPIER.ColliderDesc.capsule(0.48, 0.36).setCollisionGroups(ACTOR_COLLISION_GROUPS), body);
|
||||
world.step();
|
||||
expect(createPartyRecoveryPointValidator(RAPIER, world, null)([0, 0, 0], [0, 0, 0])).toEqual([0, 0, 0]);
|
||||
world.free();
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,8 @@ import type RAPIER from "@dimforge/rapier3d-compat";
|
||||
import type { World } from "@dimforge/rapier3d-compat";
|
||||
import type { PartyWorldPosition } from "../game/partyRuntimeRegistry";
|
||||
import { ACTOR_TERRAIN_QUERY_GROUPS } from "./actorCollision";
|
||||
import { findNearestPartyNavigationSurface, type PartyNavigationGraph } from "../game/partyNavigation";
|
||||
import { planPartyRoute } from "../game/partyPathing";
|
||||
|
||||
const COLLISION_RADIUS = 0.34;
|
||||
const COLLISION_HALF_HEIGHT = 0.48;
|
||||
@@ -72,3 +74,39 @@ export function createPartyRouteSegmentValidator(rapier: typeof RAPIER, world: W
|
||||
return !hit || hit.timeOfImpact >= distance - 0.04;
|
||||
};
|
||||
}
|
||||
|
||||
/** Recovery landings must have real walkable ground, free body space, and a route back to the group. */
|
||||
export function createPartyRecoveryPointValidator(rapier: typeof RAPIER, world: World, graph: PartyNavigationGraph | null) {
|
||||
const directAllowed = createPartyDirectSegmentValidator(rapier, world);
|
||||
const routeAllowed = createPartyRouteSegmentValidator(rapier, world);
|
||||
const capsule = new rapier.Capsule(COLLISION_HALF_HEIGHT - WALKABLE_STEP_HEIGHT * 0.5, COLLISION_RADIUS);
|
||||
const flags = rapier.QueryFilterFlags.EXCLUDE_SENSORS;
|
||||
const project = (point: PartyWorldPosition): PartyWorldPosition | null => {
|
||||
const surface = graph ? findNearestPartyNavigationSurface(graph, point, 0.6)?.position : point;
|
||||
if (!surface || Math.abs(surface[1] - point[1]) > 0.55) return null;
|
||||
if (graph && Math.hypot(surface[0] - point[0], surface[1] - point[1], surface[2] - point[2]) > 0.04
|
||||
&& !directAllowed(point, surface)) return null;
|
||||
const ray = new rapier.Ray({ x: surface[0], y: surface[1] + PARTY_FLOOR_RAY_HEIGHT, z: surface[2] }, { x: 0, y: -1, z: 0 });
|
||||
const floor = world.castRayAndGetNormal(ray, PARTY_FLOOR_RAY_DEPTH, true, flags, ACTOR_TERRAIN_QUERY_GROUPS);
|
||||
if (!floor || floor.normal.y < 0.65 || Math.abs(ray.origin.y - floor.timeOfImpact - surface[1]) > MAX_FLOOR_DELTA) return null;
|
||||
const obstruction = world.intersectionWithShape(
|
||||
{ x: surface[0], y: surface[1] + COLLISION_CENTER_HEIGHT + WALKABLE_STEP_HEIGHT * 0.5, z: surface[2] },
|
||||
{ x: 0, y: 0, z: 0, w: 1 }, capsule, flags, ACTOR_TERRAIN_QUERY_GROUPS,
|
||||
);
|
||||
return obstruction ? null : surface;
|
||||
};
|
||||
return (point: PartyWorldPosition, anchor: PartyWorldPosition): PartyWorldPosition | null => {
|
||||
const position = project(point);
|
||||
const destination = project(anchor);
|
||||
if (!position || !destination) return null;
|
||||
if (!graph) return directAllowed(position, destination) ? position : null;
|
||||
const route = planPartyRoute(graph, "stuck-recovery-validation", position, destination, 0, {
|
||||
maxOriginSnapDistance: 0.1,
|
||||
maxDestinationSnapDistance: 0.1,
|
||||
localFinishDistance: 0.2,
|
||||
directSegmentAllowed: directAllowed,
|
||||
routeSegmentAllowed: graph.triangles?.length ? routeAllowed : undefined,
|
||||
});
|
||||
return route.blocked ? null : position;
|
||||
};
|
||||
}
|
||||
@@ -731,6 +731,16 @@ kbd { color: var(--moss-bright); font: inherit; font-size: 0.64rem; }
|
||||
.party-frame.is-selected { border-color: rgba(229, 207, 110, .75); background: linear-gradient(100deg, rgba(39, 43, 20, .96), rgba(18, 38, 24, .88)); box-shadow: 0 0 0 1px rgba(229,207,110,.14), 0 6px 18px rgba(0,0,0,.32); }
|
||||
.party-frame.is-dispellable { border-color: var(--dispel-bright); box-shadow: 0 0 0 1px rgba(var(--dispel-rgb),.72), 0 0 12px rgba(var(--dispel-rgb),.62), 0 6px 18px rgba(0,0,0,.32); }
|
||||
.party-frame.is-down { filter: grayscale(.75); opacity: .58; }
|
||||
.party-rez-button { display: grid; width: 100%; min-height: 44px; gap: 3px; padding: 6px 8px; border: 1px solid var(--party-rez-border, rgba(224,197,101,.5)); border-radius: 4px; background: var(--party-rez-background, linear-gradient(110deg, rgba(41,45,23,.96), rgba(13,28,20,.94))); color: var(--party-rez-color, #fff0b0); cursor: pointer; }
|
||||
.party-rez-button strong { font-size: 10px; }
|
||||
.party-rez-button small { color: #b6c4a8; font-size: 8px; }
|
||||
.party-rez-button:hover:not(:disabled), .party-rez-button:focus-visible { border-color: #fff0b0; outline: 1px solid #fff0b0; outline-offset: 2px; }
|
||||
.party-rez-button:disabled { opacity: .6; cursor: not-allowed; }
|
||||
.party-resurrection { display: grid; gap: 4px; }
|
||||
.resurrection-destination { display: grid; width: 100%; gap: 3px; margin-bottom: 6px; color: #b6c4a8; font-size: 10px; text-align: left; }
|
||||
.resurrection-destination select { width: 100%; min-width: 0; min-height: 44px; padding: 4px 7px; border: 1px solid rgba(224,197,101,.5); border-radius: 4px; background: #142219; color: #fff0b0; font: inherit; }
|
||||
.resurrection-destination select:focus-visible { outline: 1px solid #fff0b0; outline-offset: 2px; }
|
||||
.resurrection-destination select:disabled { opacity: .6; }
|
||||
.party-frame--player { opacity: .9; }
|
||||
.party-frame--player.is-selected { opacity: 1; }
|
||||
.party-frame--online { border-color: rgba(79,184,154,.3); background: linear-gradient(100deg, rgba(9,35,31,.94), rgba(11,24,21,.9)); }
|
||||
|
||||
@@ -30,6 +30,10 @@ import { ExperienceBar } from "./ExperienceBar";
|
||||
import { BrandMark, FrontSurface } from "./FrontSurface";
|
||||
import { ShelvedCompanionPanel } from "@shelvedCompanionPanel";
|
||||
import { SHELVED_MODES_ENABLED } from "../app/releaseFeatures";
|
||||
import { EditableHudElement } from "./EditableHudElement";
|
||||
import { DpsMeterWidget } from "./DpsMeterWidget";
|
||||
import { useUiLayoutStore } from "../game/uiLayoutStore";
|
||||
import { useDpsMeterStore } from "../game/dpsMeterStore";
|
||||
|
||||
function safePercent(current: number, maximum: number): number {
|
||||
if (!Number.isFinite(current) || !Number.isFinite(maximum) || maximum <= 0) return 0;
|
||||
@@ -456,14 +460,21 @@ export function CompanionPanel({ snapshot, send, preferControllerBinds = false }
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
<section className="companion-map-card">
|
||||
<DungeonMapGraphic position={snapshot.game.playerPosition} yaw={snapshot.game.cameraYaw} compact />
|
||||
<div>
|
||||
<span>{snapshot.game.overlay ? "Upper display" : "Current area"}</span>
|
||||
<strong>{snapshot.game.overlay ? overlayName(snapshot.game.overlay, romSkills) : snapshot.game.areaName}</strong>
|
||||
<small>{Math.round(snapshot.game.distanceTravelled)} m explored / {heading} degrees</small>
|
||||
</div>
|
||||
</section>
|
||||
<div className="companion-tactical__overview">
|
||||
<section className="companion-map-card">
|
||||
<DungeonMapGraphic position={snapshot.game.playerPosition} yaw={snapshot.game.cameraYaw} compact />
|
||||
<div>
|
||||
<span>{snapshot.game.overlay ? "Upper display" : "Current area"}</span>
|
||||
<strong>{snapshot.game.overlay ? overlayName(snapshot.game.overlay, romSkills) : snapshot.game.areaName}</strong>
|
||||
<small>{Math.round(snapshot.game.distanceTravelled)} m explored / {heading} degrees</small>
|
||||
</div>
|
||||
</section>
|
||||
{snapshot.game.dpsMeter.display === "companion" && <EditableHudElement id="dps" hostSelector=".companion-panel"
|
||||
elementLayout={snapshot.game.dpsMeter.layout} editing={snapshot.game.dpsMeter.editing}
|
||||
onChange={(update) => send({ name: "editDpsMeter", update })}>
|
||||
<DpsMeterWidget compact snapshot={snapshot.game.dpsMeter.values} width={snapshot.game.dpsMeter.layout.width} />
|
||||
</EditableHudElement>}
|
||||
</div>
|
||||
<div className="companion-tactical__actions" style={{ gridColumn: "1 / 3", minWidth: 0 }}>
|
||||
<ActionBar
|
||||
items={actionItems}
|
||||
@@ -501,5 +512,7 @@ export function LocalCompanionPanel() {
|
||||
useGameStore();
|
||||
useCombatStore();
|
||||
usePartyStore();
|
||||
useUiLayoutStore();
|
||||
useDpsMeterStore();
|
||||
return <CompanionPanel snapshot={currentCompanionSnapshot()} send={executeCompanionCommand} />;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { currentDisplayLayout } from "../platform/displayLayout";
|
||||
import { currentUiDevice, useUiLayoutStore } from "../game/uiLayoutStore";
|
||||
import { resolveDpsMeterDisplay } from "../game/uiLayout";
|
||||
import { currentDpsMeterSnapshot, useDpsMeterStore } from "../game/dpsMeterStore";
|
||||
import { EditableHudElement } from "./EditableHudElement";
|
||||
import { DpsMeterWidget } from "./DpsMeterWidget";
|
||||
|
||||
export function DpsMeter() {
|
||||
useDpsMeterStore();
|
||||
const ui = useUiLayoutStore();
|
||||
const layout = ui.draft ?? ui.preferences.current[currentUiDevice()];
|
||||
if (resolveDpsMeterDisplay(layout, currentDisplayLayout() !== "single") !== "main") return null;
|
||||
return <EditableHudElement id="dps"><DpsMeterWidget snapshot={currentDpsMeterSnapshot()} width={layout.elements.dps.width} /></EditableHudElement>;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { CSSProperties } from "react";
|
||||
import type { DpsMeterSnapshot } from "../game/dpsMeter";
|
||||
import "./dpsMeter.css";
|
||||
|
||||
export function DpsMeterWidget({ snapshot, width, compact = false }: {
|
||||
snapshot: DpsMeterSnapshot; width?: number | null; compact?: boolean;
|
||||
}) {
|
||||
const number = (value: number) => Math.round(value).toLocaleString();
|
||||
return <section className={`dps-meter ${compact ? "dps-meter--compact" : ""}`} aria-label="DPS meter"
|
||||
style={{ "--dps-meter-width": `${width ?? 230}px` } as CSSProperties}>
|
||||
<header><strong>DPS</strong><small>{snapshot.rows.length === 0 ? "Waiting for combat" : snapshot.active ? "Current fight" : "Last fight"}</small>
|
||||
<span>{Math.floor(snapshot.durationSeconds / 60)}:{String(Math.floor(snapshot.durationSeconds % 60)).padStart(2, "0")}</span></header>
|
||||
{snapshot.rows.length > 0 ? <ol>{snapshot.rows.map((row) => <li key={row.id}
|
||||
title={`${row.name}: ${number(row.dps)} DPS · ${number(row.total)} damage`}
|
||||
style={{ "--dps-color": row.color } as CSSProperties}>
|
||||
<i style={{ width: `${row.percent}%` }} aria-hidden="true" /><span>{row.name}</span><b>{number(row.dps)}</b>
|
||||
</li>)}</ol> : <p>Damage dealt per second</p>}
|
||||
{snapshot.rows.length > 0 && <footer><span>Party · {number(snapshot.totalDamage)} damage</span><b>{number(snapshot.totalDps)} DPS</b></footer>}
|
||||
</section>;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { useMemo, useRef, useState, type FormEvent } from "react";
|
||||
import { useShellStore } from "../app/shellStore";
|
||||
import { DUNGEON_REPORT_COMMENT_MAX_LENGTH, submitDungeonReport } from "../app/dungeonReports";
|
||||
import { dungeonById } from "../game/dungeonCatalog";
|
||||
import { useGameStore } from "../game/store";
|
||||
import { useMenuController, type MenuAction } from "../input/useMenuController";
|
||||
import { ControllerButton } from "./ControllerButton";
|
||||
|
||||
export function DungeonIssueReportPanel() {
|
||||
const dungeonId = useGameStore((state) => state.activeDungeonId);
|
||||
const openOverlay = useGameStore((state) => state.openOverlay);
|
||||
const session = useShellStore((state) => state.session);
|
||||
const [comment, setComment] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const busy = useRef(false);
|
||||
const form = useRef<HTMLFormElement>(null);
|
||||
const back = () => { if (!busy.current) openOverlay("pause"); };
|
||||
const canSubmit = Boolean(comment.trim()) && comment.length <= DUNGEON_REPORT_COMMENT_MAX_LENGTH && !submitting;
|
||||
const actions = useMemo<MenuAction[]>(() => [
|
||||
{ id: "report-back", run: back, enabled: !submitting },
|
||||
{ id: "report-submit", run: () => form.current?.requestSubmit(), enabled: canSubmit && !submitted },
|
||||
], [canSubmit, openOverlay, submitted, submitting]);
|
||||
const controller = useMenuController(actions, { initialId: "report-back", onBack: back });
|
||||
|
||||
const submit = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!canSubmit || busy.current || submitted) return;
|
||||
busy.current = true;
|
||||
setSubmitting(true);
|
||||
setError("");
|
||||
try {
|
||||
await submitDungeonReport(session, dungeonId, comment);
|
||||
setSubmitted(true);
|
||||
} catch (failure) {
|
||||
setError(failure instanceof Error ? failure.message : "Your report could not be submitted. Please try again.");
|
||||
} finally {
|
||||
busy.current = false;
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-backdrop" role="presentation">
|
||||
<section className="pause-menu dungeon-issue-report" role="dialog" aria-modal="true" aria-labelledby="issue-report-title">
|
||||
<p className="eyebrow">Dungeon feedback</p>
|
||||
<h2 id="issue-report-title">Report an issue</h2>
|
||||
<p>{dungeonById(dungeonId)?.title ?? dungeonId}</p>
|
||||
{submitted ? <p role="status">Report submitted. You can follow its status in View Reports on the main menu.</p> : (
|
||||
<form ref={form} onSubmit={(event) => void submit(event)}>
|
||||
<label htmlFor="dungeon-issue-comment">Describe the issue</label>
|
||||
<textarea id="dungeon-issue-comment" autoFocus required maxLength={DUNGEON_REPORT_COMMENT_MAX_LENGTH}
|
||||
value={comment} disabled={submitting} onChange={(event) => setComment(event.target.value)}
|
||||
aria-describedby="dungeon-issue-count" placeholder="What happened, and what were you doing when it happened?" />
|
||||
<small id="dungeon-issue-count">{comment.length} / {DUNGEON_REPORT_COMMENT_MAX_LENGTH} characters</small>
|
||||
{error && <p role="alert" className="dungeon-report-error">{error}</p>}
|
||||
<ControllerButton controlId="report-submit" selectedId={controller.selectedId} select={controller.select}
|
||||
className="button button--primary" type="submit" disabled={!canSubmit}>
|
||||
{submitting ? "Submitting..." : "Submit report"}
|
||||
</ControllerButton>
|
||||
</form>
|
||||
)}
|
||||
<ControllerButton controlId="report-back" selectedId={controller.selectedId} select={controller.select}
|
||||
className="button" type="button" onClick={back} disabled={submitting}>Back to pause menu</ControllerButton>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
DUNGEON_REPORT_STATUSES, filterDungeonReports, sessionCanManageDungeonReports, updateDungeonReport,
|
||||
type DungeonReport, type DungeonReportStatus,
|
||||
} from "../app/dungeonReports";
|
||||
import { useShellStore } from "../app/shellStore";
|
||||
import { DualDisplayFrame } from "../components/DualDisplayFrame";
|
||||
import { DUNGEON_CATALOG, dungeonById } from "../game/dungeonCatalog";
|
||||
import { useMenuController, type MenuAction } from "../input/useMenuController";
|
||||
import { BrandMark, ControllerLegend, FrontSurface } from "./FrontSurface";
|
||||
import { ControllerButton } from "./ControllerButton";
|
||||
import { LazyLocalCompanionPanel } from "./LazyLocalCompanionPanel";
|
||||
import { useDungeonReports } from "./useDungeonReports";
|
||||
|
||||
function ReportCard({ report, canManage, saving, onUpdate, selectedId, select }: {
|
||||
readonly report: DungeonReport;
|
||||
readonly canManage: boolean;
|
||||
readonly saving: boolean;
|
||||
readonly onUpdate: (report: DungeonReport, status: DungeonReportStatus) => void;
|
||||
readonly selectedId: string;
|
||||
readonly select: (id: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<article className={`dungeon-report-card ${report.status === "Closed" ? "is-closed" : ""}`}>
|
||||
<header><h2>{dungeonById(report.dungeonId)?.title ?? report.dungeonId}</h2><span className="dungeon-report-status">{report.status}</span></header>
|
||||
<p className="dungeon-report-comment">{report.comment}</p>
|
||||
<small>Submitted by {report.submittedBy} · <time dateTime={new Date(report.createdAt).toISOString()}>{new Date(report.createdAt).toLocaleString()}</time></small>
|
||||
{report.updatedBy && <small>Updated by {report.updatedBy} · {new Date(report.updatedAt).toLocaleString()}</small>}
|
||||
{canManage && (
|
||||
<div className="dungeon-report-management">
|
||||
<label>Status
|
||||
<select aria-label={`Status for report ${report.id}`} value={report.status} disabled={saving}
|
||||
className={selectedId === `report-status-${report.id}` ? "is-controller-selected" : ""}
|
||||
onPointerEnter={() => select(`report-status-${report.id}`)}
|
||||
onChange={(event) => onUpdate(report, event.target.value as DungeonReportStatus)}>
|
||||
{DUNGEON_REPORT_STATUSES.map((status) => <option key={status} value={status}>{status}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
{report.status !== "Closed" && <ControllerButton controlId={`report-close-${report.id}`} selectedId={selectedId} select={select}
|
||||
type="button" className="front-button" disabled={saving} onClick={() => onUpdate(report, "Closed")}>Close report</ControllerButton>}
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
export function DungeonReportsScreen({ onClose }: { readonly onClose: () => void }) {
|
||||
const session = useShellStore((state) => state.session);
|
||||
const { snapshot, loading, error, refresh } = useDungeonReports();
|
||||
const [status, setStatus] = useState<DungeonReportStatus | "all">("all");
|
||||
const [dungeonId, setDungeonId] = useState("all");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [updateError, setUpdateError] = useState("");
|
||||
const [message, setMessage] = useState("");
|
||||
const busy = useRef(false);
|
||||
const canManage = sessionCanManageDungeonReports(session);
|
||||
const reports = filterDungeonReports(snapshot?.reports ?? [], status, dungeonId);
|
||||
const dungeonOptions = useMemo(() => [...DUNGEON_CATALOG].sort((a, b) => a.title.localeCompare(b.title)), []);
|
||||
const actions = useMemo<MenuAction[]>(() => [
|
||||
{ id: "reports-back", run: onClose, enabled: !saving },
|
||||
{ id: "reports-filter-status", run: () => {
|
||||
const options = ["all", ...DUNGEON_REPORT_STATUSES] as const;
|
||||
setStatus(options[(options.indexOf(status) + 1) % options.length]);
|
||||
} },
|
||||
{ id: "reports-filter-dungeon", run: () => {
|
||||
const options = ["all", ...dungeonOptions.map((dungeon) => dungeon.id)];
|
||||
setDungeonId(options[(options.indexOf(dungeonId) + 1) % options.length]);
|
||||
} },
|
||||
{ id: "reports-clear-filters", run: () => { setStatus("all"); setDungeonId("all"); } },
|
||||
{ id: "reports-refresh", run: () => void refresh(), enabled: !loading && !saving },
|
||||
...(canManage ? reports.flatMap((report) => [
|
||||
{ id: `report-status-${report.id}`, enabled: !saving, run: () => {
|
||||
const nextStatus = DUNGEON_REPORT_STATUSES[(DUNGEON_REPORT_STATUSES.indexOf(report.status) + 1) % DUNGEON_REPORT_STATUSES.length];
|
||||
void update(report, nextStatus);
|
||||
} },
|
||||
...(report.status === "Closed" ? [] : [{ id: `report-close-${report.id}`, enabled: !saving, run: () => void update(report, "Closed") }]),
|
||||
]) : []),
|
||||
], [canManage, dungeonId, dungeonOptions, loading, onClose, refresh, reports, saving, session, status]);
|
||||
const controller = useMenuController(actions, { initialId: "reports-back", onBack: saving ? undefined : onClose });
|
||||
const update = async (report: DungeonReport, nextStatus: DungeonReportStatus) => {
|
||||
if (!session || !canManage || busy.current) return;
|
||||
busy.current = true;
|
||||
setSaving(true);
|
||||
setUpdateError("");
|
||||
setMessage("");
|
||||
try {
|
||||
await updateDungeonReport(session, report, nextStatus);
|
||||
setMessage(`Report status updated to ${nextStatus}.`);
|
||||
} catch (failure) {
|
||||
setUpdateError(failure instanceof Error ? failure.message : "The status could not be updated.");
|
||||
} finally {
|
||||
await refresh();
|
||||
busy.current = false;
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<DualDisplayFrame contextLabel="Dungeon reports" top={
|
||||
<FrontSurface className="dungeon-reports-screen" ariaLabel="Dungeon reports">
|
||||
<header className="dungeon-reports-header">
|
||||
<BrandMark compact />
|
||||
<div><span>Main menu / Dungeon feedback</span><h1>View Reports</h1></div>
|
||||
<ControllerButton controlId="reports-back" selectedId={controller.selectedId} select={controller.select}
|
||||
className="front-button" type="button" onClick={onClose} disabled={saving}>Back to main menu</ControllerButton>
|
||||
</header>
|
||||
<main className="dungeon-reports-main">
|
||||
<div className="dungeon-report-filters">
|
||||
<label>Status
|
||||
<select value={status} onChange={(event) => setStatus(event.target.value as DungeonReportStatus | "all")}
|
||||
className={controller.selectedId === "reports-filter-status" ? "is-controller-selected" : ""}
|
||||
onPointerEnter={() => controller.select("reports-filter-status")}>
|
||||
<option value="all">All statuses</option>
|
||||
{DUNGEON_REPORT_STATUSES.map((value) => <option key={value} value={value}>{value}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label>Dungeon
|
||||
<select value={dungeonId} onChange={(event) => setDungeonId(event.target.value)}
|
||||
className={controller.selectedId === "reports-filter-dungeon" ? "is-controller-selected" : ""}
|
||||
onPointerEnter={() => controller.select("reports-filter-dungeon")}>
|
||||
<option value="all">All dungeons</option>
|
||||
{dungeonOptions.map((dungeon) => (
|
||||
<option key={dungeon.id} value={dungeon.id}>{dungeon.title}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<ControllerButton controlId="reports-clear-filters" selectedId={controller.selectedId} select={controller.select}
|
||||
type="button" className="front-button" onClick={() => { setStatus("all"); setDungeonId("all"); }}>Clear filters</ControllerButton>
|
||||
<ControllerButton controlId="reports-refresh" selectedId={controller.selectedId} select={controller.select}
|
||||
className="front-button" type="button" disabled={loading || saving} onClick={() => void refresh()}>
|
||||
{loading ? "Refreshing..." : "Refresh"}
|
||||
</ControllerButton>
|
||||
</div>
|
||||
<p className="dungeon-reports-summary">{reports.length} of {snapshot?.reports.length ?? 0} reports · {canManage ? "GM status controls enabled" : "All players can view reports. GMs manage their status."}</p>
|
||||
{(error || updateError) && <p role="alert" className="front-notice">{updateError || error}{snapshot && error ? " Showing the last loaded reports." : ""}</p>}
|
||||
{message && <p role="status" className="front-notice">{message}</p>}
|
||||
<section className="dungeon-report-list" aria-label="Reports" aria-busy={loading || saving}>
|
||||
{loading && !snapshot ? <p role="status">Loading reports...</p> : snapshot && reports.length === 0 ? (
|
||||
<p>{snapshot.reports.length ? "No reports match these filters." : "No dungeon reports have been submitted yet."}</p>
|
||||
) : reports.map((report) => <ReportCard key={report.id} report={report} canManage={canManage} saving={saving}
|
||||
onUpdate={(item, nextStatus) => void update(item, nextStatus)} selectedId={controller.selectedId} select={controller.select} />)}
|
||||
</section>
|
||||
</main>
|
||||
<ControllerLegend back />
|
||||
</FrontSurface>
|
||||
} bottom={<LazyLocalCompanionPanel />} />
|
||||
);
|
||||
}
|
||||
@@ -26,6 +26,8 @@ import { BrandMark, ControllerLegend, FrontSurface } from "./FrontSurface";
|
||||
import { OnlineGroupPanel } from "./OnlineGroupPanel";
|
||||
import { useOnlineGroupStore } from "../app/onlineGroupStore";
|
||||
import { specializationById } from "../game/specializationCatalog";
|
||||
import { DUNGEON_RELEASE_STAGE_LABELS } from "../game/dungeonReleaseStage";
|
||||
import { useDungeonReports } from "./useDungeonReports";
|
||||
|
||||
installDungeonShellRuntime();
|
||||
|
||||
@@ -50,6 +52,7 @@ interface PendingDungeonDownload {
|
||||
}
|
||||
|
||||
export function DungeonSelectScreen() {
|
||||
const reports = useDungeonReports();
|
||||
const contentState = useContentManagerSnapshot();
|
||||
const activeCharacter = useShellStore((state) => state.activeCharacter);
|
||||
const rosterCharacter = useShellStore(selectedCharacter);
|
||||
@@ -253,19 +256,24 @@ export function DungeonSelectScreen() {
|
||||
controlId={`dungeon-${dungeon.id}`}
|
||||
selectedId={controller.selectedId}
|
||||
select={controller.select}
|
||||
className={`dungeon-select-row ${dungeon.id === selectedDungeon?.id ? "is-active" : ""}`}
|
||||
className={`dungeon-select-row ${dungeon.id === selectedDungeon?.id ? "is-active" : ""} ${(reports.snapshot?.openCounts[dungeon.id] ?? 0) > 0 ? "has-open-reports" : ""}`}
|
||||
type="button"
|
||||
aria-pressed={dungeon.id === selectedDungeon?.id}
|
||||
onClick={() => selectDungeon(dungeon.id)}
|
||||
>
|
||||
<b aria-hidden="true">{String(index + 1).padStart(2, "0")}</b>
|
||||
<span><strong>{dungeon.title}</strong><small>{dungeon.entranceName}</small></span>
|
||||
<span><strong>{dungeon.title}</strong><small>{DUNGEON_RELEASE_STAGE_LABELS[dungeon.releaseStage]} · {dungeon.entranceName}</small></span>
|
||||
{(reports.snapshot?.openCounts[dungeon.id] ?? 0) > 0 && (
|
||||
<sup className="dungeon-open-report-count" aria-label={`${reports.snapshot!.openCounts[dungeon.id]} open reports`}
|
||||
title={`${reports.snapshot!.openCounts[dungeon.id]} open reports`}>{reports.snapshot!.openCounts[dungeon.id]}</sup>
|
||||
)}
|
||||
<em>{dungeon.available
|
||||
? dungeonContentIsUsable(dungeon.id) ? "Ready" : "Download required"
|
||||
: "Coming soon"}</em>
|
||||
</ControllerButton>
|
||||
))}
|
||||
</div>
|
||||
{reports.error && <small role="status" className="dungeon-report-load-error">Report counts unavailable. {reports.snapshot ? "Showing the last loaded counts." : ""}</small>}
|
||||
<ControllerButton
|
||||
controlId="dungeon-back"
|
||||
selectedId={controller.selectedId}
|
||||
@@ -295,6 +303,7 @@ export function DungeonSelectScreen() {
|
||||
<section className="dungeon-select-copy">
|
||||
<p className="front-kicker">{canStart ? "Playable expedition" : selectedDungeon.available ? "Content required" : "In development"}</p>
|
||||
<h2>{selectedDungeon.title}</h2>
|
||||
<span className="dungeon-release-stage">{DUNGEON_RELEASE_STAGE_LABELS[selectedDungeon.releaseStage]}</span>
|
||||
<p>{selectedDungeon.summary}</p>
|
||||
{!canStart && selectedDungeon.availabilityReason && (
|
||||
<p className="front-notice" role="status">{selectedDungeon.availabilityReason}</p>
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { HUD_ELEMENTS, type HudElementId } from "../game/uiLayout";
|
||||
import { HUD_ELEMENTS, type HudElementId, type HudElementLayout } from "../game/uiLayout";
|
||||
import { currentUiDevice, useUiLayoutStore } from "../game/uiLayoutStore";
|
||||
import { useGameStore } from "../game/store";
|
||||
import "./uiLayout.css";
|
||||
|
||||
const FALLBACKS: Partial<Record<HudElementId, [number, number, number]>> = {
|
||||
party: [.1, .3, 210], summons: [.27, .25, 150], minimap: [.9, .12, 160], player: [.9, .34, 160],
|
||||
timeline: [.9, .52, 160], target: [.5, .15, 230], friendly: [.3, .15, 230], threat: [.78, .25, 160],
|
||||
timeline: [.9, .52, 160], target: [.5, .15, 230], friendly: [.3, .15, 230], threat: [.78, .25, 160], dps: [.13, .8, 230],
|
||||
cast: [.5, .68, 330], actions: [.5, .86, 420], hints: [.5, .65, 420], experience: [.5, .99, 900],
|
||||
reticle: [.5, .5, 20], loot: [.5, .3, 320], interaction: [.5, .6, 320], manastorm: [.5, .05, 400],
|
||||
completion: [.5, .39, 360], assets: [.78, .6, 200], feedback: [.5, .71, 400],
|
||||
@@ -14,14 +15,17 @@ const FALLBACKS: Partial<Record<HudElementId, [number, number, number]>> = {
|
||||
};
|
||||
|
||||
/** Defaults remain in their original CSS flow until the player moves the widget. */
|
||||
export function EditableHudElement({ id, children, intrinsicWidth, bottomAnchor }: {
|
||||
export function EditableHudElement({ id, children, intrinsicWidth, bottomAnchor, elementLayout,
|
||||
editing: editingOverride, hostSelector = ".game-shell", onChange }: {
|
||||
id: HudElementId; children?: ReactNode; intrinsicWidth?: number; bottomAnchor?: number;
|
||||
elementLayout?: HudElementLayout; editing?: boolean; hostSelector?: string;
|
||||
onChange?: (update: Partial<HudElementLayout>) => void;
|
||||
}) {
|
||||
const overlay = useGameStore((state) => state.overlay);
|
||||
const editing = overlay === "ui-layout";
|
||||
const editing = editingOverride ?? overlay === "ui-layout";
|
||||
const state = useUiLayoutStore();
|
||||
const layout = state.draft ?? state.preferences.current[currentUiDevice()];
|
||||
const element = layout.elements[id];
|
||||
const element = elementLayout ?? layout.elements[id];
|
||||
const [origin, setOrigin] = useState<{ x: number; y: number; width: number } | null>(null);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [host, setHost] = useState<Element | null>(null);
|
||||
@@ -34,14 +38,14 @@ export function EditableHudElement({ id, children, intrinsicWidth, bottomAnchor
|
||||
: origin ?? { x: fallback[0], y: fallback[1] });
|
||||
const width = intrinsicWidth ?? element.width ?? origin?.width ?? fallback[2];
|
||||
|
||||
useLayoutEffect(() => { if (ref.current) setHost(ref.current.closest(".game-shell")); }, []);
|
||||
useLayoutEffect(() => { if (ref.current) setHost(ref.current.closest(hostSelector)); }, [hostSelector]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (positioned || !ref.current) return;
|
||||
const node = ref.current;
|
||||
const measure = () => {
|
||||
const child = node.firstElementChild?.getBoundingClientRect();
|
||||
const host = node.closest(".game-shell")?.getBoundingClientRect();
|
||||
const host = node.closest(hostSelector)?.getBoundingClientRect();
|
||||
if (child && host && child.width > 0 && child.height > 0) {
|
||||
setOrigin({ x: (child.left - host.left + child.width / 2) / host.width,
|
||||
y: (child.top - host.top + child.height / 2) / host.height, width: child.width });
|
||||
@@ -52,21 +56,23 @@ export function EditableHudElement({ id, children, intrinsicWidth, bottomAnchor
|
||||
if (node.firstElementChild) observer.observe(node.firstElementChild);
|
||||
window.addEventListener("resize", measure);
|
||||
return () => { observer.disconnect(); window.removeEventListener("resize", measure); };
|
||||
}, [positioned, children]);
|
||||
}, [positioned, children, hostSelector]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const node = ref.current;
|
||||
const host = node?.closest(".game-shell");
|
||||
const host = node?.closest(hostSelector);
|
||||
if (!positioned || !node || !host) return;
|
||||
const measure = () => setBounds({ width: host.clientWidth, height: host.clientHeight, panelWidth: node.offsetWidth, panelHeight: node.offsetHeight });
|
||||
const observer = new ResizeObserver(measure);
|
||||
observer.observe(node); observer.observe(host); measure();
|
||||
return () => observer.disconnect();
|
||||
}, [positioned, host]);
|
||||
}, [positioned, host, hostSelector]);
|
||||
|
||||
const updatePosition = (x: number, y: number) => state.updateDraft((draft) => ({ ...draft, elements: {
|
||||
...draft.elements, [id]: { ...draft.elements[id], position: { x: Math.max(0, Math.min(1, x)), y: Math.max(0, Math.min(1, y)) }, width },
|
||||
} }));
|
||||
const updatePosition = (x: number, y: number) => {
|
||||
const update = { position: { x: Math.max(0, Math.min(1, x)), y: Math.max(0, Math.min(1, y)) }, width };
|
||||
if (onChange) onChange(update);
|
||||
else state.updateDraft((draft) => ({ ...draft, elements: { ...draft.elements, [id]: { ...draft.elements[id], ...update } } }));
|
||||
};
|
||||
const left = Math.max(6, Math.min(Math.max(6, bounds.width - bounds.panelWidth - 6), position.x * bounds.width - bounds.panelWidth / 2));
|
||||
const top = Math.max(6, Math.min(Math.max(6, bounds.height - bounds.panelHeight - 6), position.y * bounds.height - bounds.panelHeight / 2));
|
||||
const style = positioned ? { left, top, width: `min(${width}px, calc(100% - 12px))` } as CSSProperties : undefined;
|
||||
@@ -85,7 +91,7 @@ export function EditableHudElement({ id, children, intrinsicWidth, bottomAnchor
|
||||
}}
|
||||
onPointerMove={(event) => {
|
||||
if (drag.current?.pointerId !== event.pointerId) return;
|
||||
const host = ref.current!.closest(".game-shell")!.getBoundingClientRect();
|
||||
const host = ref.current!.closest(hostSelector)!.getBoundingClientRect();
|
||||
updatePosition((event.clientX - host.left - drag.current.dx) / host.width, (event.clientY - host.top - drag.current.dy) / host.height);
|
||||
}}
|
||||
onPointerUp={() => { drag.current = null; }} onPointerCancel={() => { drag.current = null; }} onLostPointerCapture={() => { drag.current = null; }}
|
||||
|
||||
+31
-10
@@ -13,7 +13,7 @@ import { useShellStore } from "../app/shellStore";
|
||||
import { abilityAtLevel, resourceProfileForClass } from "../game/abilityCatalog";
|
||||
import { ACTION_CONTROLS, actionBindingId } from "../game/actionBindings";
|
||||
import { resolvedAbilityCosts, useCombatStore } from "../game/combatStore";
|
||||
import { respawnDefeatedPlayer } from "../game/deathRespawn";
|
||||
import { partyFightStillActive, respawnDefeatedPlayer } from "../game/deathRespawn";
|
||||
import { requireDungeonDefinition } from "../game/dungeonRegistry";
|
||||
import { resurrectManastormSession } from "../game/manastormSession";
|
||||
import { resolveStagePresentation } from "../game/manastormStagePresentation";
|
||||
@@ -28,12 +28,15 @@ import { Minimap } from "./Minimap";
|
||||
import { PlayerStatusPanel } from "./PlayerStatusPanel";
|
||||
import { EncounterTimeline } from "./EncounterTimeline";
|
||||
import { PartyFrames } from "./PartyFrames";
|
||||
import { ResurrectionDestination, useResurrectionCheckpoints } from "./ResurrectionDestination";
|
||||
import type { ResurrectionCheckpoint } from "../game/resurrectionCheckpoints";
|
||||
import { TimedEffectStrip } from "./TimedEffectStrip";
|
||||
import { AuraStrip } from "./AuraStrip";
|
||||
import { ShelvedHud } from "@shelvedHud";
|
||||
import { ThreatMeterWidget } from "./ThreatMeterWidget";
|
||||
import { BossLootWindow } from "./BossLootWindow";
|
||||
import { EditableHudElement } from "./EditableHudElement";
|
||||
import { DpsMeter } from "./DpsMeter";
|
||||
import { DesktopActionBars } from "./DesktopActionBars";
|
||||
import { currentUiDevice, useUiLayoutStore } from "../game/uiLayoutStore";
|
||||
import { SummonFrames } from "./SummonFrames";
|
||||
@@ -82,12 +85,18 @@ export function DefeatedPrompt({
|
||||
resurrectionCharges,
|
||||
onReturn,
|
||||
sharedOnline = false,
|
||||
checkpoints = [],
|
||||
partyStillFighting = false,
|
||||
}: {
|
||||
gameMode: "dungeon" | "manastorm";
|
||||
resurrectionCharges: number;
|
||||
onReturn: () => void;
|
||||
onReturn: (checkpointId?: string) => void;
|
||||
sharedOnline?: boolean;
|
||||
checkpoints?: readonly ResurrectionCheckpoint[];
|
||||
partyStillFighting?: boolean;
|
||||
}) {
|
||||
const [requestedCheckpoint, setRequestedCheckpoint] = useState("entrance");
|
||||
const checkpointId = checkpoints.some((checkpoint) => checkpoint.id === requestedCheckpoint) ? requestedCheckpoint : "entrance";
|
||||
const canReturn = !sharedOnline && (gameMode === "dungeon" || resurrectionCharges > 0);
|
||||
return (
|
||||
<section
|
||||
@@ -103,18 +112,25 @@ export function DefeatedPrompt({
|
||||
Stay down and wait for a party member to resurrect you, or choose to return now.
|
||||
</p>
|
||||
{canReturn ? (
|
||||
<button type="button" className="button button--primary" onClick={onReturn}>
|
||||
{gameMode === "manastorm"
|
||||
? `Resurrect now · ${resurrectionCharges} remaining`
|
||||
: "Respawn at entrance"}
|
||||
</button>
|
||||
<>
|
||||
{gameMode === "dungeon" && checkpoints.length > 0 ? (
|
||||
<ResurrectionDestination checkpoints={checkpoints} value={checkpointId} onChange={setRequestedCheckpoint} />
|
||||
) : null}
|
||||
<button type="button" className="button button--primary" onClick={() => onReturn(gameMode === "dungeon" ? checkpointId : undefined)}>
|
||||
{gameMode === "manastorm"
|
||||
? `Resurrect now · ${resurrectionCharges} remaining`
|
||||
: checkpointId === "entrance" ? "Respawn at entrance" : "Respawn at checkpoint"}
|
||||
</button>
|
||||
</>
|
||||
) : sharedOnline ? (
|
||||
<small>Your shared run is still active. A living player can resurrect you.</small>
|
||||
) : (
|
||||
<small>No shared resurrection charges remain. A living ally must resurrect you.</small>
|
||||
)}
|
||||
{gameMode === "dungeon" && !sharedOnline ? (
|
||||
<small>Respawning resets the current encounter and restores the party.</small>
|
||||
<small>{partyStillFighting
|
||||
? "Your party will keep fighting where they are. Use Recall if you want to regroup."
|
||||
: "Respawning resets the current encounter and restores the party."}</small>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
@@ -159,6 +175,7 @@ export function Hud() {
|
||||
const assetStatus = useGameStore((state) => state.assetStatus);
|
||||
const assetMessage = useGameStore((state) => state.assetMessage);
|
||||
const activeDungeonId = useGameStore((state) => state.activeDungeonId);
|
||||
const resurrectionCheckpoints = useResurrectionCheckpoints();
|
||||
const dungeon = requireDungeonDefinition(activeDungeonId);
|
||||
const encounter = useManastormStore((state) => state.currentEncounter);
|
||||
const resurrectionCharges = useManastormStore((state) => state.resurrectionCharges);
|
||||
@@ -191,6 +208,7 @@ export function Hud() {
|
||||
: null);
|
||||
const selfSelected = usePartyStore((state) => state.selfSelected);
|
||||
const partyMembers = usePartyStore((state) => state.members);
|
||||
const partyStillFighting = useCombatStore((state) => partyFightStillActive(partyMembers, state.mobs, state.actors));
|
||||
const playerRole = usePartyStore((state) => state.playerRole);
|
||||
const settings = useCombatStore((state) => state.settings);
|
||||
const onlineSession = useOnlineSessionStore((state) => state.snapshot);
|
||||
@@ -349,12 +367,12 @@ export function Hud() {
|
||||
mapId: dungeon.mapId,
|
||||
areaName,
|
||||
});
|
||||
const returnFromDefeat = () => {
|
||||
const returnFromDefeat = (checkpointId?: string) => {
|
||||
if (gameMode === "manastorm") {
|
||||
resurrectManastormSession({ kind: "player" });
|
||||
return;
|
||||
}
|
||||
respawnDefeatedPlayer();
|
||||
respawnDefeatedPlayer(checkpointId ?? "entrance");
|
||||
};
|
||||
const finishDungeon = () => {
|
||||
useShellStore.getState().saveActiveDungeonRunCheckpoint(null);
|
||||
@@ -364,6 +382,7 @@ export function Hud() {
|
||||
return (
|
||||
<div className="hud" style={{ "--game-ui-scale": settings.uiScale } as CSSProperties}>
|
||||
<ShelvedHud />
|
||||
<DpsMeter />
|
||||
<BossLootWindow />
|
||||
<EditableHudElement id="completion">{dungeonComplete && health > 0 ? (
|
||||
<DungeonCompletePrompt dungeonTitle={dungeon.title} onFinish={finishDungeon} />
|
||||
@@ -501,6 +520,8 @@ export function Hud() {
|
||||
gameMode={gameMode}
|
||||
resurrectionCharges={resurrectionCharges}
|
||||
onReturn={returnFromDefeat}
|
||||
checkpoints={resurrectionCheckpoints}
|
||||
partyStillFighting={partyStillFighting}
|
||||
sharedOnline={onlineSession !== null}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { ContentManagerScreen } from "./ContentManagerScreen";
|
||||
import { BrandMark, ControllerLegend, FrontSurface } from "./FrontSurface";
|
||||
import { OnlineGroupPanel } from "./OnlineGroupPanel";
|
||||
import { sessionCanUseShelvedGmTools, SHELVED_MODES_ENABLED } from "../app/releaseFeatures";
|
||||
import { DungeonReportsScreen } from "./DungeonReportsScreen";
|
||||
|
||||
const GAME_MODES = [
|
||||
{
|
||||
@@ -64,6 +65,16 @@ const GAME_MODES = [
|
||||
|
||||
export function MainMenuScreen() {
|
||||
const [contentManagerOpen, setContentManagerOpen] = useState(false);
|
||||
const [reportsOpen, setReportsOpen] = useState(false);
|
||||
if (contentManagerOpen) return <ContentManagerScreen onClose={() => setContentManagerOpen(false)} />;
|
||||
if (reportsOpen) return <DungeonReportsScreen onClose={() => setReportsOpen(false)} />;
|
||||
return <MainMenuDestinations onOpenDownloads={() => setContentManagerOpen(true)} onOpenReports={() => setReportsOpen(true)} />;
|
||||
}
|
||||
|
||||
function MainMenuDestinations({ onOpenDownloads, onOpenReports }: {
|
||||
readonly onOpenDownloads: () => void;
|
||||
readonly onOpenReports: () => void;
|
||||
}) {
|
||||
const activeCharacter = useShellStore((state) => state.activeCharacter);
|
||||
const rosterCharacter = useShellStore(selectedCharacter);
|
||||
const openDungeons = useShellStore((state) => state.openDungeons);
|
||||
@@ -95,7 +106,7 @@ export function MainMenuScreen() {
|
||||
}] : []),
|
||||
{
|
||||
id: "mode-downloads",
|
||||
run: () => setContentManagerOpen(true),
|
||||
run: onOpenDownloads,
|
||||
neighbors: { left: SHELVED_MODES_ENABLED ? "mode-manastorms" : "mode-dungeons", down: "back-to-roster" },
|
||||
},
|
||||
{
|
||||
@@ -105,7 +116,8 @@ export function MainMenuScreen() {
|
||||
},
|
||||
...(character ? [{ id: "mode-class-hall", run: openClassHall }] : []),
|
||||
...(isGm ? [{ id: "mode-gm-tools", run: openGmAdmin }] : []),
|
||||
], [character, isGm, openArenas, openClassHall, openDungeons, openGmAdmin, openManastorms, returnToCharacters]);
|
||||
{ id: "mode-reports", run: onOpenReports },
|
||||
], [character, isGm, onOpenDownloads, onOpenReports, openArenas, openClassHall, openDungeons, openGmAdmin, openManastorms, returnToCharacters]);
|
||||
const controller = useMenuController(actions, {
|
||||
initialId: "mode-dungeons",
|
||||
onBack: returnToCharacters,
|
||||
@@ -114,8 +126,6 @@ export function MainMenuScreen() {
|
||||
const race = character ? raceById(character.raceId) : null;
|
||||
const characterClass = character ? classById(character.classId) : null;
|
||||
|
||||
if (contentManagerOpen) return <ContentManagerScreen onClose={() => setContentManagerOpen(false)} />;
|
||||
|
||||
return (
|
||||
<DualDisplayFrame
|
||||
contextLabel="Main menu details"
|
||||
@@ -163,6 +173,12 @@ export function MainMenuScreen() {
|
||||
</header>
|
||||
<OnlineGroupPanel />
|
||||
<nav className="main-menu-mode-grid" aria-label="Game modes">
|
||||
<ControllerButton controlId="mode-reports" selectedId={controller.selectedId} select={controller.select}
|
||||
className="front-button main-menu-mode main-menu-mode--available" type="button" onClick={onOpenReports}>
|
||||
<b aria-hidden="true">!</b>
|
||||
<span><strong>View Reports</strong><small>Browse dungeon issues and follow their status.</small></span>
|
||||
<em>Open</em>
|
||||
</ControllerButton>
|
||||
{isGm && (
|
||||
<ControllerButton
|
||||
controlId="mode-gm-tools"
|
||||
@@ -204,7 +220,7 @@ export function MainMenuScreen() {
|
||||
: mode.id === "arenas"
|
||||
? openArenas
|
||||
: mode.id === "downloads"
|
||||
? () => setContentManagerOpen(true)
|
||||
? onOpenDownloads
|
||||
: openDungeons}
|
||||
>
|
||||
<b aria-hidden="true">{mode.number}</b>
|
||||
|
||||
+29
-2
@@ -1,4 +1,4 @@
|
||||
import type { CSSProperties } from "react";
|
||||
import { useState, type CSSProperties } from "react";
|
||||
import { classById, raceById } from "../app/characterCatalog";
|
||||
import { useShellStore } from "../app/shellStore";
|
||||
import { useCombatStore } from "../game/combatStore";
|
||||
@@ -15,8 +15,11 @@ import { useOnlineGroupStore } from "../app/onlineGroupStore";
|
||||
import type { OnlineGroupMember } from "../app/onlineGroupTypes";
|
||||
import { useOnlineSessionStore } from "../app/onlineSessionStore";
|
||||
import type { OnlineSessionPlayer } from "../app/onlineSessionTypes";
|
||||
import { onlinePlayerActorId } from "../game/onlineSessionRuntime";
|
||||
import { onlinePlayerActorId, onlineSessionOwnsWorldSimulation } from "../game/onlineSessionRuntime";
|
||||
import { auraDefinitionIsDispellableDebuff } from "./auraDisplay";
|
||||
import { partyResurrectionBlockedByBoss, resurrectDeadPartyMembers } from "../game/partyResurrection";
|
||||
import { ResurrectionDestination, useResurrectionCheckpoints } from "./ResurrectionDestination";
|
||||
import { useGameStore } from "../game/store";
|
||||
|
||||
const PARTY_COMMANDS: readonly {
|
||||
command: PartyCommand;
|
||||
@@ -230,6 +233,11 @@ export function PartyFrames() {
|
||||
.map((aura) => aura.definition.name)
|
||||
.join(", "));
|
||||
const members = usePartyStore((state) => state.members);
|
||||
const gameMode = useGameStore((state) => state.gameMode);
|
||||
const bossCombat = useCombatStore((state) => partyResurrectionBlockedByBoss(state.mobs));
|
||||
const checkpoints = useResurrectionCheckpoints();
|
||||
const [requestedCheckpoint, setRequestedCheckpoint] = useState("entrance");
|
||||
const checkpointId = checkpoints.some((checkpoint) => checkpoint.id === requestedCheckpoint) ? requestedCheckpoint : "entrance";
|
||||
const selectedMemberId = usePartyStore((state) => state.selectedMemberId);
|
||||
const selfSelected = usePartyStore((state) => state.selfSelected);
|
||||
const routeBlocked = usePartyStore((state) => state.routeBlocked);
|
||||
@@ -321,6 +329,25 @@ export function PartyFrames() {
|
||||
onSelect={() => selectMember(member.id)}
|
||||
/>
|
||||
))}
|
||||
{gameMode === "dungeon" && members.some((member) => member.health <= 0) ? (
|
||||
<div className="party-resurrection">
|
||||
<ResurrectionDestination checkpoints={checkpoints} value={checkpointId} onChange={setRequestedCheckpoint}
|
||||
disabled={bossCombat || !onlineSessionOwnsWorldSimulation()} />
|
||||
<button
|
||||
type="button"
|
||||
className="party-rez-button"
|
||||
disabled={bossCombat || !onlineSessionOwnsWorldSimulation()}
|
||||
title={bossCombat ? "Unavailable during boss combat." : !onlineSessionOwnsWorldSimulation()
|
||||
? "Only the party's simulation host can resurrect NPC teammates."
|
||||
: "Resurrect fallen NPC teammates at the selected checkpoint. They will return to you."}
|
||||
onClick={() => resurrectDeadPartyMembers(checkpointId)}
|
||||
>
|
||||
<strong>Rez dead members</strong>
|
||||
<small>{bossCombat ? "Unavailable during boss combat" : !onlineSessionOwnsWorldSimulation()
|
||||
? "Simulation host only" : "Then return to you"}</small>
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { ControllerButton } from "./ControllerButton";
|
||||
import { sessionCanUseShelvedGmTools } from "../app/releaseFeatures";
|
||||
import { useInputBindingLabels } from "../input/useInputBindingLabels";
|
||||
import { INPUT_ACTION_BY_ID } from "../input/keybinds";
|
||||
import { DungeonIssueReportPanel } from "./DungeonIssueReportPanel";
|
||||
|
||||
function PauseDialog() {
|
||||
const bindingLabel = useInputBindingLabels();
|
||||
@@ -47,6 +48,7 @@ function PauseDialog() {
|
||||
{ id: "pause-keybinds", run: () => openOverlay("keybinds") },
|
||||
{ id: "pause-options", run: () => openOverlay("options") },
|
||||
{ id: "pause-ui-layout", run: () => openOverlay("ui-layout") },
|
||||
...(gameMode === "dungeon" ? [{ id: "pause-report-issue", run: () => openOverlay("report-issue") }] : []),
|
||||
...(isGm ? [{ id: "pause-gm", run: () => openOverlay("gm") }] : []),
|
||||
...(gameMode === "manastorm"
|
||||
? [{ id: "pause-abandon-manastorm", run: abandonManastorm }]
|
||||
@@ -84,6 +86,7 @@ function PauseDialog() {
|
||||
{menuButton("pause-keybinds", "Keybinds", () => openOverlay("keybinds"))}
|
||||
{menuButton("pause-options", "Options", () => openOverlay("options"))}
|
||||
{menuButton("pause-ui-layout", "Edit UI & templates", () => openOverlay("ui-layout"))}
|
||||
{gameMode === "dungeon" ? menuButton("pause-report-issue", "Report an issue", () => openOverlay("report-issue")) : null}
|
||||
{isGm ? menuButton("pause-gm", "GM placement editor", () => openOverlay("gm")) : null}
|
||||
{gameMode === "manastorm"
|
||||
? menuButton("pause-abandon-manastorm", "Abandon Manastorm run", abandonManastorm)
|
||||
@@ -103,6 +106,7 @@ function PauseDialog() {
|
||||
}
|
||||
|
||||
export function PauseMenu() {
|
||||
const open = useGameStore((state) => state.overlay === "pause");
|
||||
return open ? <PauseDialog /> : null;
|
||||
const overlay = useGameStore((state) => state.overlay);
|
||||
if (overlay === "report-issue") return <DungeonIssueReportPanel />;
|
||||
return overlay === "pause" ? <PauseDialog /> : null;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { useMemo, useSyncExternalStore } from "react";
|
||||
import { useCombatStore } from "../game/combatStore";
|
||||
import { activeDungeonCompletedObjectiveIds, activeDungeonEncounterRevision, subscribeDungeonEncounterSnapshots } from "../game/dungeonEncounterRuntime";
|
||||
import { requireDungeonDefinition } from "../game/dungeonRegistry";
|
||||
import { dungeonResurrectionCheckpoints, type ResurrectionCheckpoint } from "../game/resurrectionCheckpoints";
|
||||
import { useGameStore } from "../game/store";
|
||||
|
||||
export function useResurrectionCheckpoints() {
|
||||
const dungeonId = useGameStore((state) => state.activeDungeonId);
|
||||
const mobs = useCombatStore((state) => state.mobs);
|
||||
const revision = useSyncExternalStore(subscribeDungeonEncounterSnapshots,
|
||||
activeDungeonEncounterRevision, activeDungeonEncounterRevision);
|
||||
return useMemo(() => dungeonResurrectionCheckpoints(requireDungeonDefinition(dungeonId), mobs,
|
||||
activeDungeonCompletedObjectiveIds(dungeonId)), [dungeonId, mobs, revision]);
|
||||
}
|
||||
|
||||
export function ResurrectionDestination({ checkpoints, value, onChange, disabled = false }: {
|
||||
checkpoints: readonly ResurrectionCheckpoint[];
|
||||
value: string;
|
||||
onChange: (id: string) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<label className="resurrection-destination">
|
||||
<span>Resurrect at</span>
|
||||
<select value={value} onChange={(event) => onChange(event.target.value)} disabled={disabled}>
|
||||
{checkpoints.map((checkpoint) => <option key={checkpoint.id} value={checkpoint.id}>{checkpoint.name}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -138,6 +138,23 @@ export function UiLayoutEditor() {
|
||||
</div>)}
|
||||
<button type="button" onClick={() => ui.updateDraft((layout) => ({ ...layout, elements: defaultUiLayout().elements }))}>Reset all positions and visibility</button>
|
||||
</details>
|
||||
<details open className="ui-layout-editor__dps"><summary>DPS meter</summary>
|
||||
<label><input type="checkbox" checked={draft.elements.dps.visible} onChange={(event) => ui.updateDraft((layout) => ({ ...layout,
|
||||
elements: { ...layout.elements, dps: { ...layout.elements.dps, visible: event.target.checked } },
|
||||
}))} />Show DPS meter</label>
|
||||
<label>Screen<select aria-label="DPS meter screen" value={draft.dpsMeterDisplay} onChange={(event) => ui.updateDraft((layout) => ({ ...layout,
|
||||
dpsMeterDisplay: event.target.value as typeof layout.dpsMeterDisplay,
|
||||
elements: { ...layout.elements, dps: { ...layout.elements.dps, position: null } },
|
||||
}))}>
|
||||
<option value="auto">Automatic · PC lower left / Thor bottom</option>
|
||||
<option value="main">Main screen</option><option value="companion">Companion screen</option>
|
||||
</select></label>
|
||||
<label>Width<input type="range" aria-label="DPS meter width" min={160} max={600} step={10} value={draft.elements.dps.width ?? 230}
|
||||
onChange={(event) => ui.updateDraft((layout) => ({ ...layout, elements: { ...layout.elements,
|
||||
dps: { ...layout.elements.dps, width: Number(event.target.value) },
|
||||
} }))} /><output>{draft.elements.dps.width ?? 230}px</output></label>
|
||||
<p>Drag the DPS label on its screen to move it. Damage includes pets and summons. The last fight stays visible until the next pull.</p>
|
||||
</details>
|
||||
{ui.device === "pc" && <details open><summary>PC action bars</summary>
|
||||
<label className="ui-layout-editor__check"><input type="checkbox" checked={draft.wowBars} onChange={(event) => ui.updateDraft((layout) => {
|
||||
const combat = useCombatStore.getState();
|
||||
|
||||
@@ -2,6 +2,7 @@ import { createElement } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { DefeatedPrompt, DungeonCompletePrompt } from "./Hud";
|
||||
import { ResurrectionDestination } from "./ResurrectionDestination";
|
||||
|
||||
describe("defeated prompt", () => {
|
||||
it("offers an explicit dungeon respawn while explaining that waiting is allowed", () => {
|
||||
@@ -13,6 +14,17 @@ describe("defeated prompt", () => {
|
||||
|
||||
expect(markup).toContain("wait for a party member to resurrect you");
|
||||
expect(markup).toContain("Respawn at entrance");
|
||||
expect(markup).toContain("Respawning resets the current encounter and restores the party.");
|
||||
});
|
||||
|
||||
it("explains player-only resurrection and optional Recall during an ongoing party fight", () => {
|
||||
const markup = renderToStaticMarkup(createElement(DefeatedPrompt, {
|
||||
gameMode: "dungeon", resurrectionCharges: 0, partyStillFighting: true, onReturn: () => undefined,
|
||||
}));
|
||||
expect(markup).toContain("Your party will keep fighting where they are.");
|
||||
expect(markup).toContain("Use Recall if you want to regroup.");
|
||||
expect(markup).toContain("Respawn at entrance");
|
||||
expect(markup).not.toContain("resets the current encounter");
|
||||
});
|
||||
|
||||
it("does not offer a Manastorm resurrection when no shared charges remain", () => {
|
||||
@@ -25,6 +37,29 @@ describe("defeated prompt", () => {
|
||||
expect(markup).toContain("No shared resurrection charges remain");
|
||||
expect(markup).not.toContain("Resurrect now");
|
||||
});
|
||||
|
||||
it("offers labeled entrance and killed-boss choices on the player defeat prompt", () => {
|
||||
const markup = renderToStaticMarkup(createElement(DefeatedPrompt, {
|
||||
gameMode: "dungeon", resurrectionCharges: 0, onReturn: () => undefined,
|
||||
checkpoints: [
|
||||
{ id: "entrance", name: "Dungeon entrance", spawn: { footPosition: [0, 0, 0], yaw: 0 } },
|
||||
{ id: "boss:bazil", name: "Bazil Thredd", spawn: { footPosition: [20, 0, 0], yaw: 0 } },
|
||||
],
|
||||
}));
|
||||
expect(markup).toContain("Resurrect at");
|
||||
expect(markup).toContain("value=\"boss:bazil\"");
|
||||
expect(markup).toContain("Dungeon entrance");
|
||||
});
|
||||
|
||||
it("retains a selected boss destination in the shared chooser", () => {
|
||||
const markup = renderToStaticMarkup(createElement(ResurrectionDestination, {
|
||||
checkpoints: [
|
||||
{ id: "entrance", name: "Dungeon entrance", spawn: { footPosition: [0, 0, 0], yaw: 0 } },
|
||||
{ id: "boss:bazil", name: "Bazil Thredd", spawn: { footPosition: [20, 0, 0], yaw: 0 } },
|
||||
], value: "boss:bazil", onChange: () => undefined,
|
||||
}));
|
||||
expect(markup).toContain("value=\"boss:bazil\" selected=\"\"");
|
||||
});
|
||||
});
|
||||
|
||||
describe("dungeon completion prompt", () => {
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
.dps-meter {
|
||||
position: absolute;
|
||||
z-index: 12;
|
||||
bottom: max(32px, env(safe-area-inset-bottom));
|
||||
left: max(14px, env(safe-area-inset-left));
|
||||
width: min(var(--dps-meter-width, 230px), calc(100% - 28px));
|
||||
max-height: 220px;
|
||||
overflow: auto;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--edge, #536344);
|
||||
border-radius: 6px;
|
||||
background: rgba(5, 17, 12, .9);
|
||||
color: var(--ink, #ece5d0);
|
||||
font-size: 11px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.dps-meter > header { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
|
||||
.dps-meter > header strong { color: var(--gold, #d5c28b); font: bold 13px Georgia, serif; }
|
||||
.dps-meter > header small { flex: 1; color: var(--muted, #a4b69a); font-size: 10px; }
|
||||
.dps-meter > header span { font-size: 10px; }
|
||||
.dps-meter > p { margin: 4px 0; color: var(--muted, #a4b69a); font-size: 10px; }
|
||||
.dps-meter > ol { display: grid; gap: 3px; margin: 0; padding: 0; list-style: none; }
|
||||
.dps-meter li { position: relative; display: flex; align-items: center; gap: 8px; min-height: 20px; padding: 2px 5px; overflow: hidden; border-radius: 3px; background: #ffffff06; }
|
||||
.dps-meter li > i { position: absolute; inset: 0 auto 0 0; background: var(--dps-color); opacity: .3; }
|
||||
.dps-meter li > span { position: relative; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.dps-meter li > b { position: relative; font-size: 10px; }
|
||||
.dps-meter > footer { display: flex; justify-content: space-between; gap: 5px; margin-top: 6px; color: var(--muted, #a4b69a); font-size: 9px; }
|
||||
.dps-meter > footer b { color: var(--ink, #ece5d0); white-space: nowrap; }
|
||||
.editable-hud--editing:has(> .dps-meter) { padding-top: 24px; }
|
||||
.dps-meter--compact { position: relative; inset: auto; width: min(var(--dps-meter-width, 230px), 100%); max-height: 150px; padding: 5px; font-size: 8px; }
|
||||
.dps-meter--compact > header { gap: 5px; margin-bottom: 3px; }
|
||||
.dps-meter--compact > header strong { font-size: 10px; }
|
||||
.dps-meter--compact > header :is(small, span), .dps-meter--compact > p { font-size: 7px; }
|
||||
.dps-meter--compact > ol { gap: 2px; }
|
||||
.dps-meter--compact li { min-height: 13px; padding: 1px 4px; }
|
||||
.dps-meter--compact li > b { font-size: 7px; }
|
||||
.dps-meter--compact > footer { margin-top: 3px; font-size: 6px; }
|
||||
.companion-tactical__overview { display: grid; grid-template-rows: minmax(0, 1fr) auto; gap: 6px; min-width: 0; min-height: 0; }
|
||||
.companion-tactical__overview > .companion-map-card { grid-template-columns: minmax(0, 1fr) 110px; min-height: 0; }
|
||||
.ui-layout-editor__dps { padding: 5px 0; }
|
||||
.ui-layout-editor__dps input[type="range"] { flex: 1; min-width: 0; }
|
||||
@@ -0,0 +1,89 @@
|
||||
:root { --report-orange: #f1a044; }
|
||||
.dungeon-select-row { position: relative; }
|
||||
.dungeon-select-row.has-open-reports { border-color: var(--report-orange); }
|
||||
.dungeon-select-row.has-open-reports.is-controller-selected { box-shadow: 0 0 0 2px rgba(241,160,68,.5) !important; }
|
||||
.dungeon-select-row.has-open-reports.is-controller-selected, .dungeon-select-row.has-open-reports:focus-visible { outline-color: var(--report-orange) !important; }
|
||||
.dungeon-open-report-count {
|
||||
position: absolute;
|
||||
top: 3px;
|
||||
right: 4px;
|
||||
display: grid;
|
||||
min-width: 19px;
|
||||
height: 19px;
|
||||
padding: 0 4px;
|
||||
place-items: center;
|
||||
border-radius: 10px;
|
||||
background: var(--report-orange);
|
||||
color: #211608;
|
||||
font: 700 11px/1 sans-serif;
|
||||
}
|
||||
.dungeon-release-stage {
|
||||
display: inline-block;
|
||||
margin-bottom: 9px;
|
||||
padding: 3px 7px;
|
||||
border: 1px solid var(--front-line);
|
||||
border-radius: 5px;
|
||||
color: var(--front-gold);
|
||||
font-size: 10px;
|
||||
}
|
||||
.dungeon-report-load-error { margin: 0 0 5px; color: var(--report-orange); font-size: 10px; }
|
||||
.dungeon-issue-report form { display: grid; gap: 10px; margin-bottom: 12px; }
|
||||
.dungeon-issue-report textarea {
|
||||
width: 100%;
|
||||
min-height: 140px;
|
||||
max-height: 30vh;
|
||||
padding: 12px;
|
||||
resize: vertical;
|
||||
border: 1px solid var(--edge);
|
||||
border-radius: 8px;
|
||||
background: rgba(0,0,0,.25);
|
||||
color: var(--ink);
|
||||
font: inherit;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.dungeon-issue-report form > small { color: var(--muted); text-align: right; }
|
||||
.dungeon-issue-report > .button { width: 100%; }
|
||||
.dungeon-report-error { margin: 0; color: var(--report-orange); }
|
||||
.dungeon-reports-screen { display: grid; grid-template-rows: auto minmax(0,1fr) auto; gap: 14px; }
|
||||
.dungeon-reports-header { display: flex; align-items: center; gap: 20px; padding-bottom: 12px; border-bottom: 1px solid var(--front-line); }
|
||||
.dungeon-reports-header > div:nth-child(2) { flex: 1; }
|
||||
.dungeon-reports-header > div > span { color: var(--front-dim); font-size: 10px; }
|
||||
.dungeon-reports-header h1 { margin: 3px 0 0; font: 26px Georgia, serif; }
|
||||
.dungeon-reports-main { display: flex; min-height: 0; flex-direction: column; }
|
||||
.dungeon-report-filters { display: flex; flex-wrap: wrap; align-items: end; gap: 10px; }
|
||||
.dungeon-report-filters label, .dungeon-report-management label { display: grid; min-width: 0; gap: 5px; color: var(--front-dim); font-size: 12px; }
|
||||
.dungeon-report-filters select, .dungeon-report-management select {
|
||||
width: 100%;
|
||||
max-width: 360px;
|
||||
min-height: 40px;
|
||||
padding: 7px 10px;
|
||||
border: 1px solid var(--front-line);
|
||||
border-radius: 7px;
|
||||
background: #182015;
|
||||
color: var(--front-cream);
|
||||
font: inherit;
|
||||
}
|
||||
.dungeon-report-filters > label:nth-child(2) { flex: 1; }
|
||||
.dungeon-reports-summary { color: var(--front-dim); font-size: 12px; }
|
||||
.dungeon-report-list { display: grid; min-height: 0; align-content: start; gap: 12px; overflow: auto; padding: 2px 4px 10px 0; }
|
||||
.dungeon-report-card { padding: 16px; border: 1px solid var(--report-orange); border-radius: 9px; background: rgba(255,255,255,.025); }
|
||||
.dungeon-report-card.is-closed { border-color: var(--front-line); }
|
||||
.dungeon-report-card header { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; }
|
||||
.dungeon-report-card h2 { flex: 1; margin: 0; font: 19px Georgia, serif; }
|
||||
.dungeon-report-status { padding: 4px 8px; border-radius: 5px; background: rgba(241,160,68,.12); color: var(--report-orange); font-size: 12px; }
|
||||
.is-closed .dungeon-report-status { color: var(--front-dim); background: rgba(255,255,255,.05); }
|
||||
.dungeon-report-comment { white-space: pre-wrap; overflow-wrap: anywhere; font-size: 14px; line-height: 1.5; }
|
||||
.dungeon-report-card > small { display: block; margin-top: 5px; color: var(--front-dim); font-size: 11px; }
|
||||
.dungeon-report-management { display: flex; flex-wrap: wrap; align-items: end; gap: 12px; margin-top: 14px; }
|
||||
.dungeon-reports-screen > .controller-legend { position: static; justify-self: end; }
|
||||
.dungeon-issue-report textarea:focus-visible, .dungeon-reports-screen select:focus-visible { outline: 2px solid var(--report-orange); outline-offset: 2px; }
|
||||
.dungeon-reports-screen select.is-controller-selected { outline: 2px solid var(--front-gold); outline-offset: 2px; }
|
||||
@media (max-width: 600px) {
|
||||
.dungeon-reports-header { gap: 10px; }
|
||||
.dungeon-reports-header > .front-brand { display: none; }
|
||||
.dungeon-reports-header h1 { font-size: 21px; }
|
||||
.dungeon-report-filters { display: grid; grid-template-columns: minmax(0,1fr) minmax(0,1fr); }
|
||||
.dungeon-report-card { padding: 12px; }
|
||||
.dungeon-report-card h2 { font-size: 17px; }
|
||||
.dungeon-reports-screen > .controller-legend { display: none; }
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { createElement } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useCombatStore } from "../game/combatStore";
|
||||
import { activateDungeonEncounterDefinitions, resetDungeonEncounters } from "../game/dungeonEncounterRuntime";
|
||||
import { requireDungeonDefinition } from "../game/dungeonRegistry";
|
||||
import { usePartyStore } from "../game/partyStore";
|
||||
import { useGameStore } from "../game/store";
|
||||
import { PartyFrames } from "./PartyFrames";
|
||||
|
||||
// Exercise the client HUD against live stores rather than their initial SSR snapshots.
|
||||
vi.mock("../game/partyStore", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../game/partyStore")>();
|
||||
return { ...actual, usePartyStore: Object.assign(
|
||||
(select: (state: ReturnType<typeof actual.usePartyStore.getState>) => unknown) => select(actual.usePartyStore.getState()), actual.usePartyStore,
|
||||
) };
|
||||
});
|
||||
vi.mock("../game/combatStore", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../game/combatStore")>();
|
||||
return { ...actual, useCombatStore: Object.assign(
|
||||
(select: (state: ReturnType<typeof actual.useCombatStore.getState>) => unknown) => select(actual.useCombatStore.getState()), actual.useCombatStore,
|
||||
) };
|
||||
});
|
||||
vi.mock("../game/store", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../game/store")>();
|
||||
return { ...actual, useGameStore: Object.assign(
|
||||
(select: (state: ReturnType<typeof actual.useGameStore.getState>) => unknown) => select(actual.useGameStore.getState()), actual.useGameStore,
|
||||
) };
|
||||
});
|
||||
|
||||
const dungeon = requireDungeonDefinition("stormwind-stockade");
|
||||
const render = () => renderToStaticMarkup(createElement(PartyFrames));
|
||||
function killMember() {
|
||||
const member = usePartyStore.getState().members[0];
|
||||
useCombatStore.getState().commitCompanionActors([{ ...member, health: 0, status: "down" }]);
|
||||
}
|
||||
|
||||
describe("party resurrection UI", () => {
|
||||
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: {} });
|
||||
usePartyStore.getState().initializeParty("rez-ui", 16, useCombatStore.getState().health);
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
});
|
||||
|
||||
it("hides the control when all NPCs are alive, even if only the player is dead", () => {
|
||||
expect(render()).not.toContain("Rez dead members");
|
||||
useCombatStore.getState().damagePlayer(useCombatStore.getState().maxHealth * 10);
|
||||
expect(render()).not.toContain("Rez dead members");
|
||||
});
|
||||
|
||||
it("places the button and labeled destination selector below every party frame", () => {
|
||||
killMember();
|
||||
const markup = render();
|
||||
expect(markup).toContain("Rez dead members");
|
||||
expect(markup).toContain("Resurrect at");
|
||||
expect(markup).toContain("Dungeon entrance");
|
||||
expect(markup.indexOf("party-resurrection")).toBeGreaterThan(markup.lastIndexOf("party-frame__identity"));
|
||||
expect(markup).not.toMatch(/class="party-rez-button" disabled/);
|
||||
});
|
||||
|
||||
it("shows why resurrection is disabled during a living boss encounter", () => {
|
||||
killMember();
|
||||
useCombatStore.getState().registerMob("boss", { name: "Boss", boss: true, maxHealth: 100 });
|
||||
useCombatStore.getState().damageMob("boss", 1, 100);
|
||||
expect(render()).toMatch(/class="party-rez-button" disabled=""/);
|
||||
expect(render()).toContain("Unavailable during boss combat");
|
||||
});
|
||||
|
||||
it("offers killed boss destinations but not bosses that are still alive", () => {
|
||||
killMember();
|
||||
const boss = dungeon.bosses[0];
|
||||
expect(render()).not.toContain(`value="boss:${boss.id}"`);
|
||||
useCombatStore.getState().registerMob(boss.id, { name: boss.name, boss: true, maxHealth: 1 });
|
||||
useCombatStore.getState().damageMob(boss.id, 1, 100);
|
||||
expect(render()).toContain(`value="boss:${boss.id}"`);
|
||||
});
|
||||
|
||||
it("does not offer free resurrection in Manastorm", () => {
|
||||
killMember();
|
||||
useGameStore.setState({ gameMode: "manastorm" });
|
||||
expect(render()).not.toContain("Rez dead members");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { fetchDungeonReports, type DungeonReportSnapshot } from "../app/dungeonReports";
|
||||
|
||||
export function useDungeonReports() {
|
||||
const [snapshot, setSnapshot] = useState<DungeonReportSnapshot | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const sequence = useRef(0);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
const request = ++sequence.current;
|
||||
setLoading(true);
|
||||
try {
|
||||
const next = await fetchDungeonReports();
|
||||
if (request !== sequence.current) return;
|
||||
setSnapshot(next);
|
||||
setError("");
|
||||
} catch (failure) {
|
||||
if (request === sequence.current) setError(failure instanceof Error ? failure.message : "Reports could not be loaded.");
|
||||
} finally {
|
||||
if (request === sequence.current) setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const refreshVisible = () => { if (document.visibilityState === "visible") void refresh(); };
|
||||
void refresh();
|
||||
const timer = window.setInterval(refreshVisible, 30_000);
|
||||
window.addEventListener("focus", refreshVisible);
|
||||
document.addEventListener("visibilitychange", refreshVisible);
|
||||
return () => {
|
||||
sequence.current += 1;
|
||||
window.clearInterval(timer);
|
||||
window.removeEventListener("focus", refreshVisible);
|
||||
document.removeEventListener("visibilitychange", refreshVisible);
|
||||
};
|
||||
}, [refresh]);
|
||||
|
||||
return { snapshot, loading, error, refresh };
|
||||
}
|
||||
Reference in new issue
Block a user