Compare commits

..
4 Commits
Author SHA1 Message Date
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
phenom 40d9f346d8 Release Healer Man 0.1.35 2026-09-12 16:02:15 -04:00
phenom 46c272f889 Release Healer Man 0.1.34 2026-09-12 15:14:56 -04:00
71 changed files with 3836 additions and 449 deletions

No files matched your search

+300
View File
@@ -2258,3 +2258,303 @@ Vite's existing large-chunk advisory remains informational.
run without output; its existing report files were not changed. Stockades run without output; its existing report files were not changed. Stockades
navigation evidence comes from the actual shipping-asset regressions and navigation evidence comes from the actual shipping-asset regressions and
the live smoke pass above, not a new fleet-wide functionality certification. the live smoke pass above, not a new fleet-wide functionality certification.
## 2026-09-12 - Stockades stair-ramp follow-up
- Follow-up player feedback reported companions still sticking near stairs.
Actual collision profiles contain approximately 0.44-0.59m risers, above
the party sweep's 0.32m lower-leg clearance. Collision-backed regressions
reproduce uphill rejection on the original shipping collision GLB.
- Added three reviewed package-space traversal proxies for the entrance and
both prison-wing stair flights. Collision tread/riser vertices within the
reviewed stair relief are reshaped onto smooth ramp planes; upper walls,
ceilings, other floors, and geometry outside those footprints remain intact.
Source visual/collision GLBs are unchanged. Only prepared walking collision
and party navigation use the overrides; the rendered stairs remain steps.
- Navigation receives the same ramp planes with 0.05m surface clearance and
six new triangles stitching the exact source boundary portals. This replaces
the three vertex-only connections with filled triangle corridors, allowing
mid-stair replans and recalls to project onto the interior walking surface.
The reviewed graph now has 3,544 source triangles and 31 components, retaining
entrance-to-all-five-boss routes and discarding minor decorative islands.
- Live QA caught a second issue: terrain queries included the player's
kinematic capsule. A failing collision-backed regression confirmed false
route blockage through a nearby player. Party direct, route, and grounding
queries now exclude dynamic/kinematic actor bodies and sensors; crowd spacing
remains a separate party-separation concern. Static walls still block paths.
- Automated tests exercise shipping normalized/quantized GLBs through runtime
preparation, downhill/uphill party capsule checks on all three flights at
center and lateral offsets, repeated mid-stair replans, all boss routes with
live wall checks, tank melee approach with real collision validation, and
player capsule traversal down/back up all three ramps without jumping.
- Final desktop browser smoke pass at 1280 x 720 used offline level-15 Priest
Rolecheck and the same Blood DK/Combat Rogue/Unholy DK/Frost Mage party.
Attack began without ROUTE BLOCKED, companions changed position down the
entrance staircase, the tank moved out of the entrance view and took aggro,
and Backstab/Icy Touch/Plague Strike combat events appeared. Experience rose
from 6,576 to 11,098 as the party defeated trash with the player stationary.
Recall brought companions back to the entrance stairs. The unattended tank
eventually died during continued combat; this was not a healed full clear.
- Inline task screenshots captured the unchanged visible steps, equipped
character bodies, warm lighting, and party movement. Both wing flights have
collision-backed automated coverage; live end-to-end wing traversal, a full
dungeon clear, wipe/checkpoint behavior, and physical Android QA remain open.
- Validation: all 224 Vitest files passed, 1,380 tests passed, and six existing
tests remained skipped. Production TypeScript/Vite compilation passed using
the project's 8GB heap setting; texture/performance audits, static bundle
compression, and release-bundle verification passed. Existing build-size
advisories and deprecated physics initialization warnings remain. No hosted
release was published.
## 2026-09-12 - Non-blocking players and combat actors
- Removed party soft separation and crowd-triggered combat repositioning.
Companions may overlap or cross one another without push-apart corrections
changing their routes. Normal follow formations and role-specific attack
ranges remain; a tank already in melee range does not move just because an
ally occupies the same position.
- Added a shared actor collision layer that interacts only with physical
terrain. The player capsule uses this layer. Player controller movement,
ground probes, camera sweeps, party direct/route validation, party grounding,
and combat ground-position queries use terrain-only filters. Actor body type
cannot turn an actor into a floor or movement obstruction. Party members,
remote players, mobs, and bosses currently render without physical colliders;
any future actor capsule must use the shared actor layer.
- Walls, floors, ceilings, dungeon objects, and the Stockades walking ramps
retain their terrain collision. Target-selection meshes, combat visibility,
damage, and attack-range checks were not removed.
- Ten new Rapier regressions cover overlapping actors with no contacts or
displacement, movement through fixed/kinematic/dynamic actors (including an
overlapped starting position), continued wall/floor collision, and ignoring
actors in ground, party-route, and camera queries. A terrain-layer control
confirms real actor/terrain contacts are still detected. Stockades shipping-
asset regressions now use the live player collision policy and still pass
down/up all three ramps without jumping.
- Browser smoke pass at 1280 x 720 used offline level-15 Priest Rolecheck with
Blood DK, Combat Rogue, Unholy DK, and Frost Mage companions. Inline roster
and dungeon screenshots show character bodies, weapons, unchanged visible
stairs, and the existing warm lighting. Attack moved companions down from
the entrance and out of its view without ROUTE BLOCKED; the tank took aggro
and melee/caster combat events continued. Experience rose from 11,098 to
13,359. Stop followed by Recall returned the party to the nearby player.
Selecting Morvane and casting Power Word: Shield applied the buff successfully.
- Live QA was an entrance/trash/recall smoke pass, not a full dungeon clear or
a manual traversal of both wings. Actor overlap and passage guarantees are
backed by the physics regressions; physical Android and multiplayer QA remain
open.
- Validation: all 225 Vitest files passed, 1,390 tests passed, and six existing
tests remained skipped. The final focused checks passed again after cleanup.
Production TypeScript/Vite build, texture/performance audits, static bundle
compression, and release-bundle verification passed. Existing chunk-size
advisories and deprecated physics initialization warnings remain. No hosted
release was published.
## 2026-09-12 - Individual companion navigation recovery
- Replaced the old distant, whole-party stuck Recall with an individual
navigation watchdog in a separately tested game module. Two seconds without
meaningful waypoint progress invalidates that companion's route; five seconds
requests a safe recovery landing. Failed replans do not restart the timer,
back-and-forth movement cancels forward progress, and dense waypoint chains
still count genuine movement around corners.
- Recovery prefers a recently visited party/player trail point near the group
on the same floor, then falls back to a validated player position. Actual
terrain checks require walkable ground, clear companion body space, and a
route back to the group. Blocked or invalid landings are not applied. Each
companion has an independent ten-second teleport cooldown.
- Intentional attack-range holds, casting/channeling, crowd control, Stop,
death, inactive simulation, and UI pauses do not accumulate stuck time.
Automatic recovery changes only the affected companion's position and routing
state; it does not heal, resurrect, reset threat, or teleport other companions.
Manual Recall keeps its existing whole-party behavior and party order.
- Added regressions for recovery timing, oscillation, replanning, independent
cooldowns and member isolation, exclusions, safe-trail/player selection, wall
and ceiling rejection, missing floors, and actor-independent landing checks.
Shipping Stockades navigation/collision assets validate recovery landings on
all three ramps and along approaches to all five boss objectives.
- Browser smoke pass at 1280 x 720 used offline Priest Rolecheck and Blood DK,
Combat Rogue, Unholy DK, and Frost Mage companions. Inline roster and dungeon
screenshots show unchanged character bodies, equipment, stairs, and warm
lighting. The first catalog load needed a retry. Attack moved companions down
from the entrance without ROUTE BLOCKED, and combat continued with the player
stationary. The unattended party eventually died; Stop remained selected
after Recall returned the fallen companions without resurrecting them.
- The intermittent side-room trap and its automatic recovery were not reproduced
live. Recovery triggers and terrain safety are backed by automated tests;
extended side-room playtesting, a healed full clear, and physical Android QA
remain open. This pass did not modify existing ramp or actor-collision policy.
- Validation: all 227 Vitest files passed, 1,416 tests passed, and six existing
tests remained skipped. The final 37 focused checks passed after correcting a
test-helper type annotation. Production TypeScript/Vite compilation, texture
and performance audits, static compression, and bundle verification passed.
Existing chunk-size advisories and deprecated physics initialization warnings
remain. No hosted release was published.
## 2026-09-12 - Wall-aware combat pursuit and individual mob recovery
- Hostile mob actions now require both attack range and terrain line of sight.
Losing either cancels a pending hostile cast so pursuit can resume; companion
hostile casts also cancel when sight is lost. Self buffs and friendly heals
cannot make an otherwise obstructed mob hold an attack position. Proximity
aggro also checks sight instead of pulling enemies through nearby walls.
- Static enemies, roaming packs, and summoned enemies share separately tested
imperative pursuit rules over the already-loaded dungeon navigation surface.
Blocked direct approaches use approved routes through doorways. Routes may
finish at a reachable attack position only when that position is both in
range and visible; off-surface connectors still require body clearance.
- Two seconds without meaningful waypoint progress requests a replan. After
five seconds, a stuck mob within ten meters of its target may recover to a
validated nearby landing on the same floor and within its home leash. Ground,
body clearance, route connectivity, attack range, and sight must all pass.
This teleport preserves health and threat and has a ten-second cooldown.
- Distant, wrong-floor, out-of-leash, or unsafe near-target recovery requests
instead reset only that mob's combat state and restore its health. A validated
home landing is preferred; otherwise it evades and navigates home without a
blind teleport. Ten seconds of evade prevents immediate proximity, damage,
or taunt re-engagement. Dead enemies are never resurrected. Casting, control,
pauses, inactive simulation, and non-authoritative online clients do not
accumulate navigation failures or trigger automatic recovery.
- Regression coverage includes range-plus-sight attack/cast gating, immediate
cast cancellation, doorway detours, recovery timing and safety, reset/member
isolation, cooldown bypass protection, and near-teleport health/threat
preservation. Shipping Stockades meshes independently verify real wall
occlusion in both directions and clear sight across all three stair ramps.
Pursuing mobs traverse each actual ramp uphill and downhill without false
teleport/reset recovery. Existing party and player ramp checks still pass.
- Browser smoke pass at 1280 x 720 used offline level-16 Priest Rolecheck and
Blood DK, Combat Rogue, Unholy DK, and Frost Mage companions. Power Word:
Shield applied to Morvane; Attack moved companions down from the entrance
and out of its view without ROUTE BLOCKED, and Defias melee/cast events and
tank damage followed. Stop plus Recall returned all four living companions
to the nearby player while retaining Stop. Inline screenshots show character
bodies, equipment, unchanged visible steps, and the existing warm lighting.
The browser console had no errors and the existing physics initialization
deprecation warning only.
- Live QA was a short entrance/combat/recall smoke test, not a healed full clear
or a manually reproduced side-room wall trap. Wall-blocked pursuit and mob
recovery triggers are backed by automated tests; extended side-room combat,
physical Android, and live multiplayer QA remain open. Existing lighting,
ramp assets, and non-blocking actor collision policy were not changed.
- Validation: all 229 Vitest files passed, 1,444 tests passed, and six existing
tests remained skipped. Final production TypeScript/Vite compilation, texture
and performance audits, static compression, and bundle verification passed.
Existing chunk-size advisories remain. No hosted release was published.
## 2026-09-12 - HoTs and regeneration cannot heal corpses
- Reproduced the reported death-with-HoT state through lethal damage followed
by overdue Rejuvenation ticks; the original implementation restored health
while retaining the death animation. Separate failures confirmed passive
spirit/equipment regeneration, ground healing, triggered healing procs, and
a lingering damage tick's level-up refill could also restore corpse health.
- Kept the fix in combat simulation rather than scene movement. Ordinary
player healing in mutable combat work now uses a shared living-target guard;
passive regeneration preserves zero health. Timed effects validate their
target before ticks and procs, discard HoTs on dead targets even before the
next tick, and retain HoTs on living allies when their caster dies. Newly
applied actor HoTs also reject dead targets. Ground healing skips corpses
while continuing to heal living teammates. Experience may still be awarded
after death, but its level-up refill no longer implicitly resurrects.
- Explicit player and actor-based party resurrection remain available and
synchronize restored actor health/alive state. Party resurrection also
clears the death animation using the existing ability-cancel event. Normal
checkpoint respawn still restores health and clears lingering effects.
The existing player movement gate remains unchanged and blocks zero health.
- Fifteen new regressions cover the original self-HoT failure, actual Priest
Renew, overdue/future ticks, explicit companion-to-player HoTs and their
healing logs, dead allies, regeneration, ground healing, proc healing,
post-death level rewards, living-target healing, resurrection, and respawn.
The final focused combat/death/support checks passed 101 tests; the full
suite passed all 230 files and 1,459 tests with six existing skips.
- No live corpse-movement replay or physical Android check was performed in
this pass. Evidence is combat-state regression coverage, not a new visual
claim. Character rendering, camera/input, dungeon assets, lighting, and
navigation policy were not modified. No hosted release was published.
## 2026-09-12 - Selectable resurrection checkpoints and fallen NPC recovery
- Normal dungeons now offer the entrance plus every defeated boss as named
resurrection destinations. Choices use authored boss locations, not corpse
coordinates, and unlock from actual defeated actors or durable encounter
objective completion. Alive/script-despawned bosses and invalid destination
IDs cannot grant a checkpoint. Boss-wipe resets retain completed checkpoints;
a fresh dungeon run clears its progress.
- The player defeat prompt includes a destination chooser. Respawning at the
entrance or a cleared boss retains defeated enemies and run progress while
resetting the current pull and restoring the party through the existing
respawn lifecycle. Legacy callers without a choice retain saved-spawn behavior.
- A contextual `Rez dead members` button and matching destination chooser appear
below the party frames whenever an NPC teammate is dead. Only fallen NPCs are
revived, at full health, into their formation slots at the selected checkpoint.
Living NPCs, the player's health/location, current enemies, and party orders
are not reset. Lingering timed effects on revived NPCs are removed without
removing living allies' HoTs. Manastorm charges and remote human resurrection
behavior are unchanged; non-authority online clients cannot mutate NPC revival.
- Living, engaged bosses disable the NPC chooser/button with an explanation;
the same restriction is checked again when the action executes. Trash combat
does not block it. Individual return tasks use approved navigation toward the
player's current location, including while the party is on Stop, then resume
the current order. Returning members do not cast/taunt from the checkpoint or
skew the engaged squad's target-selection center. Existing validated stuck
recovery remains available. Recall, death, actor unmount, and run reset cancel
pending return tasks. Scene relocation clears the corpse animation explicitly.
- Live localhost Stockades screenshots at 1280x720 showed the panel with a dead
Morvane tank, an enabled entrance chooser/button during trash combat, and the
tank restored to 1707 health at the entrance after clicking while Stop remained
selected. The button disappeared when no NPC was dead; living Thorne continued
taking damage rather than receiving a party-wide refill. At 384x844 both native
chooser and button remained within the viewport, about 176px wide and 44px high.
The narrow screenshot capture was backend-scaled, so DOM bounds supplemented
the visual check. Normal viewport sizing was restored and the temporary tab
and development server were closed.
- Twenty-nine new regressions cover checkpoint unlocking/retention, invalid and
locked choices, player checkpoint respawn, boss/trash restrictions, selective
NPC revival/effect cleanup, authority/Manastorm isolation, Stop/rejoin behavior,
wall-aware return navigation, registry lifecycle, and UI visibility/placement.
The shipping Stockades navigation/collision test also simulates all four NPC
formation slots walking from the entrance and every boss checkpoint back to
the entrance through approved corridors, without requiring stuck recovery.
Final Vitest validation passed all 234 files and 1,488 tests, with six existing
skips. No live boss-kill destination-selection, distant-player return replay,
multiplayer session, or physical Android check is claimed in this pass.
- Browser logs contained no errors; Rapier initialization and KTX2-loader warnings
occurred during development hot reloads. Character assets, camera/input,
dungeon geometry, and lighting were not edited. Final production TypeScript/Vite
compilation, static compression, and release-bundle verification passed; texture
and environment-performance audits also passed during prebuild. Existing build
chunk advisories remain. No hosted release was published.
## Optional DPS meter — September 12, 2026
- Added a compact party DPS meter with current/last-fight damage, class-colored
rankings, and a shared fight timer. It consumes the lossless combat event
stream and counts effective damage, including pets/summons under their owner.
Completed fights remain visible until the next pull; encounter resets clear
the meter. Hiding it does not stop recording. Local menu/background pauses
do not dilute DPS.
- Automatic placement uses the PC lower-left corner and the Thor companion
display. The UI editor supports visibility, dragging/keyboard nudges, width,
and screen selection. These settings participate in saved layouts, all six
template slots, and export/import, including migration of older UI files.
Dedicated companion snapshots carry meter data and draft placement; lower
display movement commands update the authoritative editor draft.
- Live localhost Stockades checks at 1280x720 showed increasing party damage
and readable bars in the PC lower-left corner. Resizing from 230 to 240 pixels
worked; dragging moved the handle from approximately (9, 509) to (269, 400).
Saving visibility off hid the meter, and reopening the editor retained that
setting while damage continued accumulating. Default position/width and
visibility were restored after this check.
- In Thor preview, the meter appeared only inside the 620x540 companion surface.
Populated bars remained clear of the party strip, vital controls, and action
bar. With the main editor open, dragging on the bottom screen moved its handle
from approximately (526, 322) to (661, 369) within companion bounds. Those
preview movement edits were canceled. Screenshots are saved in
`artifacts/dps-meter/pc-default.png`, `thor-default.png`, and
`thor-editor-moved.png`. Full-page capture scaled incorrectly in the browser
backend, so the Thor screenshots use the scrolled normal viewport.
- All 47 targeted tests passed across seven files, including damage accounting,
ownership, reset/pause behavior, UI migration/persistence, and dedicated
companion publication/unsubscription. The production build and release
checks passed. Browser error logs were empty. Physical Thor hardware and a
multiplayer session were not exercised; the new per-party totals consume
this client's combat stream. No hosted release was published.
@@ -24,6 +24,41 @@
"authoredReason": "Bridges the reviewed 1.537m Recast seam from the central cell block to the south wing stairs." "authoredReason": "Bridges the reviewed 1.537m Recast seam from the central cell block to the south wing stairs."
} }
], ],
"traversalRamps": [
{
"id": "entrance-stair-ramp",
"start": [-52.8, -18.16, 0.8],
"end": [-72.4, -25.609, 0.8],
"width": 10.8,
"navigationBridge": {
"linkId": "entrance-to-central-stairs",
"startPortal": [[-66.674328, -23.150503, -3.904271], [-66.674328, -23.150503, 5.501851]],
"endPortal": [[-67.664446, -23.755576, -2.895818], [-67.664446, -23.755576, 4.493397]]
}
},
{
"id": "north-wing-stair-ramp",
"start": [-129.17, -25.609, 11.7],
"end": [-129.17, -33.944, 32.85],
"width": 10.8,
"navigationBridge": {
"linkId": "central-to-north-wing-stairs",
"startPortal": [[-133.878601, -27.349338, 16.099782], [-124.46789, -27.349338, 16.099782]],
"endPortal": [[-132.668457, -27.95441, 17.0899], [-125.476344, -27.95441, 17.0899]]
}
},
{
"id": "south-wing-stair-ramp",
"start": [-129.17, -25.609, -10.3],
"end": [-129.17, -33.944, -31.45],
"width": 10.8,
"navigationBridge": {
"linkId": "central-to-south-wing-stairs",
"startPortal": [[-133.878601, -27.349338, -14.703893], [-124.46789, -27.349338, -14.703893]],
"endPortal": [[-132.668457, -27.95441, -15.694011], [-125.476344, -27.95441, -15.694011]]
}
}
],
"presentation": { "presentation": {
"background": "#171815", "background": "#171815",
"fog": { "fog": {
@@ -49,7 +84,7 @@
"navigation": "green", "navigation": "green",
"report": "PLAYTEST_NOTES.md", "report": "PLAYTEST_NOTES.md",
"warnings": [ "warnings": [
"Three reviewed bidirectional links bridge the entrance, central block, and both prison wings; minor decorative navmesh islands remain intentionally discarded.", "Three reviewed smooth stair collision proxies and filled navigation bridges connect the entrance, central block, and both prison wings; minor decorative navmesh islands remain intentionally discarded.",
"Stockades uses a dungeon-specific warm indoor lighting profile because the imported WMO has no baked lightmap or vertex-lighting channel." "Stockades uses a dungeon-specific warm indoor lighting profile because the imported WMO has no baked lightmap or vertex-lighting channel."
] ]
} }
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "healer-man", "name": "healer-man",
"version": "0.1.33", "version": "0.1.37",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "healer-man", "name": "healer-man",
"version": "0.1.33", "version": "0.1.37",
"dependencies": { "dependencies": {
"@capacitor/android": "8.4.1", "@capacitor/android": "8.4.1",
"@capacitor/core": "8.4.1", "@capacitor/core": "8.4.1",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "healer-man", "name": "healer-man",
"private": true, "private": true,
"version": "0.1.33", "version": "0.1.37",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite --host 0.0.0.0", "dev": "vite --host 0.0.0.0",
+2 -2
View File
@@ -1,4 +1,4 @@
{ {
"versionName": "0.1.33", "versionName": "0.1.37",
"versionCode": 1033 "versionCode": 1037
} }
@@ -2011,6 +2011,19 @@ async function compile() {
bidirectional: link.bidirectional !== false, bidirectional: link.bidirectional !== false,
authoredReason: String(link.authoredReason), authoredReason: String(link.authoredReason),
})), })),
...(runtimeOverride.traversalRamps?.length ? {
traversalRamps: runtimeOverride.traversalRamps.map((ramp) => ({
id: String(ramp.id),
start: vector(ramp.start),
end: vector(ramp.end),
width: Number(ramp.width),
navigationBridge: {
linkId: String(ramp.navigationBridge.linkId),
startPortal: ramp.navigationBridge.startPortal.map(vector),
endPortal: ramp.navigationBridge.endPortal.map(vector),
},
})),
} : {}),
encounters: [compileBossProgressionEncounter(recipe, bosses, syntheticTrash)], encounters: [compileBossProgressionEncounter(recipe, bosses, syntheticTrash)],
serverGameObjects, serverGameObjects,
provenance: { provenance: {
+2
View File
@@ -3,6 +3,7 @@ import "./ui/uiLayout.css";
import { DualDisplayFrame } from "./components/DualDisplayFrame"; import { DualDisplayFrame } from "./components/DualDisplayFrame";
import { installInput, releasePointerLock } from "./game/inputManager"; import { installInput, releasePointerLock } from "./game/inputManager";
import { CombatBridge } from "./game/CombatBridge"; import { CombatBridge } from "./game/CombatBridge";
import { connectDpsMeter } from "./game/dpsMeterStore";
import { useCombatStore } from "./game/combatStore"; import { useCombatStore } from "./game/combatStore";
import { releaseDungeonGltfCache } from "./game/dungeonAssetCache"; import { releaseDungeonGltfCache } from "./game/dungeonAssetCache";
import { clearDungeonRuntimeCaches } from "./game/dungeonRuntimeReset"; import { clearDungeonRuntimeCaches } from "./game/dungeonRuntimeReset";
@@ -93,6 +94,7 @@ function InputBridge() {
} }
export default function GameRuntime() { export default function GameRuntime() {
useEffect(connectDpsMeter, []);
const editingControls = useGameStore((state) => state.overlay === "keybinds" || state.overlay === "ui-layout"); const editingControls = useGameStore((state) => state.overlay === "keybinds" || state.overlay === "ui-layout");
useEffect(() => () => { useEffect(() => () => {
const activeDungeon = requireDungeonDefinition(useGameStore.getState().activeDungeonId); const activeDungeon = requireDungeonDefinition(useGameStore.getState().activeDungeonId);
+8
View File
@@ -1,3 +1,5 @@
import type { PartyNavigationGraph } from "./partyNavigation";
export type CombatSpatialPosition = readonly [x: number, y: number, z: number]; export type CombatSpatialPosition = readonly [x: number, y: number, z: number];
export interface CombatSpatialActorSnapshot { export interface CombatSpatialActorSnapshot {
@@ -8,6 +10,8 @@ export interface CombatSpatialActorSnapshot {
} }
export interface CombatSpatialProvider { export interface CombatSpatialProvider {
/** The already-loaded, reviewed dungeon surface shared by actor navigation. */
readonly navigationGraph?: () => PartyNavigationGraph | null;
readonly actor: (actorId: string) => CombatSpatialActorSnapshot | null; readonly actor: (actorId: string) => CombatSpatialActorSnapshot | null;
readonly hasLineOfSight: ( readonly hasLineOfSight: (
source: CombatSpatialPosition, source: CombatSpatialPosition,
@@ -45,6 +49,10 @@ export function combatHasLineOfSight(
return provider?.hasLineOfSight(source, target) ?? true; return provider?.hasLineOfSight(source, target) ?? true;
} }
export function combatNavigationGraph(): PartyNavigationGraph | null {
return provider?.navigationGraph?.() ?? null;
}
export function projectCombatGroundPoint( export function projectCombatGroundPoint(
requested: CombatSpatialPosition, requested: CombatSpatialPosition,
maximumDistance: number, maximumDistance: number,
+143 -70
View File
@@ -372,6 +372,7 @@ export interface MobCombatState {
readonly lastDamageAt: number; readonly lastDamageAt: number;
readonly engaged: boolean; readonly engaged: boolean;
readonly combatPhase: "idle" | "chasing" | "attacking" | "returning"; readonly combatPhase: "idle" | "chasing" | "attacking" | "returning";
readonly navigationEvadeUntil?: number;
readonly homePosition: CombatPosition | null; readonly homePosition: CombatPosition | null;
readonly nextAttackAt: number; readonly nextAttackAt: number;
readonly abilityReadyAt: Readonly<Record<string, number>>; readonly abilityReadyAt: Readonly<Record<string, number>>;
@@ -723,6 +724,7 @@ export interface CombatState {
cancelCast: (message?: string) => boolean; cancelCast: (message?: string) => boolean;
engageNearbyMobs: (now?: number, playerPosition?: CombatPosition) => number; engageNearbyMobs: (now?: number, playerPosition?: CombatPosition) => number;
advanceMobCombat: (now?: number, playerPosition?: CombatPosition) => number; advanceMobCombat: (now?: number, playerPosition?: CombatPosition) => number;
resetMobForNavigation: (id: string, atHome: boolean, now?: number) => boolean;
tick: (deltaSeconds: number, now?: number) => void; tick: (deltaSeconds: number, now?: number) => void;
resetEncounter: (options?: { resetEncounter: (options?: {
readonly preserveDefeated?: boolean; readonly preserveDefeated?: boolean;
@@ -2173,10 +2175,7 @@ function executeTriggeredAuraProcsInWork(
const maximum = work.maxResourcePools[resource] ?? defaultResourceMaximum(resource); const maximum = work.maxResourcePools[resource] ?? defaultResourceMaximum(resource);
work.resourcePools[resource] = clamp((work.resourcePools[resource] ?? 0) + action.amount, 0, maximum); work.resourcePools[resource] = clamp((work.resourcePools[resource] ?? 0) + action.amount, 0, maximum);
} else if (action.kind === "heal" && actionTarget === PLAYER_AURA_ENTITY_ID) { } else if (action.kind === "heal" && actionTarget === PLAYER_AURA_ENTITY_ID) {
const healed = Math.min(Math.max(0, action.amount), work.maxHealth - work.health); work.totalHealing += healPlayerInWork(work, action.amount);
work.health += healed;
work.progression.health = work.health;
work.totalHealing += healed;
} else if (action.kind === "heal" && actionTarget && !work.mobs[actionTarget]) { } else if (action.kind === "heal" && actionTarget && !work.mobs[actionTarget]) {
work.totalHealing += healCompanionActorInWork(work, actionTarget, Math.max(0, action.amount)); work.totalHealing += healCompanionActorInWork(work, actionTarget, Math.max(0, action.amount));
} else if (action.kind === "damage" && actionTarget && work.mobs[actionTarget]) { } else if (action.kind === "damage" && actionTarget && work.mobs[actionTarget]) {
@@ -2377,7 +2376,8 @@ function grantMobExperience(
work.secondaryClassId, work.secondaryClassId,
work.auras, work.auras,
); );
progression.health = progression.maxHealth; // A lingering DoT may grant experience after death, but leveling is not resurrection.
progression.health = work.health > 0 ? progression.maxHealth : 0;
work.health = progression.health; work.health = progression.health;
work.maxHealth = progression.maxHealth; work.maxHealth = progression.maxHealth;
work.maxResource = talentAdjustedMaxResource( work.maxResource = talentAdjustedMaxResource(
@@ -2418,6 +2418,7 @@ function engageMobGroup(
let changed = false; let changed = false;
for (const [id, mob] of Object.entries(mobs)) { for (const [id, mob] of Object.entries(mobs)) {
if (mob.dead || mob.despawned || mobGroupId(id) !== groupId) continue; if (mob.dead || mob.despawned || mobGroupId(id) !== groupId) continue;
if (mob.combatPhase === "returning" || (mob.navigationEvadeUntil ?? 0) > now) continue;
const threatByActor = groupWasEngaged const threatByActor = groupWasEngaged
? mob.threatByActor ? mob.threatByActor
: seedActorThreat(mob.threatByActor, sourceActorId); : seedActorThreat(mob.threatByActor, sourceActorId);
@@ -2459,7 +2460,7 @@ function nearestAggroActorId(
let nearestDistanceSquared = aggroRange * aggroRange; let nearestDistanceSquared = aggroRange * aggroRange;
if (state.health > 0) { if (state.health > 0) {
const playerDistanceSquared = distanceSquared(mobPosition, state.playerPosition); const playerDistanceSquared = distanceSquared(mobPosition, state.playerPosition);
if (playerDistanceSquared <= nearestDistanceSquared) { if (playerDistanceSquared <= nearestDistanceSquared && combatHasLineOfSight(mobPosition, state.playerPosition)) {
nearestId = PLAYER_AGGRO_ID; nearestId = PLAYER_AGGRO_ID;
nearestDistanceSquared = playerDistanceSquared; nearestDistanceSquared = playerDistanceSquared;
} }
@@ -2469,7 +2470,7 @@ function nearestAggroActorId(
const position = getPartyRuntimePosition(member.id); const position = getPartyRuntimePosition(member.id);
if (!position) continue; if (!position) continue;
const memberDistanceSquared = distanceSquared(mobPosition, position); const memberDistanceSquared = distanceSquared(mobPosition, position);
if (memberDistanceSquared < nearestDistanceSquared) { if (memberDistanceSquared < nearestDistanceSquared && combatHasLineOfSight(mobPosition, position)) {
nearestId = member.id; nearestId = member.id;
nearestDistanceSquared = memberDistanceSquared; nearestDistanceSquared = memberDistanceSquared;
} }
@@ -2479,7 +2480,7 @@ function nearestAggroActorId(
const position = getPartyRuntimePosition(actor.id); const position = getPartyRuntimePosition(actor.id);
if (!position) continue; if (!position) continue;
const actorDistanceSquared = distanceSquared(mobPosition, position); const actorDistanceSquared = distanceSquared(mobPosition, position);
if (actorDistanceSquared < nearestDistanceSquared) { if (actorDistanceSquared < nearestDistanceSquared && combatHasLineOfSight(mobPosition, position)) {
nearestId = actor.id; nearestId = actor.id;
nearestDistanceSquared = actorDistanceSquared; nearestDistanceSquared = actorDistanceSquared;
} }
@@ -2487,7 +2488,7 @@ function nearestAggroActorId(
for (const summon of state.summons) { for (const summon of state.summons) {
if (summon.health <= 0) continue; if (summon.health <= 0) continue;
const summonDistanceSquared = distanceSquared(mobPosition, summon.position); const summonDistanceSquared = distanceSquared(mobPosition, summon.position);
if (summonDistanceSquared < nearestDistanceSquared) { if (summonDistanceSquared < nearestDistanceSquared && combatHasLineOfSight(mobPosition, summon.position)) {
nearestId = summon.id; nearestId = summon.id;
nearestDistanceSquared = summonDistanceSquared; nearestDistanceSquared = summonDistanceSquared;
} }
@@ -2533,7 +2534,9 @@ function damageMobInWork(
combatResult?: CombatResult, combatResult?: CombatResult,
): DamageResult { ): DamageResult {
const mob = work.mobs[id]; const mob = work.mobs[id];
if (!mob || mob.dead || amount <= 0) return { amount: 0, killed: false, xpAwarded: 0, levelsGained: 0 }; if (!mob || mob.dead || amount <= 0 || (mob.navigationEvadeUntil ?? 0) > now) {
return { amount: 0, killed: false, xpAwarded: 0, levelsGained: 0 };
}
const mitigatedBeforeAuras = combatResult?.finalDamage ?? damageAfterMobResistance( const mitigatedBeforeAuras = combatResult?.finalDamage ?? damageAfterMobResistance(
amount, amount,
mob, mob,
@@ -2678,7 +2681,7 @@ function resolveAndDamageMobInWork(
rules: CombatResultRules = {}, rules: CombatResultRules = {},
): DamageResult { ): DamageResult {
const mob = work.mobs[id]; const mob = work.mobs[id];
if (!mob || mob.dead || amount <= 0) { if (!mob || mob.dead || amount <= 0 || (mob.navigationEvadeUntil ?? 0) > now) {
return { amount: 0, killed: false, xpAwarded: 0, levelsGained: 0 }; return { amount: 0, killed: false, xpAwarded: 0, levelsGained: 0 };
} }
const sourceAuras = aurasForEntity(work.auras, source.actorId); const sourceAuras = aurasForEntity(work.auras, source.actorId);
@@ -2872,10 +2875,7 @@ function executeTriggeredSpellInWork(
"taken", "taken",
)); ));
if (recipient === PLAYER_AURA_ENTITY_ID) { if (recipient === PLAYER_AURA_ENTITY_ID) {
const healed = Math.min(amount, work.maxHealth - work.health); work.totalHealing += healPlayerInWork(work, amount);
work.health += healed;
work.progression.health = work.health;
work.totalHealing += healed;
} else { } else {
const healed = healCompanionActorInWork(work, recipient, amount); const healed = healCompanionActorInWork(work, recipient, amount);
work.totalHealing += healed; work.totalHealing += healed;
@@ -3183,6 +3183,18 @@ function applyAuraHealthTransitionInWork(
mirrorCompanionActor(nextActor); mirrorCompanionActor(nextActor);
} }
/** Ordinary healing never transitions a corpse back to a living actor. */
function healPlayerInWork(work: MutableCastWork, amount: number): number {
const requested = Math.max(0, Math.round(Number.isFinite(amount) ? amount : 0));
if (work.health <= 0 || requested <= 0) return 0;
const healed = Math.min(requested, Math.max(0, work.maxHealth - work.health));
work.health += healed;
work.progression.health = work.health;
const actor = work.actors[PLAYER_AURA_ENTITY_ID];
if (actor) work.actors[PLAYER_AURA_ENTITY_ID] = { ...actor, health: work.health, alive: true };
return healed;
}
function healCompanionActorInWork(work: MutableCastWork, id: string, amount: number): number { function healCompanionActorInWork(work: MutableCastWork, id: string, amount: number): number {
const actor = ensureCompanionActorInWork(work, id); const actor = ensureCompanionActorInWork(work, id);
const requested = Math.max(0, Math.round(Number.isFinite(amount) ? amount : 0)); const requested = Math.max(0, Math.round(Number.isFinite(amount) ? amount : 0));
@@ -4695,7 +4707,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
? Math.min(resolution.rawAmount, friendlySummon.maxHealth - friendlySummon.health) ? Math.min(resolution.rawAmount, friendlySummon.maxHealth - friendlySummon.health)
: friendlyOnline : friendlyOnline
? Math.min(resolution.rawAmount, friendlyOnline.maxHealth - friendlyOnline.health) ? Math.min(resolution.rawAmount, friendlyOnline.maxHealth - friendlyOnline.health)
: resolution.effectiveAmount; : healPlayerInWork(work, resolution.rawAmount);
if (friendlySummon && healed > 0) { if (friendlySummon && healed > 0) {
work.summons = work.summons.map((summon) => summon.id === healingTargetId work.summons = work.summons.map((summon) => summon.id === healingTargetId
? { ...summon, health: summon.health + healed } ? { ...summon, health: summon.health + healed }
@@ -4705,9 +4717,6 @@ export const useCombatStore = create<CombatState>((set, get) => ({
} else if (friendlyOnline && healed > 0) { } else if (friendlyOnline && healed > 0) {
const actor = work.actors[healingTargetId] ?? friendlyOnline; const actor = work.actors[healingTargetId] ?? friendlyOnline;
work.actors[healingTargetId] = { ...actor, health: actor.health + healed, alive: true }; work.actors[healingTargetId] = { ...actor, health: actor.health + healed, alive: true };
} else if (!friendlyTarget) {
work.health += healed;
work.progression.health = work.health;
} }
recordCombatEventInWork(work, { recordCombatEventInWork(work, {
occurredAt: now, occurredAt: now,
@@ -4739,10 +4748,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
&& aura.definition.utilityTags?.includes("beacon-of-light") && aura.definition.utilityTags?.includes("beacon-of-light")
)); ));
if (beacon?.targetId === PLAYER_AURA_ENTITY_ID) { if (beacon?.targetId === PLAYER_AURA_ENTITY_ID) {
const beaconHealing = Math.min(directHealing, work.maxHealth - work.health); work.totalHealing += healPlayerInWork(work, directHealing);
work.health += beaconHealing;
work.progression.health = work.health;
work.totalHealing += beaconHealing;
} else if (beacon) { } else if (beacon) {
work.totalHealing += healCompanionActorInWork(work, beacon.targetId, directHealing); work.totalHealing += healCompanionActorInWork(work, beacon.targetId, directHealing);
} }
@@ -5326,10 +5332,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
work.resource = clamp(work.resource + restored, 0, work.maxResource); work.resource = clamp(work.resource + restored, 0, work.maxResource);
work.resourcePools[activeResourceType] = work.resource; work.resourcePools[activeResourceType] = work.resource;
} else if (/food|refreshment|health|firestone/i.test(ability.name)) { } else if (/food|refreshment|health|firestone/i.test(ability.name)) {
const healed = Math.min(Math.max(1, Math.round(work.maxHealth * 0.25)), work.maxHealth - work.health); work.totalHealing += healPlayerInWork(work, Math.max(1, Math.round(work.maxHealth * 0.25)));
work.health += healed;
work.progression.health = work.health;
work.totalHealing += healed;
} }
} else if (effect.action === "portal") { } else if (effect.action === "portal") {
summonSequence += 1; summonSequence += 1;
@@ -5358,13 +5361,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
aura.targetId === PLAYER_AURA_ENTITY_ID aura.targetId === PLAYER_AURA_ENTITY_ID
&& aura.definition.utilityTags?.includes("rom-revival-charge") && aura.definition.utilityTags?.includes("rom-revival-charge")
)); ));
const restored = Math.min( work.totalHealing += healPlayerInWork(work, Math.round(work.maxHealth * 0.05 * (revival?.stacks ?? 0)));
work.maxHealth - work.health,
Math.round(work.maxHealth * 0.05 * (revival?.stacks ?? 0)),
);
work.health += restored;
work.progression.health = work.health;
work.totalHealing += restored;
if (revival) work.auras = work.auras.filter((aura) => aura.instanceId !== revival.instanceId); if (revival) work.auras = work.auras.filter((aura) => aura.instanceId !== revival.instanceId);
} }
} }
@@ -5407,9 +5404,8 @@ export const useCombatStore = create<CombatState>((set, get) => ({
// Judgement of Light restores 2% health when the marked target is struck. // Judgement of Light restores 2% health when the marked target is struck.
if (work.totalDamage > 0 && targetId && work.mobs[targetId]?.statuses.some((entry) => entry.kind === "marked")) { if (work.totalDamage > 0 && targetId && work.mobs[targetId]?.statuses.some((entry) => entry.kind === "marked")) {
const healed = Math.min(talentAdjustedAmount(Math.round(work.maxHealth * 0.02), modifiers, "healing-percent", ability), work.maxHealth - work.health); const healed = healPlayerInWork(work,
work.health += healed; talentAdjustedAmount(Math.round(work.maxHealth * 0.02), modifiers, "healing-percent", ability));
work.progression.health = work.health;
work.totalHealing += healed; work.totalHealing += healed;
addHealingThreatInWork(work, castThreatSource, healed); addHealingThreatInWork(work, castThreatSource, healed);
} }
@@ -5656,7 +5652,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
tauntMob: (id, source, durationMs, now = combatNow()) => { tauntMob: (id, source, durationMs, now = combatNow()) => {
const target = get().mobs[id]; const target = get().mobs[id];
if (!target || target.dead || durationMs <= 0) return false; if (!target || target.dead || durationMs <= 0 || (target.navigationEvadeUntil ?? 0) > now) return false;
const previous = target.tauntDiminishingReturns; const previous = target.tauntDiminishingReturns;
const applications = target.boss && previous && previous.resetAt > now ? previous.applications : 0; const applications = target.boss && previous && previous.resetAt > now ? previous.applications : 0;
const multiplier = target.boss ? [1, 0.65, 0.4225, 0][Math.min(3, applications)] : 1; const multiplier = target.boss ? [1, 0.65, 0.4225, 0][Math.min(3, applications)] : 1;
@@ -5676,6 +5672,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
)); ));
for (const [mobId, mob] of Object.entries(mobs)) { for (const [mobId, mob] of Object.entries(mobs)) {
if (mob.dead || mobGroupId(mobId) !== groupId) continue; if (mob.dead || mobGroupId(mobId) !== groupId) continue;
if ((mob.navigationEvadeUntil ?? 0) > now) continue;
const seeded = groupWasEngaged const seeded = groupWasEngaged
? mob.threatByActor ? mob.threatByActor
: seedActorThreat(mob.threatByActor, source.actorId); : seedActorThreat(mob.threatByActor, source.actorId);
@@ -5868,10 +5865,15 @@ export const useCombatStore = create<CombatState>((set, get) => ({
if (id === PLAYER_AURA_ENTITY_ID) { if (id === PLAYER_AURA_ENTITY_ID) {
const restored = state.health > 0 ? 0 : Math.max(1, Math.round(state.maxHealth * fraction)); const restored = state.health > 0 ? 0 : Math.max(1, Math.round(state.maxHealth * fraction));
if (restored <= 0) return 0; if (restored <= 0) return 0;
const playerActor = state.actors[id];
set({ set({
health: restored, health: restored,
controlledUntil: 0, controlledUntil: 0,
controlMechanic: null, controlMechanic: null,
playerAnimationEvent: nextAnimationEvent("ability-cancel"),
actors: playerActor ? { ...state.actors, [id]: {
...playerActor, health: restored, alive: true, controlledUntil: 0, cast: null,
} } : state.actors,
feedback: nextFeedback("heal", `Resurrected with ${restored} health.`, { amount: restored, abilityId }), feedback: nextFeedback("heal", `Resurrected with ${restored} health.`, { amount: restored, abilityId }),
combatEvents: recordCombatEvent(state.combatEvents, { combatEvents: recordCombatEvent(state.combatEvents, {
occurredAt: now, occurredAt: now,
@@ -6095,7 +6097,12 @@ export const useCombatStore = create<CombatState>((set, get) => ({
const state = get(); const state = get();
if (!state.actors[sourceActorId]) return false; if (!state.actors[sourceActorId]) return false;
if (effect.kind === "dot" && (!state.mobs[targetActorId] || state.mobs[targetActorId].dead)) return false; if (effect.kind === "dot" && (!state.mobs[targetActorId] || state.mobs[targetActorId].dead)) return false;
if (effect.kind === "hot" && !state.actors[targetActorId]) return false; if (effect.kind === "hot") {
const targetHealth = targetActorId === PLAYER_AURA_ENTITY_ID ? state.health
: state.summons.find((summon) => summon.id === targetActorId)?.health
?? state.actors[targetActorId]?.health ?? 0;
if (!state.actors[targetActorId] || targetHealth <= 0) return false;
}
const sourceActor = state.actors[sourceActorId]; const sourceActor = state.actors[sourceActorId];
const sourceMember = usePartyStore.getState().members.find((member) => member.id === sourceActorId); const sourceMember = usePartyStore.getState().members.find((member) => member.id === sourceActorId);
const amount = sourceMember const amount = sourceMember
@@ -6396,11 +6403,15 @@ export const useCombatStore = create<CombatState>((set, get) => ({
const restored = Math.max(1, Math.round( const restored = Math.max(1, Math.round(
state.maxHealth * Math.max(0.01, Math.min(1, percentMaxHealth)), state.maxHealth * Math.max(0.01, Math.min(1, percentMaxHealth)),
)); ));
const playerActor = state.actors[PLAYER_AURA_ENTITY_ID];
set({ set({
health: restored, health: restored,
controlledUntil: 0, controlledUntil: 0,
controlMechanic: null, controlMechanic: null,
playerAnimationEvent: nextAnimationEvent("ability-cancel"), playerAnimationEvent: nextAnimationEvent("ability-cancel"),
actors: playerActor ? { ...state.actors, [PLAYER_AURA_ENTITY_ID]: {
...playerActor, health: restored, alive: true, controlledUntil: 0, cast: null,
} } : state.actors,
feedback: nextFeedback("heal", `Resurrected with ${restored} health.`, { amount: restored }), feedback: nextFeedback("heal", `Resurrected with ${restored} health.`, { amount: restored }),
}); });
return restored; return restored;
@@ -6441,7 +6452,8 @@ export const useCombatStore = create<CombatState>((set, get) => ({
let engagedGroups = 0; let engagedGroups = 0;
for (const id of Object.keys(mobs)) { for (const id of Object.keys(mobs)) {
const mob = mobs[id]; const mob = mobs[id];
if (mob.dead || mob.despawned || mob.engaged || mob.aggroRange <= 0) continue; if (mob.dead || mob.despawned || mob.engaged || mob.aggroRange <= 0
|| mob.combatPhase === "returning" || (mob.navigationEvadeUntil ?? 0) > now) continue;
const position = getMobPosition(id); const position = getMobPosition(id);
if (!position) continue; if (!position) continue;
const actorId = nearestAggroActorId(runtimeState, position, mob.aggroRange); const actorId = nearestAggroActorId(runtimeState, position, mob.aggroRange);
@@ -6452,6 +6464,38 @@ export const useCombatStore = create<CombatState>((set, get) => ({
return engagedGroups; return engagedGroups;
}, },
resetMobForNavigation: (id, atHome, now = combatNow()) => {
const state = get();
const mob = state.mobs[id];
if (!mob || mob.dead || mob.despawned || !Number.isFinite(now)) return false;
set({
mobs: { ...state.mobs, [id]: {
...mob,
engaged: false,
combatPhase: atHome ? "idle" : "returning",
navigationEvadeUntil: Math.max(mob.navigationEvadeUntil ?? 0, now + 10_000),
health: mob.maxHealth,
statuses: [],
controlDiminishingReturns: {},
tauntDiminishingReturns: { applications: 0, resetAt: 0 },
auras: [],
abilityReadyAt: {},
abilityCastCounts: {},
activeCast: null,
schoolLockedUntil: {},
nextAttackAt: 0,
nextAttackIndex: 0,
threatByActor: {},
targetActorId: null,
forcedTarget: null,
} },
effects: state.effects.filter((effect) => effect.targetId !== id && effect.sourceActorId !== id),
auras: state.auras.filter((aura) => aura.targetId !== id && aura.sourceId !== id),
areaEffects: state.areaEffects.filter((area) => area.sourceActorId !== id),
});
return true;
},
advanceMobCombat: (now = combatNow(), playerPosition) => { advanceMobCombat: (now = combatNow(), playerPosition) => {
const state = get(); const state = get();
const runtimeState = playerPosition const runtimeState = playerPosition
@@ -6527,6 +6571,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
const dy = position[1] - targetPosition[1]; const dy = position[1] - targetPosition[1];
const dz = position[2] - targetPosition[2]; const dz = position[2] - targetPosition[2];
const distanceSquared = dx * dx + dy * dy + dz * dz; const distanceSquared = dx * dx + dy * dy + dz * dz;
const targetVisible = combatHasLineOfSight(position, targetPosition);
const abilityReadyAt = Object.keys(mob.abilityReadyAt).length const abilityReadyAt = Object.keys(mob.abilityReadyAt).length
? { ...mob.abilityReadyAt } ? { ...mob.abilityReadyAt }
: { ...initialEnemyAbilityReadyAt(mob, now) }; : { ...initialEnemyAbilityReadyAt(mob, now) };
@@ -6537,6 +6582,20 @@ export const useCombatStore = create<CombatState>((set, get) => ({
let enemyActiveCast = cannotAttack || (silenced && mob.activeCast?.school !== "physical") let enemyActiveCast = cannotAttack || (silenced && mob.activeCast?.school !== "physical")
? null ? null
: mob.activeCast; : mob.activeCast;
if (enemyActiveCast) {
const pendingAttack = mob.attacks.find((attack) => attack.id === enemyActiveCast!.attackId);
const pendingPosition = enemyActiveCast.targetActorId
? actorPosition(runtimeState, enemyActiveCast.targetActorId) : null;
if (pendingAttack && enemyAbilityNeedsHostileRange(pendingAttack)
&& (!pendingPosition || !combatHasLineOfSight(position, pendingPosition)
|| Math.hypot(position[0] - pendingPosition[0], position[1] - pendingPosition[1],
position[2] - pendingPosition[2]) > pendingAttack.range)) {
emitEnemyAttackPresentation(mob, pendingAttack, "cast-cancel", now, position,
enemyActiveCast.targetActorId, pendingPosition);
abilityReadyAt[pendingAttack.id] = now + 250;
enemyActiveCast = null;
}
}
let completingEnemyCast = false; let completingEnemyCast = false;
let selectedAttackIndex = -1; let selectedAttackIndex = -1;
let selectedAttack: EnemyAttackDefinition | null = null; let selectedAttack: EnemyAttackDefinition | null = null;
@@ -6551,7 +6610,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
const attack = mob.attacks[index]; const attack = mob.attacks[index];
const attackKind = enemyAttackKind(attack); const attackKind = enemyAttackKind(attack);
const inAbilityRange = !enemyAbilityNeedsHostileRange(attack) const inAbilityRange = !enemyAbilityNeedsHostileRange(attack)
|| distanceSquared <= attack.range * attack.range; || (targetVisible && distanceSquared <= attack.range * attack.range);
if ( if (
inAbilityRange inAbilityRange
&& !(silenced && attackKind === "spell") && !(silenced && attackKind === "spell")
@@ -6567,10 +6626,24 @@ export const useCombatStore = create<CombatState>((set, get) => ({
} }
} }
} }
const inRange = mob.attacks.some((attack) => ( // A self buff/heal must not pin an otherwise blocked attacker in place.
!enemyAbilityNeedsHostileRange(attack) const hostileAttacks = mob.attacks.filter(enemyAbilityNeedsHostileRange);
|| distanceSquared <= attack.range * attack.range const inRange = targetVisible && (hostileAttacks.length
)); ? hostileAttacks.some((attack) => distanceSquared <= attack.range * attack.range)
: distanceSquared <= mob.attackRange * mob.attackRange);
if (selectedAttack && completingEnemyCast && enemyAbilityNeedsHostileRange(selectedAttack)) {
const castTargetPosition = enemyActiveCast?.targetActorId
? actorPosition(runtimeState, enemyActiveCast.targetActorId) : null;
if (!castTargetPosition || !combatHasLineOfSight(position, castTargetPosition)
|| Math.hypot(position[0] - castTargetPosition[0], position[1] - castTargetPosition[1],
position[2] - castTargetPosition[2]) > selectedAttack.range) {
emitEnemyAttackPresentation(mob, selectedAttack, "cast-cancel", now, position,
enemyActiveCast?.targetActorId ?? resolution.targetActorId, castTargetPosition);
abilityReadyAt[selectedAttack.id] = now + 250;
enemyActiveCast = null;
selectedAttack = null;
}
}
const phase: MobCombatState["combatPhase"] = inRange ? "attacking" : rooted ? "idle" : "chasing"; const phase: MobCombatState["combatPhase"] = inRange ? "attacking" : rooted ? "idle" : "chasing";
let nextAttackAt = Math.min(...Object.values(abilityReadyAt), Number.POSITIVE_INFINITY); let nextAttackAt = Math.min(...Object.values(abilityReadyAt), Number.POSITIVE_INFINITY);
let attackRevision = mob.attackRevision; let attackRevision = mob.attackRevision;
@@ -7510,7 +7583,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
combatEvents: [...state.combatEvents], combatEvents: [...state.combatEvents],
actors: { ...state.actors }, actors: { ...state.actors },
cooldowns: { ...state.cooldowns }, cooldowns: { ...state.cooldowns },
health: Math.min( health: state.health <= 0 ? 0 : Math.min(
state.maxHealth, state.maxHealth,
state.health + resolveAuraStatValue( state.health + resolveAuraStatValue(
state.gearStats.healthPer5 / 5 + state.gearStats.spirit * 0.01, state.gearStats.healthPer5 / 5 + state.gearStats.spirit * 0.01,
@@ -7639,10 +7712,24 @@ export const useCombatStore = create<CombatState>((set, get) => ({
} }
} }
const timedEffectTargetAlive = (effect: TimedCombatEffect): boolean => {
if (effect.kind === "dot") {
const mob = effect.targetId ? work.mobs[effect.targetId] : null;
return Boolean(mob && !mob.dead && mob.health > 0);
}
const targetId = effect.targetId ?? PLAYER_AURA_ENTITY_ID;
if (targetId === PLAYER_AURA_ENTITY_ID) return work.health > 0;
const summon = work.summons.find((candidate) => candidate.id === targetId);
if (summon) return summon.health > 0;
const actor = ensureCompanionActorInWork(work, targetId) ?? work.actors[targetId];
return Boolean(actor?.alive && actor.health > 0);
};
for (const effect of state.effects) { for (const effect of state.effects) {
// Check before due ticks/procs, not only after applying their health changes.
if (!timedEffectTargetAlive(effect)) continue;
let remainingTicks = effect.remainingTicks; let remainingTicks = effect.remainingTicks;
let nextTickAt = effect.nextTickAt; let nextTickAt = effect.nextTickAt;
while (remainingTicks > 0 && nextTickAt <= now) { while (remainingTicks > 0 && nextTickAt <= now && timedEffectTargetAlive(effect)) {
const ability = abilityById(effect.sourceAbilityId); const ability = abilityById(effect.sourceAbilityId);
const baseAmount = effect.fixedAmount ?? (ability const baseAmount = effect.fixedAmount ?? (ability
? equipmentAdjustedCombatAmount( ? equipmentAdjustedCombatAmount(
@@ -7696,10 +7783,10 @@ export const useCombatStore = create<CombatState>((set, get) => ({
"taken", "taken",
)); ));
if (effect.targetId) { if (effect.targetId) {
const target = work.actors[effect.targetId] const target = ensureCompanionActorInWork(work, hotTargetId)
?? ensureCompanionActorInWork(work, effect.targetId) ?? work.actors[hotTargetId]
?? (work.summons.some((summon) => summon.id === effect.targetId) ?? (work.summons.some((summon) => summon.id === hotTargetId)
? combatActorFromSummon(work.summons.find((summon) => summon.id === effect.targetId)!, progression.level, work.auras) ? combatActorFromSummon(work.summons.find((summon) => summon.id === hotTargetId)!, progression.level, work.auras)
: null); : null);
if (target?.alive) partyHotTicks.push({ if (target?.alive) partyHotTicks.push({
memberId: target.id, memberId: target.id,
@@ -7708,9 +7795,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
sourceAbilityId: effect.sourceAbilityId, sourceAbilityId: effect.sourceAbilityId,
}); });
} else { } else {
const healed = Math.min(modifiedHealing, work.maxHealth - work.health); const healed = healPlayerInWork(work, modifiedHealing);
work.health += healed;
work.progression.health = work.health;
work.totalHealing += healed; work.totalHealing += healed;
addHealingThreatInWork( addHealingThreatInWork(
work, work,
@@ -7755,12 +7840,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
remainingTicks -= 1; remainingTicks -= 1;
nextTickAt += effect.intervalMs; nextTickAt += effect.intervalMs;
} }
const targetAlive = effect.kind === "dot" if (remainingTicks > 0 && timedEffectTargetAlive(effect)) {
? Boolean(effect.targetId && !work.mobs[effect.targetId]?.dead)
: !effect.targetId || usePartyStore.getState().members.some(
(member) => member.id === effect.targetId && member.health > 0,
);
if (remainingTicks > 0 && targetAlive) {
work.effects.push( work.effects.push(
remainingTicks === effect.remainingTicks && nextTickAt === effect.nextTickAt remainingTicks === effect.remainingTicks && nextTickAt === effect.nextTickAt
? effect ? effect
@@ -7816,13 +7896,10 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|| Math.sqrt(distanceSquared(area.position, position)) > area.radius || Math.sqrt(distanceSquared(area.position, position)) > area.radius
|| !combatHasLineOfSight(area.position, position)) continue; || !combatHasLineOfSight(area.position, position)) continue;
const member = partyMembers.find((candidate) => candidate.id === friendlyId); const member = partyMembers.find((candidate) => candidate.id === friendlyId);
if (member ? member.health <= 0 : work.health <= 0) continue;
const healed = member const healed = member
? healCompanionActorInWork(work, friendlyId, amount) ? healCompanionActorInWork(work, friendlyId, amount)
: Math.min(amount, work.maxHealth - work.health); : healPlayerInWork(work, amount);
if (!member) {
work.health += healed;
work.progression.health = work.health;
}
work.totalHealing += healed; work.totalHealing += healed;
recordCombatEventInWork(work, { recordCombatEventInWork(work, {
occurredAt: nextTickAt, occurredAt: nextTickAt,
@@ -7873,12 +7950,8 @@ export const useCombatStore = create<CombatState>((set, get) => ({
const target = injured[0]; const target = injured[0];
if (target) { if (target) {
const healed = target.id === PLAYER_AURA_ENTITY_ID const healed = target.id === PLAYER_AURA_ENTITY_ID
? Math.min(summon.attackDamage, work.maxHealth - work.health) ? healPlayerInWork(work, summon.attackDamage)
: healCompanionActorInWork(work, target.id, summon.attackDamage); : healCompanionActorInWork(work, target.id, summon.attackDamage);
if (target.id === PLAYER_AURA_ENTITY_ID) {
work.health += healed;
work.progression.health = work.health;
}
work.totalHealing += healed; work.totalHealing += healed;
addHealingThreatInWork(work, { actorId: summon.ownerActorId, role: "healer", abilityId: summon.sourceAbilityId }, healed); addHealingThreatInWork(work, { actorId: summon.ownerActorId, role: "healer", abilityId: summon.sourceAbilityId }, healed);
recordCombatEventInWork(work, { recordCombatEventInWork(work, {
+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 });
});
});
+6 -1
View File
@@ -1,6 +1,7 @@
import { useCombatStore } from "./combatStore"; import { useCombatStore } from "./combatStore";
import { requireDungeonDefinition } from "./dungeonRegistry"; import { requireDungeonDefinition } from "./dungeonRegistry";
import { useGameStore } from "./store"; import { useGameStore } from "./store";
import { resolveResurrectionCheckpoint } from "./resurrectionCheckpoints";
import { import {
activeDungeonCompletedObjectiveIds, activeDungeonCompletedObjectiveIds,
resetDungeonEncounters, resetDungeonEncounters,
@@ -11,9 +12,11 @@ import {
* current pull. Defeated enemies and persisted dungeon progress remain clear, * current pull. Defeated enemies and persisted dungeon progress remain clear,
* matching a wipe instead of silently starting a brand-new dungeon. * matching a wipe instead of silently starting a brand-new dungeon.
*/ */
export function respawnDefeatedPlayer(): boolean { export function respawnDefeatedPlayer(checkpointId?: string): boolean {
const combat = useCombatStore.getState(); const combat = useCombatStore.getState();
if (combat.health > 0) return false; if (combat.health > 0) return false;
const checkpoint = checkpointId === undefined ? null : resolveResurrectionCheckpoint(checkpointId);
if (checkpointId !== undefined && !checkpoint) return false;
const dungeonId = useGameStore.getState().activeDungeonId; const dungeonId = useGameStore.getState().activeDungeonId;
const completedObjectiveIds = new Set(activeDungeonCompletedObjectiveIds(dungeonId)); const completedObjectiveIds = new Set(activeDungeonCompletedObjectiveIds(dungeonId));
@@ -31,6 +34,8 @@ export function respawnDefeatedPlayer(): boolean {
if (typeof document !== "undefined" && document.pointerLockElement) { if (typeof document !== "undefined" && document.pointerLockElement) {
document.exitPointerLock(); document.exitPointerLock();
} }
// Select the saved return spawn without relocating first or resetting twice.
if (checkpoint) useGameStore.setState({ activeSpawn: checkpoint.spawn });
useGameStore.getState().resetAfterDefeat(); useGameStore.getState().resetAfterDefeat();
resetDungeonEncounters("boss-wipe", dungeonId); resetDungeonEncounters("boss-wipe", dungeonId);
const respawnPosition = useGameStore.getState().playerPosition; const respawnPosition = useGameStore.getState().playerPosition;
+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);
};
}
+52
View File
@@ -0,0 +1,52 @@
import { BufferGeometry, Float32BufferAttribute } from "three";
import { describe, expect, it } from "vitest";
import type { DungeonTraversalRamp } from "./dungeonTypes";
import { bridgeDungeonStairNavigation, smoothDungeonStairGeometry } from "./dungeonTraversalRamps";
const RAMP: DungeonTraversalRamp = {
id: "stairs",
start: [0, 0, 0],
end: [0, 2, 6],
width: 4,
navigationBridge: {
linkId: "stair-seam",
startPortal: [[-1, 0.5, 2], [1, 0.5, 2]],
endPortal: [[-1, 1, 3], [1, 1, 3]],
},
};
describe("reviewed dungeon stair proxies", () => {
it("smooths tread and riser vertices but preserves upper walls, ceilings and geometry outside the flight", () => {
const geometry = new BufferGeometry();
geometry.setAttribute("position", new Float32BufferAttribute([
0, 0.5, 2,
0, 1, 2,
0, 3, 2,
0, 5, 2,
3, 0.5, 2,
0, 0.5, -1,
], 3));
smoothDungeonStairGeometry(geometry, [RAMP]);
const position = geometry.getAttribute("position");
expect(position.getY(0)).toBeCloseTo(2 / 3);
expect(position.getY(1)).toBeCloseTo(2 / 3);
expect([2, 3, 4, 5].map((vertex) => position.getY(vertex))).toEqual([3, 5, 0.5, 0.5]);
geometry.dispose();
});
it("bridges exactly the existing reviewed portal vertices and rejects stale asset coordinates", () => {
const geometry = new BufferGeometry();
geometry.setAttribute("position", new Float32BufferAttribute([
...RAMP.navigationBridge.startPortal.flat(),
...RAMP.navigationBridge.endPortal.flat(),
], 3));
geometry.setIndex([]);
smoothDungeonStairGeometry(geometry, [RAMP], 0.05);
bridgeDungeonStairNavigation(geometry, [RAMP], 0.05);
expect(Array.from(geometry.index!.array)).toEqual([0, 1, 3, 0, 3, 2]);
geometry.getAttribute("position").setX(0, -1.1);
expect(() => bridgeDungeonStairNavigation(geometry, [RAMP], 0.05))
.toThrow("reviewed navigation portal no longer matches the source GLB");
geometry.dispose();
});
});
+77
View File
@@ -0,0 +1,77 @@
import type { BufferGeometry } from "three";
import type { DungeonTraversalRamp, Vector3Tuple } from "./dungeonTypes";
// Only the stair relief is reshaped. Upper walls, ceilings and other floors
// remain untouched even where they share the staircase's horizontal footprint.
const MAX_STAIR_SURFACE_DELTA = 0.85;
const PORTAL_VERTEX_TOLERANCE = 0.002;
function rampHeight(
ramp: DungeonTraversalRamp,
x: number,
z: number,
): number | null {
const dx = ramp.end[0] - ramp.start[0];
const dz = ramp.end[2] - ramp.start[2];
const length = Math.hypot(dx, dz);
if (
![...ramp.start, ...ramp.end, ramp.width].every(Number.isFinite)
|| length <= 0
|| ramp.width <= 0
) {
throw new Error(`Invalid dungeon traversal ramp: ${ramp.id}.`);
}
const offsetX = x - ramp.start[0];
const offsetZ = z - ramp.start[2];
const along = (offsetX * dx + offsetZ * dz) / length;
const across = (offsetX * dz - offsetZ * dx) / length;
if (along < 0 || along > length || Math.abs(across) > ramp.width * 0.5) return null;
return ramp.start[1] + (ramp.end[1] - ramp.start[1]) * along / length;
}
/** Flatten tread/riser relief onto a shared plane, without removing walls. */
export function smoothDungeonStairGeometry(
geometry: BufferGeometry,
ramps: readonly DungeonTraversalRamp[],
surfaceClearance = 0,
): void {
const position = geometry.getAttribute("position");
for (let vertex = 0; vertex < position.count; vertex += 1) {
for (const ramp of ramps) {
const height = rampHeight(ramp, position.getX(vertex), position.getZ(vertex));
if (height === null || Math.abs(position.getY(vertex) - height) > MAX_STAIR_SURFACE_DELTA) continue;
position.setY(vertex, height + surfaceClearance);
break;
}
}
}
/** Stitch reviewed boundary edges, so routing can use filled triangles rather than vertex-only links. */
export function bridgeDungeonStairNavigation(
geometry: BufferGeometry,
ramps: readonly DungeonTraversalRamp[],
surfaceClearance: number,
): void {
const position = geometry.getAttribute("position");
const indices = geometry.index
? Array.from(geometry.index.array)
: Array.from({ length: position.count }, (_, vertex) => vertex);
const portalVertex = (ramp: DungeonTraversalRamp, point: Vector3Tuple): number => {
const height = rampHeight(ramp, point[0], point[2]);
if (height === null) throw new Error(`${ramp.id}: navigation portal is outside the stair ramp.`);
for (let vertex = 0; vertex < position.count; vertex += 1) {
if (Math.hypot(
position.getX(vertex) - point[0],
position.getY(vertex) - height - surfaceClearance,
position.getZ(vertex) - point[2],
) <= PORTAL_VERTEX_TOLERANCE) return vertex;
}
throw new Error(`${ramp.id}: reviewed navigation portal no longer matches the source GLB.`);
};
for (const ramp of ramps) {
const [a, b] = ramp.navigationBridge.startPortal.map((point) => portalVertex(ramp, point));
const [c, d] = ramp.navigationBridge.endPortal.map((point) => portalVertex(ramp, point));
indices.push(a, b, d, a, d, c);
}
geometry.setIndex(indices);
}
+14
View File
@@ -156,6 +156,19 @@ export interface DungeonNavigationLink {
readonly authoredReason: string; readonly authoredReason: string;
} }
/** Reviewed package-space walking proxy; the rendered staircase is unchanged. */
export interface DungeonTraversalRamp {
readonly id: string;
readonly start: Vector3Tuple;
readonly end: Vector3Tuple;
readonly width: number;
readonly navigationBridge: {
readonly linkId: string;
readonly startPortal: readonly [Vector3Tuple, Vector3Tuple];
readonly endPortal: readonly [Vector3Tuple, Vector3Tuple];
};
}
export interface DungeonEncounterSpawnGroupDefinition { export interface DungeonEncounterSpawnGroupDefinition {
readonly id: string; readonly id: string;
readonly spawns: readonly StaticMobSpawnDefinition[]; readonly spawns: readonly StaticMobSpawnDefinition[];
@@ -241,6 +254,7 @@ export interface DungeonDefinition {
readonly bosses: readonly DungeonBossObjective[]; readonly bosses: readonly DungeonBossObjective[];
readonly objectiveOrder: readonly DungeonBossObjective[]; readonly objectiveOrder: readonly DungeonBossObjective[];
readonly navigationLinks: readonly DungeonNavigationLink[]; readonly navigationLinks: readonly DungeonNavigationLink[];
readonly traversalRamps?: readonly DungeonTraversalRamp[];
/** Serializable dungeon-wide orchestration kept separate from render state. */ /** Serializable dungeon-wide orchestration kept separate from render state. */
readonly encounters?: readonly DungeonEncounterDefinition[]; readonly encounters?: readonly DungeonEncounterDefinition[];
/** Declarative groups activated by encounter actions; renderer ownership stays outside the reducer. */ /** Declarative groups activated by encounter actions; renderer ownership stays outside the reducer. */
+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);
});
});
@@ -1,2 +1,2 @@
// Generated by npm run dungeon:catalog. Do not edit. // Generated by npm run dungeon:catalog. Do not edit.
export default "H4sIAAAAAAACCuy9a5PjNpY2+FcY+e7GzkRIKODgXhP7wa5y237b1fa6PO3d6HBMQCSUySlKzKGUWa7uqP++cQBSgu5USk63s9kfqp2ESNzOec4VB/+4WeR3fub+6ptFWc9vXrPRzaJ+aHJ/8/ofN7mb3bvydv7OzcupXyxvXt8UD/NbX8/H9+W9r8q5f9U96H5L/ntRz29GN37+WDb1fObnyx9c/sHd+sXN67/dLJr81a2b+Ve3fu4bt/TFq2n56H+o3CffvI3f+nZ2XzfLRfelPa/M3NwtlnUz+2Kx8Kvvhxeyf1t6fN81n7Kpq6qJyz/8+80vOKC8fpgvffPGLV1V3968PvrlHx/my3Lm2x93Y3F/9029vHtTN/5NPZuVuCSisAUrxERq4N4wm8ucWzpVuWNMazC8mEwnxvPN19+HVf6uzj/sH8jen66W5N5X1Woe/7hxi9zP4wbeEPLqO1eUHxbvfvh/virKZd28yqvSz5fj/KFp/Hz5yjXejSW8unfL/G789tXbL/9UVn7xJvzq1Xv8NikmOW7ifZ3fxW9+413lm3duPn6/rBt36191JDD+WDcfOjpYvArvjGOXBz/9+fPoJq8fPX7o5vXf/tHR1bcFrsadK+qPUze/HX/w/v5mdDNz99jCebKNi5vXGv9clssSaYsj6S7dsszf37uP88XNa0bt6Kap3ayc3yKNLG5eg1jTd7cbed34m8+jrTEgDXws58V4sazzD67wyTDE5jBkOgy2MwzLt0dBew6i8K6YlXO/SPpWR5YAYGcJ9O4S8J69f3RlVc5vx7l79M08GYPY2gazMQa6PQaQsD0G3XMIjft73Uz9fPyhcQ9VMgJ9jBBMj1Vg0HMIk8rlH6ZueVfPxoX39+k6mM1RqI110DvrQNnOKPpSwkNVuJmbr/vW9KwV4BSevAK383rmG3+bdm+3uhfHOZHrnalz1pcTH+Yf/HyMqF4lTBiY+/AQ+M7qC7k9BHEuFRb1x5QPGNhjBCB3yNCKnU3oy4yL3DWVX45nNcon33xKhmHsETziO3TIkeq2hqF6DuPvD9XUNY37sO4e6LHugfWAAybOYcamzj+MF/dlk1ADbG8F2xiF3mEIqdQOMMLZoyj8/fIuoQjgW3zBeDoMtUdEid3NkL1p4q6u6pmb5+lKbFODOYEMCFRPHcCyQWCsZqlw3N4Jm/Yvd6SD4DvQgF/o1f/MNe6hqOepaD46+x1UkLCDCkB7w8Ktn5aNH+d3bjFLxmCOIdOufsB2d6AvPxTY/WxTNO5wAmxwwg4qabnDCbLvBizu3HLpG1+M71xVJYwgxRYjbPAB39VTrHkyNE6qui7G04dm7lJGkAKODIHtwCLj9slDuPNVFUihQSuoWW6sxJa2tLEdbGcljHyyoF4svZs9uodqmXYvz1uFPXDQV1t7mBe+mdS3ae/qCCvsUVcVe7K6uqjcox/f+/nG4usjs4c9rLgjm3jf1XdN7paN+3vSu4Rjs7ent74vH07qpZuXuUv75sekst1VkdmTqX/m8zs3d03auziL6ox4skoQjcVx5SafmnK+vEsHIc8jPvN0AFj45Z3/8GEHBqU6xvxqjzrwZIsVPRfjZT2bbPR/jP7DGm/2z+yTlQH3kN/5uSvHefPpfgMBpTm2COK0vcx17zW4LVE7XoyXvmk2xYE5CoS7NqPYAUJ1jn44ntWNW6S6obJHBrBHHD1ZK/CL3N378bRBk/Whmd/VVeq+kOqYbN6FBm52N6TvUB6Wt64p/JYfR+pj8LCHMxk8XSi1I7gv53OXpxak1MfwoQ9I9V2F5Z0fz/2vDylTaHXMdtxBBuBPh0jsvs4fqs3+zbHJ99mCvoIxf6iCH6mejvcZDNIehelddxJ7sj8toHMcRz3fGII9ixK0frKWUjTuw/LONfV8iycUpWdJK2ufbMS7v/93XY3nvnmYpP2zY/i4Yzlp+lR6WO1CVd7eLefl/DYdBZxFDPvIsq9j6fZhXmx4MhQ95tjd7dzoJ/vzHssavTmIzGn/5sgW7Bhu9qls4O7mH9ydX47rqhh/KOe3RZ2Yr4od9efs6gycP1lnmNbNrQ8MWT+kepPicExl2CFGsaO8mr7Y2JSuwhEgSOZ3bnZfpv4EJemRxdjZEvnULbkvl2EZEBjS3o8RBN/lBmWebDyuuLLx08rny81lUEdVuN2BPBmil+X8g2+WPr8bL+7qj+hxXQ/DwBZGsXQYu5Spjo3ivqlzXzw0rhp3ccJd+mzqv/v5uPEuv0ud3+aZh1E3t/Vy6edjjAYlnl9tfusF+WV0U/hpOS+RIBYhVLcbMS73R+2W5bLCD79ftWR/3onnVdPbLuxb4PcNhmp/vXddODOv3GJR5psR5Xd1EQipWI7vPk2aEkG0KKfTMn+olp/+6prSzZdxtGFs87qZuWrjR+nTBa7PO7f4EGZT+Udf/ejmITLJzIjTX9plQNfK29UXvt34snt0ZeUmZVWGb7d/Bn3zvvELP8edwBn946aq8/a/9yzN8s7PcG7f3/s5RlarIvO/3vuibDly8TCbuQa7+BOKz2x519QPt3fZ1pdGWeGn3i2zcrnIVhSSNTWaR6PMzYsMdylb3vkMPVafMleVj57cjG6q2hXl/PadXyxCdPbmh8bfu6ac3253Qgj+/mGe37lm6YsvGu/+4sLo/7N7lu3OEAnrtqkf5rh+/4saWlDc3mkMZed1VTehwTFg9GZ0M/euiYHPKf4HKPp5dONmEwwpf4drsPGaMxNhUa8p50uMieNuUALweXRz52fl4v7ON3712uLDpzfdm0bb3KALJQ5u9ZxaOqF2+4tKfB4F/2uASlftjqSAXNti8z02uqmn04VfImnBCOhorH/5jAyx9CiJFuELD8v6YflDcK0Gnrv5ry/Z/4E5DLEF1xgf/zK6mVR+Xmz+EsIvQ0PyQ1cgBT36zd+K8NuubfXzMKB7HMrHsljeoZOe4vLFKXLUVO/xpfltVAjyer6sH5rw0XcgMqAye2NUxpjJmIAMhM6Amoxplr3nXGSaZYKqjIG6Gd284yYDbrM3jNqMGZ4xDRkok4Gg4bX3XIjMqoxbnWl98wsmDzhM/Air9VguHly15vX279HNtKx8S47xGbmtkDAemurm9c2r+IWYdrHK+njF+XgNZK823svvfP5h8TC7eX3Dc+GhsN4aj8kdzGGWh+fMc8nAGkWld0ZMCsM5ALM5dVRz55gwEzt1WiMfl3/3qClZo4USo6CIzG8r/wY59ea1sEAZRdzJ66oqIxh2U1w9GlNKN2e60XT+hPe9nsyb2mmucko9OGanU2FyVkxEMTHWT7ylTHI6KaxhGqbc6okSntlCc0clnRZa0WTeTFi1M2slJVco/XamyQ5Pk102TbY7TaWY0Uw6x11hnIKJ1UZSy5jTiikrpaKaG8oL4AYknzJGJznkwKYUitzKdJoKvS29pwmHpwmXTRN2p6klkyCm0kyYVs6zyUROClCF01IXPNe0AC35pNBGm9xKSZ2YOOnkNPecSjdJpgkUzBnT5IenyS+bJt+d5oQzp8QUBMicFrmUU80YCADgmtqpLQrpvLOSTqmSTkjtncmnjOqJnfrc+Y3dNOdMUxyeprhsmmJ3mmzCxNRZMQGTazE1lHqcCgVhhJlKJ7QRnGrqppO8mFg7meTeTycqtyafAJebvAlnTFMenqa8bJpyD9Eya8XEGS3ddKJzAANTyBVzEykMp5ZR6cE4J21hJsXEw2TKinwi1dS5nHmbTFNYdc5uqsPTVJdNU+1OE6ZTNoVcQ8GpLpRiVMmJd3o6pTlIjoNnjHmgXljFJoJNOIepoRrERFkmkmlKI86Zpj48TX3ZNPXuNIuJdJpScLnjuXWOmSnT1CvquZ0q4LkwTPpcWa8UCC+nArTPJ7YoOLd2YpJpKkHFGdM0h6dpLpum2SNQCjcpFOeCFgqUmSjlQDvnvAZKpZxMPZtA4b1yVjLwAnJq1BSmfGqUsCadJqfyHN60h6dpL5um3Z2mkdRQD1Om81wZ1IGcN/m04MZ5Psl9PtFScs+E1VL5KfWSCmONs1pw7aapQJESzpCb7LAWxC7TgtgeLchSmHBTeBB8Mply76a+8MKaYjIFPZ1QxVkhmKXFlOspsJxSWgg2RdUA2ESvp8mtNHSXN7Xi1u6d5mEtiF2mBbF9WhCYnJuJNCJXeWEceDBgmbXSeKMFc3SqCjmZOm+8EkJTnivMfi6o916kAsUado4WxA5rQewyLYjt0YJkrqy2aCzbiVK51tQwP9FeKw/TiWAKvPBMCeqBTimnwMFDLiaOWqF0IlCAcb4PaZU2Yu80D2tB7DItiO3RgizLvc5zqwstQckJ0IJZxaVSkrHCUmONBMscz3M3LYwXWio6zWmuuKdTnkCQ4EywXd0dkWv/dh5Wg9hlahDbowYp1GcnE4EKAlfFdAJcKZUXzhk+NZQZZZhT1nCwhcwdeGUFV26qYEqN1W69naD5HuZUmhu5d5qH1SB2mRrE9qhBdMINTDjaIwqM8xOaC6dyyp0UzDifg+dawMQXBUwMeJpP7JS7Qk/clE51qgYxq8+QKOywGsQuU4PYHjXIeJ/Twno6pdJapUHCxHCRWyWnHNUG6SdsQqcTZSbe5uDAyqgWCmkZX0+TK672TFMLS/cz52E1iF2mBrE9apA0uTLGS27YxMkcvMGJUiNdoXTuhQFRWA6QT50UQtFcSea9FULAhAkNa6JVRuyDWmNg/zQPq0HsMjWI7VGDtCmMUc4WxjIJmuYTwYQXlLuJ5LlxggHN7WSqp2BzkEI5qyR4KBSXOZvy1TQlpVbsantWM9gvOA+rQewyNYjtUYN4URjGclEowS0KzYk2fJLnclJoy6gFwRnlTuemKIxDP8qkkK6YFpBTPbVr7xA3iu/ZTa0o5/umCYfVILhMDYI9apCYUFc4aSUFLlC6cKWdlnzqcllI6Yw2zoIvKJdWTY2aiIILyQo9nUzB0MRE0caKM/QDOKwGwWVLine truncated export default "H4sIAAAAAAACCuy9a5PjNpY2+FcY+e7GzkRIKODgXhP7wa5y237b1fa6PO3d6HBMQCSUySlKzKGUWa7uqP++cQBSgu5USk63s9kfqp2ESNzOec4VB/+4WeR3fub+6ptFWc9vXrPRzaJ+aHJ/8/ofN7mb3bvydv7OzcupXyxvXt8UD/NbX8/H9+W9r8q5f9U96H5L/ntRz29GN37+WDb1fObnyx9c/sHd+sXN67/dLJr81a2b+Ve3fu4bt/TFq2n56H+o3CffvI3f+nZ2XzfLRfelPa/M3NwtlnUz+2Kx8Kvvhxeyf1t6fN81n7Kpq6qJyz/8+80vOKC8fpgvffPGLV1V3968PvrlHx/my3Lm2x93Y3F/9029vHtTN/5NPZuVuCSisAUrxERq4N4wm8ucWzpVuWNMazC8mEwnxvPN19+HVf6uzj/sH8jen66W5N5X1Woe/7hxi9zP4wbeEPLqO1eUHxbvfvh/virKZd28yqvSz5fj/KFp/Hz5yjXejSW8unfL/G789tXbL/9UVn7xJvzq1Xv8NikmOW7ifZ3fxW9+413lm3duPn6/rBt36191JDD+WDcfOjpYvArvjGOXBz/9+fPoJq8fPX7o5vXf/tHR1bcFrsadK+qPUze/HX/w/v5mdDNz99jCebKNi5vXGv9clssSaYsj6S7dsszf37uP88XNa0bt6Kap3ayc3yKNLG5eg1jTd7cbed34m8+jrTEgDXws58V4sazzD67wyTDE5jBkOgy2MwzLt0dBew6i8K6YlXO/SPpWR5YAYGcJ9O4S8J69f3RlVc5vx7l79M08GYPY2gazMQa6PQaQsD0G3XMIjft73Uz9fPyhcQ9VMgJ9jBBMj1Vg0HMIk8rlH6ZueVfPxoX39+k6mM1RqI110DvrQNnOKPpSwkNVuJmbr/vW9KwV4BSevAK383rmG3+bdm+3uhfHOZHrnalz1pcTH+Yf/HyMqF4lTBiY+/AQ+M7qC7k9BHEuFRb1x5QPGNhjBCB3yNCKnU3oy4yL3DWVX45nNcon33xKhmHsETziO3TIkeq2hqF6DuPvD9XUNY37sO4e6LHugfWAAybOYcamzj+MF/dlk1ADbG8F2xiF3mEIqdQOMMLZoyj8/fIuoQjgW3zBeDoMtUdEid3NkL1p4q6u6pmb5+lKbFODOYEMCFRPHcCyQWCsZqlw3N4Jm/Yvd6SD4DvQgF/o1f/MNe6hqOepaD46+x1UkLCDCkB7w8Ktn5aNH+d3bjFLxmCOIdOufsB2d6AvPxTY/WxTNO5wAmxwwg4qabnDCbLvBizu3HLpG1+M71xVJYwgxRYjbPAB39VTrHkyNE6qui7G04dm7lJGkAKODIHtwCLj9slDuPNVFUihQSuoWW6sxJa2tLEdbGcljHyyoF4svZs9uodqmXYvz1uFPXDQV1t7mBe+mdS3ae/qCCvsUVcVe7K6uqjcox/f+/nG4usjs4c9rLgjm3jf1XdN7paN+3vSu4Rjs7ent74vH07qpZuXuUv75sekst1VkdmTqX/m8zs3d03auziL6ox4skoQjcVx5SafmnK+vEsHIc8jPvN0AFj45Z3/8GEHBqU6xvxqjzrwZIsVPRfjZT2bbPR/jP7DGm/2z+yTlQH3kN/5uSvHefPpfgMBpTm2COK0vcx17zW4LVE7XoyXvmk2xYE5CoS7NqPYAUJ1jn44ntWNW6S6obJHBrBHHD1ZK/CL3N378bRBk/Whmd/VVeq+kOqYbN6FBm52N6TvUB6Wt64p/JYfR+pj8LCHMxk8XSi1I7gv53OXpxak1MfwoQ9I9V2F5Z0fz/2vDylTaHXMdtxBBuBPh0jsvs4fqs3+zbHJ99mCvoIxf6iCH6mejvcZDNIehelddxJ7sj8toHMcRz3fGII9ixK0frKWUjTuw/LONfV8iycUpWdJK2ufbMS7v/93XY3nvnmYpP2zY/i4Yzlp+lR6WO1CVd7eLefl/DYdBZxFDPvIsq9j6fZhXmx4MhQ95tjd7dzoJ/vzHssavTmIzGn/5sgW7Bhu9qls4O7mH9ydX47rqhh/KOe3RZ2Yr4od9efs6gycP1lnmNbNrQ8MWT+kepPicExl2CFGsaO8mr7Y2JSuwhEgSOZ3bnZfpv4EJemRxdjZEvnULbkvl2EZEBjS3o8RBN/lBmWebDyuuLLx08rny81lUEdVuN2BPBmil+X8g2+WPr8bL+7qj+hxXQ/DwBZGsXQYu5Spjo3ivqlzXzw0rhp3ccJd+mzqv/v5uPEuv0ud3+aZh1E3t/Vy6edjjAYlnl9tfusF+WV0U/hpOS+RIBYhVLcbMS73R+2W5bLCD79ftWR/3onnVdPbLuxb4PcNhmp/vXddODOv3GJR5psR5Xd1EQipWI7vPk2aEkG0KKfTMn+olp/+6prSzZdxtGFs87qZuWrjR+nTBa7PO7f4EGZT+Udf/ejmITLJzIjTX9plQNfK29UXvt34snt0ZeUmZVWGb7d/Bn3zvvELP8edwBn946aq8/a/9yzN8s7PcG7f3/s5RlarIvO/3vuibDly8TCbuQa7+BOKz2x519QPt3fZ1pdGWeGn3i2zcrnIVhSSNTWaR6PMzYsMdylb3vkMPVafMleVj57cjG6q2hXl/PadXyxCdPbmh8bfu6ac3253Qgj+/mGe37lm6YsvGu/+4sLo/7N7lu3OEAnrtqkf5rh+/4saWlDc3mkMZed1VTehwTFg9GZ0M/euiYHPKf4HKPp5dONmEwwpf4drsPGaMxNhUa8p50uMieNuUALweXRz52fl4v7ON3712uLDpzfdm0bb3KALJQ5u9ZxaOqF2+4tKfB4F/2uASlftjqSAXNti8z02uqmn04VfImnBCOhorH/5jAyx9CiJFuELD8v6YflDcK0Gnrv5ry/Z/4E5DLEF1xgf/zK6mVR+Xmz+EsIvQ0PyQ1cgBT36zd+K8NuubfXzMKB7HMrHsljeoZOe4vLFKXLUVO/xpfltVAjyer6sH5rw0XcgMqAye2NUxpjJmIAMhM6Amoxplr3nXGSaZYKqjIG6Gd284yYDbrM3jNqMGZ4xDRkok4Gg4bX3XIjMqoxbnWl98wsmDzhM/Air9VguHly15vX279HNtKx8S47xGbmtkDAemurm9c2r+IWYdrHK+njF+XgNZK823svvfP5h8TC7eX3Dc+GhsN4aj8kdzGGWh+fMc8nAGkWld0ZMCsM5ALM5dVRz55gwEzt1WiMfl3/3qClZo4USo6CIzG8r/wY59ea1sEAZRdzJ66oqIxh2U1w9GlNKN2e60XT+hPe9nsyb2mmucko9OGanU2FyVkxEMTHWT7ylTHI6KaxhGqbc6okSntlCc0clnRZa0WTeTFi1M2slJVco/XamyQ5Pk102TbY7TaWY0Uw6x11hnIKJ1UZSy5jTiikrpaKaG8oL4AYknzJGJznkwKYUitzKdJoKvS29pwmHpwmXTRN2p6klkyCm0kyYVs6zyUROClCF01IXPNe0AC35pNBGm9xKSZ2YOOnkNPecSjdJpgkUzBnT5IenyS+bJt+d5oQzp8QUBMicFrmUU80YCADgmtqpLQrpvLOSTqmSTkjtncmnjOqJnfrc+Y3dNOdMUxyeprhsmmJ3mmzCxNRZMQGTazE1lHqcCgVhhJlKJ7QRnGrqppO8mFg7meTeTycqtyafAJebvAlnTFMenqa8bJpyD9Eya8XEGS3ddKJzAANTyBVzEykMp5ZR6cE4J21hJsXEw2TKinwi1dS5nHmbTFNYdc5uqsPTVJdNU+1OE6ZTNoVcQ8GpLpRiVMmJd3o6pTlIjoNnjHmgXljFJoJNOIepoRrERFkmkmlKI86Zpj48TX3ZNPXuNIuJdJpScLnjuXWOmSnT1CvquZ0q4LkwTPpcWa8UCC+nArTPJ7YoOLd2YpJpKkHFGdM0h6dpLpum2SNQCjcpFOeCFgqUmSjlQDvnvAZKpZxMPZtA4b1yVjLwAnJq1BSmfGqUsCadJqfyHN60h6dpL5um3Z2mkdRQD1Om81wZ1IGcN/m04MZ5Psl9PtFScs+E1VL5KfWSCmONs1pw7aapQJESzpCb7LAWxC7TgtgeLchSmHBTeBB8Mply76a+8MKaYjIFPZ1QxVkhmKXFlOspsJxSWgg2RdUA2ESvp8mtNHSXN7Xi1u6d5mEtiF2mBbF9WhCYnJuJNCJXeWEceDBgmbXSeKMFc3SqCjmZOm+8EkJTnivMfi6o916kAsUado4WxA5rQewyLYjt0YJkrqy2aCzbiVK51tQwP9FeKw/TiWAKvPBMCeqBTimnwMFDLiaOWqF0IlCAcb4PaZU2Yu80D2tB7DItiO3RgizLvc5zqwstQckJ0IJZxaVSkrHCUmONBMscz3M3LYwXWio6zWmuuKdTnkCQ4EywXd0dkWv/dh5Wg9hlahDbowYp1GcnE4EKAlfFdAJcKZUXzhk+NZQZZZhT1nCwhcwdeGUFV26qYEqN1W69naD5HuZUmhu5d5qH1SB2mRrE9qhBdMINTDjaIwqM8xOaC6dyyp0UzDifg+dawMQXBUwMeJpP7JS7Qk/clE51qgYxq8+QKOywGsQuU4PYHjXIeJ/Twno6pdJapUHCxHCRWyWnHNUG6SdsQqcTZSbe5uDAyqgWCmkZX0+TK672TFMLS/cz52E1iF2mBrE9apA0uTLGS27YxMkcvMGJUiNdoXTuhQFRWA6QT50UQtFcSea9FULAhAkNa6JVRuyDWmNg/zQPq0HsMjWI7VGDtCmMUc4WxjIJmuYTwYQXlLuJ5LlxggHN7WSqp2BzkEI5qyR4KBSXOZvy1TQlpVbsantWM9gvOA+rQewyNYjtUYN4URjGclEowS0KzYk2fJLnclJoy6gFwRnlTuemKIxDP8qkkK6YFpBTPbVr7xA3iu/ZTa0o5/umCYfVILhMDYI9apCYUFc4aSUFLlC6cKWdlnzqcllI6Yw2zoIvKJdWTY2aiIILyQo9nUzB0MRE0caKM/QDOKwGwWVLine truncated
@@ -1 +1 @@
{"schemaVersion":1,"source":{"campaignManifest":"dungeon-pipeline/dungeon-campaign.json","environmentPackages":["src/game/generated/fivePlayerDungeonImports.json","src/game/generated/manastormAssetPackages.json (temporary fallback)"],"encounterCatalog":"src/game/generated/manastormRuntimeCatalog.json","azerothCoreCommit":"4d9d1d4b5723e819c5c390f6ca1177283dbfb8e3","azerothCoreSourceLock":"src/game/generated/azerothCoreSourceLock.json","spellCatalog":{"ascension":"../LadiksMPQEditor/client-current/area-52/patch-D/DBFilesClient/Spell.dbc","epoch":"../HealerMan-Storage/pipeline-work/dungeons/epoch-client/DBFilesClient/Spell.dbc"}},"coverage":[{"dungeonId":"shadowfang-keep","mapId":33,"encounters":7,"entities":31,"staticSpawns":109,"roamingPacks":24,"source":"azerothcore"},{"dungeonId":"stormwind-stockade","mapId":34,"encounters":5,"entities":11,"staticSpawns":93,"roamingPacks":0,"source":"azerothcore"},{"dungeonId":"deadmines","mapId":36,"encounters":7,"entities":22,"staticSpawns":179,"roamingPacks":23,"source":"azerothcore"},{"dungeonId":"wailing-caverns","mapId":43,"encounters":8,"entities":20,"staticSpawns":252,"roamingPacks":7,"source":"azerothcore"},{"dungeonId":"razorfen-kraul","mapId":47,"encounters":7,"entities":38,"staticSpawns":179,"roamingPacks":12,"source":"azerothcore"},{"dungeonId":"blackfathom-deeps","mapId":48,"encounters":6,"entities":27,"staticSpawns":201,"roamingPacks":10,"source":"azerothcore"},{"dungeonId":"uldaman","mapId":70,"encounters":7,"entities":38,"staticSpawns":302,"roamingPacks":12,"source":"azerothcore"},{"dungeonId":"gnomeregan","mapId":90,"encounters":4,"entities":31,"staticSpawns":371,"roamingPacks":31,"source":"azerothcore"},{"dungeonId":"sunken-temple","mapId":109,"encounters":4,"entities":33,"staticSpawns":245,"roamingPacks":4,"source":"azerothcore"},{"dungeonId":"razorfen-downs","mapId":129,"encounters":6,"entities":25,"staticSpawns":194,"roamingPacks":13,"source":"azerothcore"},{"dungeonId":"scarlet-monastery","mapId":189,"encounters":5,"entities":37,"staticSpawns":310,"roamingPacks":16,"source":"azerothcore"},{"dungeonId":"zulfarrak","mapId":209,"encounters":5,"entities":21,"staticSpawns":252,"roamingPacks":14,"source":"azerothcore"},{"dungeonId":"blackrock-spire","mapId":229,"encounters":15,"entities":78,"staticSpawns":566,"roamingPacks":22,"source":"azerothcore"},{"dungeonId":"blackrock-depths","mapId":230,"encounters":13,"entities":61,"staticSpawns":1040,"roamingPacks":15,"source":"azerothcore"},{"dungeonId":"scholomance","mapId":289,"encounters":8,"entities":38,"staticSpawns":327,"roamingPacks":15,"source":"azerothcore"},{"dungeonId":"stratholme","mapId":329,"encounters":9,"entities":50,"staticSpawns":431,"roamingPacks":29,"source":"azerothcore"},{"dungeonId":"maraudon","mapId":349,"encounters":8,"entities":33,"staticSpawns":525,"roamingPacks":20,"source":"azerothcore"},{"dungeonId":"ragefire-chasm","mapId":389,"encounters":4,"entities":11,"staticSpawns":127,"roamingPacks":4,"source":"azerothcore"},{"dungeonId":"dire-maul","mapId":429,"encounters":12,"entities":75,"staticSpawns":756,"roamingPacks":59,"source":"azerothcore"},{"dungeonId":"shattered-halls","mapId":540,"encounters":3,"entities":32,"staticSpawns":198,"roamingPacks":13,"source":"azerothcore"},{"dungeonId":"blood-furnace","mapId":542,"encounters":3,"entities":17,"staticSpawns":139,"roamingPacks":13,"source":"azerothcore"},{"dungeonId":"hellfire-ramparts","mapId":543,"encounters":2,"entities":12,"staticSpawns":85,"roamingPacks":12,"source":"azerothcore"},{"dungeonId":"steamvault","mapId":545,"encounters":3,"entities":17,"staticSpawns":127,"roamingPacks":17,"source":"azerothcore"},{"dungeonId":"underbog","mapId":546,"encounters":4,"entities":22,"staticSpawns":161,"roamingPacks":23,"source":"azerothcore"},{"dungeonId":"slave-pens","mapId":547,"encounters":3,"entities":21,"staticSpawns":122,"roamingPacks":32,"source":"azerothcore"},{"dungeonId":"arcatraz","mapId":552,"encounters":4,"entities":29,"staticSpawns":85,"roamingPacks":9,"source":"azerothcore"},{"dungeonId":"botanica","mapId":553,"encounters":5,"entities":29,"staticSpawns":171,"roamingPacks":13,"source":"azerothcore"},{"dungeonId":"mechanar","mapId":554,"encounters":3,"entities":17,"staticSpawns":84,"roamingPacks":14,"source":"azerothcore"},{"dungeonId":"shadow-labyrinth","mapId":555,"encounters":4,"entities":22,"staticSpawns":189,"roamingPacks":13,"source":"azerothcore"},{"dungeonId":"sethekk-halls","mapId":556,"encounters":2,"entities":16,"staticSpawns":104,"roamingPacks":24,"source":"azerothcore"},{"dungeonId":"mana-tombs","mapId":557,"encounters":3,"entities":14,"staticSpawns":119,"roamingPacks":15,"source":"azerothcore"},{"dungeonId":"auchenai-crypts","mapId":558,"encounters":2,"entities":14,"staticSpawns":93,"roamingPacks":37,"source":"azerothcore"},{"dungeonId":"magisters-terrace","mapId":585,"encounters":3,"entities":18,"staticSpawns":147,"roamingPacks":6,"source":"azerothcore"},{"dungeonId":"black-morass","mapId":269,"encounters":3,"entitiesLine truncated {"schemaVersion":1,"source":{"campaignManifest":"dungeon-pipeline/dungeon-campaign.json","environmentPackages":["src/game/generated/fivePlayerDungeonImports.json","src/game/generated/manastormAssetPackages.json (temporary fallback)"],"encounterCatalog":"src/game/generated/manastormRuntimeCatalog.json","azerothCoreCommit":"4d9d1d4b5723e819c5c390f6ca1177283dbfb8e3","azerothCoreSourceLock":"src/game/generated/azerothCoreSourceLock.json","spellCatalog":{"ascension":"../LadiksMPQEditor/client-current/area-52/patch-D/DBFilesClient/Spell.dbc","epoch":"../HealerMan-Storage/pipeline-work/dungeons/epoch-client/DBFilesClient/Spell.dbc"}},"coverage":[{"dungeonId":"shadowfang-keep","mapId":33,"encounters":7,"entities":31,"staticSpawns":109,"roamingPacks":24,"source":"azerothcore"},{"dungeonId":"stormwind-stockade","mapId":34,"encounters":5,"entities":11,"staticSpawns":93,"roamingPacks":0,"source":"azerothcore"},{"dungeonId":"deadmines","mapId":36,"encounters":7,"entities":22,"staticSpawns":179,"roamingPacks":23,"source":"azerothcore"},{"dungeonId":"wailing-caverns","mapId":43,"encounters":8,"entities":20,"staticSpawns":252,"roamingPacks":7,"source":"azerothcore"},{"dungeonId":"razorfen-kraul","mapId":47,"encounters":7,"entities":38,"staticSpawns":179,"roamingPacks":12,"source":"azerothcore"},{"dungeonId":"blackfathom-deeps","mapId":48,"encounters":6,"entities":27,"staticSpawns":201,"roamingPacks":10,"source":"azerothcore"},{"dungeonId":"uldaman","mapId":70,"encounters":7,"entities":38,"staticSpawns":302,"roamingPacks":12,"source":"azerothcore"},{"dungeonId":"gnomeregan","mapId":90,"encounters":4,"entities":31,"staticSpawns":371,"roamingPacks":31,"source":"azerothcore"},{"dungeonId":"sunken-temple","mapId":109,"encounters":4,"entities":33,"staticSpawns":245,"roamingPacks":4,"source":"azerothcore"},{"dungeonId":"razorfen-downs","mapId":129,"encounters":6,"entities":25,"staticSpawns":194,"roamingPacks":13,"source":"azerothcore"},{"dungeonId":"scarlet-monastery","mapId":189,"encounters":5,"entities":37,"staticSpawns":310,"roamingPacks":16,"source":"azerothcore"},{"dungeonId":"zulfarrak","mapId":209,"encounters":5,"entities":21,"staticSpawns":252,"roamingPacks":14,"source":"azerothcore"},{"dungeonId":"blackrock-spire","mapId":229,"encounters":15,"entities":78,"staticSpawns":566,"roamingPacks":22,"source":"azerothcore"},{"dungeonId":"blackrock-depths","mapId":230,"encounters":13,"entities":61,"staticSpawns":1040,"roamingPacks":15,"source":"azerothcore"},{"dungeonId":"scholomance","mapId":289,"encounters":8,"entities":38,"staticSpawns":327,"roamingPacks":15,"source":"azerothcore"},{"dungeonId":"stratholme","mapId":329,"encounters":9,"entities":50,"staticSpawns":431,"roamingPacks":29,"source":"azerothcore"},{"dungeonId":"maraudon","mapId":349,"encounters":8,"entities":33,"staticSpawns":525,"roamingPacks":20,"source":"azerothcore"},{"dungeonId":"ragefire-chasm","mapId":389,"encounters":4,"entities":11,"staticSpawns":127,"roamingPacks":4,"source":"azerothcore"},{"dungeonId":"dire-maul","mapId":429,"encounters":12,"entities":75,"staticSpawns":756,"roamingPacks":59,"source":"azerothcore"},{"dungeonId":"shattered-halls","mapId":540,"encounters":3,"entities":32,"staticSpawns":198,"roamingPacks":13,"source":"azerothcore"},{"dungeonId":"blood-furnace","mapId":542,"encounters":3,"entities":17,"staticSpawns":139,"roamingPacks":13,"source":"azerothcore"},{"dungeonId":"hellfire-ramparts","mapId":543,"encounters":2,"entities":12,"staticSpawns":85,"roamingPacks":12,"source":"azerothcore"},{"dungeonId":"steamvault","mapId":545,"encounters":3,"entities":17,"staticSpawns":127,"roamingPacks":17,"source":"azerothcore"},{"dungeonId":"underbog","mapId":546,"encounters":4,"entities":22,"staticSpawns":161,"roamingPacks":23,"source":"azerothcore"},{"dungeonId":"slave-pens","mapId":547,"encounters":3,"entities":21,"staticSpawns":122,"roamingPacks":32,"source":"azerothcore"},{"dungeonId":"arcatraz","mapId":552,"encounters":4,"entities":29,"staticSpawns":85,"roamingPacks":9,"source":"azerothcore"},{"dungeonId":"botanica","mapId":553,"encounters":5,"entities":29,"staticSpawns":171,"roamingPacks":13,"source":"azerothcore"},{"dungeonId":"mechanar","mapId":554,"encounters":3,"entities":17,"staticSpawns":84,"roamingPacks":14,"source":"azerothcore"},{"dungeonId":"shadow-labyrinth","mapId":555,"encounters":4,"entities":22,"staticSpawns":189,"roamingPacks":13,"source":"azerothcore"},{"dungeonId":"sethekk-halls","mapId":556,"encounters":2,"entities":16,"staticSpawns":104,"roamingPacks":24,"source":"azerothcore"},{"dungeonId":"mana-tombs","mapId":557,"encounters":3,"entities":14,"staticSpawns":119,"roamingPacks":15,"source":"azerothcore"},{"dungeonId":"auchenai-crypts","mapId":558,"encounters":2,"entities":14,"staticSpawns":93,"roamingPacks":37,"source":"azerothcore"},{"dungeonId":"magisters-terrace","mapId":585,"encounters":3,"entities":18,"staticSpawns":147,"roamingPacks":6,"source":"azerothcore"},{"dungeonId":"black-morass","mapId":269,"encounters":3,"entitiesLine truncated
+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";
},
};
}
+7 -25
View File
@@ -7,15 +7,11 @@ import {
nextPartyBreadcrumbWaypoint, nextPartyBreadcrumbWaypoint,
partyCombatMovementGoal, partyCombatMovementGoal,
partyFormationPosition, partyFormationPosition,
partyMadeStuckProgress,
partyMovementProfile, partyMovementProfile,
partyNaturalFollowPosition, partyNaturalFollowPosition,
partyPersonalizedCombatMovementGoal, partyPersonalizedCombatMovementGoal,
partyPresentationY, partyPresentationY,
partySeparationGoal,
partyShouldRecallForStuck,
partyShouldMoveToComfortTarget, partyShouldMoveToComfortTarget,
PARTY_STUCK_RECALL_DISTANCE,
personalizedPartyCombatRangeBand, personalizedPartyCombatRangeBand,
resetPartyBreadcrumbTrail, resetPartyBreadcrumbTrail,
stepPartyPosition, stepPartyPosition,
@@ -102,7 +98,7 @@ describe("party world movement", () => {
} }
}); });
it("uses a stable combat bearing and separates overlapping allies", () => { it("uses a stable combat bearing without forcing in-range actors to reposition", () => {
const out: [number, number, number] = [0, 0, 0]; const out: [number, number, number] = [0, 0, 0];
expect(partyPersonalizedCombatMovementGoal( expect(partyPersonalizedCombatMovementGoal(
[20, 0, 0], [20, 0, 0],
@@ -110,34 +106,20 @@ describe("party world movement", () => {
true, true,
{ minimum: 10, preferred: 14, maximum: 18 }, { minimum: 10, preferred: 14, maximum: 18 },
Math.PI / 2, Math.PI / 2,
false,
out, out,
)).toBe("approach"); )).toBe("approach");
expect(out[0]).toBeCloseTo(14); expect(out[0]).toBeCloseTo(14);
expect(out[1]).toBe(0); expect(out[1]).toBe(0);
expect(out[2]).toBeCloseTo(0); expect(out[2]).toBeCloseTo(0);
const separated: [number, number, number] = [0, 0, 0]; expect(partyPersonalizedCombatMovementGoal(
expect(partySeparationGoal(
[1, 0, 1], [1, 0, 1],
[2, 0, 2], [0, 0, 0],
[[1, 0, 1]], true,
{ minimum: 0, preferred: 2.9, maximum: 3.15 },
Math.PI / 2, Math.PI / 2,
separated, out,
)).toBe(true); )).toBe("hold");
expect(separated[0]).toBeGreaterThan(2);
expect(separated[2]).toBeCloseTo(2);
});
it("recalls only distant allies that should be moving and stopped making progress", () => {
expect(partyMadeStuckProgress([0, 0, 0], [0.45, 4, 0])).toBe(true);
expect(partyMadeStuckProgress([0, 0, 0], [0.44, 0, 0])).toBe(false);
expect(PARTY_STUCK_RECALL_DISTANCE).toBe(80);
expect(partyShouldRecallForStuck([80, 0, 0], [0, 0, 0], true, 4_500, 1_000)).toBe(true);
expect(partyShouldRecallForStuck([79.9, 0, 0], [0, 0, 0], true, 4_500, 1_000)).toBe(false);
expect(partyShouldRecallForStuck([80, 0, 0], [0, 0, 0], false, 4_500, 1_000)).toBe(false);
expect(partyShouldRecallForStuck([80, 0, 0], [0, 0, 0], true, 4_499, 1_000)).toBe(false);
}); });
it("makes melee approach a fleeing target and hold once in range", () => { it("makes melee approach a fleeing target and hold once in range", () => {
+3 -81
View File
@@ -19,7 +19,7 @@ export interface PartyBreadcrumbCursor {
breadcrumbId: number | null; breadcrumbId: number | null;
} }
export type PartyCombatMovementIntent = "hold" | "approach" | "retreat" | "reposition"; export type PartyCombatMovementIntent = "hold" | "approach" | "retreat";
export interface PartyMovementProfile { export interface PartyMovementProfile {
readonly followDistanceJitter: number; readonly followDistanceJitter: number;
@@ -86,45 +86,6 @@ export function partyMovementProfile(memberId: string, livingRank: number): Part
}; };
} }
// Allow companions to operate across a full boss arena before the stuck
// watchdog treats their separation from the player as a reason to recall.
export const PARTY_STUCK_RECALL_DISTANCE = 80;
export const PARTY_STUCK_RECALL_DELAY_MS = 3_500;
export const PARTY_STUCK_PROGRESS_DISTANCE = 0.45;
/** Tests whether an expected move has made enough planar progress to reset the stuck timer. */
export function partyMadeStuckProgress(
previous: PartyWorldPosition,
current: PartyWorldPosition,
minimumDistance = PARTY_STUCK_PROGRESS_DISTANCE,
): boolean {
const minimum = Math.max(0, Number.isFinite(minimumDistance) ? minimumDistance : 0);
const distance = Math.hypot(current[0] - previous[0], current[2] - previous[2]);
return Number.isFinite(distance) && distance >= minimum;
}
/**
* A distant ally is stuck only while it should be moving and its progress
* timer has expired. Intentional ranged/melee holds never enter this state.
*/
export function partyShouldRecallForStuck(
current: PartyWorldPosition,
player: PartyWorldPosition,
movementExpected: boolean,
nowMs: number,
lastProgressAtMs: number,
recallDistance = PARTY_STUCK_RECALL_DISTANCE,
stuckDelayMs = PARTY_STUCK_RECALL_DELAY_MS,
): boolean {
if (!movementExpected || !Number.isFinite(nowMs) || !Number.isFinite(lastProgressAtMs)) return false;
const distance = Math.hypot(current[0] - player[0], current[2] - player[2]);
const minimumDistance = Math.max(0, Number.isFinite(recallDistance) ? recallDistance : 0);
const delay = Math.max(0, Number.isFinite(stuckDelayMs) ? stuckDelayMs : 0);
return Number.isFinite(distance)
&& distance >= minimumDistance
&& nowMs - lastProgressAtMs >= delay;
}
export function createPartyBreadcrumbTrail(): PartyBreadcrumbTrail { export function createPartyBreadcrumbTrail(): PartyBreadcrumbTrail {
return { points: [], nextId: 0 }; return { points: [], nextId: 0 };
} }
@@ -427,44 +388,6 @@ export function personalizedPartyCombatRangeBand(
}; };
} }
/** Adds a bounded local repulsion so allies do not occupy the same point. */
export function partySeparationGoal(
current: PartyWorldPosition,
desired: PartyWorldPosition,
peers: readonly PartyWorldPosition[],
fallbackAngle: number,
out: MutablePartyWorldPosition,
separationDistance = 0.85,
maximumCorrection = 0.35,
): boolean {
let offsetX = 0;
let offsetZ = 0;
const safeSeparation = Math.max(EPSILON, separationDistance);
for (const peer of peers) {
const dx = current[0] - peer[0];
const dz = current[2] - peer[2];
const distance = Math.hypot(dx, dz);
if (!Number.isFinite(distance) || distance >= safeSeparation) continue;
const weight = (safeSeparation - distance) / safeSeparation;
if (distance <= EPSILON) {
offsetX += Math.sin(fallbackAngle) * weight;
offsetZ += Math.cos(fallbackAngle) * weight;
} else {
offsetX += dx / distance * weight;
offsetZ += dz / distance * weight;
}
}
const length = Math.hypot(offsetX, offsetZ);
out[0] = desired[0];
out[1] = desired[1];
out[2] = desired[2];
if (length <= EPSILON) return false;
const correction = Math.min(Math.max(0, maximumCorrection), length);
out[0] += offsetX / length * correction;
out[2] += offsetZ / length * correction;
return true;
}
/** /**
* Chooses an individual combat destination. Melee only closes distance; * Chooses an individual combat destination. Melee only closes distance;
* ranged attackers also back out when a target collapses their preferred band. * ranged attackers also back out when a target collapses their preferred band.
@@ -524,7 +447,6 @@ export function partyPersonalizedCombatMovementGoal(
targetVisible: boolean, targetVisible: boolean,
rangeBand: PartyCombatRangeLike, rangeBand: PartyCombatRangeLike,
preferredBearing: number, preferredBearing: number,
forceReposition: boolean,
out: MutablePartyWorldPosition, out: MutablePartyWorldPosition,
): PartyCombatMovementIntent { ): PartyCombatMovementIntent {
const intent = partyCombatMovementGoal( const intent = partyCombatMovementGoal(
@@ -538,10 +460,10 @@ export function partyPersonalizedCombatMovementGoal(
Math.sin(preferredBearing), Math.sin(preferredBearing),
Math.cos(preferredBearing), Math.cos(preferredBearing),
); );
if (!targetVisible || (intent === "hold" && !forceReposition)) return intent; if (!targetVisible || intent === "hold") return intent;
out[0] = target[0] + Math.sin(preferredBearing) * rangeBand.preferred; out[0] = target[0] + Math.sin(preferredBearing) * rangeBand.preferred;
out[1] = target[1]; out[1] = target[1];
out[2] = target[2] + Math.cos(preferredBearing) * rangeBand.preferred; out[2] = target[2] + Math.cos(preferredBearing) * rangeBand.preferred;
return intent === "hold" ? "reposition" : intent; return intent;
} }
+17 -5
View File
@@ -1,5 +1,6 @@
import { BufferGeometry, Float32BufferAttribute, type Group, type Mesh } from "three"; import { BufferGeometry, Float32BufferAttribute, type Group, type Mesh } from "three";
import type { DungeonNavigationLink } from "./dungeonTypes"; import type { DungeonNavigationLink, DungeonTraversalRamp } from "./dungeonTypes";
import { bridgeDungeonStairNavigation, smoothDungeonStairGeometry } from "./dungeonTraversalRamps";
import { import {
buildPartyNavigationGraph, buildPartyNavigationGraph,
type PartyNavigationGraph, type PartyNavigationGraph,
@@ -9,14 +10,18 @@ import { MAX_SYNCHRONOUS_PARTY_NAVIGATION_TRIANGLES } from "./partyNavigationPol
const MAX_CACHED_NAVIGATION_GRAPHS = 8; const MAX_CACHED_NAVIGATION_GRAPHS = 8;
const graphCache = new Map<string, PartyNavigationGraph>(); const graphCache = new Map<string, PartyNavigationGraph>();
function graphCacheKey(url: string, offMeshLinks: readonly DungeonNavigationLink[]): string { function graphCacheKey(
url: string,
offMeshLinks: readonly DungeonNavigationLink[],
ramps: readonly DungeonTraversalRamp[],
): string {
const links = offMeshLinks.map((link) => [ const links = offMeshLinks.map((link) => [
link.id, link.id,
link.start, link.start,
link.end, link.end,
link.bidirectional, link.bidirectional,
]); ]);
return `${url}:${JSON.stringify(links)}`; return `${url}:${JSON.stringify([links, ramps])}`;
} }
function navigationGeometry(scene: Group): BufferGeometry { function navigationGeometry(scene: Group): BufferGeometry {
@@ -54,8 +59,9 @@ export function preparePartyNavigationGraph(
url: string, url: string,
scene: Group, scene: Group,
offMeshLinks: readonly DungeonNavigationLink[], offMeshLinks: readonly DungeonNavigationLink[],
ramps: readonly DungeonTraversalRamp[] = [],
): PartyNavigationGraph { ): PartyNavigationGraph {
const cacheKey = graphCacheKey(url, offMeshLinks); const cacheKey = graphCacheKey(url, offMeshLinks, ramps);
const cached = graphCache.get(cacheKey); const cached = graphCache.get(cacheKey);
if (cached) { if (cached) {
graphCache.delete(cacheKey); graphCache.delete(cacheKey);
@@ -66,9 +72,15 @@ export function preparePartyNavigationGraph(
const geometry = navigationGeometry(scene); const geometry = navigationGeometry(scene);
let graph: PartyNavigationGraph; let graph: PartyNavigationGraph;
try { try {
if (ramps.length) {
// Recast normally lifts its walkable surface slightly above collision.
smoothDungeonStairGeometry(geometry, ramps, 0.05);
bridgeDungeonStairNavigation(geometry, ramps, 0.05);
}
const bridgedLinks = new Set(ramps.map((ramp) => ramp.navigationBridge.linkId));
graph = buildPartyNavigationGraph(geometry, { graph = buildPartyNavigationGraph(geometry, {
maxSourceTriangles: MAX_SYNCHRONOUS_PARTY_NAVIGATION_TRIANGLES, maxSourceTriangles: MAX_SYNCHRONOUS_PARTY_NAVIGATION_TRIANGLES,
offMeshLinks, offMeshLinks: offMeshLinks.filter((link) => !bridgedLinks.has(link.id)),
}); });
} finally { } finally {
geometry.dispose(); geometry.dispose();
+13
View File
@@ -92,6 +92,19 @@ describe("party path controller", () => {
expect(route.blocked).toBe(true); expect(route.blocked).toBe(true);
}); });
it("accepts an in-range surface finish only when combat sight approves it", () => {
const options = { maxSnapDistance: 1, localFinishDistance: 1, directSegmentAllowed: () => false };
const visible = planPartyRoute(corridorGraph, "mob", [0, 0, 0], [2.5, 0, 2], 100, {
...options, destinationReached: (surface, target) => Math.hypot(surface[0] - target[0], surface[2] - target[2]) <= 1,
});
expect(visible.blocked).toBe(false);
expect(visible.waypoints.at(-1)).toEqual([2, 0, 2]);
const obstructed = planPartyRoute(corridorGraph, "mob", [0, 0, 0], [2.5, 0, 2], 100, {
...options, destinationReached: () => false,
});
expect(obstructed.blocked).toBe(true);
});
it("advances only one close-spaced waypoint at a corner", () => { it("advances only one close-spaced waypoint at a corner", () => {
const route = { const route = {
...emptyPartyRouteState(), ...emptyPartyRouteState(),
+3 -1
View File
@@ -34,6 +34,8 @@ export interface PartyRouteOptions {
start: PartyWorldPosition, start: PartyWorldPosition,
end: PartyWorldPosition, end: PartyWorldPosition,
) => boolean; ) => boolean;
/** Combat may finish on the retained surface only when range AND sight allow a hit. */
readonly destinationReached?: (surface: PartyWorldPosition, destination: PartyWorldPosition) => boolean;
} }
const DEFAULT_MAX_SNAP_DISTANCE = 3; const DEFAULT_MAX_SNAP_DISTANCE = 3;
@@ -117,7 +119,7 @@ export function planPartyRoute(
) return blocked(waypoints); ) return blocked(waypoints);
const finishDistance = fullDistance(waypoints[waypoints.length - 1], destination); const finishDistance = fullDistance(waypoints[waypoints.length - 1], destination);
if (finishDistance > localFinishDistance) return blocked(waypoints); if (finishDistance > localFinishDistance) return blocked(waypoints);
if (finishDistance > reachedDistance * 0.5) { if (finishDistance > reachedDistance * 0.5 && !options.destinationReached?.(waypoints[waypoints.length - 1], destination)) {
if (!options.directSegmentAllowed?.(waypoints[waypoints.length - 1], destination)) { if (!options.directSegmentAllowed?.(waypoints[waypoints.length - 1], destination)) {
return blocked(waypoints); return blocked(waypoints);
} }
+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, registerPartyRuntimePosition,
updatePartyRuntimeAttackCorridor, updatePartyRuntimeAttackCorridor,
updatePartyRuntimeTargetVisibility, updatePartyRuntimeTargetVisibility,
beginPartyRuntimeRejoin,
finishPartyRuntimeRejoin,
} from "./partyRuntimeRegistry"; } from "./partyRuntimeRegistry";
import { import {
activePartyTargetId, activePartyTargetId,
@@ -47,6 +49,36 @@ describe("party combat runtime", () => {
expect(activePartyTargetId(mobs, null)).toBe("pack:a"); expect(activePartyTargetId(mobs, null)).toBe("pack:a");
}); });
it("lets a resurrected tank rejoin independently instead of taunting or casting from its checkpoint", () => {
registerEngagedMob();
const tank = usePartyStore.getState().members.find((member) => member.role === "tank")!;
for (const member of usePartyStore.getState().members) registerPartyRuntimePosition(member.id, [0, 0, 0]);
beginPartyRuntimeRejoin(tank.id, [1_000, 0, 0]);
const beforeHealth = useCombatStore.getState().mobs["pack:a"].health;
advanceDungeonPartyCombat(1_000);
expect(usePartyStore.getState().members.find((member) => member.id === tank.id))
.toMatchObject({ status: "advancing", activeCast: null, activeCastTargetId: null });
expect(usePartyStore.getState().memberTargetIds[tank.id]).toBeUndefined();
expect(useCombatStore.getState().mobs["pack:a"].forcedTarget?.actorId).not.toBe(tank.id);
expect(useCombatStore.getState().mobs["pack:a"].health).toBeLessThan(beforeHealth);
finishPartyRuntimeRejoin(tank.id);
registerPartyRuntimePosition(tank.id, [0, 0, 0]);
advanceDungeonPartyCombat(2_000);
expect(usePartyStore.getState().memberTargetIds[tank.id]).toBe("pack:a");
});
it("keeps returning teammates advancing during Stop, then resumes the party order when they arrive", () => {
const tank = usePartyStore.getState().members.find((member) => member.role === "tank")!;
usePartyStore.getState().setCommand("stop");
beginPartyRuntimeRejoin(tank.id, [0, 0, 0]);
advanceDungeonPartyCombat(1_000);
expect(usePartyStore.getState().members.find((member) => member.id === tank.id)?.status).toBe("advancing");
finishPartyRuntimeRejoin(tank.id);
advanceDungeonPartyCombat(2_000);
expect(usePartyStore.getState().command).toBe("stop");
expect(usePartyStore.getState().members.find((member) => member.id === tank.id)?.status).toBe("holding");
});
it("has the tank taunt the active mob while all damage allies attack it", () => { it("has the tank taunt the active mob while all damage allies attack it", () => {
registerEngagedMob(); registerEngagedMob();
for (const member of usePartyStore.getState().members) { for (const member of usePartyStore.getState().members) {
+17 -2
View File
@@ -21,6 +21,7 @@ import { requireDungeonDefinition } from "./dungeonRegistry";
import { getMobPosition } from "./mobRuntimeRegistry"; import { getMobPosition } from "./mobRuntimeRegistry";
import { import {
getPartyRuntimePosition, getPartyRuntimePosition,
partyRuntimeIsRejoining,
partyRuntimeCenter, partyRuntimeCenter,
partyRuntimePositionAlongAttackCorridor, partyRuntimePositionAlongAttackCorridor,
partyRuntimeTargetVisible, partyRuntimeTargetVisible,
@@ -611,7 +612,7 @@ function emitPartyAbility(
member: PartyMember, member: PartyMember,
ability: AbilityDefinition, ability: AbilityDefinition,
targetId: string, targetId: string,
phase: "cast-start" | "release" | "impact", phase: "cast-start" | "cast-cancel" | "release" | "impact",
now: number, now: number,
instantCast = false, instantCast = false,
): void { ): void {
@@ -1058,7 +1059,7 @@ export function advanceDungeonPartyCombat(now = Date.now()): void {
)); ));
let members: readonly PartyMember[] = authoritativeMembers; let members: readonly PartyMember[] = authoritativeMembers;
const partyOrigin = partyRuntimeCenter( const partyOrigin = partyRuntimeCenter(
members.map((member) => member.id), members.filter((member) => !partyRuntimeIsRejoining(member.id)).map((member) => member.id),
combat.playerPosition, combat.playerPosition,
); );
let targetId = partyCommandTargetId( let targetId = partyCommandTargetId(
@@ -1122,6 +1123,10 @@ export function advanceDungeonPartyCombat(now = Date.now()): void {
if (member.status !== "down") nextMembers[index] = { ...member, status: "down" }; if (member.status !== "down") nextMembers[index] = { ...member, status: "down" };
continue; continue;
} }
if (partyRuntimeIsRejoining(member.id)) {
nextMembers[index] = { ...member, status: "advancing", activeCast: null, activeCastTargetId: null };
continue;
}
if (member.controlledUntil > now) { if (member.controlledUntil > now) {
nextMembers[index] = member.status === "holding" nextMembers[index] = member.status === "holding"
? member ? member
@@ -1148,6 +1153,16 @@ export function advanceDungeonPartyCombat(now = Date.now()): void {
const desiredStatus = hostileCast const desiredStatus = hostileCast
? member.role === "tank" ? "guarding" : "attacking" ? member.role === "tank" ? "guarding" : "attacking"
: "healing"; : "healing";
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 (now < member.activeCast.completesAt) { if (now < member.activeCast.completesAt) {
nextMembers[index] = member.status === desiredStatus ? member : { ...member, status: desiredStatus }; nextMembers[index] = member.status === desiredStatus ? member : { ...member, status: desiredStatus };
if (hostileCast) memberTargetIds[member.id] = castTargetId; if (hostileCast) memberTargetIds[member.id] = castTargetId;
+30
View File
@@ -11,11 +11,41 @@ import {
updatePartyRuntimeTargetVisibility, updatePartyRuntimeTargetVisibility,
updatePartyRuntimeAttackCorridor, updatePartyRuntimeAttackCorridor,
updatePartyRuntimePosition, updatePartyRuntimePosition,
beginPartyRuntimeRejoin,
consumePartyRuntimeRejoin,
partyRuntimeIsRejoining,
finishPartyRuntimeRejoin,
} from "./partyRuntimeRegistry"; } from "./partyRuntimeRegistry";
describe("party runtime position registry", () => { describe("party runtime position registry", () => {
beforeEach(clearPartyRuntimeRegistry); beforeEach(clearPartyRuntimeRegistry);
it("consumes a copied relocation once and keeps the individual return task until completed", () => {
const origin: [number, number, number] = [1, 2, 3];
beginPartyRuntimeRejoin("tank", origin);
origin[0] = 999;
const result = consumePartyRuntimeRejoin("tank")! as [number, number, number];
expect(result).toEqual([1, 2, 3]);
result[0] = 888;
expect(getPartyRuntimePosition("tank")).toEqual([1, 2, 3]);
expect(consumePartyRuntimeRejoin("tank")).toBeNull();
expect(partyRuntimeIsRejoining("tank")).toBe(true);
expect(partyRuntimeIsRejoining("healer")).toBe(false);
finishPartyRuntimeRejoin("tank");
expect(partyRuntimeIsRejoining("tank")).toBe(false);
});
it("clears pending resurrection tasks on actor unmount and dungeon switch", () => {
const unregister = registerPartyRuntimePosition("tank", [0, 0, 0]);
beginPartyRuntimeRejoin("tank", [1, 0, 0]);
unregister();
expect(partyRuntimeIsRejoining("tank")).toBe(false);
beginPartyRuntimeRejoin("tank", [1, 0, 0]);
clearPartyRuntimeRegistry();
expect(consumePartyRuntimeRejoin("tank")).toBeNull();
expect(partyRuntimeIsRejoining("tank")).toBe(false);
});
it("tracks world actors without exposing mutable registry storage", () => { it("tracks world actors without exposing mutable registry storage", () => {
const unregister = registerPartyRuntimePosition("tank", [1, 2, 3]); const unregister = registerPartyRuntimePosition("tank", [1, 2, 3]);
updatePartyRuntimePosition("tank", 4, 5, 6); updatePartyRuntimePosition("tank", 4, 5, 6);
+26
View File
@@ -3,6 +3,29 @@ export type MutablePartyWorldPosition = [x: number, y: number, z: number];
const positions = new Map<string, MutablePartyWorldPosition>(); const positions = new Map<string, MutablePartyWorldPosition>();
const targetVisibility = new Map<string, { targetId: string | null; visible: boolean }>(); const targetVisibility = new Map<string, { targetId: string | null; visible: boolean }>();
const rejoins = new Map<string, { position: PartyWorldPosition; pending: boolean }>();
/** A one-shot scene relocation followed by an individual return-to-player task. */
export function beginPartyRuntimeRejoin(id: string, position: PartyWorldPosition): void {
rejoins.set(id, { position: [...position], pending: true });
updatePartyRuntimePosition(id, position[0], position[1], position[2]);
targetVisibility.delete(id);
}
export function consumePartyRuntimeRejoin(id: string): PartyWorldPosition | null {
const rejoin = rejoins.get(id);
if (!rejoin?.pending) return null;
rejoin.pending = false;
return [...rejoin.position];
}
export function partyRuntimeIsRejoining(id: string): boolean {
return rejoins.has(id);
}
export function finishPartyRuntimeRejoin(id: string): void {
rejoins.delete(id);
}
let attackCorridor: { let attackCorridor: {
readonly origin: MutablePartyWorldPosition; readonly origin: MutablePartyWorldPosition;
readonly waypoints: readonly PartyWorldPosition[]; readonly waypoints: readonly PartyWorldPosition[];
@@ -21,6 +44,7 @@ export function registerPartyRuntimePosition(
return () => { return () => {
positions.delete(id); positions.delete(id);
targetVisibility.delete(id); targetVisibility.delete(id);
rejoins.delete(id);
}; };
} }
@@ -111,6 +135,7 @@ export function updatePartyRuntimePosition(
export function removePartyRuntimePosition(id: string): void { export function removePartyRuntimePosition(id: string): void {
positions.delete(id); positions.delete(id);
targetVisibility.delete(id); targetVisibility.delete(id);
rejoins.delete(id);
} }
export function getPartyRuntimePosition(id: string): PartyWorldPosition | null { export function getPartyRuntimePosition(id: string): PartyWorldPosition | null {
@@ -166,5 +191,6 @@ export function partyRuntimeCenter(
export function clearPartyRuntimeRegistry(): void { export function clearPartyRuntimeRegistry(): void {
positions.clear(); positions.clear();
targetVisibility.clear(); targetVisibility.clear();
rejoins.clear();
attackCorridor = null; attackCorridor = null;
} }
+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;
}
+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;
}
+355 -6
View File
@@ -1,7 +1,8 @@
import { NodeIO } from "@gltf-transform/core"; import { NodeIO } from "@gltf-transform/core";
import RAPIER from "@dimforge/rapier3d-compat";
import { ALL_EXTENSIONS } from "@gltf-transform/extensions"; import { ALL_EXTENSIONS } from "@gltf-transform/extensions";
import { MeshoptDecoder } from "meshoptimizer"; 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 { beforeAll, describe, expect, it } from "vitest";
import { requireDungeonDefinition } from "./dungeonRegistry"; import { requireDungeonDefinition } from "./dungeonRegistry";
import { import {
@@ -12,17 +13,27 @@ import {
} from "./partyNavigation"; } from "./partyNavigation";
import { preparePartyNavigationGraph } from "./partyNavigationAssetCache"; import { preparePartyNavigationGraph } from "./partyNavigationAssetCache";
import { shouldLoadPartyNavigation } from "./partyNavigationPolicy"; import { shouldLoadPartyNavigation } from "./partyNavigationPolicy";
import { planPartyRoute } from "./partyPathing"; import { emptyPartyRouteState, planPartyRoute } from "./partyPathing";
import { partyRejoinMovement } from "./partyRejoin";
import { partyCombatMovementGoal, stepPartyPosition } from "./partyMovement"; import { partyCombatMovementGoal, stepPartyPosition } from "./partyMovement";
import { derivePartySpawnSlots } from "./partySpawnSlots"; import { derivePartySpawnSlots } from "./partySpawnSlots";
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 STOCKADES_NAVIGATION = "public/assets/game/manastorm/34-stormwindjail/navigation.glb";
const TANK_RANGE = Object.freeze({ minimum: 0, preferred: 2.9, maximum: 3.15 }); const TANK_RANGE = Object.freeze({ minimum: 0, preferred: 2.9, maximum: 3.15 });
const stockades = requireDungeonDefinition("stormwind-stockade"); const stockades = requireDungeonDefinition("stormwind-stockade");
let graph: PartyNavigationGraph; let graph: PartyNavigationGraph;
let sourceCollision: Group;
beforeAll(async () => { beforeAll(async () => {
await RAPIER.init();
await MeshoptDecoder.ready; await MeshoptDecoder.ready;
const document = await new NodeIO() const document = await new NodeIO()
.registerExtensions(ALL_EXTENSIONS) .registerExtensions(ALL_EXTENSIONS)
@@ -50,15 +61,178 @@ beforeAll(async () => {
scene.add(mesh); scene.add(mesh);
// Exercise the same quantized-buffer and node-transform path as GLTFLoader // Exercise the same quantized-buffer and node-transform path as GLTFLoader
// in the shipping runtime, not a separately pre-expanded test geometry. // in the shipping runtime, not a separately pre-expanded test geometry.
graph = preparePartyNavigationGraph("/test/stockades-navigation.glb", scene, stockades.navigationLinks); graph = preparePartyNavigationGraph(
"/test/stockades-navigation.glb", scene, stockades.navigationLinks, stockades.traversalRamps,
);
geometry.dispose(); geometry.dispose();
const collisionDocument = await new NodeIO()
.registerExtensions(ALL_EXTENSIONS)
.registerDependencies({ "meshopt.decoder": MeshoptDecoder })
.read("public/assets/game/manastorm/34-stormwindjail/collision.glb");
sourceCollision = new Group();
for (const node of collisionDocument.getRoot().listNodes()) {
for (const collisionPrimitive of node.getMesh()?.listPrimitives() ?? []) {
const positions = collisionPrimitive.getAttribute("POSITION")!;
const values = positions.getArray();
if (!(values instanceof Int16Array)) throw new Error("Expected quantized Stockades collision positions.");
const collisionGeometry = new BufferGeometry();
collisionGeometry.setAttribute("position", new BufferAttribute(values, 3, positions.getNormalized()));
collisionGeometry.setIndex(Array.from(collisionPrimitive.getIndices()!.getArray()!));
const collisionMesh = new Mesh(collisionGeometry);
collisionMesh.matrixAutoUpdate = false;
collisionMesh.matrix.fromArray(node.getWorldMatrix());
sourceCollision.add(collisionMesh);
}
}
}); });
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();
return world;
}
function distance(left: readonly number[], right: readonly number[]): number { function distance(left: readonly number[], right: readonly number[]): number {
return Math.hypot(left[0] - right[0], left[1] - right[1], left[2] - right[2]); return Math.hypot(left[0] - right[0], left[1] - right[1], left[2] - right[2]);
} }
describe("Stormwind Stockade party navigation", () => { 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", () => { it("uses the reviewed warm indoor lighting profile", () => {
expect(stockades.presentation).toMatchObject({ expect(stockades.presentation).toMatchObject({
background: "#171815", background: "#171815",
@@ -74,10 +248,12 @@ describe("Stormwind Stockade party navigation", () => {
expect(stockades.navigationLinks).toHaveLength(3); expect(stockades.navigationLinks).toHaveLength(3);
expect(shouldLoadPartyNavigation("dungeon", stockades, null)).toBe(true); expect(shouldLoadPartyNavigation("dungeon", stockades, null)).toBe(true);
expect(graph).toMatchObject({ expect(graph).toMatchObject({
sourceTriangleCount: 3_538, sourceTriangleCount: 3_544,
componentCount: 31, componentCount: 31,
}); });
expect(graph.discardedNodeCount).toBeGreaterThan(0); expect(graph.discardedNodeCount).toBeGreaterThan(0);
expect(graph.triangles?.length).toBeGreaterThan(0);
expect(graph.triangleNodes?.length).toBe(graph.triangles?.length);
}); });
it("projects every entrance formation slot onto the retained walkable surface", () => { it("projects every entrance formation slot onto the retained walkable surface", () => {
@@ -95,6 +271,8 @@ describe("Stormwind Stockade party navigation", () => {
}); });
it("routes from the shipping entrance to every authored boss objective", () => { it("routes from the shipping entrance to every authored boss objective", () => {
const world = collisionWorld();
const routeSegmentAllowed = createPartyRouteSegmentValidator(RAPIER, world);
const entrance = findNearestPartyNavigationSurface( const entrance = findNearestPartyNavigationSurface(
graph, graph,
stockades.entrance.footPosition, stockades.entrance.footPosition,
@@ -109,12 +287,15 @@ describe("Stormwind Stockade party navigation", () => {
graph, graph,
entrance!.position, entrance!.position,
approach!.position, approach!.position,
{ maxStartSnapDistance: 0.1, maxEndSnapDistance: 0.1 }, { maxStartSnapDistance: 0.1, maxEndSnapDistance: 0.1, routeSegmentAllowed },
), objective.name).not.toBeNull(); ), objective.name).not.toBeNull();
} }
world.free();
}); });
it("gives the tank a usable route into melee range of the first enemy", () => { it("gives the tank a usable route into melee range of the first enemy", () => {
const world = collisionWorld();
const segmentAllowed = createPartyDirectSegmentValidator(RAPIER, world);
const tankSlot = derivePartySpawnSlots({ const tankSlot = derivePartySpawnSlots({
footPosition: stockades.entrance.footPosition, footPosition: stockades.entrance.footPosition,
yaw: stockades.entrance.yaw, yaw: stockades.entrance.yaw,
@@ -146,7 +327,8 @@ describe("Stormwind Stockade party navigation", () => {
maxOriginSnapDistance: 3, maxOriginSnapDistance: 3,
maxDestinationSnapDistance: 5, maxDestinationSnapDistance: 5,
localFinishDistance: TANK_RANGE.maximum, localFinishDistance: TANK_RANGE.maximum,
directSegmentAllowed: () => true, directSegmentAllowed: segmentAllowed,
routeSegmentAllowed: createPartyRouteSegmentValidator(RAPIER, world),
}, },
); );
expect(route.blocked).toBe(false); expect(route.blocked).toBe(false);
@@ -170,5 +352,172 @@ describe("Stormwind Stockade party navigation", () => {
TANK_RANGE.maximum, TANK_RANGE.maximum,
desired, desired,
)).toBe("hold"); )).toBe("hold");
world.free();
});
it("traverses all three shipping stair flights in both directions with the live capsule check", () => {
const world = collisionWorld();
const allowed = createPartyDirectSegmentValidator(RAPIER, world);
expect(stockades.traversalRamps).toHaveLength(3);
for (const ramp of stockades.traversalRamps!) {
const point = (t: number, lateral: number): [number, number, number] => {
const dx = ramp.end[0] - ramp.start[0];
const dz = ramp.end[2] - ramp.start[2];
const length = Math.hypot(dx, dz);
return [
ramp.start[0] + dx * t + dz / length * lateral,
ramp.start[1] + (ramp.end[1] - ramp.start[1]) * t + 0.05,
ramp.start[2] + dz * t - dx / length * lateral,
];
};
for (const lateral of [-2, 0, 2]) {
const start = point(0.05, lateral);
const end = point(0.95, lateral);
expect(allowed(start, end), `${ramp.id}: downhill, offset ${lateral}`).toBe(true);
expect(allowed(end, start), `${ramp.id}: uphill, offset ${lateral}`).toBe(true);
}
const start = findNearestPartyNavigationSurface(graph, point(0.15, 0), 2)!.position;
const end = findNearestPartyNavigationSurface(graph, point(0.85, 0), 2)!.position;
expect(findPartyNavigationPath(graph, start, end, { maxSnapDistance: 0.1 }), ramp.id).not.toBeNull();
// Replanning midway through a stair must not require a blocked snap to
// a distant boundary vertex, as the previous off-mesh graph did.
for (const t of [0.2, 0.4, 0.6, 0.8]) {
const current = findNearestPartyNavigationSurface(graph, point(t, 0), 2)!.position;
const route = planPartyRoute(graph, ramp.id, current, end, t * 1_000, {
maxSnapDistance: 0.1,
directSegmentAllowed: allowed,
});
expect(route.blocked, `${ramp.id}: replan at ${t}`).toBe(false);
}
}
world.free();
});
it("reproduces the old step-face rejection on the unmodified source collision", () => {
const world = collisionWorld(false);
const allowed = createPartyDirectSegmentValidator(RAPIER, world);
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.95), point(0.05)), ramp.id).toBe(false);
}
world.free();
});
it("still blocks movement through the stairwell walls", () => {
const world = collisionWorld();
const directAllowed = createPartyDirectSegmentValidator(RAPIER, world);
const routeAllowed = createPartyRouteSegmentValidator(RAPIER, world);
const ramp = stockades.traversalRamps![0];
const center: [number, number, number] = [
(ramp.start[0] + ramp.end[0]) / 2,
(ramp.start[1] + ramp.end[1]) / 2 + 0.05,
ramp.start[2],
];
const acrossWall: [number, number, number] = [center[0], center[1], center[2] + 9];
expect(directAllowed(center, acrossWall)).toBe(false);
expect(routeAllowed(center, acrossWall)).toBe(false);
world.free();
});
it("does not mistake the nearby player's kinematic capsule for stair terrain", () => {
const world = collisionWorld();
const ramp = stockades.traversalRamps![0];
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],
];
const player = point(0.5);
const body = world.createRigidBody(RAPIER.RigidBodyDesc.kinematicPositionBased().setTranslation(
player[0], player[1] + 0.84, player[2],
));
world.createCollider(RAPIER.ColliderDesc.capsule(0.48, 0.36).setCollisionGroups(ACTOR_COLLISION_GROUPS), body);
world.step();
expect(createPartyDirectSegmentValidator(RAPIER, world)(point(0.4), point(0.6))).toBe(true);
expect(createPartyRouteSegmentValidator(RAPIER, world)(point(0.4), point(0.6))).toBe(true);
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();
const dx = ramp.end[0] - ramp.start[0];
const dz = ramp.end[2] - ramp.start[2];
const length = Math.hypot(dx, dz);
const centerHeight = 0.36 + 0.48;
const startT = 0.03;
const startY = ramp.start[1] + (ramp.end[1] - ramp.start[1]) * startT;
const body = world.createRigidBody(RAPIER.RigidBodyDesc.kinematicPositionBased().setTranslation(
ramp.start[0] + dx * startT, startY + centerHeight + 0.02, ramp.start[2] + dz * startT,
));
const collider = world.createCollider(RAPIER.ColliderDesc.capsule(0.48, 0.36).setCollisionGroups(ACTOR_COLLISION_GROUPS), body);
world.step();
const controller = configurePlayerCharacterController(world.createCharacterController(PLAYER_CHARACTER_CONTROLLER_OFFSET));
const progress = () => {
const position = body.translation();
return ((position.x - ramp.start[0]) * dx + (position.z - ramp.start[2]) * dz) / (length * length);
};
for (const direction of [1, -1]) {
// Rapier projects uphill movement onto the slope, reducing planar
// speed. Bound the walk by twice the flat-ground travel time.
const frames = Math.ceil(length * 2 / (4.8 / 60));
for (let frame = 0; frame < frames; frame += 1) {
if (direction === 1 ? progress() >= 0.95 : progress() <= 0.05) break;
controller.computeColliderMovement(collider, {
x: dx / length * direction * 4.8 / 60,
y: -0.5 / 60,
z: dz / length * direction * 4.8 / 60,
}, RAPIER.QueryFilterFlags.EXCLUDE_SENSORS, ACTOR_TERRAIN_QUERY_GROUPS);
const position = body.translation();
const move = controller.computedMovement();
body.setNextKinematicTranslation({ x: position.x + move.x, y: position.y + move.y, z: position.z + move.z });
world.step();
}
if (direction === 1) expect(progress(), `${ramp.id}: player downhill`).toBeGreaterThan(0.9);
else expect(progress(), `${ramp.id}: player uphill`).toBeLessThan(0.12);
}
world.removeCharacterController(controller);
world.free();
}
}); });
}); });
+22 -1
View File
@@ -1,10 +1,31 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import {
actionKeyForEvent, bindDesktopSlot, defaultUiLayout, desktopSlotForKey, actionKeyForEvent, bindDesktopSlot, defaultUiLayout, desktopSlotForKey,
exportUiPreferences, importUiPreferences, normalizeActionKey, normalizeUiPreferences, exportUiPreferences, importUiPreferences, normalizeActionKey, normalizeUiPreferences, resolveDpsMeterDisplay,
} from "./uiLayout"; } from "./uiLayout";
describe("personal UI layouts", () => { 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", () => { it("migrates old accounts to independent Mobile and PC layouts with exactly six empty slots", () => {
const ui = normalizeUiPreferences(undefined); const ui = normalizeUiPreferences(undefined);
expect(ui.slots.mobile).toEqual([null, null, null]); 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 = { export const HUD_ELEMENTS = {
party: "Party frames", summons: "Pet and summon frames", minimap: "Minimap", party: "Party frames", summons: "Pet and summon frames", minimap: "Minimap",
player: "Player status", timeline: "Encounter timeline", target: "Enemy target", 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", hints: "Control hints", actions: "Controller action bar", experience: "Experience bar",
reticle: "Crosshair", loot: "Loot notifications", interaction: "Interaction prompt", reticle: "Crosshair", loot: "Loot notifications", interaction: "Interaction prompt",
manastorm: "Manastorm status", completion: "Dungeon completion", assets: "Asset notice", 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 DesktopActionBar { columns: number; slots: DesktopActionSlot[] }
export interface UiLayout { export interface UiLayout {
elements: Record<HudElementId, HudElementLayout>; elements: Record<HudElementId, HudElementLayout>;
dpsMeterDisplay: "auto" | "main" | "companion";
wowBars: boolean; wowBars: boolean;
barCount: number; barCount: number;
bars: DesktopActionBar[]; bars: DesktopActionBar[];
@@ -42,6 +43,7 @@ export function defaultUiLayout(): UiLayout {
elements: Object.fromEntries(Object.keys(HUD_ELEMENTS).map((id) => [id, { elements: Object.fromEntries(Object.keys(HUD_ELEMENTS).map((id) => [id, {
visible: true, position: null, width: null, visible: true, position: null, width: null,
}])) as UiLayout["elements"], }])) as UiLayout["elements"],
dpsMeterDisplay: "auto",
wowBars: false, wowBars: false,
barCount: 1, barCount: 1,
bars: Array.from({ length: 5 }, (_, bar) => ({ 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 { export function normalizeUiPreferences(value: unknown): UiPreferences {
const source = record(value); 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."); if (source.format !== "healer-man-ui" || source.version !== 1) throw new Error("Unsupported UI file format or version.");
const validateLayout = (value: unknown) => { const validateLayout = (value: unknown) => {
const layout = record(value); 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 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."); || !Array.isArray(layout.bars) || layout.bars.length !== 5) throw new Error("Invalid action bar configuration.");
for (const id of Object.keys(HUD_ELEMENTS)) { 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 element = record(record(layout.elements)[id]);
const position = record(element.position); 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)) if (typeof element.visible !== "boolean" || (element.position !== null && [position.x, position.y].some((v) => typeof v !== "number" || !Number.isFinite(v) || v < 0 || v > 1))
+10 -1
View File
@@ -5,6 +5,8 @@ import { useShellStore } from "../app/shellStore";
import { emitControllerToken, setExternalControllerMovement } from "../input/controller"; import { emitControllerToken, setExternalControllerMovement } from "../input/controller";
import { currentCompanionSnapshot, executeCompanionCommand, type DualScreenMessage } from "./dualScreenSync"; import { currentCompanionSnapshot, executeCompanionCommand, type DualScreenMessage } from "./dualScreenSync";
import { createRateLimitedPublisher } from "./rateLimitedPublisher"; import { createRateLimitedPublisher } from "./rateLimitedPublisher";
import { useUiLayoutStore } from "../game/uiLayoutStore";
import { useDpsMeterStore } from "../game/dpsMeterStore";
export function connectAuthoritativeDualScreen( export function connectAuthoritativeDualScreen(
channel: BroadcastChannel, channel: BroadcastChannel,
@@ -30,6 +32,12 @@ export function connectAuthoritativeDualScreen(
const unsubscribeParty = usePartyStore.subscribe(() => { const unsubscribeParty = usePartyStore.subscribe(() => {
if (companionReady) gamePublisher.request(); 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>) => { channel.onmessage = (event: MessageEvent<DualScreenMessage>) => {
if (event.data.type === "companion-ready") { if (event.data.type === "companion-ready") {
companionReady = true; companionReady = true;
@@ -52,7 +60,8 @@ export function connectAuthoritativeDualScreen(
unsubscribeGame(); unsubscribeGame();
unsubscribeCombat(); unsubscribeCombat();
unsubscribeParty(); unsubscribeParty();
unsubscribeUi();
unsubscribeDps();
gamePublisher.dispose(); 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 type { DungeonId } from "../game/dungeonCatalog";
import { useManastormStore } from "../game/manastormStore"; import { useManastormStore } from "../game/manastormStore";
import { resolveStagePresentation } from "../game/manastormStagePresentation"; 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"; export { createDualScreenChannel } from "./dualScreenChannel";
@@ -89,6 +95,12 @@ export interface CompanionPartyObjectiveSnapshot {
} }
export interface CompanionGameSnapshot { export interface CompanionGameSnapshot {
dpsMeter: {
values: DpsMeterSnapshot;
layout: HudElementLayout;
display: "main" | "companion";
editing: boolean;
};
gameMode: GameMode; gameMode: GameMode;
dungeonId: string; dungeonId: string;
dungeonTitle: string; dungeonTitle: string;
@@ -159,7 +171,8 @@ export type CompanionCommand =
| { name: "cyclePartyMember"; direction: 1 | -1 } | { name: "cyclePartyMember"; direction: 1 | -1 }
| { name: "setPartyCommand"; command: PartyCommand } | { name: "setPartyCommand"; command: PartyCommand }
| { name: "recallParty" } | { name: "recallParty" }
| { name: "resetAtEntrance" }; | { name: "resetAtEntrance" }
| { name: "editDpsMeter"; update: Partial<HudElementLayout> };
export type DualScreenMessage = export type DualScreenMessage =
| { type: "companion-ready" } | { type: "companion-ready" }
@@ -180,6 +193,7 @@ export function currentCompanionSnapshot(): CompanionSnapshot {
const combat = useCombatStore.getState(); const combat = useCombatStore.getState();
const party = usePartyStore.getState(); const party = usePartyStore.getState();
const manastorm = useManastormStore.getState(); const manastorm = useManastormStore.getState();
const uiLayout = activeUiLayout();
const dungeon = requireDungeonDefinition(game.activeDungeonId); const dungeon = requireDungeonDefinition(game.activeDungeonId);
const stagePresentation = resolveStagePresentation(game.gameMode, manastorm.currentEncounter, { const stagePresentation = resolveStagePresentation(game.gameMode, manastorm.currentEncounter, {
title: dungeon.title, title: dungeon.title,
@@ -251,6 +265,12 @@ export function currentCompanionSnapshot(): CompanionSnapshot {
draft: shell.draft, draft: shell.draft,
notice: shell.notice, notice: shell.notice,
game: { game: {
dpsMeter: {
values: currentDpsMeterSnapshot(),
layout: uiLayout.elements.dps,
display: resolveDpsMeterDisplay(uiLayout, typeof window !== "undefined" && currentDisplayLayout() !== "single"),
editing: game.overlay === "ui-layout",
},
gameMode: game.gameMode, gameMode: game.gameMode,
dungeonId: dungeon.id, dungeonId: dungeon.id,
dungeonTitle: stagePresentation.title, dungeonTitle: stagePresentation.title,
@@ -322,6 +342,17 @@ export function executeCompanionCommand(command: CompanionCommand): void {
const game = useGameStore.getState(); const game = useGameStore.getState();
const combat = useCombatStore.getState(); const combat = useCombatStore.getState();
switch (command.name) { 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 "selectCharacter": shell.selectCharacter(command.characterId); break;
case "openCreator": shell.openCreator(); break; case "openCreator": shell.openCreator(); break;
case "cancelCreator": shell.cancelCreator(); break; case "cancelCreator": shell.cancelCreator(); break;
+8 -2
View File
@@ -24,7 +24,7 @@ import { useCombatStore } from "../game/combatStore";
import { PLAYER_EQUIPMENT_OWNER_ID } from "../game/equipment"; import { PLAYER_EQUIPMENT_OWNER_ID } from "../game/equipment";
import { resolvedEquipmentItems } from "../game/heirloomEquipment"; import { resolvedEquipmentItems } from "../game/heirloomEquipment";
import type { InventoryItem } from "../game/lootTypes"; import type { InventoryItem } from "../game/lootTypes";
import type { DungeonNavigationLink } from "../game/dungeonTypes"; import type { DungeonNavigationLink, DungeonTraversalRamp } from "../game/dungeonTypes";
import { preparePartyNavigationGraph } from "../game/partyNavigationAssetCache"; import { preparePartyNavigationGraph } from "../game/partyNavigationAssetCache";
import { usePartyStore } from "../game/partyStore"; import { usePartyStore } from "../game/partyStore";
import { preloadTalentGameplayResources } from "../ui/talentGameplayResources"; import { preloadTalentGameplayResources } from "../ui/talentGameplayResources";
@@ -63,11 +63,13 @@ function WarmupCommit({ onSettled }: { readonly onSettled: () => void }) {
function SceneAssetProbe({ function SceneAssetProbe({
navigationLinks, navigationLinks,
traversalRamps,
navigationUrl, navigationUrl,
urls, urls,
onSettled, onSettled,
}: { }: {
readonly navigationLinks: readonly DungeonNavigationLink[]; readonly navigationLinks: readonly DungeonNavigationLink[];
readonly traversalRamps: readonly DungeonTraversalRamp[];
readonly navigationUrl: string | null; readonly navigationUrl: string | null;
readonly urls: readonly string[]; readonly urls: readonly string[];
readonly onSettled: () => void; readonly onSettled: () => void;
@@ -80,8 +82,9 @@ function SceneAssetProbe({
navigationUrl, navigationUrl,
gltfs[navigationIndex].scene, gltfs[navigationIndex].scene,
navigationLinks, navigationLinks,
traversalRamps,
); );
}, [gltfs, navigationIndex, navigationLinks, navigationUrl]); }, [gltfs, navigationIndex, navigationLinks, navigationUrl, traversalRamps]);
return <WarmupCommit onSettled={onSettled} />; return <WarmupCommit onSettled={onSettled} />;
} }
@@ -248,11 +251,13 @@ function PartyDataProbe({ onSettled }: { readonly onSettled: () => void }) {
export function ActivityAssetWarmup({ export function ActivityAssetWarmup({
navigationLinks, navigationLinks,
traversalRamps,
navigationUrl, navigationUrl,
sceneAssetUrls, sceneAssetUrls,
onReady, onReady,
}: { }: {
readonly navigationLinks: readonly DungeonNavigationLink[]; readonly navigationLinks: readonly DungeonNavigationLink[];
readonly traversalRamps: readonly DungeonTraversalRamp[];
readonly navigationUrl: string | null; readonly navigationUrl: string | null;
readonly sceneAssetUrls: readonly string[]; readonly sceneAssetUrls: readonly string[];
readonly onReady: () => void; readonly onReady: () => void;
@@ -276,6 +281,7 @@ export function ActivityAssetWarmup({
<Suspense fallback={null}> <Suspense fallback={null}>
<SceneAssetProbe <SceneAssetProbe
navigationLinks={navigationLinks} navigationLinks={navigationLinks}
traversalRamps={traversalRamps}
navigationUrl={navigationUrl} navigationUrl={navigationUrl}
urls={sceneAssetUrls} urls={sceneAssetUrls}
onSettled={markSceneReady} onSettled={markSceneReady}
+1
View File
@@ -347,6 +347,7 @@ export function GameScene() {
<ActivityAssetWarmup <ActivityAssetWarmup
key={`activity-warmup:${sessionRevision}`} key={`activity-warmup:${sessionRevision}`}
navigationLinks={navigationLinks} navigationLinks={navigationLinks}
traversalRamps={manastormAssetPackage ? [] : dungeon.traversalRamps ?? []}
navigationUrl={navigationUrl} navigationUrl={navigationUrl}
sceneAssetUrls={activitySceneAssetUrls} sceneAssetUrls={activitySceneAssetUrls}
onReady={markActivityAssetsReady} onReady={markActivityAssetsReady}
+43 -34
View File
@@ -27,7 +27,7 @@ import {
sampleMobAnimation, sampleMobAnimation,
} from "../game/mobAnimation"; } from "../game/mobAnimation";
import type { MobAnimationState } 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 { PRIMARY_MOUSE_BUTTON } from "../game/inputManager";
import { TimedEffectStrip } from "../ui/TimedEffectStrip"; import { TimedEffectStrip } from "../ui/TimedEffectStrip";
import { AuraStrip } from "../ui/AuraStrip"; import { AuraStrip } from "../ui/AuraStrip";
@@ -76,9 +76,10 @@ import {
resolveMobAnimationInterval, resolveMobAnimationInterval,
} from "./mobAnimationCadence"; } from "./mobAnimationCadence";
import { useGameGLTF } from "./useGameGLTF"; import { useGameGLTF } from "./useGameGLTF";
import { onlineSynchronizedMobTransform, requestOnlineInteraction } from "../game/onlineSessionRuntime"; import { onlineSessionOwnsWorldSimulation, onlineSynchronizedMobTransform, requestOnlineInteraction } from "../game/onlineSessionRuntime";
import { isMobAggroRangeVisible } from "./mobAggroRangeVisibility"; import { isMobAggroRangeVisible } from "./mobAggroRangeVisibility";
import { DungeonOccludedHtml } from "./DungeonOccludedHtml"; import { DungeonOccludedHtml } from "./DungeonOccludedHtml";
import { useMobNavigation } from "./mobNavigation";
const MOVEMENT_LOCKING_MOB_STATUSES: ReadonlySet<string> = new Set([ const MOVEMENT_LOCKING_MOB_STATUSES: ReadonlySet<string> = new Set([
"stun", "root", "sleep", "knockdown", "freeze", "charm", "confuse", "stun", "root", "sleep", "knockdown", "freeze", "charm", "confuse",
@@ -119,8 +120,32 @@ function mobTargetPosition(
playerPosition: MobVector3, playerPosition: MobVector3,
): MobVector3 { ): MobVector3 {
if (!targetActorId || targetActorId === PLAYER_AGGRO_ID) return playerPosition; 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; 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 PROXIMITY_CHECK_INTERVAL = 0.3;
const AGGRO_RANGE_VISIBILITY_CHECK_INTERVAL = 0.25; const AGGRO_RANGE_VISIBILITY_CHECK_INTERVAL = 0.25;
let preparedCreatureTemplates = new WeakMap<Object3D, Object3D>(); 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. * Activates content when the player or an autonomous party member approaches.
* The stores are read * The stores are read
@@ -1159,6 +1167,7 @@ function RoamingPack({
useOriginalModels, useOriginalModels,
allowedRuntimeIds, allowedRuntimeIds,
}: RoamingPackProps) { }: RoamingPackProps) {
const navigator = useMobNavigation();
const path = useMemo(() => createLoopedPath(definition.waypoints), [definition.waypoints]); const path = useMemo(() => createLoopedPath(definition.waypoints), [definition.waypoints]);
const distanceRef = useRef(definition.startDistance ?? 0); const distanceRef = useRef(definition.startDistance ?? 0);
const { scratchPositions, scratchForwards, chasePositions } = useMemo(() => { const { scratchPositions, scratchForwards, chasePositions } = useMemo(() => {
@@ -1277,7 +1286,7 @@ function RoamingPack({
const maximumStep = Math.min(delta, 0.05) const maximumStep = Math.min(delta, 0.05)
* mob!.moveSpeed * mob!.moveSpeed
* Math.max(0.1, 1 - slow); * Math.max(0.1, 1 - slow);
if (!movementLocked && feared) { if (active && !movementLocked && feared) {
stepFearedMobAway( stepFearedMobAway(
chasePosition, chasePosition,
targetPosition, targetPosition,
@@ -1286,19 +1295,16 @@ function RoamingPack({
mob!.leashRange, mob!.leashRange,
chasePosition, 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; position = chasePosition;
const facingSign = feared && !movementLocked ? -1 : 1; const facingSign = feared && !movementLocked ? -1 : 1;
forward[0] = (targetPosition[0] - position[0]) * facingSign; forward[0] = (targetPosition[0] - position[0]) * facingSign;
forward[2] = (targetPosition[2] - position[2]) * facingSign; forward[2] = (targetPosition[2] - position[2]) * facingSign;
} else if (mob?.combatPhase === "returning") { } 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; position = chasePosition;
} else { } else {
chasePosition[0] = patrolPosition[0]; chasePosition[0] = patrolPosition[0];
@@ -1360,6 +1366,7 @@ interface StaticPopulationProps {
} }
function StaticPopulationMember({ spawn, entities, active, showLabels, useOriginalModels }: StaticPopulationProps) { function StaticPopulationMember({ spawn, entities, active, showLabels, useOriginalModels }: StaticPopulationProps) {
const navigator = useMobNavigation();
const entity = entities[spawn.entityId]; const entity = entities[spawn.entityId];
const groupRef = useRef<Group>(null); const groupRef = useRef<Group>(null);
const positionRef = useRef<MutableMobVector3>([...spawn.position]); 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"); 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 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); const maximumStep = Math.min(delta, 0.05) * mob.moveSpeed * Math.max(0.1, 1 - slow);
if (!movementLocked && feared) { if (active && !movementLocked && feared) {
stepFearedMobAway( stepFearedMobAway(
position, position,
targetPosition, targetPosition,
@@ -1407,11 +1414,12 @@ function StaticPopulationMember({ spawn, entities, active, showLabels, useOrigin
mob.leashRange, mob.leashRange,
position, 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") { } 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 { } else {
position[0] = spawn.position[0]; position[0] = spawn.position[0];
position[1] = spawn.position[1]; position[1] = spawn.position[1];
@@ -1500,6 +1508,7 @@ function SummonedPopulationMember({
readonly active: boolean; readonly active: boolean;
readonly useOriginalModels: boolean; readonly useOriginalModels: boolean;
}) { }) {
const navigator = useMobNavigation();
const groupRef = useRef<Group>(null); const groupRef = useRef<Group>(null);
const initial = getMobRuntimeTransform(instanceId)?.position ?? [0, 0, 0]; const initial = getMobRuntimeTransform(instanceId)?.position ?? [0, 0, 0];
const positionRef = useRef<MutableMobVector3>([...initial]); const positionRef = useRef<MutableMobVector3>([...initial]);
@@ -1515,7 +1524,7 @@ function SummonedPopulationMember({
const feared = mob.statuses.some((status) => status.endsAt > now && status.kind === "fear"); 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 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); const maximumStep = Math.min(delta, 0.05) * mob.moveSpeed * Math.max(0.1, 1 - slow);
if (!movementLocked && feared) { if (active && !movementLocked && feared) {
stepFearedMobAway( stepFearedMobAway(
position, position,
targetPosition, targetPosition,
@@ -1524,9 +1533,9 @@ function SummonedPopulationMember({
mob.leashRange, mob.leashRange,
position, 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 facingSign = feared && !movementLocked ? -1 : 1;
const yaw = Math.atan2( const yaw = Math.atan2(
(targetPosition[0] - position[0]) * facingSign, (targetPosition[0] - position[0]) * facingSign,
+162 -177
View File
@@ -14,7 +14,7 @@ import {
type ErrorInfo, type ErrorInfo,
type ReactNode, type ReactNode,
} from "react"; } from "react";
import { Quaternion, type Group } from "three"; import { type Group } from "three";
import { classById, raceById, type GenderId, type RaceId } from "../app/characterCatalog"; import { classById, raceById, type GenderId, type RaceId } from "../app/characterCatalog";
import { CharacterModel } from "../avatar/CharacterModel"; import { CharacterModel } from "../avatar/CharacterModel";
import { ProceduralAvatar } from "../avatar/ProceduralAvatar"; import { ProceduralAvatar } from "../avatar/ProceduralAvatar";
@@ -25,7 +25,7 @@ import { resolveDungeonAssets, resolveManastormStageAssets } from "../game/dunge
import { activeManastormStageAssetPackage } from "../game/manastormStageLoader"; import { activeManastormStageAssetPackage } from "../game/manastormStageLoader";
import { derivePartySpawnSlots } from "../game/partySpawnSlots"; import { derivePartySpawnSlots } from "../game/partySpawnSlots";
import { requireDungeonDefinition } from "../game/dungeonRegistry"; import { requireDungeonDefinition } from "../game/dungeonRegistry";
import type { DungeonBossObjective } from "../game/dungeonTypes"; import type { DungeonBossObjective, DungeonTraversalRamp } from "../game/dungeonTypes";
import { getMobPosition } from "../game/mobRuntimeRegistry"; import { getMobPosition } from "../game/mobRuntimeRegistry";
import { import {
appendPartyBreadcrumb, appendPartyBreadcrumb,
@@ -33,13 +33,10 @@ import {
createPartyBreadcrumbTrail, createPartyBreadcrumbTrail,
nextPartyBreadcrumbFollowWaypoint, nextPartyBreadcrumbFollowWaypoint,
partyFormationPosition, partyFormationPosition,
partyMadeStuckProgress,
partyMovementProfile, partyMovementProfile,
partyNaturalFollowPosition, partyNaturalFollowPosition,
partyPersonalizedCombatMovementGoal, partyPersonalizedCombatMovementGoal,
partyPresentationY, partyPresentationY,
partySeparationGoal,
partyShouldRecallForStuck,
partyShouldMoveToComfortTarget, partyShouldMoveToComfortTarget,
personalizedPartyCombatRangeBand, personalizedPartyCombatRangeBand,
resetPartyBreadcrumbTrail, resetPartyBreadcrumbTrail,
@@ -48,6 +45,15 @@ import {
type PartyBreadcrumbCursor, type PartyBreadcrumbCursor,
type PartyMovementProfile, type PartyMovementProfile,
} from "../game/partyMovement"; } from "../game/partyMovement";
import {
applyPartyStuckRecoveryLanding,
choosePartyStuckRecoveryPosition,
createPartyStuckRecoveryState,
markPartyStuckRecovered,
resetPartyStuckRecoveryProgress,
updatePartyStuckRecovery,
type PartyStuckRecoveryState,
} from "../game/partyStuckRecovery";
import { import {
findNearestPartyNavigationSurface, findNearestPartyNavigationSurface,
type PartyNavigationGraph, type PartyNavigationGraph,
@@ -66,10 +72,14 @@ import {
type PartyRouteState, type PartyRouteState,
} from "../game/partyPathing"; } from "../game/partyPathing";
import { partyCombatRangeBand } from "../game/partyRuntime"; import { partyCombatRangeBand } from "../game/partyRuntime";
import { partyRejoinMovement } from "../game/partyRejoin";
import { useWailingEncounterStore } from "../game/wailingCavernsEncounter"; import { useWailingEncounterStore } from "../game/wailingCavernsEncounter";
import { import {
clearPartyRuntimeAttackCorridor, clearPartyRuntimeAttackCorridor,
consumePartyRuntimeRejoin,
finishPartyRuntimeRejoin,
getPartyRuntimePosition, getPartyRuntimePosition,
partyRuntimeIsRejoining,
registerPartyRuntimePosition, registerPartyRuntimePosition,
updatePartyRuntimeAttackCorridor, updatePartyRuntimeAttackCorridor,
updatePartyRuntimePosition, updatePartyRuntimePosition,
@@ -84,20 +94,20 @@ import { registerCombatSpatialProvider } from "../game/combatSpatial";
import { PLAYER_AGGRO_ID } from "../game/aggro"; import { PLAYER_AGGRO_ID } from "../game/aggro";
import { hasDungeonLineOfSight } from "./dungeonLineOfSight"; import { hasDungeonLineOfSight } from "./dungeonLineOfSight";
import { registerCombatEffectAnchor } from "./combatEffectAnchorRegistry"; import { registerCombatEffectAnchor } from "./combatEffectAnchorRegistry";
import { ACTOR_TERRAIN_QUERY_GROUPS } from "./actorCollision";
import {
createPartyDirectSegmentValidator,
createPartyRouteSegmentValidator,
createPartyRecoveryPointValidator,
PARTY_FLOOR_RAY_DEPTH,
PARTY_FLOOR_RAY_HEIGHT,
} from "./partyTraversal";
const PARTY_MOVE_SPEED = 5.25; const PARTY_MOVE_SPEED = 5.25;
const PARTY_CATCH_UP_SPEED = 7.5; const PARTY_CATCH_UP_SPEED = 7.5;
const BREADCRUMB_REACHED_DISTANCE = 0.04; const BREADCRUMB_REACHED_DISTANCE = 0.04;
const PARTY_COLLISION_RADIUS = 0.34;
const PARTY_COLLISION_HALF_HEIGHT = 0.48;
const PARTY_COLLISION_CENTER_HEIGHT = PARTY_COLLISION_RADIUS + PARTY_COLLISION_HALF_HEIGHT + 0.04;
const PARTY_WALKABLE_STEP_HEIGHT = 0.32;
const PARTY_OBJECTIVE_APPROACH_SNAP_DISTANCE = 15; const PARTY_OBJECTIVE_APPROACH_SNAP_DISTANCE = 15;
const PARTY_NAVIGATION_SNAP_DISTANCE = 6; const PARTY_NAVIGATION_SNAP_DISTANCE = 6;
const PARTY_FLOOR_SAMPLE_SPACING = 0.6;
const PARTY_FLOOR_RAY_HEIGHT = 1.25;
const PARTY_FLOOR_RAY_DEPTH = 2;
const PARTY_MAX_FLOOR_DELTA = 0.55;
const PARTY_MAX_GROUND_CORRECTION = 0.85; const PARTY_MAX_GROUND_CORRECTION = 0.85;
const EMPTY_NAVIGATION_LINKS = [] as const; const EMPTY_NAVIGATION_LINKS = [] as const;
@@ -114,11 +124,6 @@ interface PartyAvatarMetrics {
readonly hitCenterY: number; readonly hitCenterY: number;
} }
interface PartyStuckProgress {
readonly position: MutablePartyWorldPosition;
lastProgressAt: number;
}
interface PartyCombatAnchor { interface PartyCombatAnchor {
readonly targetId: string; readonly targetId: string;
readonly bearing: number; readonly bearing: number;
@@ -201,16 +206,18 @@ class NavigationAssetBoundary extends Component<NavigationAssetBoundaryProps, Na
function PartyNavigationLoader({ function PartyNavigationLoader({
url, url,
offMeshLinks, offMeshLinks,
ramps,
onReady, onReady,
}: { }: {
readonly url: string; readonly url: string;
readonly offMeshLinks: ReturnType<typeof requireDungeonDefinition>["navigationLinks"]; readonly offMeshLinks: ReturnType<typeof requireDungeonDefinition>["navigationLinks"];
readonly ramps: readonly DungeonTraversalRamp[];
readonly onReady: (graph: PartyNavigationGraph) => void; readonly onReady: (graph: PartyNavigationGraph) => void;
}) { }) {
const gltf = useGLTF(resolveContentUrl(url)); const gltf = useGLTF(resolveContentUrl(url));
const graph = useMemo( const graph = useMemo(
() => preparePartyNavigationGraph(url, gltf.scene, offMeshLinks), () => preparePartyNavigationGraph(url, gltf.scene, offMeshLinks, ramps),
[gltf.scene, offMeshLinks, url], [gltf.scene, offMeshLinks, ramps, url],
); );
useEffect(() => onReady(graph), [graph, onReady]); useEffect(() => onReady(graph), [graph, onReady]);
@@ -289,16 +296,6 @@ function PartyActors({
); );
const resetRevision = useGameStore((state) => state.resetRevision); const resetRevision = useGameStore((state) => state.resetRevision);
const { rapier, world } = useRapier(); const { rapier, world } = useRapier();
const navigationCapsule = useMemo(
// Trim the lower leg band while keeping the capsule top at normal head
// height. This supplies step clearance without demanding extra ceiling.
() => new rapier.Capsule(
PARTY_COLLISION_HALF_HEIGHT - PARTY_WALKABLE_STEP_HEIGHT * 0.5,
PARTY_COLLISION_RADIUS,
),
[rapier],
);
const navigationRotation = useMemo(() => new Quaternion(), []);
const presentationY = useCallback((position: PartyWorldPosition): number => { const presentationY = useCallback((position: PartyWorldPosition): number => {
const ray = new rapier.Ray({ const ray = new rapier.Ray({
x: position[0], x: position[0],
@@ -309,92 +306,28 @@ function PartyActors({
ray, ray,
PARTY_FLOOR_RAY_DEPTH, PARTY_FLOOR_RAY_DEPTH,
true, true,
rapier.QueryFilterFlags.EXCLUDE_SENSORS | rapier.QueryFilterFlags.EXCLUDE_DYNAMIC, rapier.QueryFilterFlags.EXCLUDE_SENSORS,
ACTOR_TERRAIN_QUERY_GROUPS,
); );
const floorY = floor ? ray.origin.y - floor.timeOfImpact : null; const floorY = floor ? ray.origin.y - floor.timeOfImpact : null;
return partyPresentationY(position[1], floorY, PARTY_MAX_GROUND_CORRECTION); return partyPresentationY(position[1], floorY, PARTY_MAX_GROUND_CORRECTION);
}, [rapier, world]); }, [rapier, world]);
const directSegmentAllowed = useCallback(( const directSegmentAllowed = useMemo(
start: PartyWorldPosition, () => createPartyDirectSegmentValidator(rapier, world),
end: PartyWorldPosition, [rapier, world],
): boolean => {
const dx = end[0] - start[0];
const dy = end[1] - start[1];
const dz = end[2] - start[2];
const distance = Math.hypot(dx, dy, dz);
if (!Number.isFinite(distance)) return false;
const planarDistance = Math.hypot(dx, dz);
// Collision and navigation surfaces commonly disagree by a few centimeters.
// Treat a same-column height correction as grounding, not a blocked move.
if (planarDistance <= 0.04) {
return Math.abs(dy) <= PARTY_WALKABLE_STEP_HEIGHT;
}
const queryFlags = rapier.QueryFilterFlags.EXCLUDE_SENSORS
| rapier.QueryFilterFlags.EXCLUDE_DYNAMIC;
const obstacle = world.castShape(
{
x: start[0],
// Raise only the trimmed lower band over walkable relief. The floor
// samples below still reject real ledges and excessive elevation.
y: start[1] + PARTY_COLLISION_CENTER_HEIGHT + PARTY_WALKABLE_STEP_HEIGHT * 0.5,
z: start[2],
},
navigationRotation,
{ x: dx / distance, y: dy / distance, z: dz / distance },
navigationCapsule,
0.025,
distance,
false,
queryFlags,
); );
if (obstacle && obstacle.time_of_impact < distance - 0.04) return false;
// A clear chest-height cast is insufficient across a ledge. Require the
// collision floor to remain close to the planned segment at short samples.
const sampleCount = Math.max(1, Math.ceil(distance / PARTY_FLOOR_SAMPLE_SPACING));
for (let sample = 0; sample <= sampleCount; sample += 1) {
const progress = sample / sampleCount;
const expectedY = start[1] + dy * progress;
const ray = new rapier.Ray({
x: start[0] + dx * progress,
y: expectedY + PARTY_FLOOR_RAY_HEIGHT,
z: start[2] + dz * progress,
}, { x: 0, y: -1, z: 0 });
const floor = world.castRay(ray, PARTY_FLOOR_RAY_DEPTH, true, queryFlags);
if (!floor) return false;
const floorY = ray.origin.y - floor.timeOfImpact;
if (Math.abs(floorY - expectedY) > PARTY_MAX_FLOOR_DELTA) return false;
}
return true;
}, [navigationCapsule, navigationRotation, rapier, world]);
const hasStaticLineOfSight = useCallback(( const hasStaticLineOfSight = useCallback((
start: PartyWorldPosition, start: PartyWorldPosition,
end: PartyWorldPosition, end: PartyWorldPosition,
): boolean => hasDungeonLineOfSight(rapier, world, start, end), [rapier, world]); ): boolean => hasDungeonLineOfSight(rapier, world, start, end), [rapier, world]);
const routeSurfaceSegmentAllowed = useCallback(( const routeSurfaceSegmentAllowed = useMemo(
start: PartyWorldPosition, () => createPartyRouteSegmentValidator(rapier, world),
end: PartyWorldPosition, [rapier, world],
): boolean => { );
const dx = end[0] - start[0]; const recoveryPointAllowed = useMemo(
const dy = end[1] - start[1]; () => createPartyRecoveryPointValidator(rapier, world, navigationGraph),
const dz = end[2] - start[2]; [rapier, world, navigationGraph],
const distance = Math.hypot(dx, dy, dz); );
if (!Number.isFinite(distance)) return false;
if (distance <= 0.04) return true;
const queryFlags = rapier.QueryFilterFlags.EXCLUDE_SENSORS
| rapier.QueryFilterFlags.EXCLUDE_DYNAMIC;
// Portal-centered paths already stay inside walkable triangles. One
// body-center ray catches a true wall/overhang without treating the floor
// or a tessellation edge as missing agent-radius clearance. Failed
// triangles are excluded and A* searches another corridor.
const ray = new rapier.Ray({
x: start[0],
y: start[1] + 0.9,
z: start[2],
}, { x: dx / distance, y: dy / distance, z: dz / distance });
const hit = world.castRay(ray, distance, true, queryFlags);
return !hit || hit.timeOfImpact >= distance - 0.04;
}, [rapier, world]);
// Authored-link graphs use their eroded Recast node edges directly. Their // Authored-link graphs use their eroded Recast node edges directly. Their
// boundary vertices are already agent-radius safe, while a center ray can // 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. // falsely read the stair floor as a wall across a short vertical seam.
@@ -422,7 +355,7 @@ function PartyActors({
const lastLeaderWaypointRef = useRef<MutablePartyWorldPosition | null>(null); const lastLeaderWaypointRef = useRef<MutablePartyWorldPosition | null>(null);
const lastCommandRef = useRef(usePartyStore.getState().command); const lastCommandRef = useRef(usePartyStore.getState().command);
const lastRecallRevisionRef = useRef(usePartyStore.getState().recallRevision); 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 movementProfilesRef = useRef(new Map<string, PartyMovementProfile>());
const directFollowMovingRef = useRef(new Map<string, boolean>()); const directFollowMovingRef = useRef(new Map<string, boolean>());
const combatAnchorsRef = useRef(new Map<string, PartyCombatAnchor>()); const combatAnchorsRef = useRef(new Map<string, PartyCombatAnchor>());
@@ -459,6 +392,7 @@ function PartyActors({
return { id: actorId, position: mobPosition, yaw }; return { id: actorId, position: mobPosition, yaw };
}, },
hasLineOfSight: hasStaticLineOfSight, hasLineOfSight: hasStaticLineOfSight,
navigationGraph: () => navigationGraphRef.current,
projectGroundPoint: (requested, maximumDistance, requestedOrigin) => { projectGroundPoint: (requested, maximumDistance, requestedOrigin) => {
const origin = requestedOrigin ?? useGameStore.getState().playerPosition; const origin = requestedOrigin ?? useGameStore.getState().playerPosition;
const dx = requested[0] - origin[0]; const dx = requested[0] - origin[0];
@@ -472,7 +406,8 @@ function PartyActors({
ray, ray,
20, 20,
true, true,
rapier.QueryFilterFlags.EXCLUDE_SENSORS | rapier.QueryFilterFlags.EXCLUDE_DYNAMIC, rapier.QueryFilterFlags.EXCLUDE_SENSORS,
ACTOR_TERRAIN_QUERY_GROUPS,
); );
return hit ? [x, ray.origin.y - hit.timeOfImpact, z] : null; return hit ? [x, ray.origin.y - hit.timeOfImpact, z] : null;
}, },
@@ -488,6 +423,7 @@ function PartyActors({
: null; : null;
const recallPosition = navigationRecall?.position ?? player; const recallPosition = navigationRecall?.position ?? player;
party.members.forEach((member, index) => { party.members.forEach((member, index) => {
finishPartyRuntimeRejoin(member.id);
const position = positionsRef.current.get(member.id) ?? initialMemberPosition(index); const position = positionsRef.current.get(member.id) ?? initialMemberPosition(index);
// Attach a recalled squad to the reviewed surface just as initial // Attach a recalled squad to the reviewed surface just as initial
// spawns do. An exact visual-floor point can sit between Recast stair // spawns do. An exact visual-floor point can sit between Recast stair
@@ -502,10 +438,9 @@ function PartyActors({
if (movingSignal) movingSignal.current = false; if (movingSignal) movingSignal.current = false;
const body = bodyRefs.current[index]; const body = bodyRefs.current[index];
if (body) body.position.y = 0; if (body) body.position.y = 0;
stuckProgressRef.current.set(member.id, { const progress = stuckProgressRef.current.get(member.id) ?? createPartyStuckRecoveryState(position, now);
position: [position[0], position[1], position[2]], markPartyStuckRecovered(progress, position, now);
lastProgressAt: now, stuckProgressRef.current.set(member.id, progress);
});
}); });
const firstLiving = party.members.find((member) => member.health > 0); const firstLiving = party.members.find((member) => member.health > 0);
resetPartyBreadcrumbTrail(playerTrailRef.current, player); resetPartyBreadcrumbTrail(playerTrailRef.current, player);
@@ -588,6 +523,7 @@ function PartyActors({
members.forEach((member, index) => { members.forEach((member, index) => {
const position = positionsRef.current.get(member.id) ?? initialMemberPosition(index); const position = positionsRef.current.get(member.id) ?? initialMemberPosition(index);
const slot = spawnSlots[index + 1] ?? spawnSlots[0]; const slot = spawnSlots[index + 1] ?? spawnSlots[0];
finishPartyRuntimeRejoin(member.id);
position[0] = slot.footPosition[0]; position[0] = slot.footPosition[0];
position[1] = slot.footPosition[1]; position[1] = slot.footPosition[1];
position[2] = slot.footPosition[2]; position[2] = slot.footPosition[2];
@@ -646,7 +582,7 @@ function PartyActors({
// Time spent in a pause/menu must not count toward the stuck watchdog. // Time spent in a pause/menu must not count toward the stuck watchdog.
useEffect(() => { useEffect(() => {
stuckProgressRef.current.clear(); for (const state of stuckProgressRef.current.values()) resetPartyStuckRecoveryProgress(state);
}, [active]); }, [active]);
useEffect(() => clearPartyRuntimeAttackCorridor, []); useEffect(() => clearPartyRuntimeAttackCorridor, []);
@@ -674,6 +610,30 @@ function PartyActors({
const playerNavigation = game.playerNavigationPosition; const playerNavigation = game.playerNavigationPosition;
appendPartyBreadcrumb(playerTrailRef.current, playerNavigation); appendPartyBreadcrumb(playerTrailRef.current, playerNavigation);
const routeNow = clock.elapsedTime * 1_000; 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 playerTrail = playerTrailRef.current.points;
const latestPlayerTrailPoint = playerTrail[playerTrail.length - 1]; const latestPlayerTrailPoint = playerTrail[playerTrail.length - 1];
const previousPlayerTrailPoint = playerTrail[playerTrail.length - 2]; const previousPlayerTrailPoint = playerTrail[playerTrail.length - 2];
@@ -708,17 +668,15 @@ function PartyActors({
lastLeaderWaypointRef.current = null; lastLeaderWaypointRef.current = null;
} }
const objective = objectives.find((candidate) => candidate.id === party.objectiveBossId) ?? 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 safeDelta = Math.min(delta, 0.05);
const desired: MutablePartyWorldPosition = [0, 0, 0]; const desired: MutablePartyWorldPosition = [0, 0, 0];
const nextPosition: MutablePartyWorldPosition = [0, 0, 0]; const nextPosition: MutablePartyWorldPosition = [0, 0, 0];
const leaderDestination: MutablePartyWorldPosition = [0, 0, 0]; const leaderDestination: MutablePartyWorldPosition = [0, 0, 0];
const naturalFollowTarget: MutablePartyWorldPosition = [0, 0, 0]; const naturalFollowTarget: MutablePartyWorldPosition = [0, 0, 0];
const separatedDesired: MutablePartyWorldPosition = [0, 0, 0];
const livingIndices = party.members const livingIndices = party.members
.map((member, index) => member.health > 0 ? index : -1) .map((member, index) => member.health > 0 ? index : -1)
.filter((index) => index >= 0); .filter((index) => index >= 0);
const livingIds = new Set(livingIndices.map((index) => party.members[index].id));
const movementProfileFor = (memberId: string, memberIndex: number): PartyMovementProfile => { const movementProfileFor = (memberId: string, memberIndex: number): PartyMovementProfile => {
const livingRank = Math.max(0, livingIndices.indexOf(memberIndex)); const livingRank = Math.max(0, livingIndices.indexOf(memberIndex));
const key = `${memberId}|${livingRank}`; const key = `${memberId}|${livingRank}`;
@@ -729,13 +687,6 @@ function PartyActors({
} }
return profile; return profile;
}; };
const peerPositionsFor = (memberId: string): PartyWorldPosition[] => {
const peers: PartyWorldPosition[] = [];
for (const [id, position] of positionsRef.current) {
if (id !== memberId && livingIds.has(id)) peers.push(position);
}
return peers;
};
let leaderHasWaypoint = false; let leaderHasWaypoint = false;
let leaderMovementExpected = false; let leaderMovementExpected = false;
let leaderFollowingPlayer = false; let leaderFollowingPlayer = false;
@@ -790,15 +741,12 @@ function PartyActors({
}; };
combatAnchorsRef.current.set(leader.id, combatAnchor); combatAnchorsRef.current.set(leader.id, combatAnchor);
} }
const peers = peerPositionsFor(leader.id);
const crowded = peers.some((peer) => planarDistance(leaderPosition, peer) < 0.85);
const movementIntent = partyPersonalizedCombatMovementGoal( const movementIntent = partyPersonalizedCombatMovementGoal(
leaderPosition, leaderPosition,
targetPosition, targetPosition,
hasStaticLineOfSight(leaderPosition, targetPosition), hasStaticLineOfSight(leaderPosition, targetPosition),
rangeBand, rangeBand,
combatAnchor.bearing, combatAnchor.bearing,
crowded,
leaderDestination, leaderDestination,
); );
if (movementIntent === "hold") { if (movementIntent === "hold") {
@@ -978,6 +926,13 @@ function PartyActors({
localFinishDistance: leaderLocalFinishDistance, localFinishDistance: leaderLocalFinishDistance,
directSegmentAllowed, directSegmentAllowed,
routeSegmentAllowed: navigationRouteSegmentAllowed, 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,
}, },
); );
} }
@@ -1042,7 +997,6 @@ function PartyActors({
: navigationFailed || Boolean(navigationGraph && routeRef.current.blocked) : navigationFailed || Boolean(navigationGraph && routeRef.current.blocked)
), ),
); );
let autoRecallRequested = false;
for (let index = 0; index < party.members.length; index += 1) { for (let index = 0; index < party.members.length; index += 1) {
const member = party.members[index]; const member = party.members[index];
@@ -1069,7 +1023,8 @@ function PartyActors({
} }
lastHealthRef.current.set(member.id, member.health); lastHealthRef.current.set(member.id, member.health);
if (!actor || !current) continue; 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 memberTargetPosition = memberTargetId ? getMobPosition(memberTargetId) : null;
const canSeeActiveTarget = Boolean( const canSeeActiveTarget = Boolean(
memberTargetId memberTargetId
@@ -1087,6 +1042,26 @@ function PartyActors({
if (member.health <= 0) { if (member.health <= 0) {
const signal = movingSignalsRef.current.get(member.id); const signal = movingSignalsRef.current.get(member.id);
if (signal) signal.current = false; 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") { } else if (party.command === "stop") {
// Holding is represented by retaining the current world-space tuple. // Holding is represented by retaining the current world-space tuple.
} else if (index === leaderIndex) { } else if (index === leaderIndex) {
@@ -1117,15 +1092,12 @@ function PartyActors({
}; };
combatAnchorsRef.current.set(member.id, combatAnchor); combatAnchorsRef.current.set(member.id, combatAnchor);
} }
const peers = peerPositionsFor(member.id);
const crowded = peers.some((peer) => planarDistance(current, peer) < 0.85);
const movementIntent = partyPersonalizedCombatMovementGoal( const movementIntent = partyPersonalizedCombatMovementGoal(
current, current,
memberTargetPosition, memberTargetPosition,
canSeeActiveTarget, canSeeActiveTarget,
rangeBand, rangeBand,
combatAnchor.bearing, combatAnchor.bearing,
crowded,
desired, desired,
); );
movementExpected = movementIntent !== "hold"; movementExpected = movementIntent !== "hold";
@@ -1161,6 +1133,11 @@ function PartyActors({
blockedRetryMs: 800, blockedRetryMs: 800,
directSegmentAllowed, directSegmentAllowed,
routeSegmentAllowed: navigationRouteSegmentAllowed, 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); combatRoutesRef.current.set(member.id, route);
@@ -1323,26 +1300,9 @@ function PartyActors({
} }
const movementProfile = movementProfileFor(member.id, index); const movementProfile = movementProfileFor(member.id, index);
if (hasDesired) {
const peers = peerPositionsFor(member.id);
if (
partySeparationGoal(
current,
desired,
peers,
movementProfile.swayPhase,
separatedDesired,
)
&& directSegmentAllowed(current, separatedDesired)
) {
desired[0] = separatedDesired[0];
desired[1] = separatedDesired[1];
desired[2] = separatedDesired[2];
}
}
const currentPlanarDistance = planarDistance(current, playerNavigation); 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) const speed = (currentPlanarDistance > 8 ? PARTY_CATCH_UP_SPEED : PARTY_MOVE_SPEED)
* movementProfile.speedMultiplier; * movementProfile.speedMultiplier;
const moved = stepPartyPosition(current, desired, speed * safeDelta, nextPosition); const moved = stepPartyPosition(current, desired, speed * safeDelta, nextPosition);
@@ -1372,30 +1332,59 @@ function PartyActors({
let stuckProgress = stuckProgressRef.current.get(member.id); let stuckProgress = stuckProgressRef.current.get(member.id);
if (!stuckProgress) { if (!stuckProgress) {
stuckProgress = { stuckProgress = createPartyStuckRecoveryState(current, routeNow);
position: [current[0], current[1], current[2]],
lastProgressAt: routeNow,
};
stuckProgressRef.current.set(member.id, stuckProgress); stuckProgressRef.current.set(member.id, stuckProgress);
} }
if (member.health <= 0 || party.command === "stop" || !movementExpected) { const recoveryGoal = rejoining ? "resurrection-rejoin:player" : memberTargetPosition ? `mob:${memberTargetId}`
stuckProgress.position[0] = current[0]; : index === leaderIndex ? `objective:${party.objectiveBossId}` : `follow:${leaderMember?.id}`;
stuckProgress.position[1] = current[1]; const recovery = updatePartyStuckRecovery(stuckProgress, {
stuckProgress.position[2] = current[2]; position: current,
stuckProgress.lastProgressAt = routeNow; waypoint: hasDesired ? desired : null,
} else if (partyMadeStuckProgress(stuckProgress.position, current)) { goalKey: `${party.command}|${recoveryGoal}`,
stuckProgress.position[0] = current[0];
stuckProgress.position[1] = current[1];
stuckProgress.position[2] = current[2];
stuckProgress.lastProgressAt = routeNow;
} else if (partyShouldRecallForStuck(
current,
playerNavigation,
movementExpected, movementExpected,
routeNow, health: member.health,
stuckProgress.lastProgressAt, paused: !active || game.paused || game.mapOpen || game.companionOpen,
)) { stopped: party.command === "stop" && !rejoining,
autoRecallRequested = true; 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) { if (Math.hypot(faceX, faceZ) > 0.001) {
@@ -1414,12 +1403,6 @@ function PartyActors({
body.rotation.x = 0; body.rotation.x = 0;
} }
} }
if (autoRecallRequested) {
party.recallParty();
lastRecallRevisionRef.current = usePartyStore.getState().recallRevision;
teleportPartyToPlayer(playerNavigation, routeNow);
return;
}
party.setRouteBlocked(frameRouteBlocked); party.setRouteBlocked(frameRouteBlocked);
}); });
@@ -1542,6 +1525,7 @@ export function PartyPopulation({
const navigationEnabled = Boolean(assets.navigation) const navigationEnabled = Boolean(assets.navigation)
&& shouldLoadPartyNavigation(gameMode, dungeon, manastormPackage); && shouldLoadPartyNavigation(gameMode, dungeon, manastormPackage);
const navigationLinks = manastormPackage ? EMPTY_NAVIGATION_LINKS : dungeon.navigationLinks; const navigationLinks = manastormPackage ? EMPTY_NAVIGATION_LINKS : dungeon.navigationLinks;
const traversalRamps = manastormPackage ? EMPTY_NAVIGATION_LINKS : dungeon.traversalRamps ?? EMPTY_NAVIGATION_LINKS;
const [navigationState, setNavigationState] = useState<{ const [navigationState, setNavigationState] = useState<{
readonly url: string; readonly url: string;
readonly graph: PartyNavigationGraph; readonly graph: PartyNavigationGraph;
@@ -1589,6 +1573,7 @@ export function PartyPopulation({
<PartyNavigationLoader <PartyNavigationLoader
url={assets.navigation} url={assets.navigation}
offMeshLinks={navigationLinks} offMeshLinks={navigationLinks}
ramps={traversalRamps}
onReady={acceptNavigationGraph} onReady={acceptNavigationGraph}
/> />
</Suspense> </Suspense>
+13 -7
View File
@@ -60,6 +60,7 @@ import {
import { activeManastormStageAssetPackage } from "../game/manastormStageLoader"; import { activeManastormStageAssetPackage } from "../game/manastormStageLoader";
import { useGameStore } from "../game/store"; import { useGameStore } from "../game/store";
import { registerCombatEffectAnchor } from "./combatEffectAnchorRegistry"; import { registerCombatEffectAnchor } from "./combatEffectAnchorRegistry";
import { ACTOR_COLLISION_GROUPS, ACTOR_TERRAIN_QUERY_GROUPS } from "./actorCollision";
const PLAYER_RADIUS = 0.36; const PLAYER_RADIUS = 0.36;
const PLAYER_CAPSULE_HALF_HEIGHT = 0.48; const PLAYER_CAPSULE_HALF_HEIGHT = 0.48;
@@ -212,8 +213,8 @@ function PlayerController({
groundRay, groundRay,
groundProbeLength, groundProbeLength,
true, true,
rapier.QueryFilterFlags.EXCLUDE_SENSORS | rapier.QueryFilterFlags.EXCLUDE_DYNAMIC, rapier.QueryFilterFlags.EXCLUDE_SENSORS,
undefined, ACTOR_TERRAIN_QUERY_GROUPS,
undefined, undefined,
body, body,
); );
@@ -267,7 +268,8 @@ function PlayerController({
y: verticalVelocity * movementDeltaSeconds, y: verticalVelocity * movementDeltaSeconds,
z: worldZ * MOVE_SPEED * movementDeltaSeconds, z: worldZ * MOVE_SPEED * movementDeltaSeconds,
}, },
rapier.QueryFilterFlags.EXCLUDE_SENSORS | rapier.QueryFilterFlags.EXCLUDE_DYNAMIC, rapier.QueryFilterFlags.EXCLUDE_SENSORS,
ACTOR_TERRAIN_QUERY_GROUPS,
); );
const computedMovement = characterController.computedMovement(); const computedMovement = characterController.computedMovement();
const nextTranslation = { const nextTranslation = {
@@ -347,7 +349,11 @@ function PlayerController({
spawn.footPosition[2], spawn.footPosition[2],
]} ]}
> >
<CapsuleCollider args={[PLAYER_CAPSULE_HALF_HEIGHT, PLAYER_RADIUS]} friction={0} /> <CapsuleCollider
args={[PLAYER_CAPSULE_HALF_HEIGHT, PLAYER_RADIUS]}
friction={0}
collisionGroups={ACTOR_COLLISION_GROUPS}
/>
<group ref={avatarRef} position={[0, -PLAYER_CENTER_HEIGHT, 0]}> <group ref={avatarRef} position={[0, -PLAYER_CENTER_HEIGHT, 0]}>
<CharacterModel <CharacterModel
identity={identity} identity={identity}
@@ -408,7 +414,7 @@ function ThirdPersonCamera({
// Cast a small sphere instead of a single ray. This behaves like a camera // Cast a small sphere instead of a single ray. This behaves like a camera
// spring arm and keeps the whole near-camera volume out of walls, pillars, // spring arm and keeps the whole near-camera volume out of walls, pillars,
// and low ceilings. The player's own capsule is explicitly ignored. // and low ceilings. All actor capsules are ignored, including the player.
const hit = world.castShape( const hit = world.castShape(
target, target,
shapeRotation, shapeRotation,
@@ -417,8 +423,8 @@ function ThirdPersonCamera({
CAMERA_WALL_GAP, CAMERA_WALL_GAP,
fullDistance, fullDistance,
false, false,
rapier.QueryFilterFlags.EXCLUDE_SENSORS | rapier.QueryFilterFlags.EXCLUDE_DYNAMIC, rapier.QueryFilterFlags.EXCLUDE_SENSORS,
undefined, ACTOR_TERRAIN_QUERY_GROUPS,
undefined, undefined,
body, body,
); );
+4 -1
View File
@@ -116,7 +116,10 @@ function CollisionChunk({
readonly registerPrepared: (url: string, ready: boolean) => void; readonly registerPrepared: (url: string, ready: boolean) => void;
}) { }) {
const gltf = useGameGLTF(url); const gltf = useGameGLTF(url);
const collision = useMemo(() => prepareCollision(gltf.scene), [gltf.scene]); const collision = useMemo(
() => prepareCollision(gltf.scene, definition.traversalRamps),
[definition.traversalRamps, gltf.scene],
);
const trimeshes = useMemo(() => collisionTrimeshes(collision), [collision]); const trimeshes = useMemo(() => collisionTrimeshes(collision), [collision]);
const sightBlockers = useMemo(() => collisionLineOfSightTrimeshes(collision), [collision]); const sightBlockers = useMemo(() => collisionLineOfSightTrimeshes(collision), [collision]);
useEffect(() => () => { useEffect(() => () => {
+114
View File
@@ -0,0 +1,114 @@
import RAPIER from "@dimforge/rapier3d-compat";
import { beforeAll, describe, expect, it } from "vitest";
import { configurePlayerCharacterController, PLAYER_CHARACTER_CONTROLLER_OFFSET } from "../game/playerMovement";
import { isWalkableGroundHit } from "../game/playerJump";
import { ACTOR_COLLISION_GROUPS, ACTOR_TERRAIN_QUERY_GROUPS } from "./actorCollision";
import { DUNGEON_PHYSICAL_COLLISION_GROUPS, DUNGEON_WALL_COLLISION_GROUPS } from "./dungeonLineOfSight";
import { createPartyDirectSegmentValidator, createPartyRouteSegmentValidator } from "./partyTraversal";
const BODY_TYPES = ["fixed", "kinematic", "dynamic"] as const;
type BodyType = typeof BODY_TYPES[number];
beforeAll(async () => {
await RAPIER.init();
});
function actor(world: RAPIER.World, bodyType: BodyType, z = 0, groups = ACTOR_COLLISION_GROUPS) {
const description = bodyType === "fixed"
? RAPIER.RigidBodyDesc.fixed()
: bodyType === "kinematic"
? RAPIER.RigidBodyDesc.kinematicPositionBased()
: RAPIER.RigidBodyDesc.dynamic();
const body = world.createRigidBody(description.setTranslation(0, 0.84, z));
const collider = world.createCollider(
RAPIER.ColliderDesc.capsule(0.48, 0.36)
.setCollisionGroups(groups)
.setActiveCollisionTypes(RAPIER.ActiveCollisionTypes.ALL),
body,
);
return { body, collider };
}
function terrain(world: RAPIER.World, wall = false) {
const floor = world.createCollider(RAPIER.ColliderDesc.cuboid(5, 0.1, 8)
.setTranslation(0, -0.1, 0).setCollisionGroups(DUNGEON_PHYSICAL_COLLISION_GROUPS));
if (wall) world.createCollider(RAPIER.ColliderDesc.cuboid(5, 2, 0.1)
.setTranslation(0, 2, 2.5).setCollisionGroups(DUNGEON_WALL_COLLISION_GROUPS));
return floor;
}
describe("non-blocking combat actors", () => {
it.each(BODY_TYPES)("has no physical contacts with overlapping %s actors", (bodyType) => {
const world = new RAPIER.World({ x: 0, y: 0, z: 0 });
const player = actor(world, "dynamic");
// Party members, mobs, bosses, and remote players use the same actor policy.
const others = ["party", "mob", "boss", "remote-player"].map(() => actor(world, bodyType));
world.step();
for (const other of others) {
world.contactPair(player.collider, other.collider, () => {
throw new Error("Actors must not produce physical contacts");
});
expect(other.body.translation().z).toBeCloseTo(0);
}
expect(player.body.translation().z).toBeCloseTo(0);
world.free();
});
it("detects contacts when an actor mistakenly uses the terrain layer", () => {
const world = new RAPIER.World({ x: 0, y: 0, z: 0 });
const player = actor(world, "dynamic");
const obstacle = actor(world, "fixed", 0.4, DUNGEON_PHYSICAL_COLLISION_GROUPS);
world.step();
let contacts = 0;
world.contactPair(player.collider, obstacle.collider, (manifold) => { contacts += manifold.numContacts(); });
expect(contacts).toBeGreaterThan(0);
world.free();
});
it.each(BODY_TYPES)("walks through %s actors but still stops at a dungeon wall", (bodyType) => {
for (const hasWall of [false, true]) {
const world = new RAPIER.World({ x: 0, y: 0, z: 0 });
terrain(world, hasWall);
actor(world, bodyType);
actor(world, bodyType, -2); // Start overlapped: no depenetration or detour.
const player = actor(world, "kinematic", -2);
const controller = configurePlayerCharacterController(world.createCharacterController(PLAYER_CHARACTER_CONTROLLER_OFFSET));
world.step();
for (let frame = 0; frame < 90; frame += 1) {
controller.computeColliderMovement(player.collider, { x: 0, y: -0.5 / 60, z: 4.8 / 60 },
RAPIER.QueryFilterFlags.EXCLUDE_SENSORS, ACTOR_TERRAIN_QUERY_GROUPS);
const position = player.body.translation();
const movement = controller.computedMovement();
player.body.setNextKinematicTranslation({ x: position.x + movement.x, y: position.y + movement.y, z: position.z + movement.z });
world.step();
}
const position = player.body.translation();
if (hasWall) {
expect(position.z).toBeGreaterThan(1.8);
expect(position.z).toBeLessThan(2.1);
} else expect(position.z).toBeGreaterThan(5);
expect(position.x).toBeCloseTo(0);
expect(position.y).toBeCloseTo(0.84, 1);
world.removeCharacterController(controller);
world.free();
}
});
it.each(BODY_TYPES)("never treats a %s actor as ground, a route obstacle, or a camera wall", (bodyType) => {
const world = new RAPIER.World({ x: 0, y: 0, z: 0 });
const floor = terrain(world);
actor(world, bodyType);
world.step();
const ground = world.castRayAndGetNormal(new RAPIER.Ray({ x: 0, y: 4, z: 0 }, { x: 0, y: -1, z: 0 }),
5, true, RAPIER.QueryFilterFlags.EXCLUDE_SENSORS, ACTOR_TERRAIN_QUERY_GROUPS);
expect(ground?.collider.handle).toBe(floor.handle);
expect(ground?.timeOfImpact).toBeCloseTo(4);
expect(isWalkableGroundHit(ground, 5)).toBe(true);
expect(createPartyDirectSegmentValidator(RAPIER, world)([0, 0, -1.5], [0, 0, 1.5])).toBe(true);
expect(createPartyRouteSegmentValidator(RAPIER, world)([0, 0, -1.5], [0, 0, 1.5])).toBe(true);
expect(world.castShape({ x: 0, y: 0.9, z: -1.5 }, { x: 0, y: 0, z: 0, w: 1 },
{ x: 0, y: 0, z: 1 }, new RAPIER.Ball(0.24), 0.08, 3, false,
RAPIER.QueryFilterFlags.EXCLUDE_SENSORS, ACTOR_TERRAIN_QUERY_GROUPS)).toBeNull();
world.free();
});
});
+15
View File
@@ -0,0 +1,15 @@
import { interactionGroups } from "@react-three/rapier";
export const TERRAIN_COLLISION_GROUP = 0;
export const ACTOR_COLLISION_GROUP = 1;
// Players, companions, mobs, and bosses never block or push one another.
// Any actor capsule added in future must use this layer, not the terrain layer.
export const ACTOR_COLLISION_GROUPS = interactionGroups(
ACTOR_COLLISION_GROUP,
TERRAIN_COLLISION_GROUP,
);
// Movement, ground probes, and camera sweeps see only physical terrain,
// regardless of whether an actor's body is fixed, kinematic, or dynamic.
export const ACTOR_TERRAIN_QUERY_GROUPS = ACTOR_COLLISION_GROUPS;
+3 -2
View File
@@ -1,4 +1,5 @@
import { interactionGroups } from "@react-three/rapier"; import { interactionGroups } from "@react-three/rapier";
import { TERRAIN_COLLISION_GROUP } from "./actorCollision";
import type { import type {
QueryFilterFlags, QueryFilterFlags,
Ray, Ray,
@@ -17,9 +18,9 @@ interface RapierApi {
type WorldPosition = readonly [number, number, number]; type WorldPosition = readonly [number, number, number];
export const DUNGEON_LINE_OF_SIGHT_GROUP = 15; export const DUNGEON_LINE_OF_SIGHT_GROUP = 15;
export const DUNGEON_PHYSICAL_COLLISION_GROUPS = interactionGroups(0); export const DUNGEON_PHYSICAL_COLLISION_GROUPS = interactionGroups(TERRAIN_COLLISION_GROUP);
export const DUNGEON_SIGHT_BLOCKER_COLLISION_GROUPS = interactionGroups(DUNGEON_LINE_OF_SIGHT_GROUP); export const DUNGEON_SIGHT_BLOCKER_COLLISION_GROUPS = interactionGroups(DUNGEON_LINE_OF_SIGHT_GROUP);
export const DUNGEON_WALL_COLLISION_GROUPS = interactionGroups([0, DUNGEON_LINE_OF_SIGHT_GROUP]); export const DUNGEON_WALL_COLLISION_GROUPS = interactionGroups([TERRAIN_COLLISION_GROUP, DUNGEON_LINE_OF_SIGHT_GROUP]);
export const DUNGEON_SIGHT_QUERY_GROUPS = interactionGroups( export const DUNGEON_SIGHT_QUERY_GROUPS = interactionGroups(
DUNGEON_LINE_OF_SIGHT_GROUP, DUNGEON_LINE_OF_SIGHT_GROUP,
DUNGEON_LINE_OF_SIGHT_GROUP, DUNGEON_LINE_OF_SIGHT_GROUP,
+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();
});
});
+112
View File
@@ -0,0 +1,112 @@
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;
const COLLISION_CENTER_HEIGHT = COLLISION_RADIUS + COLLISION_HALF_HEIGHT + 0.04;
const WALKABLE_STEP_HEIGHT = 0.32;
export const PARTY_FLOOR_RAY_HEIGHT = 1.25;
export const PARTY_FLOOR_RAY_DEPTH = 2;
const FLOOR_SAMPLE_SPACING = 0.6;
const MAX_FLOOR_DELTA = 0.55;
/** Shared by live party routing and collision-backed traversal regressions. */
export function createPartyDirectSegmentValidator(rapier: typeof RAPIER, world: World) {
const capsule = new rapier.Capsule(
COLLISION_HALF_HEIGHT - WALKABLE_STEP_HEIGHT * 0.5,
COLLISION_RADIUS,
);
const rotation = { x: 0, y: 0, z: 0, w: 1 };
const flags = rapier.QueryFilterFlags.EXCLUDE_SENSORS;
return (start: PartyWorldPosition, end: PartyWorldPosition): boolean => {
const dx = end[0] - start[0];
const dy = end[1] - start[1];
const dz = end[2] - start[2];
const distance = Math.hypot(dx, dy, dz);
if (!Number.isFinite(distance)) return false;
if (Math.hypot(dx, dz) <= 0.04) return Math.abs(dy) <= WALKABLE_STEP_HEIGHT;
const obstacle = world.castShape(
{ x: start[0], y: start[1] + COLLISION_CENTER_HEIGHT + WALKABLE_STEP_HEIGHT * 0.5, z: start[2] },
rotation,
{ x: dx / distance, y: dy / distance, z: dz / distance },
capsule,
0.025,
distance,
false,
flags,
ACTOR_TERRAIN_QUERY_GROUPS,
);
if (obstacle && obstacle.time_of_impact < distance - 0.04) return false;
const sampleCount = Math.max(1, Math.ceil(distance / FLOOR_SAMPLE_SPACING));
for (let sample = 0; sample <= sampleCount; sample += 1) {
const progress = sample / sampleCount;
const expectedY = start[1] + dy * progress;
const ray = new rapier.Ray({
x: start[0] + dx * progress,
y: expectedY + PARTY_FLOOR_RAY_HEIGHT,
z: start[2] + dz * progress,
}, { x: 0, y: -1, z: 0 });
const floor = world.castRay(ray, PARTY_FLOOR_RAY_DEPTH, true, flags, ACTOR_TERRAIN_QUERY_GROUPS);
if (!floor || Math.abs(ray.origin.y - floor.timeOfImpact - expectedY) > MAX_FLOOR_DELTA) return false;
}
return true;
};
}
/** Triangle corridors need wall/overhang validation, not a second radius erosion. */
export function createPartyRouteSegmentValidator(rapier: typeof RAPIER, world: World) {
const flags = rapier.QueryFilterFlags.EXCLUDE_SENSORS;
return (start: PartyWorldPosition, end: PartyWorldPosition): boolean => {
const dx = end[0] - start[0];
const dy = end[1] - start[1];
const dz = end[2] - start[2];
const distance = Math.hypot(dx, dy, dz);
if (!Number.isFinite(distance)) return false;
if (distance <= 0.04) return true;
const ray = new rapier.Ray({
x: start[0], y: start[1] + 0.9, z: start[2],
}, { x: dx / distance, y: dy / distance, z: dz / distance });
const hit = world.castRay(ray, distance, true, flags, ACTOR_TERRAIN_QUERY_GROUPS);
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;
};
}
+4 -1
View File
@@ -4,6 +4,8 @@ import {
Mesh, Mesh,
type Object3D, type Object3D,
} from "three"; } from "three";
import type { DungeonTraversalRamp } from "../game/dungeonTypes";
import { smoothDungeonStairGeometry } from "../game/dungeonTraversalRamps";
export interface PreparedCollisionTrimesh { export interface PreparedCollisionTrimesh {
readonly id: string; readonly id: string;
@@ -15,7 +17,7 @@ export interface PreparedCollisionTrimesh {
// This deliberately rejects floors, ramps, slopes, and ceilings. // This deliberately rejects floors, ramps, slopes, and ceilings.
const MAX_SIGHT_BLOCKER_NORMAL_Y = 0.5; const MAX_SIGHT_BLOCKER_NORMAL_Y = 0.5;
export function prepareCollision(root: Object3D): Group { export function prepareCollision(root: Object3D, ramps: readonly DungeonTraversalRamp[] = []): Group {
root.updateWorldMatrix(true, true); root.updateWorldMatrix(true, true);
const inverseRootMatrix = root.matrixWorld.clone().invert(); const inverseRootMatrix = root.matrixWorld.clone().invert();
const prepared = new Group(); const prepared = new Group();
@@ -40,6 +42,7 @@ export function prepareCollision(root: Object3D): Group {
); );
} }
geometry.applyMatrix4(inverseRootMatrix.clone().multiply(mesh.matrixWorld)); geometry.applyMatrix4(inverseRootMatrix.clone().multiply(mesh.matrixWorld));
if (ramps.length) smoothDungeonStairGeometry(geometry, ramps);
const preparedMesh = new Mesh(geometry, mesh.material); const preparedMesh = new Mesh(geometry, mesh.material);
preparedMesh.name = mesh.name; preparedMesh.name = mesh.name;
preparedMesh.visible = false; preparedMesh.visible = false;
+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-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-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-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 { opacity: .9; }
.party-frame--player.is-selected { opacity: 1; } .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)); } .party-frame--online { border-color: rgba(79,184,154,.3); background: linear-gradient(100deg, rgba(9,35,31,.94), rgba(11,24,21,.9)); }
+13
View File
@@ -30,6 +30,10 @@ import { ExperienceBar } from "./ExperienceBar";
import { BrandMark, FrontSurface } from "./FrontSurface"; import { BrandMark, FrontSurface } from "./FrontSurface";
import { ShelvedCompanionPanel } from "@shelvedCompanionPanel"; import { ShelvedCompanionPanel } from "@shelvedCompanionPanel";
import { SHELVED_MODES_ENABLED } from "../app/releaseFeatures"; 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 { function safePercent(current: number, maximum: number): number {
if (!Number.isFinite(current) || !Number.isFinite(maximum) || maximum <= 0) return 0; if (!Number.isFinite(current) || !Number.isFinite(maximum) || maximum <= 0) return 0;
@@ -456,6 +460,7 @@ export function CompanionPanel({ snapshot, send, preferControllerBinds = false }
</div> </div>
</article> </article>
</section> </section>
<div className="companion-tactical__overview">
<section className="companion-map-card"> <section className="companion-map-card">
<DungeonMapGraphic position={snapshot.game.playerPosition} yaw={snapshot.game.cameraYaw} compact /> <DungeonMapGraphic position={snapshot.game.playerPosition} yaw={snapshot.game.cameraYaw} compact />
<div> <div>
@@ -464,6 +469,12 @@ export function CompanionPanel({ snapshot, send, preferControllerBinds = false }
<small>{Math.round(snapshot.game.distanceTravelled)} m explored / {heading} degrees</small> <small>{Math.round(snapshot.game.distanceTravelled)} m explored / {heading} degrees</small>
</div> </div>
</section> </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 }}> <div className="companion-tactical__actions" style={{ gridColumn: "1 / 3", minWidth: 0 }}>
<ActionBar <ActionBar
items={actionItems} items={actionItems}
@@ -501,5 +512,7 @@ export function LocalCompanionPanel() {
useGameStore(); useGameStore();
useCombatStore(); useCombatStore();
usePartyStore(); usePartyStore();
useUiLayoutStore();
useDpsMeterStore();
return <CompanionPanel snapshot={currentCompanionSnapshot()} send={executeCompanionCommand} />; 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>;
}
+20 -14
View File
@@ -1,12 +1,13 @@
import { useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode } from "react"; import { useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
import { createPortal } from "react-dom"; 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 { currentUiDevice, useUiLayoutStore } from "../game/uiLayoutStore";
import { useGameStore } from "../game/store"; import { useGameStore } from "../game/store";
import "./uiLayout.css";
const FALLBACKS: Partial<Record<HudElementId, [number, number, number]>> = { const FALLBACKS: Partial<Record<HudElementId, [number, number, number]>> = {
party: [.1, .3, 210], summons: [.27, .25, 150], minimap: [.9, .12, 160], player: [.9, .34, 160], 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], 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], 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], 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. */ /** 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; 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 overlay = useGameStore((state) => state.overlay);
const editing = overlay === "ui-layout"; const editing = editingOverride ?? overlay === "ui-layout";
const state = useUiLayoutStore(); const state = useUiLayoutStore();
const layout = state.draft ?? state.preferences.current[currentUiDevice()]; 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 [origin, setOrigin] = useState<{ x: number; y: number; width: number } | null>(null);
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
const [host, setHost] = useState<Element | null>(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] }); : origin ?? { x: fallback[0], y: fallback[1] });
const width = intrinsicWidth ?? element.width ?? origin?.width ?? fallback[2]; 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(() => { useLayoutEffect(() => {
if (positioned || !ref.current) return; if (positioned || !ref.current) return;
const node = ref.current; const node = ref.current;
const measure = () => { const measure = () => {
const child = node.firstElementChild?.getBoundingClientRect(); 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) { if (child && host && child.width > 0 && child.height > 0) {
setOrigin({ x: (child.left - host.left + child.width / 2) / host.width, setOrigin({ x: (child.left - host.left + child.width / 2) / host.width,
y: (child.top - host.top + child.height / 2) / host.height, width: child.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); if (node.firstElementChild) observer.observe(node.firstElementChild);
window.addEventListener("resize", measure); window.addEventListener("resize", measure);
return () => { observer.disconnect(); window.removeEventListener("resize", measure); }; return () => { observer.disconnect(); window.removeEventListener("resize", measure); };
}, [positioned, children]); }, [positioned, children, hostSelector]);
useLayoutEffect(() => { useLayoutEffect(() => {
const node = ref.current; const node = ref.current;
const host = node?.closest(".game-shell"); const host = node?.closest(hostSelector);
if (!positioned || !node || !host) return; if (!positioned || !node || !host) return;
const measure = () => setBounds({ width: host.clientWidth, height: host.clientHeight, panelWidth: node.offsetWidth, panelHeight: node.offsetHeight }); const measure = () => setBounds({ width: host.clientWidth, height: host.clientHeight, panelWidth: node.offsetWidth, panelHeight: node.offsetHeight });
const observer = new ResizeObserver(measure); const observer = new ResizeObserver(measure);
observer.observe(node); observer.observe(host); measure(); observer.observe(node); observer.observe(host); measure();
return () => observer.disconnect(); return () => observer.disconnect();
}, [positioned, host]); }, [positioned, host, hostSelector]);
const updatePosition = (x: number, y: number) => state.updateDraft((draft) => ({ ...draft, elements: { const updatePosition = (x: number, y: number) => {
...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 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 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 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; 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) => { onPointerMove={(event) => {
if (drag.current?.pointerId !== event.pointerId) return; 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); 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; }} onPointerUp={() => { drag.current = null; }} onPointerCancel={() => { drag.current = null; }} onLostPointerCapture={() => { drag.current = null; }}
+20 -5
View File
@@ -28,12 +28,15 @@ import { Minimap } from "./Minimap";
import { PlayerStatusPanel } from "./PlayerStatusPanel"; import { PlayerStatusPanel } from "./PlayerStatusPanel";
import { EncounterTimeline } from "./EncounterTimeline"; import { EncounterTimeline } from "./EncounterTimeline";
import { PartyFrames } from "./PartyFrames"; import { PartyFrames } from "./PartyFrames";
import { ResurrectionDestination, useResurrectionCheckpoints } from "./ResurrectionDestination";
import type { ResurrectionCheckpoint } from "../game/resurrectionCheckpoints";
import { TimedEffectStrip } from "./TimedEffectStrip"; import { TimedEffectStrip } from "./TimedEffectStrip";
import { AuraStrip } from "./AuraStrip"; import { AuraStrip } from "./AuraStrip";
import { ShelvedHud } from "@shelvedHud"; import { ShelvedHud } from "@shelvedHud";
import { ThreatMeterWidget } from "./ThreatMeterWidget"; import { ThreatMeterWidget } from "./ThreatMeterWidget";
import { BossLootWindow } from "./BossLootWindow"; import { BossLootWindow } from "./BossLootWindow";
import { EditableHudElement } from "./EditableHudElement"; import { EditableHudElement } from "./EditableHudElement";
import { DpsMeter } from "./DpsMeter";
import { DesktopActionBars } from "./DesktopActionBars"; import { DesktopActionBars } from "./DesktopActionBars";
import { currentUiDevice, useUiLayoutStore } from "../game/uiLayoutStore"; import { currentUiDevice, useUiLayoutStore } from "../game/uiLayoutStore";
import { SummonFrames } from "./SummonFrames"; import { SummonFrames } from "./SummonFrames";
@@ -82,12 +85,16 @@ export function DefeatedPrompt({
resurrectionCharges, resurrectionCharges,
onReturn, onReturn,
sharedOnline = false, sharedOnline = false,
checkpoints = [],
}: { }: {
gameMode: "dungeon" | "manastorm"; gameMode: "dungeon" | "manastorm";
resurrectionCharges: number; resurrectionCharges: number;
onReturn: () => void; onReturn: (checkpointId?: string) => void;
sharedOnline?: boolean; sharedOnline?: boolean;
checkpoints?: readonly ResurrectionCheckpoint[];
}) { }) {
const [requestedCheckpoint, setRequestedCheckpoint] = useState("entrance");
const checkpointId = checkpoints.some((checkpoint) => checkpoint.id === requestedCheckpoint) ? requestedCheckpoint : "entrance";
const canReturn = !sharedOnline && (gameMode === "dungeon" || resurrectionCharges > 0); const canReturn = !sharedOnline && (gameMode === "dungeon" || resurrectionCharges > 0);
return ( return (
<section <section
@@ -103,11 +110,16 @@ export function DefeatedPrompt({
Stay down and wait for a party member to resurrect you, or choose to return now. Stay down and wait for a party member to resurrect you, or choose to return now.
</p> </p>
{canReturn ? ( {canReturn ? (
<button type="button" className="button button--primary" onClick={onReturn}> <>
{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" {gameMode === "manastorm"
? `Resurrect now · ${resurrectionCharges} remaining` ? `Resurrect now · ${resurrectionCharges} remaining`
: "Respawn at entrance"} : checkpointId === "entrance" ? "Respawn at entrance" : "Respawn at checkpoint"}
</button> </button>
</>
) : sharedOnline ? ( ) : sharedOnline ? (
<small>Your shared run is still active. A living player can resurrect you.</small> <small>Your shared run is still active. A living player can resurrect you.</small>
) : ( ) : (
@@ -159,6 +171,7 @@ export function Hud() {
const assetStatus = useGameStore((state) => state.assetStatus); const assetStatus = useGameStore((state) => state.assetStatus);
const assetMessage = useGameStore((state) => state.assetMessage); const assetMessage = useGameStore((state) => state.assetMessage);
const activeDungeonId = useGameStore((state) => state.activeDungeonId); const activeDungeonId = useGameStore((state) => state.activeDungeonId);
const resurrectionCheckpoints = useResurrectionCheckpoints();
const dungeon = requireDungeonDefinition(activeDungeonId); const dungeon = requireDungeonDefinition(activeDungeonId);
const encounter = useManastormStore((state) => state.currentEncounter); const encounter = useManastormStore((state) => state.currentEncounter);
const resurrectionCharges = useManastormStore((state) => state.resurrectionCharges); const resurrectionCharges = useManastormStore((state) => state.resurrectionCharges);
@@ -349,12 +362,12 @@ export function Hud() {
mapId: dungeon.mapId, mapId: dungeon.mapId,
areaName, areaName,
}); });
const returnFromDefeat = () => { const returnFromDefeat = (checkpointId?: string) => {
if (gameMode === "manastorm") { if (gameMode === "manastorm") {
resurrectManastormSession({ kind: "player" }); resurrectManastormSession({ kind: "player" });
return; return;
} }
respawnDefeatedPlayer(); respawnDefeatedPlayer(checkpointId ?? "entrance");
}; };
const finishDungeon = () => { const finishDungeon = () => {
useShellStore.getState().saveActiveDungeonRunCheckpoint(null); useShellStore.getState().saveActiveDungeonRunCheckpoint(null);
@@ -364,6 +377,7 @@ export function Hud() {
return ( return (
<div className="hud" style={{ "--game-ui-scale": settings.uiScale } as CSSProperties}> <div className="hud" style={{ "--game-ui-scale": settings.uiScale } as CSSProperties}>
<ShelvedHud /> <ShelvedHud />
<DpsMeter />
<BossLootWindow /> <BossLootWindow />
<EditableHudElement id="completion">{dungeonComplete && health > 0 ? ( <EditableHudElement id="completion">{dungeonComplete && health > 0 ? (
<DungeonCompletePrompt dungeonTitle={dungeon.title} onFinish={finishDungeon} /> <DungeonCompletePrompt dungeonTitle={dungeon.title} onFinish={finishDungeon} />
@@ -501,6 +515,7 @@ export function Hud() {
gameMode={gameMode} gameMode={gameMode}
resurrectionCharges={resurrectionCharges} resurrectionCharges={resurrectionCharges}
onReturn={returnFromDefeat} onReturn={returnFromDefeat}
checkpoints={resurrectionCheckpoints}
sharedOnline={onlineSession !== null} sharedOnline={onlineSession !== null}
/> />
) : null} ) : null}
+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 { classById, raceById } from "../app/characterCatalog";
import { useShellStore } from "../app/shellStore"; import { useShellStore } from "../app/shellStore";
import { useCombatStore } from "../game/combatStore"; import { useCombatStore } from "../game/combatStore";
@@ -15,8 +15,11 @@ import { useOnlineGroupStore } from "../app/onlineGroupStore";
import type { OnlineGroupMember } from "../app/onlineGroupTypes"; import type { OnlineGroupMember } from "../app/onlineGroupTypes";
import { useOnlineSessionStore } from "../app/onlineSessionStore"; import { useOnlineSessionStore } from "../app/onlineSessionStore";
import type { OnlineSessionPlayer } from "../app/onlineSessionTypes"; import type { OnlineSessionPlayer } from "../app/onlineSessionTypes";
import { onlinePlayerActorId } from "../game/onlineSessionRuntime"; import { onlinePlayerActorId, onlineSessionOwnsWorldSimulation } from "../game/onlineSessionRuntime";
import { auraDefinitionIsDispellableDebuff } from "./auraDisplay"; import { auraDefinitionIsDispellableDebuff } from "./auraDisplay";
import { partyResurrectionBlockedByBoss, resurrectDeadPartyMembers } from "../game/partyResurrection";
import { ResurrectionDestination, useResurrectionCheckpoints } from "./ResurrectionDestination";
import { useGameStore } from "../game/store";
const PARTY_COMMANDS: readonly { const PARTY_COMMANDS: readonly {
command: PartyCommand; command: PartyCommand;
@@ -230,6 +233,11 @@ export function PartyFrames() {
.map((aura) => aura.definition.name) .map((aura) => aura.definition.name)
.join(", ")); .join(", "));
const members = usePartyStore((state) => state.members); 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 selectedMemberId = usePartyStore((state) => state.selectedMemberId);
const selfSelected = usePartyStore((state) => state.selfSelected); const selfSelected = usePartyStore((state) => state.selfSelected);
const routeBlocked = usePartyStore((state) => state.routeBlocked); const routeBlocked = usePartyStore((state) => state.routeBlocked);
@@ -321,6 +329,25 @@ export function PartyFrames() {
onSelect={() => selectMember(member.id)} 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> </section>
); );
} }
+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>)} </div>)}
<button type="button" onClick={() => ui.updateDraft((layout) => ({ ...layout, elements: defaultUiLayout().elements }))}>Reset all positions and visibility</button> <button type="button" onClick={() => ui.updateDraft((layout) => ({ ...layout, elements: defaultUiLayout().elements }))}>Reset all positions and visibility</button>
</details> </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> {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) => { <label className="ui-layout-editor__check"><input type="checkbox" checked={draft.wowBars} onChange={(event) => ui.updateDraft((layout) => {
const combat = useCombatStore.getState(); const combat = useCombatStore.getState();
+24
View File
@@ -2,6 +2,7 @@ import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server"; import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { DefeatedPrompt, DungeonCompletePrompt } from "./Hud"; import { DefeatedPrompt, DungeonCompletePrompt } from "./Hud";
import { ResurrectionDestination } from "./ResurrectionDestination";
describe("defeated prompt", () => { describe("defeated prompt", () => {
it("offers an explicit dungeon respawn while explaining that waiting is allowed", () => { it("offers an explicit dungeon respawn while explaining that waiting is allowed", () => {
@@ -25,6 +26,29 @@ describe("defeated prompt", () => {
expect(markup).toContain("No shared resurrection charges remain"); expect(markup).toContain("No shared resurrection charges remain");
expect(markup).not.toContain("Resurrect now"); 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", () => { 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; }
+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");
});
});