Compare commits

...
3 Commits
Author SHA1 Message Date
phenom b417b7c3c3 Release Healer Man 0.1.38 2026-09-12 23:55:24 -04:00
phenom 361734bc41 Release Healer Man 0.1.37 2026-09-12 21:14:41 -04:00
phenom 03a4e3f51c Release Healer Man 0.1.36 2026-09-12 19:16:01 -04:00
94 changed files with 5070 additions and 338 deletions

No files matched your search

+414
View File
@@ -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.
+2 -2
View File
@@ -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
View File
@@ -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",
+2 -2
View File
@@ -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/);
});
+2
View File
@@ -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,
+94
View File
@@ -0,0 +1,94 @@
import { randomUUID } from "node:crypto";
import availability from "../src/game/generated/dungeonAvailability.json" with { type: "json" };
import policy from "../shared/dungeon-report-policy.json" with { type: "json" };
export const REPORT_DUNGEON_IDS = new Set(["forsaken-abbey", ...Object.keys(availability.enabled)]);
export function createDungeonReportRepository(database, { isGameMaster, ErrorType }) {
database.exec(`
CREATE TABLE IF NOT EXISTS dungeon_reports (
id TEXT PRIMARY KEY,
dungeon_id TEXT NOT NULL,
comment TEXT NOT NULL CHECK (length(comment) BETWEEN 1 AND 500),
status TEXT NOT NULL CHECK (status IN ('Submitted', 'In-Progress', 'Testing Fixes', 'Closed')),
submitted_by_account_id TEXT REFERENCES accounts(id) ON DELETE SET NULL,
submitted_by_username TEXT NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
updated_by_username TEXT,
revision INTEGER NOT NULL DEFAULT 0
) STRICT;
CREATE INDEX IF NOT EXISTS dungeon_reports_dungeon_status_idx ON dungeon_reports(dungeon_id, status);
`);
const insert = database.prepare(`
INSERT INTO dungeon_reports (
id, dungeon_id, comment, status, submitted_by_account_id, submitted_by_username, created_at, updated_at
) VALUES (?, ?, ?, 'Submitted', ?, ?, ?, ?)
`);
const byId = database.prepare("SELECT * FROM dungeon_reports WHERE id = ?");
const list = database.prepare(`
SELECT * FROM dungeon_reports
WHERE (? IS NULL OR status = ?) AND (? IS NULL OR dungeon_id = ?)
ORDER BY created_at DESC, id ASC
`);
const counts = database.prepare(`
SELECT dungeon_id, COUNT(*) AS count FROM dungeon_reports WHERE status != 'Closed' GROUP BY dungeon_id
`);
const update = database.prepare(`
UPDATE dungeon_reports SET status = ?, updated_at = ?, updated_by_username = ?, revision = revision + 1
WHERE id = ? AND revision = ? RETURNING *
`);
function validateDungeon(id) {
if (!REPORT_DUNGEON_IDS.has(id)) throw new ErrorType("Choose a valid dungeon.", 400, "invalid_dungeon");
}
function validateStatus(status) {
if (!policy.statuses.includes(status)) throw new ErrorType("Choose a valid report status.", 400, "invalid_report_status");
}
function publicReport(row) {
return {
id: row.id,
dungeonId: row.dungeon_id,
comment: row.comment,
status: row.status,
submittedBy: row.submitted_by_username,
createdAt: Number(row.created_at),
updatedAt: Number(row.updated_at),
updatedBy: row.updated_by_username,
revision: Number(row.revision),
};
}
return {
listDungeonReports({ status = null, dungeonId = null } = {}) {
if (status !== null) validateStatus(status);
if (dungeonId !== null) validateDungeon(dungeonId);
return {
reports: list.all(status, status, dungeonId, dungeonId).map(publicReport),
openCounts: Object.fromEntries(counts.all().map((row) => [row.dungeon_id, Number(row.count)])),
};
},
submitDungeonReport(account, input) {
validateDungeon(input?.dungeonId);
if (typeof input?.comment !== "string" || !input.comment.trim() || input.comment.length > policy.commentMaxLength) {
throw new ErrorType("Describe the issue in 1-500 characters.", 400, "invalid_report_comment");
}
const id = randomUUID();
const now = Date.now();
insert.run(id, input.dungeonId, input.comment.replace(/\r\n?/g, "\n").trim(), account?.id ?? null,
account?.username ?? "Guest player", now, now);
return publicReport(byId.get(id));
},
updateDungeonReport(account, id, input) {
if (!isGameMaster(account)) throw new ErrorType("Game Master access is required.", 403, "gm_required");
validateStatus(input?.status);
if (!Number.isInteger(input?.expectedRevision) || input.expectedRevision < 0) {
throw new ErrorType("A valid expected revision is required.", 400, "invalid_revision");
}
if (!byId.get(id)) throw new ErrorType("Report not found.", 404, "report_not_found");
const row = update.get(input.status, Date.now(), account.username, id, input.expectedRevision);
if (!row) throw new ErrorType("This report has changed. Refresh reports and try again.", 409, "report_conflict");
return publicReport(row);
},
};
}
+104
View File
@@ -0,0 +1,104 @@
import assert from "node:assert/strict";
import { mkdtempSync, rmSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import { openGameDatabase } from "./database.mjs";
import { createGameServer } from "./server.mjs";
async function withDatabase(run) {
const directory = mkdtempSync(path.join(os.tmpdir(), "healer-man-reports-test-"));
const database = openGameDatabase({ dataDir: directory });
try { await run(database); } finally { database.close(); rmSync(directory, { recursive: true, force: true }); }
}
test("validates comments and dungeon IDs and stamps new reports as Submitted", () => withDatabase(async (database) => {
assert.deepEqual(database.listDungeonReports(), { reports: [], openCounts: {} });
for (const comment of ["", " \n ", "x".repeat(501), null, 123]) {
assert.throws(() => database.submitDungeonReport(null, { dungeonId: "stormwind-stockade", comment }), { code: "invalid_report_comment" });
}
assert.throws(() => database.submitDungeonReport(null, { dungeonId: "fake", comment: "Broken" }), { code: "invalid_dungeon" });
const account = (await database.register("Reporter", "report-pass")).account;
const report = database.submitDungeonReport(account, {
dungeonId: "stormwind-stockade", comment: "x".repeat(500), status: "Closed", submittedBy: "Phenom",
});
assert.equal(report.comment.length, 500);
assert.equal(report.status, "Submitted");
assert.equal(report.submittedBy, "Reporter");
assert.equal(database.submitDungeonReport(null, { dungeonId: "forsaken-abbey", comment: " Door is stuck\r\nAfter a wipe " }).comment, "Door is stuck\nAfter a wipe");
assert.deepEqual(database.listDungeonReports().openCounts, { "forsaken-abbey": 1, "stormwind-stockade": 1 });
}));
test("enforces GM updates, counts all open statuses, supports reopening, and prevents stale changes", () => withDatabase(async (database) => {
const gm = (await database.register("Phenom", "report-admin")).account;
const player = (await database.register("Tester", "report-player")).account;
let report = database.submitDungeonReport(player, { dungeonId: "stormwind-stockade", comment: "Gate is stuck" });
database.submitDungeonReport(player, { dungeonId: "wailing-caverns", comment: "Boss stuck" });
assert.throws(() => database.updateDungeonReport({ ...player, roles: ["gm"] }, report.id, { status: "Closed", expectedRevision: 0 }), { status: 403 });
assert.throws(() => database.updateDungeonReport(gm, report.id, { status: "Other", expectedRevision: 0 }), { code: "invalid_report_status" });
assert.throws(() => database.updateDungeonReport(gm, report.id, { status: "Closed" }), { code: "invalid_revision" });
assert.throws(() => database.updateDungeonReport(gm, "missing", { status: "Closed", expectedRevision: 0 }), { status: 404 });
for (const status of ["In-Progress", "Testing Fixes"]) {
report = database.updateDungeonReport(gm, report.id, { status, expectedRevision: report.revision });
assert.equal(report.updatedBy, "Phenom");
assert.equal(database.listDungeonReports().openCounts[report.dungeonId], 1);
}
assert.throws(() => database.updateDungeonReport(gm, report.id, { status: "Closed", expectedRevision: 0 }), { status: 409 });
report = database.updateDungeonReport(gm, report.id, { status: "Closed", expectedRevision: report.revision });
assert.deepEqual(database.listDungeonReports().openCounts, { "wailing-caverns": 1 });
assert.equal(database.listDungeonReports().reports.length, 2);
const filtered = database.listDungeonReports({ status: "Closed", dungeonId: report.dungeonId });
assert.equal(filtered.reports.length, 1);
assert.deepEqual(filtered.openCounts, { "wailing-caverns": 1 });
report = database.updateDungeonReport(gm, report.id, { status: "Submitted", expectedRevision: report.revision });
assert.equal(database.listDungeonReports().openCounts[report.dungeonId], 1);
}));
test("persists reports and status changes across database restarts", async () => {
const directory = mkdtempSync(path.join(os.tmpdir(), "healer-man-report-restart-"));
let database;
try {
database = openGameDatabase({ dataDir: directory });
const gm = (await database.register("Phenom", "report-admin")).account;
const report = database.submitDungeonReport(null, { dungeonId: "stormwind-stockade", comment: "Entrance issue" });
const updated = database.updateDungeonReport(gm, report.id, { status: "Testing Fixes", expectedRevision: 0 });
database.close();
database = openGameDatabase({ dataDir: directory });
assert.deepEqual(database.listDungeonReports().reports, [updated]);
assert.equal(database.listDungeonReports().openCounts[report.dungeonId], 1);
} finally { database?.close(); rmSync(directory, { recursive: true, force: true }); }
});
test("serves public reports and GM-only status API with shelved modes disabled", async () => {
const directory = mkdtempSync(path.join(os.tmpdir(), "healer-man-report-api-"));
const runtime = createGameServer({ dataDir: directory, enableShelvedModes: false });
await new Promise((resolve) => runtime.server.listen(0, "127.0.0.1", resolve));
const origin = `http://127.0.0.1:${runtime.server.address().port}`;
const request = (route, method = "GET", body, token) => fetch(`${origin}/api/dungeon-reports${route}`, {
method, headers: { "Content-Type": "application/json", ...(token ? { Authorization: `Bearer ${token}` } : {}) },
...(body === undefined ? {} : { body: JSON.stringify(body) }),
});
try {
const player = await runtime.database.register("ApiPlayer", "report-player");
const gm = await runtime.database.register("Phenom", "report-admin");
const created = await request("", "POST", { dungeonId: "stormwind-stockade", comment: "Issue via API" }, player.token);
assert.equal(created.status, 201);
const { report } = await created.json();
assert.equal((await request("", "POST", { dungeonId: "wailing-caverns", comment: "Guest issue" })).status, 201);
assert.equal((await request(`/${report.id}`, "PATCH", { status: "Closed", expectedRevision: 0 })).status, 401);
assert.equal((await request(`/${report.id}`, "PATCH", { status: "Closed", expectedRevision: 0 }, player.token)).status, 403);
assert.equal((await request("", "POST", { dungeonId: "stormwind-stockade", comment: "x".repeat(501) })).status, 400);
const closed = await request(`/${report.id}`, "PATCH", { status: "Closed", expectedRevision: 0 }, gm.token);
assert.equal(closed.status, 200);
assert.equal((await closed.json()).report.status, "Closed");
const all = await (await request("")).json();
assert.equal(all.reports.length, 2);
assert.deepEqual(all.openCounts, { "wailing-caverns": 1 });
assert.equal((await (await request("?status=Closed&dungeon=stormwind-stockade")).json()).reports.length, 1);
assert.equal((await request("?status=Bad")).status, 400);
assert.equal((await request("?dungeon=Bad")).status, 400);
} finally {
await new Promise((resolve) => runtime.server.close(resolve));
rmSync(directory, { recursive: true, force: true });
}
});
+25
View File
@@ -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));
+4
View File
@@ -0,0 +1,4 @@
{
"commentMaxLength": 500,
"statuses": ["Submitted", "In-Progress", "Testing Fixes", "Closed"]
}
+2
View File
@@ -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);
+58
View File
@@ -0,0 +1,58 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
DUNGEON_REPORT_STATUSES, fetchDungeonReports, filterDungeonReports, sessionCanManageDungeonReports,
submitDungeonReport, updateDungeonReport, type DungeonReport,
} from "./dungeonReports";
import { useGameStore } from "../game/store";
function report(id: string, dungeonId: string, status: DungeonReport["status"]): DungeonReport {
return { id, dungeonId, status, comment: "Door does not open", submittedBy: "Player", createdAt: 1, updatedAt: 1, updatedBy: null, revision: 0 };
}
afterEach(() => { vi.unstubAllGlobals(); useGameStore.getState().closeOverlay(); });
describe("dungeon reports", () => {
it("shows all statuses by default, including Closed, and combines status and dungeon filters", () => {
const reports = DUNGEON_REPORT_STATUSES.flatMap((status, index) => [
report(`stock-${index}`, "stormwind-stockade", status), report(`cave-${index}`, "wailing-caverns", status),
]);
expect(filterDungeonReports(reports, "all", "all")).toEqual(reports);
expect(filterDungeonReports(reports, "Closed", "all")).toHaveLength(2);
expect(filterDungeonReports(reports, "all", "wailing-caverns")).toHaveLength(4);
expect(filterDungeonReports(reports, "Testing Fixes", "stormwind-stockade").map((item) => item.id)).toEqual(["stock-2"]);
expect(filterDungeonReports(reports, "Submitted", "missing")).toEqual([]);
});
it("allows GM report management in the ordinary release and rejects offline or local-only authority", () => {
const gm = { kind: "account" as const, ownerId: "gm", displayName: "Phenom", accessToken: "token", roles: ["gm" as const] };
expect(sessionCanManageDungeonReports(gm)).toBe(true);
expect(sessionCanManageDungeonReports({ ...gm, roles: [] })).toBe(false);
expect(sessionCanManageDungeonReports({ ...gm, kind: "offline" })).toBe(false);
expect(sessionCanManageDungeonReports({ ...gm, accessToken: undefined })).toBe(false);
expect(sessionCanManageDungeonReports(null)).toBe(false);
});
it("sends the active dungeon and preserves the expected report revision for GM updates", async () => {
const item = report("report-id", "stormwind-stockade", "Submitted");
const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ report: item }) });
vi.stubGlobal("fetch", fetchMock);
const gm = { kind: "account" as const, ownerId: "gm", displayName: "Phenom", accessToken: "token", roles: ["gm" as const] };
await submitDungeonReport(gm, item.dungeonId, "Door does not open");
expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toEqual({ dungeonId: item.dungeonId, comment: "Door does not open" });
expect(fetchMock.mock.calls[0][1].headers.get("Authorization")).toBe("Bearer token");
await updateDungeonReport(gm, { ...item, revision: 3 }, "Closed");
expect(fetchMock.mock.calls[1][1].method).toBe("PATCH");
expect(JSON.parse(fetchMock.mock.calls[1][1].body)).toEqual({ status: "Closed", expectedRevision: 3 });
await fetchDungeonReports();
expect(fetchMock.mock.calls[2][0]).toBe("/api/dungeon-reports");
});
it("keeps the game paused and camera look stopped while reporting and returning to pause", () => {
const game = useGameStore.getState();
game.setCameraLookActive(true);
game.openOverlay("report-issue");
expect(useGameStore.getState()).toMatchObject({ paused: true, overlay: "report-issue", cameraLookActive: false });
game.openOverlay("pause");
expect(useGameStore.getState()).toMatchObject({ paused: true, overlay: "pause", cameraLookActive: false });
});
});
+61
View File
@@ -0,0 +1,61 @@
import type { PlayerSession } from "./types";
import { requestOnlineJson } from "./onlineAccountClient";
import policy from "../../shared/dungeon-report-policy.json";
export type DungeonReportStatus = "Submitted" | "In-Progress" | "Testing Fixes" | "Closed";
export const DUNGEON_REPORT_STATUSES = policy.statuses as readonly DungeonReportStatus[];
export const DUNGEON_REPORT_COMMENT_MAX_LENGTH = policy.commentMaxLength;
export interface DungeonReport {
readonly id: string;
readonly dungeonId: string;
readonly comment: string;
readonly status: DungeonReportStatus;
readonly submittedBy: string;
readonly createdAt: number;
readonly updatedAt: number;
readonly updatedBy: string | null;
readonly revision: number;
}
export interface DungeonReportSnapshot {
readonly reports: readonly DungeonReport[];
readonly openCounts: Readonly<Record<string, number>>;
}
export function sessionCanManageDungeonReports(session: PlayerSession | null): boolean {
return Boolean(session?.kind === "account" && session.accessToken && session.roles?.includes("gm"));
}
export function filterDungeonReports(
reports: readonly DungeonReport[],
status: DungeonReportStatus | "all",
dungeonId: string,
): readonly DungeonReport[] {
return reports.filter((report) => (status === "all" || report.status === status)
&& (dungeonId === "all" || report.dungeonId === dungeonId));
}
export function fetchDungeonReports(): Promise<DungeonReportSnapshot> {
return requestOnlineJson<DungeonReportSnapshot>("/api/dungeon-reports");
}
export async function submitDungeonReport(session: PlayerSession | null, dungeonId: string, comment: string): Promise<DungeonReport> {
const response = await requestOnlineJson<{ report: DungeonReport }>("/api/dungeon-reports", {
method: "POST",
body: JSON.stringify({ dungeonId, comment }),
}, session?.kind === "account" ? session.accessToken : undefined);
return response.report;
}
export async function updateDungeonReport(
session: PlayerSession,
report: DungeonReport,
status: DungeonReportStatus,
): Promise<DungeonReport> {
const response = await requestOnlineJson<{ report: DungeonReport }>(`/api/dungeon-reports/${encodeURIComponent(report.id)}`, {
method: "PATCH",
body: JSON.stringify({ status, expectedRevision: report.revision }),
}, session.accessToken);
return response.report;
}
+10
View File
@@ -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,
+2 -1
View File
@@ -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 });
+1
View File
@@ -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;
}
/**
+25
View File
@@ -0,0 +1,25 @@
import { describe, expect, it } from "vitest";
import { abilitiesForClass } from "./abilityCatalog";
import { channelAmountEffects } from "./combatChannelAmounts";
describe("channel amount expansion", () => {
it("spreads a normal channel's full amount across its pulses", () => {
const ability = abilitiesForClass("mage")[0];
const effects = channelAmountEffects({ ...ability, effects: [
{ kind: "damage", coefficient: 1, basePoints: 100 },
{ kind: "dot", coefficient: 1, ticks: 5, intervalMs: 1_000, tag: "channel" },
] }, 5);
expect(effects.map(({ multiplier }) => multiplier)).toEqual([0.2, 1]);
});
it("uses the authored periodic payload for Radiant Flame", () => {
const ability = abilitiesForClass("sun-cleric").find((candidate) => candidate.name === "Radiant Flame")!;
const amounts = channelAmountEffects(ability, 5);
expect(amounts).toHaveLength(1);
expect(amounts[0]).toMatchObject({ effect: { kind: "damage", basePoints: 1 }, multiplier: 1 });
const conditional = { ...ability, effects: ability.effects.map((effect) => effect.kind === "apply-aura"
? { ...effect, aura: { ...effect.aura, procs: effect.aura.procs?.map((proc) => ({ ...proc, chance: 0.5 })) } }
: effect) };
expect(channelAmountEffects(conditional, 5)).toEqual([]);
});
});
+33
View File
@@ -0,0 +1,33 @@
import { triggeredAbilityBySpellId, type AbilityDefinition, type AbilityEffect } from "./abilityCatalog";
type ChannelAmountEffect = Extract<AbilityEffect, { kind: "damage" | "dot" | "heal" | "hot" }>;
/** Direct channel amounts span the cast; periodic trigger payloads fire each pulse. */
export function channelAmountEffects(ability: AbilityDefinition, totalTicks: number): readonly {
effect: ChannelAmountEffect;
multiplier: number;
}[] {
const ticks = Math.max(1, totalTicks);
const amounts: { effect: ChannelAmountEffect; multiplier: number }[] = [];
for (const effect of ability.effects) {
if (effect.kind === "damage" || effect.kind === "heal" || effect.kind === "dot" || effect.kind === "hot") {
amounts.push({ effect, multiplier: (effect.kind === "dot" || effect.kind === "hot" ? effect.ticks : 1) / ticks });
} else if (effect.kind === "apply-aura") {
for (const proc of effect.aura.procs ?? []) {
const triggers = Array.isArray(proc.triggers) ? proc.triggers : [proc.triggers];
const action = proc.action;
if (!triggers.includes("periodic") || proc.chance !== 1 || proc.internalCooldownMs || proc.charges !== undefined
|| action.kind !== "custom"
|| !["wow335-trigger-spell", "rom-trigger-skill"].includes(action.id)
|| typeof action.data?.spellId !== "number") continue;
const payload = triggeredAbilityBySpellId(action.data.spellId);
for (const payloadEffect of payload?.effects ?? []) {
if (payloadEffect.kind === "damage" || payloadEffect.kind === "heal") {
amounts.push({ effect: payloadEffect, multiplier: 1 });
}
}
}
}
}
return amounts;
}
+8
View File
@@ -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
View File
@@ -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
+238
View File
@@ -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 });
});
});
+30 -7
View File
@@ -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;
+42
View File
@@ -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);
});
});
+67
View File
@@ -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 })) };
}
+64
View File
@@ -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 } });
});
});
+84
View File
@@ -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);
};
}
+38 -6
View File
@@ -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());
});
});
+5 -2
View File
@@ -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";
+20
View File
@@ -0,0 +1,20 @@
export const DUNGEON_RELEASE_STAGES = ["full-release", "beta", "alpha"] as const;
export type DungeonReleaseStage = (typeof DUNGEON_RELEASE_STAGES)[number];
export const DUNGEON_RELEASE_STAGE_LABELS: Readonly<Record<DungeonReleaseStage, string>> = {
"full-release": "Full Release",
beta: "Beta",
alpha: "Alpha",
};
export function dungeonReleaseStage(id: string): DungeonReleaseStage {
return id === "stormwind-stockade" ? "beta" : "alpha";
}
export function compareDungeonReleaseOrder(
left: { readonly title: string; readonly releaseStage: DungeonReleaseStage },
right: { readonly title: string; readonly releaseStage: DungeonReleaseStage },
): number {
return DUNGEON_RELEASE_STAGES.indexOf(left.releaseStage) - DUNGEON_RELEASE_STAGES.indexOf(right.releaseStage)
|| left.title.localeCompare(right.title, "en", { sensitivity: "base" });
}
+139
View File
@@ -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);
});
});
+225
View File
@@ -0,0 +1,225 @@
import { afterAll, describe, expect, it } from "vitest";
import { mkdirSync, writeFileSync } from "node:fs";
import { resolve } from "node:path";
import { COA_CLASS_IDS, ROM_CLASS_IDS, PLAYABLE_RACES, classesForRace, type ClassId } from "../app/characterCatalog";
import { createDungeonParty, usePartyStore, type PartyMember } from "./partyStore";
import { useCombatStore } from "./combatStore";
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
import { clearPartyRuntimeRegistry, registerPartyRuntimePosition, updatePartyRuntimeTargetVisibility } from "./partyRuntimeRegistry";
import { advanceDungeonPartyCombat, partyCombatRangeBand, resetPartyRuntimeClock } from "./partyRuntime";
import { subscribeCombatPresentation } from "./combatPresentation";
import { subscribeCombatEvents } from "./combatEvents";
import { abilitiesForClass, resourceProfileForClass } from "./abilityCatalog";
import { choosePartyAbility } from "./partyAbilityAi";
const auditReport: unknown[] = [];
afterAll(() => {
if (process.env.CLASS_DAMAGE_AUDIT_REPORT !== "1") return;
const directory = resolve("artifacts/class-damage-audit");
mkdirSync(directory, { recursive: true });
writeFileSync(resolve(directory, "report.json"), `${JSON.stringify({ durationSeconds: 30, results: auditReport }, null, 2)}\n`);
});
function initializeAudit(classId: ClassId, level: number, template?: PartyMember): PartyMember {
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
resetPartyRuntimeClock();
const raceId = PLAYABLE_RACES.find((race) => classesForRace(race.id).some((entry) => entry.id === classId))!.id;
useCombatStore.getState().initializeCharacter({ classId, raceId, level });
const combat = useCombatStore.getState();
const profile = resourceProfileForClass(classId);
const base = createDungeonParty("expanded-damage-audit", level)[0];
const member: PartyMember = template ?? { ...base, id: `audit-${classId}`, name: classId, classId, raceId, level,
role: "damage", specialization: "Primary", specializationId: null, talentTreeId: "", talentRanks: {},
health: combat.maxHealth, maxHealth: combat.maxHealth, gearStats: combat.gearStats,
resourceType: profile.type, resourceName: combat.resourceName, resource: combat.resource,
maxResource: combat.maxResource, resourcePools: combat.resourcePools, runes: combat.deathKnightRunes };
usePartyStore.getState().initializeParty("expanded-damage-audit", level, combat.health);
usePartyStore.setState({ members: [member] });
useCombatStore.getState().synchronizeActors();
registerPartyRuntimePosition(member.id, [2 - partyCombatRangeBand(member).preferred, 0, 0]);
useCombatStore.getState().registerMob("audit", { name: "Training target", maxHealth: 10_000_000, xpReward: 0 });
setMobPosition("audit", [2, 0, 0]);
useCombatStore.getState().damageMob("audit", 1, 100);
updatePartyRuntimeTargetVisibility(member.id, "audit", true);
return member;
}
describe("expanded class damage audit", () => {
for (const control of ["NPC", "player"] as const) {
it(`${control} Radiant Flame deals five pulses during the channel without repeating them on completion`, () => {
const member = initializeAudit("sun-cleric", 20);
const ability = abilitiesForClass(member.classId).find((candidate) => candidate.name === "Radiant Flame")!;
const damageEvents: number[] = [];
if (control === "NPC") {
usePartyStore.setState({ members: [{ ...member, cooldowns: Object.fromEntries(abilitiesForClass(member.classId)
.filter((candidate) => candidate.id !== ability.id).map((candidate) => [candidate.id, 60_000])) }] });
useCombatStore.getState().commitCompanionActors(usePartyStore.getState().members);
} else {
usePartyStore.setState({ members: [], selectedMemberId: null, selfSelected: false });
useCombatStore.getState().synchronizeActors();
useCombatStore.getState().selectMob("audit");
}
const stop = subscribeCombatEvents((event) => {
if (event.kind === "damage" && event.targetActorId === "audit" && event.abilityId === ability.id) damageEvents.push(event.effectiveAmount);
});
const advance = (now: number) => {
useCombatStore.getState().tick(0.1, now);
if (control === "NPC") advanceDungeonPartyCombat(now);
};
if (control === "player") expect(useCombatStore.getState().castAbility(ability.id, undefined, 1_000).ok).toBe(true);
advance(1_000);
advance(1_700);
expect(damageEvents).toHaveLength(0);
advance(1_800);
expect(damageEvents).toHaveLength(1);
expect(damageEvents[0]).toBeGreaterThan(0);
for (const now of [2_600, 3_400, 4_200, 5_000]) advance(now);
expect(damageEvents).toHaveLength(5);
usePartyStore.getState().setCommand("stop");
advance(5_100);
advance(6_000);
expect(damageEvents).toHaveLength(5);
stop();
});
}
for (const [mode, classes, levels] of [["CoA", COA_CLASS_IDS, [1, 2, 4, 9, 20, 80]], ["RoM", ROM_CLASS_IDS, [1, 20, 70]]] as const) {
for (const level of levels) {
it(`${mode} NPCs execute damaging abilities at level ${level}`, () => {
const report: unknown[] = [];
const failures: string[] = [];
for (const classId of classes) {
const member = initializeAudit(classId, level);
const releases = new Set<string>();
let damage = 0;
let abilityDamage = 0;
const stopEvents = subscribeCombatEvents((event) => {
const actor = useCombatStore.getState().actors[event.sourceActorId];
if (event.kind === "damage" && event.targetActorId === "audit"
&& (event.sourceActorId === member.id || actor?.ownerId === member.id)) {
damage += event.effectiveAmount;
if (event.abilityId !== "party-auto-attack") abilityDamage += event.effectiveAmount;
}
});
const stopPresentation = subscribeCombatPresentation((message) => {
if (message.kind === "event" && message.event.sourceActorId === member.id && message.event.phase === "release") releases.add(message.event.name);
});
for (let now = 1_000; now <= 31_000; now += 100) {
useCombatStore.getState().tick(0.1, now);
advanceDungeonPartyCombat(now);
}
stopEvents();
stopPresentation();
expect(useCombatStore.getState().mobs.audit.dead, `${classId} level ${level} must sustain the full audit`).toBe(false);
report.push({ classId, damage, abilityDamage, releases: [...releases], resource: usePartyStore.getState().members[0].resource });
auditReport.push({ mode, control: "NPC", classId, level, damage, abilityDamage, abilities: [...releases] });
const hasDamageAbility = abilitiesForClass(classId).some((ability) => !ability.passive
&& ability.talentEntryId === undefined && ability.unlockLevel <= level
&& ability.effects.some((effect) => ["damage", "dot", "finisher-damage", "execute", "summon"].includes(effect.kind)));
if (damage <= 1 || (hasDamageAbility && abilityDamage <= 0)) failures.push(classId);
}
expect(failures, JSON.stringify(report)).toEqual([]);
});
}
for (const level of levels) {
it(`${mode} players execute damaging abilities at level ${level}`, () => {
const failures: unknown[] = [];
for (const classId of classes) {
const member = initializeAudit(classId, level);
usePartyStore.setState({ members: [], selectedMemberId: null, selfSelected: false });
useCombatStore.getState().synchronizeActors();
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
setMobPosition("audit", [partyCombatRangeBand(member).preferred, 0, 0]);
useCombatStore.getState().selectMob("audit");
const abilities = abilitiesForClass(classId);
const harmful = (ability: (typeof abilities)[number]) => !ability.passive && ability.talentEntryId === undefined
&& ability.effects.some((effect) => ["damage", "dot", "finisher-damage", "execute", "summon"].includes(effect.kind));
const firstAttack = abilities.filter(harmful).sort((left, right) => left.unlockLevel - right.unlockLevel)[0];
let damage = 0;
let abilityDamage = 0;
const casts = new Set<string>();
const reasons = new Set<string>();
const stop = subscribeCombatEvents((event) => {
if (event.kind === "damage" && event.targetActorId === "audit") {
damage += event.effectiveAmount;
if (event.abilityId && !event.abilityId.includes("auto-attack")) abilityDamage += event.effectiveAmount;
}
});
const kickoff = firstAttack ? useCombatStore.getState().castAbility(firstAttack.id, undefined, 1_000) : null;
if (kickoff?.ok && firstAttack) casts.add(firstAttack.name);
for (let now = 1_000; now <= 31_000; now += 100) {
useCombatStore.getState().tick(0.1, now);
const combat = useCombatStore.getState();
if (combat.activeCast) continue;
const rejected = new Set<string>();
const playerMember: PartyMember = { ...member, resource: combat.resource, resourcePools: combat.resourcePools,
cooldowns: combat.cooldowns, globalCooldownEndsAt: combat.globalCooldownEndsAt, runes: combat.deathKnightRunes };
for (let attempts = 0; attempts < abilities.length; attempts += 1) {
const ability = choosePartyAbility(playerMember, { now, target: combat.mobs.audit,
canUseAbility: (candidate) => harmful(candidate) && !rejected.has(candidate.id)
&& !combat.effects.some((effect) => effect.sourceAbilityId === candidate.id && effect.remainingTicks > 0)
&& !candidate.effects.some((effect) => effect.kind === "apply-aura"
&& combat.auras.some((aura) => aura.definition.id === effect.aura.id && aura.targetId === "audit")) });
if (!ability) break;
const result = useCombatStore.getState().castAbility(ability.id, undefined, now);
if (result.ok) { casts.add(ability.name); break; }
rejected.add(ability.id);
if (result.reason) reasons.add(result.reason);
}
}
stop();
expect(useCombatStore.getState().mobs.audit.dead, `${classId} level ${level} must sustain the full audit`).toBe(false);
const hasAttack = abilities.some((ability) => harmful(ability) && ability.unlockLevel <= level);
auditReport.push({ mode, control: "player", classId, level, damage, abilityDamage, abilities: [...casts],
firstOffensiveAbilityLevel: firstAttack?.unlockLevel ?? null, failureReasons: [...reasons] });
if (hasAttack && abilityDamage <= 0) failures.push({ classId, damage, abilityDamage, casts: [...casts], reasons: [...reasons], kickoff,
firstAttack: firstAttack?.name, autoAttackTargetId: useCombatStore.getState().autoAttackTargetId });
}
expect(failures).toEqual([]);
});
}
}
for (const level of [1, 20, 70]) {
it(`actual RoM companion templates deal ability damage at level ${level}`, () => {
const templates = new Map<string, PartyMember>();
for (let seed = 0; seed < 100; seed += 1) {
for (const role of ["healer", "damage"] as const) {
for (const member of createDungeonParty(`rom-audit-${seed}`, level, role, 5, "rom")) {
templates.set(`${member.classId}:${member.role}`, member);
}
}
}
expect(new Set([...templates.values()].map((member) => member.classId))).toEqual(new Set(ROM_CLASS_IDS));
for (const template of templates.values()) {
const member = initializeAudit(template.classId, level, template);
let damage = 0;
let abilityDamage = 0;
const abilities = new Set<string>();
const stop = subscribeCombatEvents((event) => {
const actor = useCombatStore.getState().actors[event.sourceActorId];
if (event.kind !== "damage" || event.targetActorId !== "audit"
|| (event.sourceActorId !== member.id && actor?.ownerId !== member.id)) return;
damage += event.effectiveAmount;
if (event.abilityId !== "party-auto-attack") {
abilityDamage += event.effectiveAmount;
if (event.abilityId) abilities.add(event.abilityId);
}
});
for (let now = 1_000; now <= 31_000; now += 100) {
useCombatStore.getState().tick(0.1, now);
advanceDungeonPartyCombat(now);
}
stop();
auditReport.push({ mode: "RoM", control: "shipped NPC", classId: member.classId, role: member.role,
level, damage, abilityDamage, abilities: [...abilities] });
expect(abilityDamage, `${member.classId} ${member.role} level ${level}`).toBeGreaterThan(0);
const finalMember = usePartyStore.getState().members[0];
expect(finalMember.resource).toBeGreaterThanOrEqual(0);
expect(finalMember.resource).toBeLessThanOrEqual(finalMember.maxResource);
}
});
}
});
@@ -1 +1 @@
{"schemaVersion":1,"classId":"necromancer","abilities":[{"id":"coa-tree-30600","classId":"necromancer","dbcSpellId":805428,"name":"Animate: Frost Wyrm","unlockLevel":1,"icon":"/assets/ui/spells/5_dragoncoldbreath_border.png","sigil":"AF","description":"Animate a Frost Wyrm that flies behind the Necromancer that blasts nearby enemies every 2 sec for 30 seconds, dealing 1500 + 14.88% frost SP + 14.88% Intellect Frost damage.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":120000,"gcdMs":1500,"cost":{"resource":"mana","amount":31,"percentage":true},"effects":[{"kind":"summon","creatureId":50177,"coefficient":0.08,"durationMs":30000}],"passive":false,"source":"coa-tree","ranks":[{"rank":1,"spellId":805428,"level":1,"description":"Animate a Frost Wyrm that flies behind the Necromancer that blasts nearby enemies every 2 sec for 30 seconds, dealing 1500 + 14.88% frost SP + 14.88% Intellect Frost damage.","castMode":"instant","castTimeMs":0,"cooldownMs":120000,"gcdMs":1500,"cost":{"resource":"mana","amount":31,"percentage":true},"effects":[{"kind":"summon","creatureId":50177,"coefficient":0.08,"durationMs":30000}]}],"talentEntryId":30600},{"id":"coa-23-animate-zombies-84-504315","classId":"necromancer","dbcSpellId":504315,"name":"Animate: Zombies","unlockLevel":1,"icon":"/assets/ui/spells/zombiewc3.png","sigil":"AZ","description":"Animate: ZombiesRank 121% of base mana 3 sec cast15 sec cooldownAnimate 2 Zombies to aid you in combat for 15 sec. Every 3 sec, Zombies emit Zombie Plague, dealing 79+1.0 Points Per Level+SP*0.1 Plague Damage to nearby enemies.","target":"hostile","range":{"min":0,"max":0},"radius":3,"castMode":"cast","castTimeMs":3000,"cooldownMs":15000,"gcdMs":1000,"cost":{"resource":"mana","amount":21,"percentage":true},"effects":[{"kind":"trigger-spell","spellId":525379,"withValue":2},{"kind":"damage","coefficient":1},{"kind":"summon","creatureId":503031,"coefficient":0.08,"durationMs":15000},{"kind":"utility","action":"class-script","effectType":3,"auraType":0,"miscValue":524287,"triggerSpellId":500991,"durationMs":100}],"passive":false,"source":"coa-progression","ranks":[{"rank":1,"spellId":504315,"level":1,"description":"Animate: ZombiesRank 121% of base mana 3 sec cast15 sec cooldownAnimate 2 Zombies to aid you in combat for 15 sec. Every 3 sec, Zombies emit Zombie Plague, dealing 79+1.0 Points Per Level+SP*0.1 Plague Damage to nearby enemies.","castMode":"cast","castTimeMs":3000,"cooldownMs":15000,"gcdMs":1000,"cost":{"resource":"mana","amount":21,"percentage":true},"effects":[{"kind":"trigger-spell","spellId":525379,"withValue":2},{"kind":"damage","coefficient":1},{"kind":"summon","creatureId":503031,"coefficient":0.08,"durationMs":15000},{"kind":"utility","action":"class-script","effectType":3,"auraType":0,"miscValue":524287,"triggerSpellId":500991,"durationMs":100}]},{"rank":2,"spellId":525378,"level":30,"description":"Animate: ZombiesRank 223% of base mana 3 sec cast15 sec cooldownAnimate 3 Zombies to aid you in combat for 15 sec. Every 3 sec, Zombies emit Zombie Plague, dealing 79+1.0 Points Per Level+SP*0.1 Plague Damage to nearby enemies.","castMode":"cast","castTimeMs":3000,"cooldownMs":15000,"gcdMs":1000,"cost":{"resource":"mana","amount":23,"percentage":true},"effects":[{"kind":"trigger-spell","spellId":525379,"withValue":3},{"kind":"damage","coefficient":1},{"kind":"summon","creatureId":503031,"coefficient":0.08,"durationMs":15000},{"kind":"utility","action":"class-script","effectType":3,"auraType":0,"miscValue":524287,"triggerSpellId":500991,"durationMs":100}]},{"rank":3,"spellId":525381,"level":51,"description":"Animate: ZombiesRank 324% of base mana 3 sec cast15 sec cooldownAnimate 4 Zombies to aid you in combat for 10 sec. Every 3 sec, Zombies emit Zombie Plague, dealing 79+1.0 Points Per Level+SP*0.1 Plague Damage to nearby enemies.","castMode":"cast","castTimeMs":3000,"cooldownMs":15000,"gcdMs":1000,"cost":{"resource":"mana","amount":24,"percentage":true},"effects":[{"kind":"trigger-spell","spellId":525379,"withValue":4},{"kind":"damage","coefficient":1},{"kind":"summon","creatureId":503031,"coefficient":0.08,"durationMs":15000},{"kind":"utility","action":"class-script","effectType":3,"auraType":0,"miscValue":524287,"triggerSpellId":500991,"durationMs":100}]}]},{"id":"coa-tree-29774","classId":"necromancer","dbcSpellId":802121,"name":"Bone Tithe","unlockLevel":1,"icon":"/assets/ui/spells/ossuary_bond.png","sigil":"BT","description":"Siphon life from your undead minions, sacrificing 40% of their health to grant yourself an absorb shield equal to half of the health stolen for 20 seconds. Usable while stunned.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":120000,"gcdMs":1500,"cost":{"resource":"mana","amount":25,"percentage":true},"effects":[{"kind":"shield","coefficient":1,"durationMs":15000},{"kind":"utility","action":"class-script","effectType":77,"auraType":0,"miscValue":0,"triggerSpellId"Line truncated
{"schemaVersion":1,"classId":"necromancer","abilities":[{"id":"coa-tree-30600","classId":"necromancer","dbcSpellId":805428,"name":"Animate: Frost Wyrm","unlockLevel":1,"icon":"/assets/ui/spells/5_dragoncoldbreath_border.png","sigil":"AF","description":"Animate a Frost Wyrm that flies behind the Necromancer that blasts nearby enemies every 2 sec for 30 seconds, dealing 1500 + 14.88% frost SP + 14.88% Intellect Frost damage.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":120000,"gcdMs":1500,"cost":{"resource":"mana","amount":31,"percentage":true},"effects":[{"kind":"summon","creatureId":50177,"coefficient":0.08,"durationMs":30000}],"passive":false,"source":"coa-tree","ranks":[{"rank":1,"spellId":805428,"level":1,"description":"Animate a Frost Wyrm that flies behind the Necromancer that blasts nearby enemies every 2 sec for 30 seconds, dealing 1500 + 14.88% frost SP + 14.88% Intellect Frost damage.","castMode":"instant","castTimeMs":0,"cooldownMs":120000,"gcdMs":1500,"cost":{"resource":"mana","amount":31,"percentage":true},"effects":[{"kind":"summon","creatureId":50177,"coefficient":0.08,"durationMs":30000}]}],"talentEntryId":30600},{"id":"coa-23-animate-zombies-84-504315","classId":"necromancer","dbcSpellId":504315,"name":"Animate: Zombies","unlockLevel":1,"icon":"/assets/ui/spells/zombiewc3.png","sigil":"AZ","description":"Animate: ZombiesRank 121% of base mana 3 sec cast15 sec cooldownAnimate 2 Zombies to aid you in combat for 15 sec. Every 3 sec, Zombies emit Zombie Plague, dealing 79+1.0 Points Per Level+SP*0.1 Plague Damage to nearby enemies.","target":"hostile","range":{"min":0,"max":0},"radius":3,"castMode":"cast","castTimeMs":3000,"cooldownMs":15000,"gcdMs":1000,"cost":{"resource":"mana","amount":21,"percentage":true},"effects":[{"kind":"trigger-spell","spellId":525379,"withValue":2},{"kind":"damage","coefficient":1},{"kind":"summon","creatureId":503031,"coefficient":0.08,"durationMs":15000},{"kind":"utility","action":"class-script","effectType":3,"auraType":0,"miscValue":524287,"triggerSpellId":500991,"durationMs":100}],"passive":false,"source":"coa-progression","ranks":[{"rank":1,"spellId":504315,"level":1,"description":"Animate: ZombiesRank 121% of base mana 3 sec cast15 sec cooldownAnimate 2 Zombies to aid you in combat for 15 sec. Every 3 sec, Zombies emit Zombie Plague, dealing 79+1.0 Points Per Level+SP*0.1 Plague Damage to nearby enemies.","castMode":"cast","castTimeMs":3000,"cooldownMs":15000,"gcdMs":1000,"cost":{"resource":"mana","amount":21,"percentage":true},"effects":[{"kind":"trigger-spell","spellId":525379,"withValue":2},{"kind":"damage","coefficient":1},{"kind":"summon","creatureId":503031,"coefficient":0.08,"durationMs":15000},{"kind":"utility","action":"class-script","effectType":3,"auraType":0,"miscValue":524287,"triggerSpellId":500991,"durationMs":100}]},{"rank":2,"spellId":525378,"level":30,"description":"Animate: ZombiesRank 223% of base mana 3 sec cast15 sec cooldownAnimate 3 Zombies to aid you in combat for 15 sec. Every 3 sec, Zombies emit Zombie Plague, dealing 79+1.0 Points Per Level+SP*0.1 Plague Damage to nearby enemies.","castMode":"cast","castTimeMs":3000,"cooldownMs":15000,"gcdMs":1000,"cost":{"resource":"mana","amount":23,"percentage":true},"effects":[{"kind":"trigger-spell","spellId":525379,"withValue":3},{"kind":"damage","coefficient":1},{"kind":"summon","creatureId":503031,"coefficient":0.08,"durationMs":15000},{"kind":"utility","action":"class-script","effectType":3,"auraType":0,"miscValue":524287,"triggerSpellId":500991,"durationMs":100}]},{"rank":3,"spellId":525381,"level":51,"description":"Animate: ZombiesRank 324% of base mana 3 sec cast15 sec cooldownAnimate 4 Zombies to aid you in combat for 10 sec. Every 3 sec, Zombies emit Zombie Plague, dealing 79+1.0 Points Per Level+SP*0.1 Plague Damage to nearby enemies.","castMode":"cast","castTimeMs":3000,"cooldownMs":15000,"gcdMs":1000,"cost":{"resource":"mana","amount":24,"percentage":true},"effects":[{"kind":"trigger-spell","spellId":525379,"withValue":4},{"kind":"damage","coefficient":1},{"kind":"summon","creatureId":503031,"coefficient":0.08,"durationMs":15000},{"kind":"utility","action":"class-script","effectType":3,"auraType":0,"miscValue":524287,"triggerSpellId":500991,"durationMs":100}]}]},{"id":"coa-tree-29774","classId":"necromancer","dbcSpellId":802121,"name":"Bone Tithe","unlockLevel":1,"icon":"/assets/ui/spells/ossuary_bond.png","sigil":"BT","description":"Siphon life from your undead minions, sacrificing 40% of their health to grant yourself an absorb shield equal to half of the health stolen for 20 seconds. Usable while stunned.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":120000,"gcdMs":1500,"cost":{"resource":"mana","amount":25,"percentage":true},"effects":[{"kind":"shield","coefficient":1,"durationMs":15000},{"kind":"utility","action":"class-script","effectType":77,"auraType":0,"miscValue":0,"triggerSpellId"Line truncated
@@ -1 +1 @@
{"schemaVersion":1,"classId":"witch-doctor","abilities":[{"id":"coa-tree-30147","classId":"witch-doctor","dbcSpellId":705921,"name":"Alchemical Enhancement","unlockLevel":1,"icon":"/assets/ui/spells/inv_alchemy_71_potion4.png","sigil":"AE","description":"Increases your haste by 5%.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"energy","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:705921:effect:0:aura:216","name":"Alchemical Enhancement (Aura 216)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"haste","operation":"percent","value":0.05}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":216,"miscValue":0,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:705921:effect:1:aura:192","name":"Alchemical Enhancement (Aura 192)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"haste","operation":"percent","value":0.05}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":192,"miscValue":127,"triggerSpellId":0}],"passive":true,"source":"coa-tree","ranks":[{"rank":1,"spellId":705921,"level":1,"description":"Increases your haste by 5%.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"energy","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:705921:effect:0:aura:216","name":"Alchemical Enhancement (Aura 216)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"haste","operation":"percent","value":0.05}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":216,"miscValue":0,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:705921:effect:1:aura:192","name":"Alchemical Enhancement (Aura 192)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"haste","operation":"percent","value":0.05}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":192,"miscValue":127,"triggerSpellId":0}]}],"talentEntryId":30147},{"id":"coa-tree-11323","classId":"witch-doctor","dbcSpellId":301297,"name":"Blatant Curse","unlockLevel":1,"icon":"/assets/ui/spells/spell_nature_removecurse.png","sigil":"BC","description":"Reduces the mana cost of your Jinxes by -25%.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:301297:effect:0:aura:108","name":"Blatant Curse (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"curse","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":0}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":8,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:301297:effect:1:aura:108","name":"Blatant Curse (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"curse","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":-0.25}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":14,"triggerSpellId":0}],"passive":true,"source":"coa-tree","ranks":[{"rank":1,"spellId":301297,"level":1,"description":"Reduces the mana cost of your Jinxes by -25%.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:301297:effect:0:aura:108","name":"Blatant Curse (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"curse","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":0}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":8,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:301297:effect:1:aura:108","name":"Blatant Curse (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"curse","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":-0.25}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":14,"triggerSpellId":0}]}],"talentEntryId":11323},{"id":"coa-tree-12048","classId":"witch-doctor","dbcSpellId":572870,"name":"Blessing of Hir'eek","unlockLevel":1,"icon":"/assets/ui/spells/trade_archaeology_trollbatidol.png","sigil":"BO","description":"Your Hexbreak now removes an additional curse effect.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:572870:effect:0:aura:107","name":"Blessing of Hir'eek (Aura 107)","disposition":"buff","durationMs":null,"maxStacks":1,"sLine truncated
{"schemaVersion":1,"classId":"witch-doctor","abilities":[{"id":"coa-tree-30147","classId":"witch-doctor","dbcSpellId":705921,"name":"Alchemical Enhancement","unlockLevel":1,"icon":"/assets/ui/spells/inv_alchemy_71_potion4.png","sigil":"AE","description":"Increases your haste by 5%.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"energy","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:705921:effect:0:aura:216","name":"Alchemical Enhancement (Aura 216)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"haste","operation":"percent","value":0.05}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":216,"miscValue":0,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:705921:effect:1:aura:192","name":"Alchemical Enhancement (Aura 192)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"haste","operation":"percent","value":0.05}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":192,"miscValue":127,"triggerSpellId":0}],"passive":true,"source":"coa-tree","ranks":[{"rank":1,"spellId":705921,"level":1,"description":"Increases your haste by 5%.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"energy","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:705921:effect:0:aura:216","name":"Alchemical Enhancement (Aura 216)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"haste","operation":"percent","value":0.05}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":216,"miscValue":0,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:705921:effect:1:aura:192","name":"Alchemical Enhancement (Aura 192)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"haste","operation":"percent","value":0.05}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":192,"miscValue":127,"triggerSpellId":0}]}],"talentEntryId":30147},{"id":"coa-tree-11323","classId":"witch-doctor","dbcSpellId":301297,"name":"Blatant Curse","unlockLevel":1,"icon":"/assets/ui/spells/spell_nature_removecurse.png","sigil":"BC","description":"Reduces the mana cost of your Jinxes by -25%.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:301297:effect:0:aura:108","name":"Blatant Curse (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"curse","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":0}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":8,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:301297:effect:1:aura:108","name":"Blatant Curse (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"curse","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":-0.25}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":14,"triggerSpellId":0}],"passive":true,"source":"coa-tree","ranks":[{"rank":1,"spellId":301297,"level":1,"description":"Reduces the mana cost of your Jinxes by -25%.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:301297:effect:0:aura:108","name":"Blatant Curse (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"curse","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":0}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":8,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:301297:effect:1:aura:108","name":"Blatant Curse (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"curse","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":-0.25}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":14,"triggerSpellId":0}]}],"talentEntryId":11323},{"id":"coa-tree-12048","classId":"witch-doctor","dbcSpellId":572870,"name":"Blessing of Hir'eek","unlockLevel":1,"icon":"/assets/ui/spells/trade_archaeology_trollbatidol.png","sigil":"BO","description":"Your Hexbreak now removes an additional curse effect.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:572870:effect:0:aura:107","name":"Blessing of Hir'eek (Aura 107)","disposition":"buff","durationMs":null,"maxStacks":1,"sLine truncated
@@ -1 +1 @@
{"schemaVersion":1,"abilitiesByClass":{"barbarian":[{"id":"coa-tree-30762","classId":"barbarian","dbcSpellId":500918,"name":"Ancestral Roar","unlockLevel":1,"icon":"/assets/ui/spells/_d3threateningshout.png","sigil":"AR","description":"Ancestral RoarRank 1Instant cast25 sec cooldownRequires Enraged Roar with ancestral fervor, healing you for 100+0+AP*0.5.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":25000,"gcdMs":1500,"cost":{"resource":"energy","amount":0},"effects":[{"kind":"heal","coefficient":1.6580645243326824,"basePoints":100,"dieSides":7,"pointsPerLevel":3.200000047683716,"bonusPowerCoefficient":0,"sourceLevel":16,"maxScalingLevel":22},{"kind":"utility","action":"class-script","effectType":3,"auraType":0,"miscValue":3,"triggerSpellId":0,"durationMs":0}],"passive":false,"source":"coa-tree","ranks":[{"rank":1,"spellId":500918,"level":1,"description":"Ancestral RoarRank 1Instant cast25 sec cooldownRequires Enraged Roar with ancestral fervor, healing you for 100+0+AP*0.5.","castMode":"instant","castTimeMs":0,"cooldownMs":25000,"gcdMs":1500,"cost":{"resource":"energy","amount":0},"effects":[{"kind":"heal","coefficient":1.6580645243326824,"basePoints":100,"dieSides":7,"pointsPerLevel":3.200000047683716,"bonusPowerCoefficient":0,"sourceLevel":16,"maxScalingLevel":22},{"kind":"utility","action":"class-script","effectType":3,"auraType":0,"miscValue":3,"triggerSpellId":0,"durationMs":0}]}],"talentEntryId":30762},{"id":"coa-12-ancestral-spirit-47-705153","classId":"barbarian","dbcSpellId":705153,"name":"Ancestral Spirit","unlockLevel":1,"icon":"/assets/ui/spells/inv_misc_questionmark.png","sigil":"AS","description":"Ancestral SpiritRank 1Reduces the Energy cost of your Spirit spells and increases the duration of your Shouts by 50%.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"energy","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:705153:effect:0:aura:108","name":"Ancestral Spirit (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":-0.5}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":14,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:705153:effect:1:aura:108","name":"Ancestral Spirit (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":0.5}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":1,"triggerSpellId":0}],"passive":true,"source":"coa-progression","ranks":[{"rank":1,"spellId":705153,"level":1,"description":"Ancestral SpiritRank 1Reduces the Energy cost of your Spirit spells and increases the duration of your Shouts by 50%.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"energy","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:705153:effect:0:aura:108","name":"Ancestral Spirit (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":-0.5}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":14,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:705153:effect:1:aura:108","name":"Ancestral Spirit (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":0.5}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":1,"triggerSpellId":0}]},{"rank":2,"spellId":705154,"level":1,"description":"Ancestral SpiritRank 2Reduces the Energy cost of your Spirit spells and increases the duration of your Shouts by 100%.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"energy","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:705154:effect:0:aura:108","name":"Ancestral Spirit (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":-1}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":14,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:705154:effect:1:aura:108","name":"Ancestral Spirit (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":1}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":1,"triggerSpellId":0}]}]},{"id":"coa-tree-34279","classId":"barbarian","dbcSpellId":560456,"name":"Athleticism","unlockLevel":1,"icon":"/assets/ui/spells/5_run_border.png","sigil":"AT","descriptionLine truncated
{"schemaVersion":1,"abilitiesByClass":{"barbarian":[{"id":"coa-tree-30762","classId":"barbarian","dbcSpellId":500918,"name":"Ancestral Roar","unlockLevel":1,"icon":"/assets/ui/spells/_d3threateningshout.png","sigil":"AR","description":"Ancestral RoarRank 1Instant cast25 sec cooldownRequires Enraged Roar with ancestral fervor, healing you for 100+0+AP*0.5.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":25000,"gcdMs":1500,"cost":{"resource":"energy","amount":0},"effects":[{"kind":"heal","coefficient":1.6580645243326824,"basePoints":100,"dieSides":7,"pointsPerLevel":3.200000047683716,"bonusPowerCoefficient":0,"sourceLevel":16,"maxScalingLevel":22},{"kind":"utility","action":"class-script","effectType":3,"auraType":0,"miscValue":3,"triggerSpellId":0,"durationMs":0}],"passive":false,"source":"coa-tree","ranks":[{"rank":1,"spellId":500918,"level":1,"description":"Ancestral RoarRank 1Instant cast25 sec cooldownRequires Enraged Roar with ancestral fervor, healing you for 100+0+AP*0.5.","castMode":"instant","castTimeMs":0,"cooldownMs":25000,"gcdMs":1500,"cost":{"resource":"energy","amount":0},"effects":[{"kind":"heal","coefficient":1.6580645243326824,"basePoints":100,"dieSides":7,"pointsPerLevel":3.200000047683716,"bonusPowerCoefficient":0,"sourceLevel":16,"maxScalingLevel":22},{"kind":"utility","action":"class-script","effectType":3,"auraType":0,"miscValue":3,"triggerSpellId":0,"durationMs":0}]}],"talentEntryId":30762},{"id":"coa-12-ancestral-spirit-47-705153","classId":"barbarian","dbcSpellId":705153,"name":"Ancestral Spirit","unlockLevel":1,"icon":"/assets/ui/spells/inv_misc_questionmark.png","sigil":"AS","description":"Ancestral SpiritRank 1Reduces the Energy cost of your Spirit spells and increases the duration of your Shouts by 50%.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"energy","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:705153:effect:0:aura:108","name":"Ancestral Spirit (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":-0.5}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":14,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:705153:effect:1:aura:108","name":"Ancestral Spirit (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":0.5}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":1,"triggerSpellId":0}],"passive":true,"source":"coa-progression","ranks":[{"rank":1,"spellId":705153,"level":1,"description":"Ancestral SpiritRank 1Reduces the Energy cost of your Spirit spells and increases the duration of your Shouts by 50%.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"energy","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:705153:effect:0:aura:108","name":"Ancestral Spirit (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":-0.5}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":14,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:705153:effect:1:aura:108","name":"Ancestral Spirit (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":0.5}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":1,"triggerSpellId":0}]},{"rank":2,"spellId":705154,"level":1,"description":"Ancestral SpiritRank 2Reduces the Energy cost of your Spirit spells and increases the duration of your Shouts by 100%.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"energy","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:705154:effect:0:aura:108","name":"Ancestral Spirit (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":-1}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":14,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:705154:effect:1:aura:108","name":"Ancestral Spirit (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":1}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":1,"triggerSpellId":0}]}]},{"id":"coa-tree-34279","classId":"barbarian","dbcSpellId":560456,"name":"Athleticism","unlockLevel":1,"icon":"/assets/ui/spells/5_run_border.png","sigil":"AT","descriptionLine truncated
+133
View File
@@ -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]);
});
});
+142
View File
@@ -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";
},
};
}
+16 -1
View File
@@ -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);
});
});
+19 -8
View File
@@ -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,
);
}
+120
View File
@@ -0,0 +1,120 @@
import { describe, expect, it } from "vitest";
import { createDungeonParty, usePartyStore, type PartyMember } from "./partyStore";
import { useCombatStore } from "./combatStore";
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
import { clearPartyRuntimeRegistry, registerPartyRuntimePosition, updatePartyRuntimeTargetVisibility } from "./partyRuntimeRegistry";
import { advanceDungeonPartyCombat, partyUsesRangedAttacks, resetPartyRuntimeClock } from "./partyRuntime";
import { subscribeCombatPresentation } from "./combatPresentation";
import { abilitiesForClass } from "./abilityCatalog";
function partyTemplates(level: number): PartyMember[] {
const templates = new Map<string, PartyMember>();
for (let seed = 0; seed < 100; seed += 1) {
for (const role of ["healer", "damage"] as const) {
for (const member of createDungeonParty(`damage-audit-${seed}`, level, role)) {
templates.set(`${member.classId}:${member.specialization}`, member);
}
}
}
return [...templates.values()];
}
describe("NPC sustained damage audit", () => {
it("lets Garrick swing immediately with no Rage, and keeps swinging during the GCD", () => {
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
resetPartyRuntimeClock();
useCombatStore.getState().initializeCharacter({ classId: "priest", level: 3 });
const garrick = partyTemplates(3).find((member) => member.name === "Garrick")!;
usePartyStore.getState().initializeParty("short-pull", 3, useCombatStore.getState().health);
usePartyStore.setState({ members: [{ ...garrick, globalCooldownEndsAt: 10_000 }] });
useCombatStore.getState().synchronizeActors();
registerPartyRuntimePosition(garrick.id, [0, 0, 0]);
useCombatStore.getState().registerMob("short-pull", { name: "Training target", maxHealth: 1_000, xpReward: 0 });
setMobPosition("short-pull", [2, 0, 0]);
useCombatStore.getState().damageMob("short-pull", 1, 100);
updatePartyRuntimeTargetVisibility(garrick.id, "short-pull", true);
advanceDungeonPartyCombat(1_000);
const swings = () => useCombatStore.getState().combatEvents.filter((event) => event.sourceActorId === garrick.id && event.kind === "damage");
expect(swings()).toHaveLength(1);
expect(swings()[0]).toMatchObject({ abilityId: "party-auto-attack" });
expect(swings()[0].effectiveAmount).toBeGreaterThan(1);
expect(usePartyStore.getState().members[0].resource).toBe(6);
advanceDungeonPartyCombat(1_100);
expect(swings()).toHaveLength(1);
advanceDungeonPartyCombat(2_800);
expect(swings()).toHaveLength(2);
expect(usePartyStore.getState().members[0].resource).toBe(12);
usePartyStore.getState().setCommand("stop");
advanceDungeonPartyCombat(5_000);
expect(swings()).toHaveLength(2);
});
it("casts Thunder Clap around the tank rather than rejecting its self range or centering it on the enemy", () => {
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
resetPartyRuntimeClock();
useCombatStore.getState().initializeCharacter({ classId: "priest", level: 5 });
const garrick = partyTemplates(5).find((member) => member.name === "Garrick")!;
usePartyStore.getState().initializeParty("area-pull", 5, useCombatStore.getState().health);
usePartyStore.setState({ members: [{ ...garrick, resource: 100, resourcePools: { rage: 100 },
cooldowns: Object.fromEntries(abilitiesForClass("warrior").filter((ability) => ability.id !== "warrior-thunder-clap")
.map((ability) => [ability.id, 60_000])) }] });
useCombatStore.getState().synchronizeActors();
registerPartyRuntimePosition(garrick.id, [0, 0, 0]);
for (const [id, x] of [["area:a", 7], ["area:b", -7], ["area:c", 10]] as const) {
useCombatStore.getState().registerMob(id, { name: id, maxHealth: 1_000, xpReward: 0 });
setMobPosition(id, [x, 0, 0]);
updatePartyRuntimeTargetVisibility(garrick.id, id, true);
}
useCombatStore.getState().damageMob("area:a", 1, 100);
updatePartyRuntimeTargetVisibility(garrick.id, "area:a", true);
advanceDungeonPartyCombat(1_000);
const damage = useCombatStore.getState().combatEvents.filter((event) => event.sourceActorId === garrick.id && event.kind === "damage");
expect(damage.map((event) => event.targetActorId).sort()).toEqual(["area:a", "area:b"]);
expect(damage.every((event) => event.abilityId === "warrior-thunder-clap" && event.effectiveAmount > 0)).toBe(true);
expect(useCombatStore.getState().mobs["area:c"].health).toBe(1_000);
});
for (const level of [1, 3, 5, 20, 80]) {
it(`executes damaging abilities for every party template at level ${level}`, () => {
const templates = partyTemplates(level);
expect(templates).toHaveLength(26);
for (const member of templates) {
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
resetPartyRuntimeClock();
useCombatStore.getState().initializeCharacter({ classId: "priest", level });
usePartyStore.getState().initializeParty("damage-audit", level, useCombatStore.getState().health);
usePartyStore.setState({ members: [member] });
useCombatStore.getState().synchronizeActors();
registerPartyRuntimePosition(member.id, partyUsesRangedAttacks(member) ? [-12, 0, 0] : [0, 0, 0]);
useCombatStore.getState().registerMob("audit", { name: "Training target", maxHealth: 100_000, xpReward: 0 });
setMobPosition("audit", [2, 0, 0]);
useCombatStore.getState().damageMob("audit", 1, 100);
updatePartyRuntimeTargetVisibility(member.id, "audit", true);
const releases: string[] = [];
const unsubscribe = subscribeCombatPresentation((message) => {
if (message.kind === "event" && message.event.sourceActorId === member.id && message.event.phase === "release") {
releases.push(message.event.name);
}
});
for (let now = 1_000; now <= 31_000; now += 100) {
useCombatStore.getState().tick(0.1, now);
advanceDungeonPartyCombat(now);
}
unsubscribe();
const damageEvents = useCombatStore.getState().combatEvents.filter((event) => event.kind === "damage" && event.sourceActorId === member.id);
const damage = damageEvents
.reduce((total, event) => total + event.effectiveAmount, 0);
const description = `${member.name}, ${member.classId}:${member.specialization}, level ${level}`;
expect(damage, description).toBeGreaterThan(1);
expect(releases.filter((name) => name !== "Auto Attack").length, description).toBeGreaterThan(0);
expect(damageEvents.some((event) => event.abilityId !== "party-auto-attack" && event.effectiveAmount > 0), description).toBe(true);
const finalMember = usePartyStore.getState().members[0];
expect(finalMember.resource, description).toBeGreaterThanOrEqual(0);
expect(finalMember.resource, description).toBeLessThanOrEqual(finalMember.maxResource);
}
});
}
});
-14
View File
@@ -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(
-39
View File
@@ -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 };
}
+13
View File
@@ -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(),
+3 -1
View File
@@ -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);
}
+54
View File
@@ -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);
});
});
+33
View File
@@ -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 };
}
+131
View File
@@ -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);
});
});
+39
View File
@@ -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;
}
+32
View File
@@ -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
View File
@@ -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);
+30
View File
@@ -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);
+26
View File
@@ -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;
}
+46
View File
@@ -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) {
+3 -1
View File
@@ -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),
};
}),
};
+181
View File
@@ -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();
});
});
+175
View File
@@ -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;
}
+200
View File
@@ -0,0 +1,200 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { installCombatClock } from "./combatClock";
import { PLAYER_AURA_ENTITY_ID, useCombatStore } from "./combatStore";
import { partyFightStillActive, respawnDefeatedPlayer } from "./deathRespawn";
import { activateDungeonEncounterDefinitions, advanceDungeonEncounters, resetDungeonEncounters } from "./dungeonEncounterRuntime";
import { requireDungeonDefinition } from "./dungeonRegistry";
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
import { advanceDungeonPartyCombat, resetPartyRuntimeClock } from "./partyRuntime";
import { clearPartyRuntimeRegistry, getPartyRuntimePosition, registerPartyRuntimePosition, updatePartyRuntimeTargetVisibility } from "./partyRuntimeRegistry";
import { usePartyStore } from "./partyStore";
import { useGameStore } from "./store";
const dungeon = requireDungeonDefinition("stormwind-stockade");
const fightPosition = [2, 0, 0] as const;
function startFight(boss = false): string {
const id = boss ? dungeon.bosses[1].id : "ongoing-pack";
useCombatStore.getState().registerMob(id, { name: id, maxHealth: 100_000, boss, xpReward: 0 });
setMobPosition(id, fightPosition);
const tank = usePartyStore.getState().members[0];
useCombatStore.getState().damageMob(id, 1, 100, { actorId: tank.id, role: "tank" });
for (const member of usePartyStore.getState().members) {
updatePartyRuntimeTargetVisibility(member.id, id, true);
}
return id;
}
function killPlayer(): void {
useCombatStore.getState().damagePlayer(useCombatStore.getState().maxHealth * 10);
useCombatStore.getState().synchronizeActors();
expect(useCombatStore.getState().health).toBe(0);
}
describe("personal checkpoint resurrection during an ongoing party fight", () => {
let restoreClock: () => void;
beforeEach(() => {
restoreClock = installCombatClock(() => 1_000);
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
resetPartyRuntimeClock();
useGameStore.getState().activateDungeon(dungeon.id);
activateDungeonEncounterDefinitions(dungeon.id, dungeon.encounters ?? []);
resetDungeonEncounters("dungeon", dungeon.id);
useCombatStore.getState().initializeCharacter({ classId: "priest", level: 16 });
usePartyStore.getState().initializeParty("personal-respawn", 16, useCombatStore.getState().health);
usePartyStore.getState().selectMember(null);
for (const member of usePartyStore.getState().members) {
registerPartyRuntimePosition(member.id, [0, 0, 0]);
}
useCombatStore.getState().synchronizeActors();
});
afterEach(() => restoreClock());
it("revives only the player without restoring companions, resetting the pull, or triggering Recall", () => {
const id = startFight();
advanceDungeonPartyCombat(1_000);
useCombatStore.getState().advanceMobCombat(1_000);
const members = usePartyStore.getState().members;
useCombatStore.getState().commitCompanionActors([
{ ...members[0], health: Math.round(members[0].maxHealth / 2) },
{ ...members[1], health: 0, status: "down" },
{ ...members[2], activeCast: {
abilityId: "ongoing-party-cast", school: "fire", mode: "cast", startedAt: 1_000,
completesAt: 5_000, originalDurationMs: 4_000, pushbackHits: 0, targetId: id,
}, activeCastTargetId: id },
]);
killPlayer();
const combatBefore = useCombatStore.getState();
const partyBefore = usePartyStore.getState();
const gameBefore = useGameStore.getState();
const positions = partyBefore.members.map((member) => getPartyRuntimePosition(member.id));
expect(respawnDefeatedPlayer("entrance")).toBe(true);
const after = useCombatStore.getState();
expect(after.health).toBe(after.maxHealth);
expect(after.resource).toBe(after.maxResource);
expect(after.playerPosition).toEqual(dungeon.entrance.footPosition);
expect(after.actors[PLAYER_AURA_ENTITY_ID]).toMatchObject({ alive: true, health: after.maxHealth });
expect(after.playerAnimationEvent?.kind).toBe("ability-cancel");
expect(after.mobs).toBe(combatBefore.mobs);
expect(usePartyStore.getState()).toBe(partyBefore);
for (const [index, member] of partyBefore.members.entries()) {
expect(after.actors[member.id]).toBe(combatBefore.actors[member.id]);
expect(getPartyRuntimePosition(member.id)).toEqual(positions[index]);
}
expect(useGameStore.getState()).toMatchObject({
resetRevision: gameBefore.resetRevision,
encounterResetRevision: gameBefore.encounterResetRevision,
relocationRevision: gameBefore.relocationRevision + 1,
playerPosition: dungeon.entrance.footPosition,
});
expect(usePartyStore.getState().recallRevision).toBe(partyBefore.recallRevision);
});
it("allows personal resurrection during a boss fight at an already unlocked boss checkpoint", () => {
const completed = dungeon.bosses[0];
advanceDungeonEncounters(dungeon.id, {
kind: "tick", now: 100,
facts: { deadEntityIds: [completed.id], registeredEntityIds: [completed.id] },
});
const currentBoss = startFight(true);
advanceDungeonPartyCombat(1_000);
killPlayer();
const before = useCombatStore.getState().mobs[currentBoss];
const revision = useGameStore.getState().resetRevision;
expect(respawnDefeatedPlayer(`boss:${completed.id}`)).toBe(true);
expect(useGameStore.getState().playerPosition).toEqual(completed.position);
expect(useCombatStore.getState().mobs[currentBoss]).toBe(before);
expect(before.engaged).toBe(true);
expect(useGameStore.getState().resetRevision).toBe(revision);
});
it("continues the actual party and enemy combat ticks after the player leaves for the entrance", () => {
const id = startFight();
advanceDungeonPartyCombat(1_000);
useCombatStore.getState().advanceMobCombat(1_000);
killPlayer();
expect(respawnDefeatedPlayer("entrance")).toBe(true);
const beforeMobHealth = useCombatStore.getState().mobs[id].health;
const tank = usePartyStore.getState().members.find((member) => member.role === "tank")!;
const beforeAttackRevision = useCombatStore.getState().mobs[id].attackRevision;
advanceDungeonPartyCombat(3_000);
const nextAttackTime = Math.max(3_000, ...useCombatStore.getState().mobs[id].statuses.map((status) => status.endsAt)) + 1_000;
useCombatStore.getState().advanceMobCombat(nextAttackTime, dungeon.entrance.footPosition);
expect(useCombatStore.getState().mobs[id].engaged).toBe(true);
expect(useCombatStore.getState().mobs[id].health).toBeLessThan(beforeMobHealth);
expect(useCombatStore.getState().mobs[id].attackRevision).toBeGreaterThan(beforeAttackRevision);
expect(useCombatStore.getState().mobs[id].targetActorId).toBe(tank.id);
expect(useCombatStore.getState().health).toBe(useCombatStore.getState().maxHealth);
});
it("keeps companions' periodic healing and enemies' ongoing damage while removing old player HoTs", () => {
const id = startFight();
const renew = useCombatStore.getState().abilities.find((ability) => ability.name === "Renew")!;
expect(useCombatStore.getState().castAbility(renew.id, undefined, 1_000).ok).toBe(true);
const selfHot = useCombatStore.getState().effects.find((effect) => effect.kind === "hot")!;
const allyHot = { ...selfHot, id: "preserved-ally-hot", targetId: usePartyStore.getState().members[0].id };
const enemyDot = { ...selfHot, id: "preserved-enemy-dot", kind: "dot" as const, targetId: id };
const explicitPlayerHot = { ...selfHot, id: "removed-player-hot", targetId: PLAYER_AURA_ENTITY_ID };
useCombatStore.setState({ effects: [selfHot, allyHot, enemyDot, explicitPlayerHot] });
killPlayer();
useCombatStore.setState({ cooldowns: { "ongoing-player-cooldown": 100_000 } });
const cooldowns = useCombatStore.getState().cooldowns;
expect(respawnDefeatedPlayer("entrance")).toBe(true);
expect(useCombatStore.getState().effects).toEqual([allyHot, enemyDot]);
expect(useCombatStore.getState().cooldowns).toBe(cooldowns);
});
it.each(["party wiped", "fight ended"])("retains normal reset recovery when the %s", (reason) => {
const id = startFight();
if (reason === "party wiped") {
useCombatStore.getState().commitCompanionActors(usePartyStore.getState().members.map((member) => ({
...member, health: 0, status: "down" as const,
})));
} else {
useCombatStore.getState().damageMob(id, 100_000, 200);
}
killPlayer();
const before = useGameStore.getState().resetRevision;
expect(respawnDefeatedPlayer("entrance")).toBe(true);
expect(useGameStore.getState().resetRevision).toBe(before + 1);
expect(useCombatStore.getState().mobs[id].engaged).toBe(false);
});
it("uses canonical companion health and ignores defeated or despawned enemies", () => {
const id = startFight();
const combat = useCombatStore.getState();
const members = usePartyStore.getState().members;
expect(partyFightStillActive(members, combat.mobs, combat.actors)).toBe(true);
const actors = Object.fromEntries(members.map((member) => [member.id, { health: 0 }]));
expect(partyFightStillActive(members, combat.mobs, actors)).toBe(false);
expect(partyFightStillActive(members, { [id]: { ...combat.mobs[id], despawned: true } })).toBe(false);
expect(partyFightStillActive(members, { [id]: { ...combat.mobs[id], dead: true } })).toBe(false);
});
it("rejects locked checkpoints and leaves Manastorm's shared-charge resurrection separate", () => {
startFight(true);
killPlayer();
const before = useGameStore.getState();
expect(respawnDefeatedPlayer(`boss:${dungeon.bosses[0].id}`)).toBe(false);
expect(useGameStore.getState()).toBe(before);
expect(useCombatStore.getState().health).toBe(0);
useGameStore.setState({ gameMode: "manastorm" });
expect(respawnDefeatedPlayer("entrance")).toBe(false);
expect(useCombatStore.getState().health).toBe(0);
});
it("cannot use player-only recovery as a living-player teleport or with invalid coordinates", () => {
const before = useCombatStore.getState();
expect(before.respawnPlayerOnly([0, 0, 0], 1_000)).toBe(false);
expect(useCombatStore.getState()).toBe(before);
killPlayer();
const dead = useCombatStore.getState();
expect(dead.respawnPlayerOnly([NaN, 0, 0], 1_000)).toBe(false);
expect(dead.respawnPlayerOnly([0, 0, 0], NaN)).toBe(false);
expect(useCombatStore.getState()).toBe(dead);
});
});
+78
View File
@@ -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);
});
});
+39
View File
@@ -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
View File
@@ -1 +1 @@
{"schemaVersion":1,"source":{"rootHint":"RuneWaker source snapshot","hashes":{"learnMagic":"6148199bcd1484ba1a122a6db5e2b3c2f2a161402bfe67fc67eb53f55fc6cfa3","magicCollect":"4aae19865cb07b30f99945aa7319a0096db244ff5e5b4b5d902fc6889474f501","magicObject":"52af13a2d8cfa9a66d4caa2d3c38832edc232f058995a64ef8135a6b82e43691","imageObject":"dd5ffcfd74b253dcecaa2f41a931a634dd45ef5f7c13e849d539ddc47fdee1f9","vocationTable":"f06cdaf60c54415dcbea47e9fa0e77b7527035a2aec73d3289370dc39751e76c","strings":"0c20d43660cb2ee042a78f7a8c18b4e104a263d54dd8c4bc98a54155e8adb3b0","eliteLua":"ee9e337548abd7fc555d85bf2f96813dfd77416c3e65d4cadf74a80838da1486","roleAttr":"8143a90ce826bf3152551a4508d212f186012cbc054e505e426551cb62369c1f","objectStruct":"708f2d087f2654b97d149ab7124d9cdffc915cf8bf18108feff3f1a45f124c20","interfaceFdb":"51bbb5a9d6a19a254b302000d3dd6e3a796f4f759015f3352f576a1055496e30"}},"canonicalClasses":[{"id":"rom-warrior","sourceClassId":1,"name":"Warrior","resource":"rage"},{"id":"rom-scout","sourceClassId":2,"name":"Scout","resource":"focus"},{"id":"rom-rogue","sourceClassId":3,"name":"Rogue","resource":"energy"},{"id":"rom-mage","sourceClassId":4,"name":"Mage","resource":"mana"},{"id":"rom-priest","sourceClassId":5,"name":"Priest","resource":"mana"},{"id":"rom-knight","sourceClassId":6,"name":"Knight","resource":"mana"},{"id":"rom-warden","sourceClassId":7,"name":"Warden","resource":"mana"},{"id":"rom-druid","sourceClassId":8,"name":"Druid","resource":"mana"},{"id":"rom-warlock","sourceClassId":9,"name":"Warlock","resource":"focus"},{"id":"rom-champion","sourceClassId":10,"name":"Champion","resource":"rage"}],"baseStatGrowth":{"rom-warrior":{"strength":{"levelOne":14,"flatGrowth":5,"percentGrowth":0.02},"stamina":{"levelOne":13,"flatGrowth":4.5,"percentGrowth":0.02},"intellect":{"levelOne":5,"flatGrowth":1.9,"percentGrowth":0.02},"wisdom":{"levelOne":14,"flatGrowth":4,"percentGrowth":0.02},"agility":{"levelOne":12,"flatGrowth":4.25,"percentGrowth":0.02},"health":{"levelOne":60,"flatGrowth":2,"percentGrowth":0.02},"mana":{"levelOne":20,"flatGrowth":2,"percentGrowth":0.02}},"rom-scout":{"strength":{"levelOne":11,"flatGrowth":3.85,"percentGrowth":0.02},"stamina":{"levelOne":10,"flatGrowth":3.65,"percentGrowth":0.02},"intellect":{"levelOne":10,"flatGrowth":3.6,"percentGrowth":0.02},"wisdom":{"levelOne":14,"flatGrowth":4,"percentGrowth":0.02},"agility":{"levelOne":13,"flatGrowth":4.5,"percentGrowth":0.02},"health":{"levelOne":60,"flatGrowth":2,"percentGrowth":0.02},"mana":{"levelOne":20,"flatGrowth":2,"percentGrowth":0.02}},"rom-rogue":{"strength":{"levelOne":12,"flatGrowth":4.15,"percentGrowth":0.02},"stamina":{"levelOne":11,"flatGrowth":3.9,"percentGrowth":0.02},"intellect":{"levelOne":7,"flatGrowth":2.6,"percentGrowth":0.02},"wisdom":{"levelOne":14,"flatGrowth":4,"percentGrowth":0.02},"agility":{"levelOne":14,"flatGrowth":5,"percentGrowth":0.02},"health":{"levelOne":60,"flatGrowth":2,"percentGrowth":0.02},"mana":{"levelOne":20,"flatGrowth":2,"percentGrowth":0.02}},"rom-mage":{"strength":{"levelOne":5,"flatGrowth":1.9,"percentGrowth":0.02},"stamina":{"levelOne":9,"flatGrowth":3.4,"percentGrowth":0.02},"intellect":{"levelOne":14,"flatGrowth":5,"percentGrowth":0.02},"wisdom":{"levelOne":14,"flatGrowth":5,"percentGrowth":0.02},"agility":{"levelOne":10,"flatGrowth":3.5,"percentGrowth":0.02},"health":{"levelOne":60,"flatGrowth":2,"percentGrowth":0.02},"mana":{"levelOne":20,"flatGrowth":2,"percentGrowth":0.02}},"rom-priest":{"strength":{"levelOne":7,"flatGrowth":2.5,"percentGrowth":0.02},"stamina":{"levelOne":10,"flatGrowth":3.6,"percentGrowth":0.02},"intellect":{"levelOne":13,"flatGrowth":4.5,"percentGrowth":0.02},"wisdom":{"levelOne":14,"flatGrowth":5,"percentGrowth":0.02},"agility":{"levelOne":10,"flatGrowth":3.5,"percentGrowth":0.02},"health":{"levelOne":60,"flatGrowth":2,"percentGrowth":0.02},"mana":{"levelOne":20,"flatGrowth":2,"percentGrowth":0.02}},"rom-knight":{"strength":{"levelOne":13,"flatGrowth":4.5,"percentGrowth":0.02},"stamina":{"levelOne":14,"flatGrowth":5,"percentGrowth":0.02},"intellect":{"levelOne":11,"flatGrowth":3.75,"percentGrowth":0.02},"wisdom":{"levelOne":14,"flatGrowth":4,"percentGrowth":0.02},"agility":{"levelOne":11,"flatGrowth":3.9,"percentGrowth":0.02},"health":{"levelOne":60,"flatGrowth":2,"percentGrowth":0.02},"mana":{"levelOne":20,"flatGrowth":2,"percentGrowth":0.02}},"rom-warden":{"strength":{"levelOne":9,"flatGrowth":3.25,"percentGrowth":0.02},"stamina":{"levelOne":9,"flatGrowth":3.4,"percentGrowth":0.02},"intellect":{"levelOne":12,"flatGrowth":4.25,"percentGrowth":0.02},"wisdom":{"levelOne":13,"flatGrowth":4.5,"percentGrowth":0.02},"agility":{"levelOne":10,"flatGrowth":3.5,"percentGrowth":0.02},"health":{"levelOne":60,"flatGrowth":2,"percentGrowth":0.02},"mana":{"levelOne":20,"flatGrowth":2,"percentGrowth":0.02}},"rom-druid":{"strength":{"levelOne":12,"flatGrowth":4.35,"percentGrowth":0.02},"stamina":{"levelOne":12,"flatGrowth":4.15,"percentGrowth":0.02},"intellect":{"levelOne":11,"flatGLine truncated
{"schemaVersion":1,"source":{"rootHint":"RuneWaker source snapshot","hashes":{"learnMagic":"6148199bcd1484ba1a122a6db5e2b3c2f2a161402bfe67fc67eb53f55fc6cfa3","magicCollect":"4aae19865cb07b30f99945aa7319a0096db244ff5e5b4b5d902fc6889474f501","magicObject":"52af13a2d8cfa9a66d4caa2d3c38832edc232f058995a64ef8135a6b82e43691","imageObject":"dd5ffcfd74b253dcecaa2f41a931a634dd45ef5f7c13e849d539ddc47fdee1f9","vocationTable":"f06cdaf60c54415dcbea47e9fa0e77b7527035a2aec73d3289370dc39751e76c","strings":"0c20d43660cb2ee042a78f7a8c18b4e104a263d54dd8c4bc98a54155e8adb3b0","eliteLua":"ee9e337548abd7fc555d85bf2f96813dfd77416c3e65d4cadf74a80838da1486","roleAttr":"8143a90ce826bf3152551a4508d212f186012cbc054e505e426551cb62369c1f","objectStruct":"708f2d087f2654b97d149ab7124d9cdffc915cf8bf18108feff3f1a45f124c20","interfaceFdb":"51bbb5a9d6a19a254b302000d3dd6e3a796f4f759015f3352f576a1055496e30"}},"canonicalClasses":[{"id":"rom-warrior","sourceClassId":1,"name":"Warrior","resource":"rage"},{"id":"rom-scout","sourceClassId":2,"name":"Scout","resource":"focus"},{"id":"rom-rogue","sourceClassId":3,"name":"Rogue","resource":"energy"},{"id":"rom-mage","sourceClassId":4,"name":"Mage","resource":"mana"},{"id":"rom-priest","sourceClassId":5,"name":"Priest","resource":"mana"},{"id":"rom-knight","sourceClassId":6,"name":"Knight","resource":"mana"},{"id":"rom-warden","sourceClassId":7,"name":"Warden","resource":"mana"},{"id":"rom-druid","sourceClassId":8,"name":"Druid","resource":"mana"},{"id":"rom-warlock","sourceClassId":9,"name":"Warlock","resource":"focus"},{"id":"rom-champion","sourceClassId":10,"name":"Champion","resource":"rage"}],"baseStatGrowth":{"rom-warrior":{"strength":{"levelOne":14,"flatGrowth":5,"percentGrowth":0.02},"stamina":{"levelOne":13,"flatGrowth":4.5,"percentGrowth":0.02},"intellect":{"levelOne":5,"flatGrowth":1.9,"percentGrowth":0.02},"wisdom":{"levelOne":6,"flatGrowth":2.1,"percentGrowth":0.02},"agility":{"levelOne":12,"flatGrowth":4.25,"percentGrowth":0.02},"health":{"levelOne":60,"flatGrowth":2,"percentGrowth":0.02},"mana":{"levelOne":20,"flatGrowth":2,"percentGrowth":0.02}},"rom-scout":{"strength":{"levelOne":11,"flatGrowth":3.85,"percentGrowth":0.02},"stamina":{"levelOne":10,"flatGrowth":3.65,"percentGrowth":0.02},"intellect":{"levelOne":10,"flatGrowth":3.6,"percentGrowth":0.02},"wisdom":{"levelOne":9,"flatGrowth":3.4,"percentGrowth":0.02},"agility":{"levelOne":13,"flatGrowth":4.5,"percentGrowth":0.02},"health":{"levelOne":60,"flatGrowth":2,"percentGrowth":0.02},"mana":{"levelOne":20,"flatGrowth":2,"percentGrowth":0.02}},"rom-rogue":{"strength":{"levelOne":12,"flatGrowth":4.15,"percentGrowth":0.02},"stamina":{"levelOne":11,"flatGrowth":3.9,"percentGrowth":0.02},"intellect":{"levelOne":7,"flatGrowth":2.6,"percentGrowth":0.02},"wisdom":{"levelOne":7,"flatGrowth":2.4,"percentGrowth":0.02},"agility":{"levelOne":14,"flatGrowth":5,"percentGrowth":0.02},"health":{"levelOne":60,"flatGrowth":2,"percentGrowth":0.02},"mana":{"levelOne":20,"flatGrowth":2,"percentGrowth":0.02}},"rom-mage":{"strength":{"levelOne":5,"flatGrowth":1.9,"percentGrowth":0.02},"stamina":{"levelOne":9,"flatGrowth":3.4,"percentGrowth":0.02},"intellect":{"levelOne":14,"flatGrowth":5,"percentGrowth":0.02},"wisdom":{"levelOne":12,"flatGrowth":4.25,"percentGrowth":0.02},"agility":{"levelOne":10,"flatGrowth":3.5,"percentGrowth":0.02},"health":{"levelOne":60,"flatGrowth":2,"percentGrowth":0.02},"mana":{"levelOne":20,"flatGrowth":2,"percentGrowth":0.02}},"rom-priest":{"strength":{"levelOne":7,"flatGrowth":2.5,"percentGrowth":0.02},"stamina":{"levelOne":10,"flatGrowth":3.6,"percentGrowth":0.02},"intellect":{"levelOne":13,"flatGrowth":4.5,"percentGrowth":0.02},"wisdom":{"levelOne":14,"flatGrowth":5,"percentGrowth":0.02},"agility":{"levelOne":10,"flatGrowth":3.5,"percentGrowth":0.02},"health":{"levelOne":60,"flatGrowth":2,"percentGrowth":0.02},"mana":{"levelOne":20,"flatGrowth":2,"percentGrowth":0.02}},"rom-knight":{"strength":{"levelOne":13,"flatGrowth":4.5,"percentGrowth":0.02},"stamina":{"levelOne":14,"flatGrowth":5,"percentGrowth":0.02},"intellect":{"levelOne":11,"flatGrowth":3.75,"percentGrowth":0.02},"wisdom":{"levelOne":11,"flatGrowth":4.1,"percentGrowth":0.02},"agility":{"levelOne":11,"flatGrowth":3.9,"percentGrowth":0.02},"health":{"levelOne":60,"flatGrowth":2,"percentGrowth":0.02},"mana":{"levelOne":20,"flatGrowth":2,"percentGrowth":0.02}},"rom-warden":{"strength":{"levelOne":14,"flatGrowth":5,"percentGrowth":0.02},"stamina":{"levelOne":12,"flatGrowth":4.2,"percentGrowth":0.02},"intellect":{"levelOne":10,"flatGrowth":3.8,"percentGrowth":0.02},"wisdom":{"levelOne":10,"flatGrowth":4,"percentGrowth":0.02},"agility":{"levelOne":10,"flatGrowth":3.5,"percentGrowth":0.02},"health":{"levelOne":60,"flatGrowth":2,"percentGrowth":0.02},"mana":{"levelOne":20,"flatGrowth":2,"percentGrowth":0.02}},"rom-druid":{"strength":{"levelOne":7,"flatGrowth":2.5,"percentGrowth":0.02},"stamina":{"levelOne":11,"flatGrowth":3.7,"percentGrowth":0.02},"intellect":{"levelOne":13,"flatLine truncated
+14 -1
View File
@@ -122,7 +122,7 @@ describe("generated RuneWaker player catalog", () => {
103 * MANA_POOL_MULTIPLIER,
);
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 });
});
});
+174 -6
View File
@@ -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
View File
@@ -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;
+22 -1
View File
@@ -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
View File
@@ -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))
+1
View File
@@ -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 })));
+10 -1
View File
@@ -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();
};
}
+48
View File
@@ -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();
});
});
+32 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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);
});
+32
View File
@@ -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;
}
+73
View File
@@ -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();
});
});
+38
View File
@@ -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;
};
}
+10
View File
@@ -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)); }
+21 -8
View File
@@ -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} />;
}
+14
View File
@@ -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>;
}
+20
View File
@@ -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>;
}
+69
View File
@@ -0,0 +1,69 @@
import { useMemo, useRef, useState, type FormEvent } from "react";
import { useShellStore } from "../app/shellStore";
import { DUNGEON_REPORT_COMMENT_MAX_LENGTH, submitDungeonReport } from "../app/dungeonReports";
import { dungeonById } from "../game/dungeonCatalog";
import { useGameStore } from "../game/store";
import { useMenuController, type MenuAction } from "../input/useMenuController";
import { ControllerButton } from "./ControllerButton";
export function DungeonIssueReportPanel() {
const dungeonId = useGameStore((state) => state.activeDungeonId);
const openOverlay = useGameStore((state) => state.openOverlay);
const session = useShellStore((state) => state.session);
const [comment, setComment] = useState("");
const [submitting, setSubmitting] = useState(false);
const [submitted, setSubmitted] = useState(false);
const [error, setError] = useState("");
const busy = useRef(false);
const form = useRef<HTMLFormElement>(null);
const back = () => { if (!busy.current) openOverlay("pause"); };
const canSubmit = Boolean(comment.trim()) && comment.length <= DUNGEON_REPORT_COMMENT_MAX_LENGTH && !submitting;
const actions = useMemo<MenuAction[]>(() => [
{ id: "report-back", run: back, enabled: !submitting },
{ id: "report-submit", run: () => form.current?.requestSubmit(), enabled: canSubmit && !submitted },
], [canSubmit, openOverlay, submitted, submitting]);
const controller = useMenuController(actions, { initialId: "report-back", onBack: back });
const submit = async (event: FormEvent) => {
event.preventDefault();
if (!canSubmit || busy.current || submitted) return;
busy.current = true;
setSubmitting(true);
setError("");
try {
await submitDungeonReport(session, dungeonId, comment);
setSubmitted(true);
} catch (failure) {
setError(failure instanceof Error ? failure.message : "Your report could not be submitted. Please try again.");
} finally {
busy.current = false;
setSubmitting(false);
}
};
return (
<div className="modal-backdrop" role="presentation">
<section className="pause-menu dungeon-issue-report" role="dialog" aria-modal="true" aria-labelledby="issue-report-title">
<p className="eyebrow">Dungeon feedback</p>
<h2 id="issue-report-title">Report an issue</h2>
<p>{dungeonById(dungeonId)?.title ?? dungeonId}</p>
{submitted ? <p role="status">Report submitted. You can follow its status in View Reports on the main menu.</p> : (
<form ref={form} onSubmit={(event) => void submit(event)}>
<label htmlFor="dungeon-issue-comment">Describe the issue</label>
<textarea id="dungeon-issue-comment" autoFocus required maxLength={DUNGEON_REPORT_COMMENT_MAX_LENGTH}
value={comment} disabled={submitting} onChange={(event) => setComment(event.target.value)}
aria-describedby="dungeon-issue-count" placeholder="What happened, and what were you doing when it happened?" />
<small id="dungeon-issue-count">{comment.length} / {DUNGEON_REPORT_COMMENT_MAX_LENGTH} characters</small>
{error && <p role="alert" className="dungeon-report-error">{error}</p>}
<ControllerButton controlId="report-submit" selectedId={controller.selectedId} select={controller.select}
className="button button--primary" type="submit" disabled={!canSubmit}>
{submitting ? "Submitting..." : "Submit report"}
</ControllerButton>
</form>
)}
<ControllerButton controlId="report-back" selectedId={controller.selectedId} select={controller.select}
className="button" type="button" onClick={back} disabled={submitting}>Back to pause menu</ControllerButton>
</section>
</div>
);
}
+148
View File
@@ -0,0 +1,148 @@
import { useMemo, useRef, useState } from "react";
import {
DUNGEON_REPORT_STATUSES, filterDungeonReports, sessionCanManageDungeonReports, updateDungeonReport,
type DungeonReport, type DungeonReportStatus,
} from "../app/dungeonReports";
import { useShellStore } from "../app/shellStore";
import { DualDisplayFrame } from "../components/DualDisplayFrame";
import { DUNGEON_CATALOG, dungeonById } from "../game/dungeonCatalog";
import { useMenuController, type MenuAction } from "../input/useMenuController";
import { BrandMark, ControllerLegend, FrontSurface } from "./FrontSurface";
import { ControllerButton } from "./ControllerButton";
import { LazyLocalCompanionPanel } from "./LazyLocalCompanionPanel";
import { useDungeonReports } from "./useDungeonReports";
function ReportCard({ report, canManage, saving, onUpdate, selectedId, select }: {
readonly report: DungeonReport;
readonly canManage: boolean;
readonly saving: boolean;
readonly onUpdate: (report: DungeonReport, status: DungeonReportStatus) => void;
readonly selectedId: string;
readonly select: (id: string) => void;
}) {
return (
<article className={`dungeon-report-card ${report.status === "Closed" ? "is-closed" : ""}`}>
<header><h2>{dungeonById(report.dungeonId)?.title ?? report.dungeonId}</h2><span className="dungeon-report-status">{report.status}</span></header>
<p className="dungeon-report-comment">{report.comment}</p>
<small>Submitted by {report.submittedBy} · <time dateTime={new Date(report.createdAt).toISOString()}>{new Date(report.createdAt).toLocaleString()}</time></small>
{report.updatedBy && <small>Updated by {report.updatedBy} · {new Date(report.updatedAt).toLocaleString()}</small>}
{canManage && (
<div className="dungeon-report-management">
<label>Status
<select aria-label={`Status for report ${report.id}`} value={report.status} disabled={saving}
className={selectedId === `report-status-${report.id}` ? "is-controller-selected" : ""}
onPointerEnter={() => select(`report-status-${report.id}`)}
onChange={(event) => onUpdate(report, event.target.value as DungeonReportStatus)}>
{DUNGEON_REPORT_STATUSES.map((status) => <option key={status} value={status}>{status}</option>)}
</select>
</label>
{report.status !== "Closed" && <ControllerButton controlId={`report-close-${report.id}`} selectedId={selectedId} select={select}
type="button" className="front-button" disabled={saving} onClick={() => onUpdate(report, "Closed")}>Close report</ControllerButton>}
</div>
)}
</article>
);
}
export function DungeonReportsScreen({ onClose }: { readonly onClose: () => void }) {
const session = useShellStore((state) => state.session);
const { snapshot, loading, error, refresh } = useDungeonReports();
const [status, setStatus] = useState<DungeonReportStatus | "all">("all");
const [dungeonId, setDungeonId] = useState("all");
const [saving, setSaving] = useState(false);
const [updateError, setUpdateError] = useState("");
const [message, setMessage] = useState("");
const busy = useRef(false);
const canManage = sessionCanManageDungeonReports(session);
const reports = filterDungeonReports(snapshot?.reports ?? [], status, dungeonId);
const dungeonOptions = useMemo(() => [...DUNGEON_CATALOG].sort((a, b) => a.title.localeCompare(b.title)), []);
const actions = useMemo<MenuAction[]>(() => [
{ id: "reports-back", run: onClose, enabled: !saving },
{ id: "reports-filter-status", run: () => {
const options = ["all", ...DUNGEON_REPORT_STATUSES] as const;
setStatus(options[(options.indexOf(status) + 1) % options.length]);
} },
{ id: "reports-filter-dungeon", run: () => {
const options = ["all", ...dungeonOptions.map((dungeon) => dungeon.id)];
setDungeonId(options[(options.indexOf(dungeonId) + 1) % options.length]);
} },
{ id: "reports-clear-filters", run: () => { setStatus("all"); setDungeonId("all"); } },
{ id: "reports-refresh", run: () => void refresh(), enabled: !loading && !saving },
...(canManage ? reports.flatMap((report) => [
{ id: `report-status-${report.id}`, enabled: !saving, run: () => {
const nextStatus = DUNGEON_REPORT_STATUSES[(DUNGEON_REPORT_STATUSES.indexOf(report.status) + 1) % DUNGEON_REPORT_STATUSES.length];
void update(report, nextStatus);
} },
...(report.status === "Closed" ? [] : [{ id: `report-close-${report.id}`, enabled: !saving, run: () => void update(report, "Closed") }]),
]) : []),
], [canManage, dungeonId, dungeonOptions, loading, onClose, refresh, reports, saving, session, status]);
const controller = useMenuController(actions, { initialId: "reports-back", onBack: saving ? undefined : onClose });
const update = async (report: DungeonReport, nextStatus: DungeonReportStatus) => {
if (!session || !canManage || busy.current) return;
busy.current = true;
setSaving(true);
setUpdateError("");
setMessage("");
try {
await updateDungeonReport(session, report, nextStatus);
setMessage(`Report status updated to ${nextStatus}.`);
} catch (failure) {
setUpdateError(failure instanceof Error ? failure.message : "The status could not be updated.");
} finally {
await refresh();
busy.current = false;
setSaving(false);
}
};
return (
<DualDisplayFrame contextLabel="Dungeon reports" top={
<FrontSurface className="dungeon-reports-screen" ariaLabel="Dungeon reports">
<header className="dungeon-reports-header">
<BrandMark compact />
<div><span>Main menu / Dungeon feedback</span><h1>View Reports</h1></div>
<ControllerButton controlId="reports-back" selectedId={controller.selectedId} select={controller.select}
className="front-button" type="button" onClick={onClose} disabled={saving}>Back to main menu</ControllerButton>
</header>
<main className="dungeon-reports-main">
<div className="dungeon-report-filters">
<label>Status
<select value={status} onChange={(event) => setStatus(event.target.value as DungeonReportStatus | "all")}
className={controller.selectedId === "reports-filter-status" ? "is-controller-selected" : ""}
onPointerEnter={() => controller.select("reports-filter-status")}>
<option value="all">All statuses</option>
{DUNGEON_REPORT_STATUSES.map((value) => <option key={value} value={value}>{value}</option>)}
</select>
</label>
<label>Dungeon
<select value={dungeonId} onChange={(event) => setDungeonId(event.target.value)}
className={controller.selectedId === "reports-filter-dungeon" ? "is-controller-selected" : ""}
onPointerEnter={() => controller.select("reports-filter-dungeon")}>
<option value="all">All dungeons</option>
{dungeonOptions.map((dungeon) => (
<option key={dungeon.id} value={dungeon.id}>{dungeon.title}</option>
))}
</select>
</label>
<ControllerButton controlId="reports-clear-filters" selectedId={controller.selectedId} select={controller.select}
type="button" className="front-button" onClick={() => { setStatus("all"); setDungeonId("all"); }}>Clear filters</ControllerButton>
<ControllerButton controlId="reports-refresh" selectedId={controller.selectedId} select={controller.select}
className="front-button" type="button" disabled={loading || saving} onClick={() => void refresh()}>
{loading ? "Refreshing..." : "Refresh"}
</ControllerButton>
</div>
<p className="dungeon-reports-summary">{reports.length} of {snapshot?.reports.length ?? 0} reports · {canManage ? "GM status controls enabled" : "All players can view reports. GMs manage their status."}</p>
{(error || updateError) && <p role="alert" className="front-notice">{updateError || error}{snapshot && error ? " Showing the last loaded reports." : ""}</p>}
{message && <p role="status" className="front-notice">{message}</p>}
<section className="dungeon-report-list" aria-label="Reports" aria-busy={loading || saving}>
{loading && !snapshot ? <p role="status">Loading reports...</p> : snapshot && reports.length === 0 ? (
<p>{snapshot.reports.length ? "No reports match these filters." : "No dungeon reports have been submitted yet."}</p>
) : reports.map((report) => <ReportCard key={report.id} report={report} canManage={canManage} saving={saving}
onUpdate={(item, nextStatus) => void update(item, nextStatus)} selectedId={controller.selectedId} select={controller.select} />)}
</section>
</main>
<ControllerLegend back />
</FrontSurface>
} bottom={<LazyLocalCompanionPanel />} />
);
}
+11 -2
View File
@@ -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>
+20 -14
View File
@@ -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
View File
@@ -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}
+21 -5
View File
@@ -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
View File
@@ -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 -2
View File
@@ -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;
}
+31
View File
@@ -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>
);
}
+17
View File
@@ -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();
+35
View File
@@ -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", () => {
+42
View File
@@ -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; }
+89
View File
@@ -0,0 +1,89 @@
:root { --report-orange: #f1a044; }
.dungeon-select-row { position: relative; }
.dungeon-select-row.has-open-reports { border-color: var(--report-orange); }
.dungeon-select-row.has-open-reports.is-controller-selected { box-shadow: 0 0 0 2px rgba(241,160,68,.5) !important; }
.dungeon-select-row.has-open-reports.is-controller-selected, .dungeon-select-row.has-open-reports:focus-visible { outline-color: var(--report-orange) !important; }
.dungeon-open-report-count {
position: absolute;
top: 3px;
right: 4px;
display: grid;
min-width: 19px;
height: 19px;
padding: 0 4px;
place-items: center;
border-radius: 10px;
background: var(--report-orange);
color: #211608;
font: 700 11px/1 sans-serif;
}
.dungeon-release-stage {
display: inline-block;
margin-bottom: 9px;
padding: 3px 7px;
border: 1px solid var(--front-line);
border-radius: 5px;
color: var(--front-gold);
font-size: 10px;
}
.dungeon-report-load-error { margin: 0 0 5px; color: var(--report-orange); font-size: 10px; }
.dungeon-issue-report form { display: grid; gap: 10px; margin-bottom: 12px; }
.dungeon-issue-report textarea {
width: 100%;
min-height: 140px;
max-height: 30vh;
padding: 12px;
resize: vertical;
border: 1px solid var(--edge);
border-radius: 8px;
background: rgba(0,0,0,.25);
color: var(--ink);
font: inherit;
line-height: 1.5;
}
.dungeon-issue-report form > small { color: var(--muted); text-align: right; }
.dungeon-issue-report > .button { width: 100%; }
.dungeon-report-error { margin: 0; color: var(--report-orange); }
.dungeon-reports-screen { display: grid; grid-template-rows: auto minmax(0,1fr) auto; gap: 14px; }
.dungeon-reports-header { display: flex; align-items: center; gap: 20px; padding-bottom: 12px; border-bottom: 1px solid var(--front-line); }
.dungeon-reports-header > div:nth-child(2) { flex: 1; }
.dungeon-reports-header > div > span { color: var(--front-dim); font-size: 10px; }
.dungeon-reports-header h1 { margin: 3px 0 0; font: 26px Georgia, serif; }
.dungeon-reports-main { display: flex; min-height: 0; flex-direction: column; }
.dungeon-report-filters { display: flex; flex-wrap: wrap; align-items: end; gap: 10px; }
.dungeon-report-filters label, .dungeon-report-management label { display: grid; min-width: 0; gap: 5px; color: var(--front-dim); font-size: 12px; }
.dungeon-report-filters select, .dungeon-report-management select {
width: 100%;
max-width: 360px;
min-height: 40px;
padding: 7px 10px;
border: 1px solid var(--front-line);
border-radius: 7px;
background: #182015;
color: var(--front-cream);
font: inherit;
}
.dungeon-report-filters > label:nth-child(2) { flex: 1; }
.dungeon-reports-summary { color: var(--front-dim); font-size: 12px; }
.dungeon-report-list { display: grid; min-height: 0; align-content: start; gap: 12px; overflow: auto; padding: 2px 4px 10px 0; }
.dungeon-report-card { padding: 16px; border: 1px solid var(--report-orange); border-radius: 9px; background: rgba(255,255,255,.025); }
.dungeon-report-card.is-closed { border-color: var(--front-line); }
.dungeon-report-card header { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; }
.dungeon-report-card h2 { flex: 1; margin: 0; font: 19px Georgia, serif; }
.dungeon-report-status { padding: 4px 8px; border-radius: 5px; background: rgba(241,160,68,.12); color: var(--report-orange); font-size: 12px; }
.is-closed .dungeon-report-status { color: var(--front-dim); background: rgba(255,255,255,.05); }
.dungeon-report-comment { white-space: pre-wrap; overflow-wrap: anywhere; font-size: 14px; line-height: 1.5; }
.dungeon-report-card > small { display: block; margin-top: 5px; color: var(--front-dim); font-size: 11px; }
.dungeon-report-management { display: flex; flex-wrap: wrap; align-items: end; gap: 12px; margin-top: 14px; }
.dungeon-reports-screen > .controller-legend { position: static; justify-self: end; }
.dungeon-issue-report textarea:focus-visible, .dungeon-reports-screen select:focus-visible { outline: 2px solid var(--report-orange); outline-offset: 2px; }
.dungeon-reports-screen select.is-controller-selected { outline: 2px solid var(--front-gold); outline-offset: 2px; }
@media (max-width: 600px) {
.dungeon-reports-header { gap: 10px; }
.dungeon-reports-header > .front-brand { display: none; }
.dungeon-reports-header h1 { font-size: 21px; }
.dungeon-report-filters { display: grid; grid-template-columns: minmax(0,1fr) minmax(0,1fr); }
.dungeon-report-card { padding: 12px; }
.dungeon-report-card h2 { font-size: 17px; }
.dungeon-reports-screen > .controller-legend { display: none; }
}
+87
View File
@@ -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");
});
});
+40
View File
@@ -0,0 +1,40 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { fetchDungeonReports, type DungeonReportSnapshot } from "../app/dungeonReports";
export function useDungeonReports() {
const [snapshot, setSnapshot] = useState<DungeonReportSnapshot | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const sequence = useRef(0);
const refresh = useCallback(async () => {
const request = ++sequence.current;
setLoading(true);
try {
const next = await fetchDungeonReports();
if (request !== sequence.current) return;
setSnapshot(next);
setError("");
} catch (failure) {
if (request === sequence.current) setError(failure instanceof Error ? failure.message : "Reports could not be loaded.");
} finally {
if (request === sequence.current) setLoading(false);
}
}, []);
useEffect(() => {
const refreshVisible = () => { if (document.visibilityState === "visible") void refresh(); };
void refresh();
const timer = window.setInterval(refreshVisible, 30_000);
window.addEventListener("focus", refreshVisible);
document.addEventListener("visibilitychange", refreshVisible);
return () => {
sequence.current += 1;
window.clearInterval(timer);
window.removeEventListener("focus", refreshVisible);
document.removeEventListener("visibilitychange", refreshVisible);
};
}, [refresh]);
return { snapshot, loading, error, refresh };
}