Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
03a4e3f51c | ||
|
|
40d9f346d8 | ||
|
|
46c272f889 | ||
|
|
05b25c4341 |
No files matched your search
@@ -2212,3 +2212,230 @@ Vite's existing large-chunk advisory remains informational.
|
|||||||
Effects without source mappings retain fallback. This is representative
|
Effects without source mappings retain fallback. This is representative
|
||||||
desktop browser QA, not exhaustive spell-by-spell fidelity or a physical
|
desktop browser QA, not exhaustive spell-by-spell fidelity or a physical
|
||||||
Android/GPU performance test. No hosted release was published.
|
Android/GPU performance test. No hosted release was published.
|
||||||
|
|
||||||
|
## 2026-09-12 - Stockades party routing and indoor lighting
|
||||||
|
|
||||||
|
- Fixed quantized navigation preparation: normalized integer positions now
|
||||||
|
expand to floats before the GLB node transform is baked. Previously the
|
||||||
|
Stockades world coordinates clamped into roughly [-1, 1], leaving the live
|
||||||
|
party unable to attach to its graph even when an offline route test passed.
|
||||||
|
- Added three reviewed bidirectional links across the 1.537m Recast seams at
|
||||||
|
the entrance/central stairs and the central/north and central/south stairs.
|
||||||
|
The shipping 3,538-triangle graph connects the entrance and all five boss
|
||||||
|
approaches while discarding the remaining small decorative islands.
|
||||||
|
- Added navigation-only review state and compiler-preserved runtime overrides.
|
||||||
|
Stockades navigation is green; its wider encounter, wipe/reset, checkpoint,
|
||||||
|
and completion validation remains unvalidated.
|
||||||
|
- Leader and follower formation paths now use the graph when a breadcrumb
|
||||||
|
rejoin is blocked. Fallback comfort goals and recalls attach to the reviewed
|
||||||
|
surface, preventing an off-boundary stair target from causing a false stuck
|
||||||
|
loop. Combat approach connectors respect each member's personalized range.
|
||||||
|
- Replaced the generic dark expansion palette only for Stockades with warmer,
|
||||||
|
stronger ambient/hemisphere fill and a warm directional light. No dungeon
|
||||||
|
textures or source meshes were changed, and other dungeon palettes remain
|
||||||
|
unchanged pending individual visual review.
|
||||||
|
- Live desktop browser check at 1280 x 720 used offline level-15 Priest
|
||||||
|
Rolecheck as healer with Blood DK tank Morvane, Combat Rogue Vale, Unholy DK
|
||||||
|
Thorne, and Frost Mage Ilyra. Fresh Defend mode showed PARTY 5/5, Recall on the
|
||||||
|
entrance stairs recovered without a route warning, and Attack resumed with
|
||||||
|
visible party movement down the stairs. The tank advanced into combat and
|
||||||
|
took Defias Basic Attack/Kick/Backhand aggro; Backstab and Icy Touch events
|
||||||
|
appeared while the player remained at the entrance.
|
||||||
|
- Inline task screenshots captured the warm, readable stonework/stairs and
|
||||||
|
equipped party silhouettes in Defend and Recall-to-Attack states. The final
|
||||||
|
runtime produced no new navigation errors; the existing deprecated physics
|
||||||
|
initialization warning remains. Temporary diagnostic logging was removed.
|
||||||
|
- Automated validation: all 223 Vitest files passed, 1,373 tests passed, and
|
||||||
|
six existing tests remained skipped. Regressions cover the actual quantized
|
||||||
|
Stockades GLB through the shipping asset-preparation path, normalized integer
|
||||||
|
transforms, formation projection, entrance-to-boss routing, and tank melee
|
||||||
|
approach. This is an entrance/combat smoke pass, not a complete dungeon clear
|
||||||
|
or physical Android test.
|
||||||
|
- Production TypeScript/Vite build, texture/performance audits, bundle
|
||||||
|
verification, and the final release candidate check passed. Existing
|
||||||
|
bundle-size/import advisories remain. No hosted release was published.
|
||||||
|
- The optional all-dungeon functionality audit was stopped after an extended
|
||||||
|
run without output; its existing report files were not changed. Stockades
|
||||||
|
navigation evidence comes from the actual shipping-asset regressions and
|
||||||
|
the live smoke pass above, not a new fleet-wide functionality certification.
|
||||||
|
|
||||||
|
## 2026-09-12 - Stockades stair-ramp follow-up
|
||||||
|
|
||||||
|
- Follow-up player feedback reported companions still sticking near stairs.
|
||||||
|
Actual collision profiles contain approximately 0.44-0.59m risers, above
|
||||||
|
the party sweep's 0.32m lower-leg clearance. Collision-backed regressions
|
||||||
|
reproduce uphill rejection on the original shipping collision GLB.
|
||||||
|
- Added three reviewed package-space traversal proxies for the entrance and
|
||||||
|
both prison-wing stair flights. Collision tread/riser vertices within the
|
||||||
|
reviewed stair relief are reshaped onto smooth ramp planes; upper walls,
|
||||||
|
ceilings, other floors, and geometry outside those footprints remain intact.
|
||||||
|
Source visual/collision GLBs are unchanged. Only prepared walking collision
|
||||||
|
and party navigation use the overrides; the rendered stairs remain steps.
|
||||||
|
- Navigation receives the same ramp planes with 0.05m surface clearance and
|
||||||
|
six new triangles stitching the exact source boundary portals. This replaces
|
||||||
|
the three vertex-only connections with filled triangle corridors, allowing
|
||||||
|
mid-stair replans and recalls to project onto the interior walking surface.
|
||||||
|
The reviewed graph now has 3,544 source triangles and 31 components, retaining
|
||||||
|
entrance-to-all-five-boss routes and discarding minor decorative islands.
|
||||||
|
- Live QA caught a second issue: terrain queries included the player's
|
||||||
|
kinematic capsule. A failing collision-backed regression confirmed false
|
||||||
|
route blockage through a nearby player. Party direct, route, and grounding
|
||||||
|
queries now exclude dynamic/kinematic actor bodies and sensors; crowd spacing
|
||||||
|
remains a separate party-separation concern. Static walls still block paths.
|
||||||
|
- Automated tests exercise shipping normalized/quantized GLBs through runtime
|
||||||
|
preparation, downhill/uphill party capsule checks on all three flights at
|
||||||
|
center and lateral offsets, repeated mid-stair replans, all boss routes with
|
||||||
|
live wall checks, tank melee approach with real collision validation, and
|
||||||
|
player capsule traversal down/back up all three ramps without jumping.
|
||||||
|
- Final desktop browser smoke pass at 1280 x 720 used offline level-15 Priest
|
||||||
|
Rolecheck and the same Blood DK/Combat Rogue/Unholy DK/Frost Mage party.
|
||||||
|
Attack began without ROUTE BLOCKED, companions changed position down the
|
||||||
|
entrance staircase, the tank moved out of the entrance view and took aggro,
|
||||||
|
and Backstab/Icy Touch/Plague Strike combat events appeared. Experience rose
|
||||||
|
from 6,576 to 11,098 as the party defeated trash with the player stationary.
|
||||||
|
Recall brought companions back to the entrance stairs. The unattended tank
|
||||||
|
eventually died during continued combat; this was not a healed full clear.
|
||||||
|
- Inline task screenshots captured the unchanged visible steps, equipped
|
||||||
|
character bodies, warm lighting, and party movement. Both wing flights have
|
||||||
|
collision-backed automated coverage; live end-to-end wing traversal, a full
|
||||||
|
dungeon clear, wipe/checkpoint behavior, and physical Android QA remain open.
|
||||||
|
- Validation: all 224 Vitest files passed, 1,380 tests passed, and six existing
|
||||||
|
tests remained skipped. Production TypeScript/Vite compilation passed using
|
||||||
|
the project's 8GB heap setting; texture/performance audits, static bundle
|
||||||
|
compression, and release-bundle verification passed. Existing build-size
|
||||||
|
advisories and deprecated physics initialization warnings remain. No hosted
|
||||||
|
release was published.
|
||||||
|
|
||||||
|
## 2026-09-12 - Non-blocking players and combat actors
|
||||||
|
|
||||||
|
- Removed party soft separation and crowd-triggered combat repositioning.
|
||||||
|
Companions may overlap or cross one another without push-apart corrections
|
||||||
|
changing their routes. Normal follow formations and role-specific attack
|
||||||
|
ranges remain; a tank already in melee range does not move just because an
|
||||||
|
ally occupies the same position.
|
||||||
|
- Added a shared actor collision layer that interacts only with physical
|
||||||
|
terrain. The player capsule uses this layer. Player controller movement,
|
||||||
|
ground probes, camera sweeps, party direct/route validation, party grounding,
|
||||||
|
and combat ground-position queries use terrain-only filters. Actor body type
|
||||||
|
cannot turn an actor into a floor or movement obstruction. Party members,
|
||||||
|
remote players, mobs, and bosses currently render without physical colliders;
|
||||||
|
any future actor capsule must use the shared actor layer.
|
||||||
|
- Walls, floors, ceilings, dungeon objects, and the Stockades walking ramps
|
||||||
|
retain their terrain collision. Target-selection meshes, combat visibility,
|
||||||
|
damage, and attack-range checks were not removed.
|
||||||
|
- Ten new Rapier regressions cover overlapping actors with no contacts or
|
||||||
|
displacement, movement through fixed/kinematic/dynamic actors (including an
|
||||||
|
overlapped starting position), continued wall/floor collision, and ignoring
|
||||||
|
actors in ground, party-route, and camera queries. A terrain-layer control
|
||||||
|
confirms real actor/terrain contacts are still detected. Stockades shipping-
|
||||||
|
asset regressions now use the live player collision policy and still pass
|
||||||
|
down/up all three ramps without jumping.
|
||||||
|
- Browser smoke pass at 1280 x 720 used offline level-15 Priest Rolecheck with
|
||||||
|
Blood DK, Combat Rogue, Unholy DK, and Frost Mage companions. Inline roster
|
||||||
|
and dungeon screenshots show character bodies, weapons, unchanged visible
|
||||||
|
stairs, and the existing warm lighting. Attack moved companions down from
|
||||||
|
the entrance and out of its view without ROUTE BLOCKED; the tank took aggro
|
||||||
|
and melee/caster combat events continued. Experience rose from 11,098 to
|
||||||
|
13,359. Stop followed by Recall returned the party to the nearby player.
|
||||||
|
Selecting Morvane and casting Power Word: Shield applied the buff successfully.
|
||||||
|
- Live QA was an entrance/trash/recall smoke pass, not a full dungeon clear or
|
||||||
|
a manual traversal of both wings. Actor overlap and passage guarantees are
|
||||||
|
backed by the physics regressions; physical Android and multiplayer QA remain
|
||||||
|
open.
|
||||||
|
- Validation: all 225 Vitest files passed, 1,390 tests passed, and six existing
|
||||||
|
tests remained skipped. The final focused checks passed again after cleanup.
|
||||||
|
Production TypeScript/Vite build, texture/performance audits, static bundle
|
||||||
|
compression, and release-bundle verification passed. Existing chunk-size
|
||||||
|
advisories and deprecated physics initialization warnings remain. No hosted
|
||||||
|
release was published.
|
||||||
|
|
||||||
|
## 2026-09-12 - Individual companion navigation recovery
|
||||||
|
|
||||||
|
- Replaced the old distant, whole-party stuck Recall with an individual
|
||||||
|
navigation watchdog in a separately tested game module. Two seconds without
|
||||||
|
meaningful waypoint progress invalidates that companion's route; five seconds
|
||||||
|
requests a safe recovery landing. Failed replans do not restart the timer,
|
||||||
|
back-and-forth movement cancels forward progress, and dense waypoint chains
|
||||||
|
still count genuine movement around corners.
|
||||||
|
- Recovery prefers a recently visited party/player trail point near the group
|
||||||
|
on the same floor, then falls back to a validated player position. Actual
|
||||||
|
terrain checks require walkable ground, clear companion body space, and a
|
||||||
|
route back to the group. Blocked or invalid landings are not applied. Each
|
||||||
|
companion has an independent ten-second teleport cooldown.
|
||||||
|
- Intentional attack-range holds, casting/channeling, crowd control, Stop,
|
||||||
|
death, inactive simulation, and UI pauses do not accumulate stuck time.
|
||||||
|
Automatic recovery changes only the affected companion's position and routing
|
||||||
|
state; it does not heal, resurrect, reset threat, or teleport other companions.
|
||||||
|
Manual Recall keeps its existing whole-party behavior and party order.
|
||||||
|
- Added regressions for recovery timing, oscillation, replanning, independent
|
||||||
|
cooldowns and member isolation, exclusions, safe-trail/player selection, wall
|
||||||
|
and ceiling rejection, missing floors, and actor-independent landing checks.
|
||||||
|
Shipping Stockades navigation/collision assets validate recovery landings on
|
||||||
|
all three ramps and along approaches to all five boss objectives.
|
||||||
|
- Browser smoke pass at 1280 x 720 used offline Priest Rolecheck and Blood DK,
|
||||||
|
Combat Rogue, Unholy DK, and Frost Mage companions. Inline roster and dungeon
|
||||||
|
screenshots show unchanged character bodies, equipment, stairs, and warm
|
||||||
|
lighting. The first catalog load needed a retry. Attack moved companions down
|
||||||
|
from the entrance without ROUTE BLOCKED, and combat continued with the player
|
||||||
|
stationary. The unattended party eventually died; Stop remained selected
|
||||||
|
after Recall returned the fallen companions without resurrecting them.
|
||||||
|
- The intermittent side-room trap and its automatic recovery were not reproduced
|
||||||
|
live. Recovery triggers and terrain safety are backed by automated tests;
|
||||||
|
extended side-room playtesting, a healed full clear, and physical Android QA
|
||||||
|
remain open. This pass did not modify existing ramp or actor-collision policy.
|
||||||
|
- Validation: all 227 Vitest files passed, 1,416 tests passed, and six existing
|
||||||
|
tests remained skipped. The final 37 focused checks passed after correcting a
|
||||||
|
test-helper type annotation. Production TypeScript/Vite compilation, texture
|
||||||
|
and performance audits, static compression, and bundle verification passed.
|
||||||
|
Existing chunk-size advisories and deprecated physics initialization warnings
|
||||||
|
remain. No hosted release was published.
|
||||||
|
|
||||||
|
## 2026-09-12 - Wall-aware combat pursuit and individual mob recovery
|
||||||
|
|
||||||
|
- Hostile mob actions now require both attack range and terrain line of sight.
|
||||||
|
Losing either cancels a pending hostile cast so pursuit can resume; companion
|
||||||
|
hostile casts also cancel when sight is lost. Self buffs and friendly heals
|
||||||
|
cannot make an otherwise obstructed mob hold an attack position. Proximity
|
||||||
|
aggro also checks sight instead of pulling enemies through nearby walls.
|
||||||
|
- Static enemies, roaming packs, and summoned enemies share separately tested
|
||||||
|
imperative pursuit rules over the already-loaded dungeon navigation surface.
|
||||||
|
Blocked direct approaches use approved routes through doorways. Routes may
|
||||||
|
finish at a reachable attack position only when that position is both in
|
||||||
|
range and visible; off-surface connectors still require body clearance.
|
||||||
|
- Two seconds without meaningful waypoint progress requests a replan. After
|
||||||
|
five seconds, a stuck mob within ten meters of its target may recover to a
|
||||||
|
validated nearby landing on the same floor and within its home leash. Ground,
|
||||||
|
body clearance, route connectivity, attack range, and sight must all pass.
|
||||||
|
This teleport preserves health and threat and has a ten-second cooldown.
|
||||||
|
- Distant, wrong-floor, out-of-leash, or unsafe near-target recovery requests
|
||||||
|
instead reset only that mob's combat state and restore its health. A validated
|
||||||
|
home landing is preferred; otherwise it evades and navigates home without a
|
||||||
|
blind teleport. Ten seconds of evade prevents immediate proximity, damage,
|
||||||
|
or taunt re-engagement. Dead enemies are never resurrected. Casting, control,
|
||||||
|
pauses, inactive simulation, and non-authoritative online clients do not
|
||||||
|
accumulate navigation failures or trigger automatic recovery.
|
||||||
|
- Regression coverage includes range-plus-sight attack/cast gating, immediate
|
||||||
|
cast cancellation, doorway detours, recovery timing and safety, reset/member
|
||||||
|
isolation, cooldown bypass protection, and near-teleport health/threat
|
||||||
|
preservation. Shipping Stockades meshes independently verify real wall
|
||||||
|
occlusion in both directions and clear sight across all three stair ramps.
|
||||||
|
Pursuing mobs traverse each actual ramp uphill and downhill without false
|
||||||
|
teleport/reset recovery. Existing party and player ramp checks still pass.
|
||||||
|
- Browser smoke pass at 1280 x 720 used offline level-16 Priest Rolecheck and
|
||||||
|
Blood DK, Combat Rogue, Unholy DK, and Frost Mage companions. Power Word:
|
||||||
|
Shield applied to Morvane; Attack moved companions down from the entrance
|
||||||
|
and out of its view without ROUTE BLOCKED, and Defias melee/cast events and
|
||||||
|
tank damage followed. Stop plus Recall returned all four living companions
|
||||||
|
to the nearby player while retaining Stop. Inline screenshots show character
|
||||||
|
bodies, equipment, unchanged visible steps, and the existing warm lighting.
|
||||||
|
The browser console had no errors and the existing physics initialization
|
||||||
|
deprecation warning only.
|
||||||
|
- Live QA was a short entrance/combat/recall smoke test, not a healed full clear
|
||||||
|
or a manually reproduced side-room wall trap. Wall-blocked pursuit and mob
|
||||||
|
recovery triggers are backed by automated tests; extended side-room combat,
|
||||||
|
physical Android, and live multiplayer QA remain open. Existing lighting,
|
||||||
|
ramp assets, and non-blocking actor collision policy were not changed.
|
||||||
|
- Validation: all 229 Vitest files passed, 1,444 tests passed, and six existing
|
||||||
|
tests remained skipped. Final production TypeScript/Vite compilation, texture
|
||||||
|
and performance audits, static compression, and bundle verification passed.
|
||||||
|
Existing chunk-size advisories remain. No hosted release was published.
|
||||||
@@ -39,11 +39,11 @@
|
|||||||
"agentMaxSlope": 50
|
"agentMaxSlope": 50
|
||||||
},
|
},
|
||||||
"review": {
|
"review": {
|
||||||
"entrance": "required",
|
"entrance": "approved",
|
||||||
"bossOrder": "required",
|
"bossOrder": "required",
|
||||||
"optionalEncounters": "required",
|
"optionalEncounters": "required",
|
||||||
"presentation": "required",
|
"presentation": "approved",
|
||||||
"offMeshLinks": "required"
|
"offMeshLinks": "approved"
|
||||||
},
|
},
|
||||||
"exceptions": []
|
"exceptions": []
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"dungeonId": "stormwind-stockade",
|
||||||
|
"navigationLinks": [
|
||||||
|
{
|
||||||
|
"id": "entrance-to-central-stairs",
|
||||||
|
"start": [-66.674328, -23.150503, 5.501851],
|
||||||
|
"end": [-67.664446, -23.755576, 4.493397],
|
||||||
|
"bidirectional": true,
|
||||||
|
"authoredReason": "Bridges the reviewed 1.537m Recast seam between the source entrance stairs and the central cell block."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "central-to-north-wing-stairs",
|
||||||
|
"start": [-124.46789, -27.349338, 16.099782],
|
||||||
|
"end": [-125.476344, -27.95441, 17.0899],
|
||||||
|
"bidirectional": true,
|
||||||
|
"authoredReason": "Bridges the reviewed 1.537m Recast seam from the central cell block to the north wing stairs."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "central-to-south-wing-stairs",
|
||||||
|
"start": [-124.46789, -27.349338, -14.703893],
|
||||||
|
"end": [-125.476344, -27.95441, -15.694011],
|
||||||
|
"bidirectional": true,
|
||||||
|
"authoredReason": "Bridges the reviewed 1.537m Recast seam from the central cell block to the south wing stairs."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"traversalRamps": [
|
||||||
|
{
|
||||||
|
"id": "entrance-stair-ramp",
|
||||||
|
"start": [-52.8, -18.16, 0.8],
|
||||||
|
"end": [-72.4, -25.609, 0.8],
|
||||||
|
"width": 10.8,
|
||||||
|
"navigationBridge": {
|
||||||
|
"linkId": "entrance-to-central-stairs",
|
||||||
|
"startPortal": [[-66.674328, -23.150503, -3.904271], [-66.674328, -23.150503, 5.501851]],
|
||||||
|
"endPortal": [[-67.664446, -23.755576, -2.895818], [-67.664446, -23.755576, 4.493397]]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "north-wing-stair-ramp",
|
||||||
|
"start": [-129.17, -25.609, 11.7],
|
||||||
|
"end": [-129.17, -33.944, 32.85],
|
||||||
|
"width": 10.8,
|
||||||
|
"navigationBridge": {
|
||||||
|
"linkId": "central-to-north-wing-stairs",
|
||||||
|
"startPortal": [[-133.878601, -27.349338, 16.099782], [-124.46789, -27.349338, 16.099782]],
|
||||||
|
"endPortal": [[-132.668457, -27.95441, 17.0899], [-125.476344, -27.95441, 17.0899]]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "south-wing-stair-ramp",
|
||||||
|
"start": [-129.17, -25.609, -10.3],
|
||||||
|
"end": [-129.17, -33.944, -31.45],
|
||||||
|
"width": 10.8,
|
||||||
|
"navigationBridge": {
|
||||||
|
"linkId": "central-to-south-wing-stairs",
|
||||||
|
"startPortal": [[-133.878601, -27.349338, -14.703893], [-124.46789, -27.349338, -14.703893]],
|
||||||
|
"endPortal": [[-132.668457, -27.95441, -15.694011], [-125.476344, -27.95441, -15.694011]]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"presentation": {
|
||||||
|
"background": "#171815",
|
||||||
|
"fog": {
|
||||||
|
"color": "#1b1c18",
|
||||||
|
"near": 44
|
||||||
|
},
|
||||||
|
"ambientLight": {
|
||||||
|
"color": "#d8c9aa",
|
||||||
|
"intensity": 0.58
|
||||||
|
},
|
||||||
|
"hemisphereLight": {
|
||||||
|
"skyColor": "#cfc3a5",
|
||||||
|
"groundColor": "#292820",
|
||||||
|
"intensity": 0.86
|
||||||
|
},
|
||||||
|
"directionalLight": {
|
||||||
|
"color": "#ffe0a8",
|
||||||
|
"intensity": 1.1,
|
||||||
|
"offset": [10, 18, -5]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"validation": {
|
||||||
|
"navigation": "green",
|
||||||
|
"report": "PLAYTEST_NOTES.md",
|
||||||
|
"warnings": [
|
||||||
|
"Three reviewed smooth stair collision proxies and filled navigation bridges connect the entrance, central block, and both prison wings; minor decorative navmesh islands remain intentionally discarded.",
|
||||||
|
"Stockades uses a dungeon-specific warm indoor lighting profile because the imported WMO has no baked lightmap or vertex-lighting channel."
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "healer-man",
|
"name": "healer-man",
|
||||||
"version": "0.1.32",
|
"version": "0.1.36",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "healer-man",
|
"name": "healer-man",
|
||||||
"version": "0.1.32",
|
"version": "0.1.36",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@capacitor/android": "8.4.1",
|
"@capacitor/android": "8.4.1",
|
||||||
"@capacitor/core": "8.4.1",
|
"@capacitor/core": "8.4.1",
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "healer-man",
|
"name": "healer-man",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.32",
|
"version": "0.1.36",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --host 0.0.0.0",
|
"dev": "vite --host 0.0.0.0",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"versionName": "0.1.32",
|
"versionName": "0.1.36",
|
||||||
"versionCode": 1032
|
"versionCode": 1036
|
||||||
}
|
}
|
||||||
@@ -8,6 +8,7 @@ import { DEFAULT_AZEROTHCORE_COMMIT } from "./import-azerothcore-world.mjs";
|
|||||||
|
|
||||||
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||||
const recipesRoot = path.join(projectRoot, "dungeon-pipeline", "recipes");
|
const recipesRoot = path.join(projectRoot, "dungeon-pipeline", "recipes");
|
||||||
|
const runtimeOverridesRoot = path.join(projectRoot, "dungeon-pipeline", "runtime-overrides");
|
||||||
const workRoot = path.resolve(process.env.DUNGEON_PIPELINE_WORK ?? path.join(projectRoot, "..", "HealerMan-Storage", "pipeline-work", "dungeons"));
|
const workRoot = path.resolve(process.env.DUNGEON_PIPELINE_WORK ?? path.join(projectRoot, "..", "HealerMan-Storage", "pipeline-work", "dungeons"));
|
||||||
const outputFile = path.join(projectRoot, "src", "game", "generated", "dungeonCampaignCatalog.json");
|
const outputFile = path.join(projectRoot, "src", "game", "generated", "dungeonCampaignCatalog.json");
|
||||||
const gameObjectAssetManifestFile = path.join(
|
const gameObjectAssetManifestFile = path.join(
|
||||||
@@ -1445,13 +1446,13 @@ function finiteBounds(points) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function presentationFor(recipe, assetPackage, environmentMode, bounds) {
|
function presentationFor(recipe, assetPackage, environmentMode, bounds, override = {}) {
|
||||||
const palette = EXPANSION_PRESENTATION[recipe.expansion] ?? EXPANSION_PRESENTATION.classic;
|
const palette = EXPANSION_PRESENTATION[recipe.expansion] ?? EXPANSION_PRESENTATION.classic;
|
||||||
const span = Math.max(
|
const span = Math.max(
|
||||||
bounds.max[0] - bounds.min[0],
|
bounds.max[0] - bounds.min[0],
|
||||||
bounds.max[2] - bounds.min[2],
|
bounds.max[2] - bounds.min[2],
|
||||||
);
|
);
|
||||||
return {
|
const presentation = {
|
||||||
location: assetPackage.location || recipe.title,
|
location: assetPackage.location || recipe.title,
|
||||||
theme: environmentMode === "adt-hybrid" ? "Open-world expedition" : "Instanced dungeon",
|
theme: environmentMode === "adt-hybrid" ? "Open-world expedition" : "Instanced dungeon",
|
||||||
summary: `Fight through ${recipe.title}, defeat its encounter roster, and keep the party alive.`,
|
summary: `Fight through ${recipe.title}, defeat its encounter roster, and keep the party alive.`,
|
||||||
@@ -1480,6 +1481,16 @@ function presentationFor(recipe, assetPackage, environmentMode, bounds) {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
return {
|
||||||
|
...presentation,
|
||||||
|
...override,
|
||||||
|
fog: { ...presentation.fog, ...override.fog },
|
||||||
|
ambientLight: { ...presentation.ambientLight, ...override.ambientLight },
|
||||||
|
hemisphereLight: { ...presentation.hemisphereLight, ...override.hemisphereLight },
|
||||||
|
directionalLight: { ...presentation.directionalLight, ...override.directionalLight },
|
||||||
|
materials: presentation.materials,
|
||||||
|
map: presentation.map,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function compile() {
|
async function compile() {
|
||||||
@@ -1535,6 +1546,19 @@ async function compile() {
|
|||||||
const importsBySlug = new Map(
|
const importsBySlug = new Map(
|
||||||
(fivePlayerImports.imports ?? []).map((entry) => [entry.slug, entry]),
|
(fivePlayerImports.imports ?? []).map((entry) => [entry.slug, entry]),
|
||||||
);
|
);
|
||||||
|
const runtimeOverrides = new Map();
|
||||||
|
for (const recipe of recipes) {
|
||||||
|
const file = path.join(runtimeOverridesRoot, `${recipe.slug}.json`);
|
||||||
|
if (!await exists(file)) continue;
|
||||||
|
const override = await readJson(file);
|
||||||
|
if (override.schemaVersion !== 1 || override.dungeonId !== recipe.slug) {
|
||||||
|
throw new Error(`${recipe.slug}: runtime override identity or schema is invalid.`);
|
||||||
|
}
|
||||||
|
if (override.validation?.navigation === "green" && !(override.navigationLinks?.length > 0)) {
|
||||||
|
throw new Error(`${recipe.slug}: green navigation requires at least one reviewed link.`);
|
||||||
|
}
|
||||||
|
runtimeOverrides.set(recipe.slug, override);
|
||||||
|
}
|
||||||
const drafts = new Map();
|
const drafts = new Map();
|
||||||
const wantedSpellIds = new Set();
|
const wantedSpellIds = new Set();
|
||||||
for (const recipe of recipes) {
|
for (const recipe of recipes) {
|
||||||
@@ -1584,6 +1608,7 @@ async function compile() {
|
|||||||
const coverage = [];
|
const coverage = [];
|
||||||
for (const recipe of recipes) {
|
for (const recipe of recipes) {
|
||||||
const dungeonImport = importsBySlug.get(recipe.slug);
|
const dungeonImport = importsBySlug.get(recipe.slug);
|
||||||
|
const runtimeOverride = runtimeOverrides.get(recipe.slug) ?? {};
|
||||||
// Manastorm packages contain the complete optimized dungeon geometry.
|
// Manastorm packages contain the complete optimized dungeon geometry.
|
||||||
// Reuse them for full dungeons so the install ships one map package.
|
// Reuse them for full dungeons so the install ships one map package.
|
||||||
const assetPackage = packagesByMap.get(recipe.mapId) ?? dungeonImport?.environment;
|
const assetPackage = packagesByMap.get(recipe.mapId) ?? dungeonImport?.environment;
|
||||||
@@ -1951,7 +1976,7 @@ async function compile() {
|
|||||||
difficultyVariants: recipe.difficultyVariants,
|
difficultyVariants: recipe.difficultyVariants,
|
||||||
defaultDifficultyId: recipe.difficultyVariants[0]?.id ?? "normal",
|
defaultDifficultyId: recipe.difficultyVariants[0]?.id ?? "normal",
|
||||||
availability: "available",
|
availability: "available",
|
||||||
presentation: presentationFor(recipe, assetPackage, environmentMode, bounds),
|
presentation: presentationFor(recipe, assetPackage, environmentMode, bounds, runtimeOverride.presentation),
|
||||||
assets: {
|
assets: {
|
||||||
visual: assetPackage.visual.map(assetChunk),
|
visual: assetPackage.visual.map(assetChunk),
|
||||||
collision: assetPackage.collision.map(assetChunk),
|
collision: assetPackage.collision.map(assetChunk),
|
||||||
@@ -1979,7 +2004,26 @@ async function compile() {
|
|||||||
roamingPacks,
|
roamingPacks,
|
||||||
bosses,
|
bosses,
|
||||||
objectiveOrder: bosses,
|
objectiveOrder: bosses,
|
||||||
navigationLinks: [],
|
navigationLinks: (runtimeOverride.navigationLinks ?? []).map((link) => ({
|
||||||
|
id: String(link.id),
|
||||||
|
start: vector(link.start),
|
||||||
|
end: vector(link.end),
|
||||||
|
bidirectional: link.bidirectional !== false,
|
||||||
|
authoredReason: String(link.authoredReason),
|
||||||
|
})),
|
||||||
|
...(runtimeOverride.traversalRamps?.length ? {
|
||||||
|
traversalRamps: runtimeOverride.traversalRamps.map((ramp) => ({
|
||||||
|
id: String(ramp.id),
|
||||||
|
start: vector(ramp.start),
|
||||||
|
end: vector(ramp.end),
|
||||||
|
width: Number(ramp.width),
|
||||||
|
navigationBridge: {
|
||||||
|
linkId: String(ramp.navigationBridge.linkId),
|
||||||
|
startPortal: ramp.navigationBridge.startPortal.map(vector),
|
||||||
|
endPortal: ramp.navigationBridge.endPortal.map(vector),
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
} : {}),
|
||||||
encounters: [compileBossProgressionEncounter(recipe, bosses, syntheticTrash)],
|
encounters: [compileBossProgressionEncounter(recipe, bosses, syntheticTrash)],
|
||||||
serverGameObjects,
|
serverGameObjects,
|
||||||
provenance: {
|
provenance: {
|
||||||
@@ -2009,8 +2053,17 @@ async function compile() {
|
|||||||
},
|
},
|
||||||
validation: {
|
validation: {
|
||||||
status: "unvalidated",
|
status: "unvalidated",
|
||||||
|
...(runtimeOverride.validation?.navigation
|
||||||
|
? { navigation: runtimeOverride.validation.navigation }
|
||||||
|
: {}),
|
||||||
|
...(runtimeOverride.validation?.report
|
||||||
|
? { report: runtimeOverride.validation.report }
|
||||||
|
: {}),
|
||||||
blockers: [],
|
blockers: [],
|
||||||
warnings: sourceWarnings,
|
warnings: [
|
||||||
|
...sourceWarnings,
|
||||||
|
...(runtimeOverride.validation?.warnings ?? []),
|
||||||
|
],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
coverage.push({
|
coverage.push({
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
+81
-13
@@ -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;
|
||||||
@@ -2418,6 +2420,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 +2462,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 +2472,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 +2482,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 +2490,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 +2536,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 +2683,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);
|
||||||
@@ -5656,7 +5661,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 +5681,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);
|
||||||
@@ -6441,7 +6447,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 +6459,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 +6566,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 +6577,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 +6605,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 +6621,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;
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { BufferGeometry, Float32BufferAttribute } from "three";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { DungeonTraversalRamp } from "./dungeonTypes";
|
||||||
|
import { bridgeDungeonStairNavigation, smoothDungeonStairGeometry } from "./dungeonTraversalRamps";
|
||||||
|
|
||||||
|
const RAMP: DungeonTraversalRamp = {
|
||||||
|
id: "stairs",
|
||||||
|
start: [0, 0, 0],
|
||||||
|
end: [0, 2, 6],
|
||||||
|
width: 4,
|
||||||
|
navigationBridge: {
|
||||||
|
linkId: "stair-seam",
|
||||||
|
startPortal: [[-1, 0.5, 2], [1, 0.5, 2]],
|
||||||
|
endPortal: [[-1, 1, 3], [1, 1, 3]],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("reviewed dungeon stair proxies", () => {
|
||||||
|
it("smooths tread and riser vertices but preserves upper walls, ceilings and geometry outside the flight", () => {
|
||||||
|
const geometry = new BufferGeometry();
|
||||||
|
geometry.setAttribute("position", new Float32BufferAttribute([
|
||||||
|
0, 0.5, 2,
|
||||||
|
0, 1, 2,
|
||||||
|
0, 3, 2,
|
||||||
|
0, 5, 2,
|
||||||
|
3, 0.5, 2,
|
||||||
|
0, 0.5, -1,
|
||||||
|
], 3));
|
||||||
|
smoothDungeonStairGeometry(geometry, [RAMP]);
|
||||||
|
const position = geometry.getAttribute("position");
|
||||||
|
expect(position.getY(0)).toBeCloseTo(2 / 3);
|
||||||
|
expect(position.getY(1)).toBeCloseTo(2 / 3);
|
||||||
|
expect([2, 3, 4, 5].map((vertex) => position.getY(vertex))).toEqual([3, 5, 0.5, 0.5]);
|
||||||
|
geometry.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("bridges exactly the existing reviewed portal vertices and rejects stale asset coordinates", () => {
|
||||||
|
const geometry = new BufferGeometry();
|
||||||
|
geometry.setAttribute("position", new Float32BufferAttribute([
|
||||||
|
...RAMP.navigationBridge.startPortal.flat(),
|
||||||
|
...RAMP.navigationBridge.endPortal.flat(),
|
||||||
|
], 3));
|
||||||
|
geometry.setIndex([]);
|
||||||
|
smoothDungeonStairGeometry(geometry, [RAMP], 0.05);
|
||||||
|
bridgeDungeonStairNavigation(geometry, [RAMP], 0.05);
|
||||||
|
expect(Array.from(geometry.index!.array)).toEqual([0, 1, 3, 0, 3, 2]);
|
||||||
|
geometry.getAttribute("position").setX(0, -1.1);
|
||||||
|
expect(() => bridgeDungeonStairNavigation(geometry, [RAMP], 0.05))
|
||||||
|
.toThrow("reviewed navigation portal no longer matches the source GLB");
|
||||||
|
geometry.dispose();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,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);
|
||||||
|
}
|
||||||
@@ -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[];
|
||||||
@@ -209,6 +222,8 @@ export interface DungeonSourceProvenance {
|
|||||||
|
|
||||||
export interface DungeonValidationStatus {
|
export interface DungeonValidationStatus {
|
||||||
readonly status: "green" | "blocked" | "unvalidated";
|
readonly status: "green" | "blocked" | "unvalidated";
|
||||||
|
/** Navigation-only review state; permits routing without overstating encounter validation. */
|
||||||
|
readonly navigation?: "green" | "blocked" | "unvalidated";
|
||||||
readonly report?: string;
|
readonly report?: string;
|
||||||
readonly validatedAt?: string;
|
readonly validatedAt?: string;
|
||||||
readonly blockers: readonly string[];
|
readonly blockers: readonly string[];
|
||||||
@@ -239,6 +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. */
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { createMobPursuit, chooseMobPursuitLanding, type MobPursuitSample, type MobPursuitSpatial } from "./mobPursuit";
|
||||||
|
import type { PartyNavigationGraph } from "./partyNavigation";
|
||||||
|
|
||||||
|
function sample(patch: Partial<MobPursuitSample> = {}): MobPursuitSample {
|
||||||
|
return { id: "mob", targetId: "tank", position: [0, 0, 0], target: [4, 0, 0], home: [0, 0, 0],
|
||||||
|
phase: "chasing", health: 100, engaged: true, active: true, casting: false, controlled: false,
|
||||||
|
attackRange: 1.5, leashRange: 40, maximumStep: 0, nowMs: 0, ...patch };
|
||||||
|
}
|
||||||
|
|
||||||
|
function spatial(patch: Partial<MobPursuitSpatial> = {}): MobPursuitSpatial {
|
||||||
|
return { graph: () => null, hasLineOfSight: () => false, directAllowed: () => false,
|
||||||
|
routeAllowed: () => true, validateLanding: (point) => point, ...patch };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("wall-aware mob pursuit", () => {
|
||||||
|
it("moves toward a blocked target even when the old combat snapshot says attacking", () => {
|
||||||
|
const navigator = createMobPursuit(spatial({ directAllowed: () => true }));
|
||||||
|
const state = sample({ phase: "attacking", attackRange: 6, maximumStep: 0.2 });
|
||||||
|
expect(navigator.step(state)).toBe("none");
|
||||||
|
expect(state.position[0]).toBeCloseTo(0.2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("holds only when range and clear sight agree", () => {
|
||||||
|
const navigator = createMobPursuit(spatial({ hasLineOfSight: () => true, directAllowed: () => true }));
|
||||||
|
const state = sample({ attackRange: 6, maximumStep: 0.2 });
|
||||||
|
navigator.step(state);
|
||||||
|
expect(state.position).toEqual([0, 0, 0]);
|
||||||
|
expect(navigator.step({ ...state, nowMs: 10_000 })).toBe("none");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("routes around a wall through a doorway rather than cutting through it", () => {
|
||||||
|
const positions = [[-2, 0, 0], [-2, 0, 3], [2, 0, 3], [2, 0, 0]] as const;
|
||||||
|
const graph: PartyNavigationGraph = {
|
||||||
|
nodes: positions.map((position, id) => ({ id, position, neighbors: [id - 1, id + 1]
|
||||||
|
.filter((to) => to >= 0 && to < positions.length).map((to) => ({ to, cost: 4 })) })),
|
||||||
|
weldTolerance: 0.001, sourceVertexCount: 4, sourceTriangleCount: 0,
|
||||||
|
weldedNodeCount: 4, componentCount: 1, discardedNodeCount: 0,
|
||||||
|
};
|
||||||
|
const clear = (a: readonly number[], b: readonly number[]) => {
|
||||||
|
if (a[0] * b[0] >= 0) return true;
|
||||||
|
const t = -a[0] / (b[0] - a[0]);
|
||||||
|
return a[2] + (b[2] - a[2]) * t >= 2.5;
|
||||||
|
};
|
||||||
|
const navigator = createMobPursuit(spatial({ graph: () => graph, directAllowed: clear, hasLineOfSight: clear }));
|
||||||
|
const state = sample({ position: [-2, 0, 0], home: [-2, 0, 0], target: [2, 0, 0], maximumStep: 0.1, attackRange: 0.5 });
|
||||||
|
let reachedDoorway = false;
|
||||||
|
for (let frame = 0; frame < 120; frame += 1) {
|
||||||
|
const previous = [...state.position];
|
||||||
|
expect(navigator.step({ ...state, nowMs: frame * 50 })).toBe("none");
|
||||||
|
expect(clear(previous, state.position)).toBe(true);
|
||||||
|
if (state.position[2] >= 2.5) reachedDoorway = true;
|
||||||
|
}
|
||||||
|
expect(reachedDoorway).toBe(true);
|
||||||
|
expect(Math.hypot(state.position[0] - 2, state.position[2])).toBeLessThanOrEqual(0.6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("recovers a nearby stalled mob only after five seconds and preserves a ten-second cooldown", () => {
|
||||||
|
const navigator = createMobPursuit(spatial({ hasLineOfSight: (a) => a[0] > 2 }));
|
||||||
|
const state = sample();
|
||||||
|
navigator.step(state);
|
||||||
|
expect(navigator.step({ ...state, nowMs: 2_000 })).toBe("none");
|
||||||
|
expect(navigator.step({ ...state, nowMs: 4_999 })).toBe("none");
|
||||||
|
expect(navigator.step({ ...state, nowMs: 5_000 })).toBe("teleported");
|
||||||
|
expect(state.position[0]).toBeGreaterThan(2);
|
||||||
|
state.position[0] = 0;
|
||||||
|
navigator.step({ ...state, nowMs: 5_001 });
|
||||||
|
navigator.step({ ...state, nowMs: 7_001 });
|
||||||
|
expect(navigator.step({ ...state, nowMs: 14_999 })).toBe("none");
|
||||||
|
expect(navigator.step({ ...state, nowMs: 15_000 })).toBe("teleported");
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
{ active: false }, { casting: true }, { controlled: true }, { health: 0 }, { engaged: false },
|
||||||
|
])("does not recover intentional immobility %j", (exclusion) => {
|
||||||
|
const navigator = createMobPursuit(spatial());
|
||||||
|
const state = sample(exclusion);
|
||||||
|
navigator.step(state);
|
||||||
|
expect(navigator.step({ ...state, nowMs: 10_000 })).toBe("none");
|
||||||
|
expect(state.position).toEqual([0, 0, 0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
{ target: [12, 0, 0] as const }, { target: [4, 5, 0] as const }, { leashRange: 2 },
|
||||||
|
])("resets instead of teleporting to a distant, wrong-floor, or out-of-leash target %j", (patch) => {
|
||||||
|
const navigator = createMobPursuit(spatial());
|
||||||
|
const state = sample(patch);
|
||||||
|
navigator.step(state);
|
||||||
|
navigator.step({ ...state, nowMs: 2_000 });
|
||||||
|
expect(navigator.step({ ...state, nowMs: 5_000 })).toBe("reset");
|
||||||
|
expect(state.position).toEqual(state.home);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resets when every nearby target landing is blocked without applying an invalid home landing", () => {
|
||||||
|
const navigator = createMobPursuit(spatial({ validateLanding: () => null }));
|
||||||
|
const state = sample({ position: [1, 0, 0] });
|
||||||
|
navigator.step(state);
|
||||||
|
navigator.step({ ...state, nowMs: 2_000 });
|
||||||
|
expect(navigator.step({ ...state, nowMs: 5_000 })).toBe("reset");
|
||||||
|
expect(state.position).toEqual([1, 0, 0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("recovers a stalled return to validated home, never to the old target", () => {
|
||||||
|
const navigator = createMobPursuit(spatial());
|
||||||
|
const state = sample({ engaged: false, phase: "returning", position: [5, 0, 0] });
|
||||||
|
navigator.step(state);
|
||||||
|
navigator.step({ ...state, nowMs: 2_000 });
|
||||||
|
expect(navigator.step({ ...state, nowMs: 5_000 })).toBe("home");
|
||||||
|
expect(state.position).toEqual(state.home);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("bounds landing checks, rejects unsafe projections, and never blindly uses the target", () => {
|
||||||
|
let checks = 0;
|
||||||
|
const result = chooseMobPursuitLanding(sample(), spatial({
|
||||||
|
hasLineOfSight: () => true,
|
||||||
|
validateLanding: () => { checks += 1; return [20, 0, 0]; },
|
||||||
|
}));
|
||||||
|
expect(result).toBeNull();
|
||||||
|
expect(checks).toBe(16);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps different mobs' timers independent", () => {
|
||||||
|
const navigator = createMobPursuit(spatial({ hasLineOfSight: (a) => a[0] > 2 }));
|
||||||
|
const first = sample();
|
||||||
|
const second = sample({ id: "other", position: [0, 0, 0] });
|
||||||
|
navigator.step(first);
|
||||||
|
navigator.step({ ...second, nowMs: 4_000 });
|
||||||
|
navigator.step({ ...first, nowMs: 2_000 });
|
||||||
|
expect(navigator.step({ ...first, nowMs: 5_000 })).toBe("teleported");
|
||||||
|
expect(navigator.step({ ...second, nowMs: 5_000 })).toBe("none");
|
||||||
|
expect(second.position).toEqual([0, 0, 0]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import type { PartyNavigationGraph } from "./partyNavigation";
|
||||||
|
import { distanceSquared, moveMobToward, type MobAiPhase, type MobAiPosition, type MutableMobAiPosition } from "./mobAi";
|
||||||
|
import { emptyPartyRouteState, nextPartyRouteRecoveryWaypoint, nextPartyRouteWaypoint, partyRouteNeedsPlanning, planPartyRoute } from "./partyPathing";
|
||||||
|
import { createPartyStuckRecoveryState, markPartyStuckRecovered, updatePartyStuckRecovery, type PartyStuckRecoveryState } from "./partyStuckRecovery";
|
||||||
|
|
||||||
|
export const MOB_NEAR_RECOVERY_DISTANCE = 10;
|
||||||
|
const SAME_FLOOR_TOLERANCE = 1.25;
|
||||||
|
|
||||||
|
export interface MobPursuitSample {
|
||||||
|
readonly id: string;
|
||||||
|
readonly targetId: string | null;
|
||||||
|
readonly position: MutableMobAiPosition;
|
||||||
|
readonly target: MobAiPosition;
|
||||||
|
readonly home: MobAiPosition;
|
||||||
|
readonly phase: MobAiPhase;
|
||||||
|
readonly health: number;
|
||||||
|
readonly engaged: boolean;
|
||||||
|
readonly active: boolean;
|
||||||
|
readonly casting: boolean;
|
||||||
|
readonly controlled: boolean;
|
||||||
|
readonly attackRange: number;
|
||||||
|
readonly leashRange: number;
|
||||||
|
readonly maximumStep: number;
|
||||||
|
readonly nowMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MobPursuitSpatial {
|
||||||
|
readonly graph: () => PartyNavigationGraph | null;
|
||||||
|
readonly hasLineOfSight: (from: MobAiPosition, to: MobAiPosition) => boolean;
|
||||||
|
readonly directAllowed: (from: MobAiPosition, to: MobAiPosition) => boolean;
|
||||||
|
readonly routeAllowed: (from: MobAiPosition, to: MobAiPosition) => boolean;
|
||||||
|
readonly validateLanding: (point: MobAiPosition, anchor: MobAiPosition) => MobAiPosition | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Never blindly land inside the target, a wall, or an overlapping dungeon floor. */
|
||||||
|
export function chooseMobPursuitLanding(sample: MobPursuitSample, spatial: MobPursuitSpatial): MobAiPosition | null {
|
||||||
|
const { position, target, home } = sample;
|
||||||
|
if (distanceSquared(position, target) > MOB_NEAR_RECOVERY_DISTANCE ** 2
|
||||||
|
|| Math.abs(position[1] - target[1]) > SAME_FLOOR_TOLERANCE
|
||||||
|
|| distanceSquared(home, target) > sample.leashRange ** 2) return null;
|
||||||
|
const bearing = Math.atan2(position[0] - target[0], position[2] - target[2]);
|
||||||
|
const radius = Math.max(0.85, Math.min(2, sample.attackRange * 0.6));
|
||||||
|
for (let offset = 0; offset < 16; offset += 1) {
|
||||||
|
const angle = bearing + offset * Math.PI / 8;
|
||||||
|
const candidate: MobAiPosition = [target[0] + Math.sin(angle) * radius, target[1], target[2] + Math.cos(angle) * radius];
|
||||||
|
const landing = spatial.validateLanding(candidate, target);
|
||||||
|
if (landing?.every(Number.isFinite) && distanceSquared(landing, candidate) <= 0.85 ** 2
|
||||||
|
&& distanceSquared(landing, target) <= sample.attackRange ** 2
|
||||||
|
&& distanceSquared(landing, home) <= sample.leashRange ** 2
|
||||||
|
&& Math.abs(landing[1] - target[1]) <= SAME_FLOOR_TOLERANCE
|
||||||
|
&& spatial.hasLineOfSight(landing, target)) return [...landing];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shared imperative adapter for static spawns, roaming packs, and summoned enemies. */
|
||||||
|
export function createMobPursuit(spatial: MobPursuitSpatial) {
|
||||||
|
const states = new Map<string, {
|
||||||
|
route: ReturnType<typeof emptyPartyRouteState>;
|
||||||
|
watchdog: PartyStuckRecoveryState;
|
||||||
|
graph: PartyNavigationGraph | null;
|
||||||
|
}>();
|
||||||
|
const desired: MutableMobAiPosition = [0, 0, 0];
|
||||||
|
const next: MutableMobAiPosition = [0, 0, 0];
|
||||||
|
return {
|
||||||
|
clear: () => states.clear(),
|
||||||
|
step(sample: MobPursuitSample): "none" | "teleported" | "reset" | "home" {
|
||||||
|
const { position, target, home, nowMs } = sample;
|
||||||
|
if (![...position, ...target, ...home, nowMs, sample.attackRange, sample.leashRange, sample.maximumStep].every(Number.isFinite)) return "none";
|
||||||
|
let state = states.get(sample.id);
|
||||||
|
if (!state) {
|
||||||
|
state = { route: emptyPartyRouteState(), watchdog: createPartyStuckRecoveryState(position, nowMs), graph: spatial.graph() };
|
||||||
|
states.set(sample.id, state);
|
||||||
|
}
|
||||||
|
const returning = sample.phase === "returning";
|
||||||
|
const goal = returning ? home : target;
|
||||||
|
const visible = spatial.hasLineOfSight(position, target);
|
||||||
|
const inRange = visible && distanceSquared(position, target) <= sample.attackRange ** 2;
|
||||||
|
const movementExpected = returning ? distanceSquared(position, home) > 0.2 ** 2 : sample.engaged && !inRange;
|
||||||
|
const excluded = !sample.active || sample.health <= 0 || sample.controlled || sample.casting;
|
||||||
|
const goalKey = returning ? "return-home" : `target:${sample.targetId}`;
|
||||||
|
const graph = spatial.graph();
|
||||||
|
if (state.graph !== graph) {
|
||||||
|
state.graph = graph;
|
||||||
|
state.route = emptyPartyRouteState();
|
||||||
|
}
|
||||||
|
let waypoint: MobAiPosition | null = null;
|
||||||
|
let approvedRouteStep = false;
|
||||||
|
if (movementExpected && !excluded) {
|
||||||
|
if (spatial.directAllowed(position, goal)) waypoint = goal;
|
||||||
|
else {
|
||||||
|
if (partyRouteNeedsPlanning(state.route, goalKey, position, goal, nowMs, { replanIntervalMs: 350, blockedRetryMs: 800 })) {
|
||||||
|
state.route = planPartyRoute(graph, goalKey, position, goal, nowMs, {
|
||||||
|
maxOriginSnapDistance: 3, maxDestinationSnapDistance: 3,
|
||||||
|
directSegmentAllowed: spatial.directAllowed,
|
||||||
|
routeSegmentAllowed: graph?.triangles?.length ? spatial.routeAllowed : undefined,
|
||||||
|
destinationReached: (surface, destination) => distanceSquared(surface, destination)
|
||||||
|
<= (returning ? 0.2 : sample.attackRange) ** 2
|
||||||
|
&& (returning || spatial.hasLineOfSight(surface, destination)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (nextPartyRouteWaypoint(state.route, position, desired)) {
|
||||||
|
waypoint = desired;
|
||||||
|
approvedRouteStep = true;
|
||||||
|
} else if (nextPartyRouteRecoveryWaypoint(state.route, position, desired, spatial.directAllowed)) waypoint = desired;
|
||||||
|
}
|
||||||
|
if (waypoint) {
|
||||||
|
moveMobToward(position, waypoint, sample.maximumStep, next);
|
||||||
|
// Recast edges are already agent-radius eroded. A second full-radius
|
||||||
|
// sweep at boundary vertices can reject a valid stair corridor;
|
||||||
|
// use the same center/wall check as companions on approved edges.
|
||||||
|
// Off-surface connectors and direct shortcuts still need body sweeps.
|
||||||
|
if (spatial.directAllowed(position, next) || (approvedRouteStep && spatial.routeAllowed(position, next))) {
|
||||||
|
position[0] = next[0]; position[1] = next[1]; position[2] = next[2];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const recovery = updatePartyStuckRecovery(state.watchdog, {
|
||||||
|
position, waypoint, goalKey, movementExpected, nowMs, health: sample.health,
|
||||||
|
paused: !sample.active, stopped: false, casting: sample.casting, controlled: sample.controlled,
|
||||||
|
});
|
||||||
|
if (returning && !excluded && !movementExpected) return "home";
|
||||||
|
if (recovery !== "none") state.route = emptyPartyRouteState();
|
||||||
|
if (recovery !== "recover") return "none";
|
||||||
|
const landing = returning ? spatial.validateLanding(home, home) : chooseMobPursuitLanding(sample, spatial);
|
||||||
|
if (landing && (!returning || distanceSquared(landing, home) <= 0.85 ** 2)) {
|
||||||
|
position[0] = landing[0]; position[1] = landing[1]; position[2] = landing[2];
|
||||||
|
markPartyStuckRecovered(state.watchdog, position, nowMs);
|
||||||
|
return returning ? "home" : "teleported";
|
||||||
|
}
|
||||||
|
if (!returning) {
|
||||||
|
const homeLanding = spatial.validateLanding(home, home);
|
||||||
|
if (homeLanding?.every(Number.isFinite) && distanceSquared(homeLanding, home) <= 0.85 ** 2) {
|
||||||
|
position[0] = homeLanding[0]; position[1] = homeLanding[1]; position[2] = homeLanding[2];
|
||||||
|
}
|
||||||
|
markPartyStuckRecovered(state.watchdog, position, nowMs);
|
||||||
|
return "reset";
|
||||||
|
}
|
||||||
|
return "none";
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -7,15 +7,11 @@ import {
|
|||||||
nextPartyBreadcrumbWaypoint,
|
nextPartyBreadcrumbWaypoint,
|
||||||
partyCombatMovementGoal,
|
partyCombatMovementGoal,
|
||||||
partyFormationPosition,
|
partyFormationPosition,
|
||||||
partyMadeStuckProgress,
|
|
||||||
partyMovementProfile,
|
partyMovementProfile,
|
||||||
partyNaturalFollowPosition,
|
partyNaturalFollowPosition,
|
||||||
partyPersonalizedCombatMovementGoal,
|
partyPersonalizedCombatMovementGoal,
|
||||||
partyPresentationY,
|
partyPresentationY,
|
||||||
partySeparationGoal,
|
|
||||||
partyShouldRecallForStuck,
|
|
||||||
partyShouldMoveToComfortTarget,
|
partyShouldMoveToComfortTarget,
|
||||||
PARTY_STUCK_RECALL_DISTANCE,
|
|
||||||
personalizedPartyCombatRangeBand,
|
personalizedPartyCombatRangeBand,
|
||||||
resetPartyBreadcrumbTrail,
|
resetPartyBreadcrumbTrail,
|
||||||
stepPartyPosition,
|
stepPartyPosition,
|
||||||
@@ -102,7 +98,7 @@ describe("party world movement", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses a stable combat bearing and separates overlapping allies", () => {
|
it("uses a stable combat bearing without forcing in-range actors to reposition", () => {
|
||||||
const out: [number, number, number] = [0, 0, 0];
|
const out: [number, number, number] = [0, 0, 0];
|
||||||
expect(partyPersonalizedCombatMovementGoal(
|
expect(partyPersonalizedCombatMovementGoal(
|
||||||
[20, 0, 0],
|
[20, 0, 0],
|
||||||
@@ -110,34 +106,20 @@ describe("party world movement", () => {
|
|||||||
true,
|
true,
|
||||||
{ minimum: 10, preferred: 14, maximum: 18 },
|
{ minimum: 10, preferred: 14, maximum: 18 },
|
||||||
Math.PI / 2,
|
Math.PI / 2,
|
||||||
false,
|
|
||||||
out,
|
out,
|
||||||
)).toBe("approach");
|
)).toBe("approach");
|
||||||
expect(out[0]).toBeCloseTo(14);
|
expect(out[0]).toBeCloseTo(14);
|
||||||
expect(out[1]).toBe(0);
|
expect(out[1]).toBe(0);
|
||||||
expect(out[2]).toBeCloseTo(0);
|
expect(out[2]).toBeCloseTo(0);
|
||||||
|
|
||||||
const separated: [number, number, number] = [0, 0, 0];
|
expect(partyPersonalizedCombatMovementGoal(
|
||||||
expect(partySeparationGoal(
|
|
||||||
[1, 0, 1],
|
[1, 0, 1],
|
||||||
[2, 0, 2],
|
[0, 0, 0],
|
||||||
[[1, 0, 1]],
|
true,
|
||||||
|
{ minimum: 0, preferred: 2.9, maximum: 3.15 },
|
||||||
Math.PI / 2,
|
Math.PI / 2,
|
||||||
separated,
|
out,
|
||||||
)).toBe(true);
|
)).toBe("hold");
|
||||||
expect(separated[0]).toBeGreaterThan(2);
|
|
||||||
expect(separated[2]).toBeCloseTo(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("recalls only distant allies that should be moving and stopped making progress", () => {
|
|
||||||
expect(partyMadeStuckProgress([0, 0, 0], [0.45, 4, 0])).toBe(true);
|
|
||||||
expect(partyMadeStuckProgress([0, 0, 0], [0.44, 0, 0])).toBe(false);
|
|
||||||
|
|
||||||
expect(PARTY_STUCK_RECALL_DISTANCE).toBe(80);
|
|
||||||
expect(partyShouldRecallForStuck([80, 0, 0], [0, 0, 0], true, 4_500, 1_000)).toBe(true);
|
|
||||||
expect(partyShouldRecallForStuck([79.9, 0, 0], [0, 0, 0], true, 4_500, 1_000)).toBe(false);
|
|
||||||
expect(partyShouldRecallForStuck([80, 0, 0], [0, 0, 0], false, 4_500, 1_000)).toBe(false);
|
|
||||||
expect(partyShouldRecallForStuck([80, 0, 0], [0, 0, 0], true, 4_499, 1_000)).toBe(false);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("makes melee approach a fleeing target and hold once in range", () => {
|
it("makes melee approach a fleeing target and hold once in range", () => {
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export interface PartyBreadcrumbCursor {
|
|||||||
breadcrumbId: number | null;
|
breadcrumbId: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PartyCombatMovementIntent = "hold" | "approach" | "retreat" | "reposition";
|
export type PartyCombatMovementIntent = "hold" | "approach" | "retreat";
|
||||||
|
|
||||||
export interface PartyMovementProfile {
|
export interface PartyMovementProfile {
|
||||||
readonly followDistanceJitter: number;
|
readonly followDistanceJitter: number;
|
||||||
@@ -86,45 +86,6 @@ export function partyMovementProfile(memberId: string, livingRank: number): Part
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Allow companions to operate across a full boss arena before the stuck
|
|
||||||
// watchdog treats their separation from the player as a reason to recall.
|
|
||||||
export const PARTY_STUCK_RECALL_DISTANCE = 80;
|
|
||||||
export const PARTY_STUCK_RECALL_DELAY_MS = 3_500;
|
|
||||||
export const PARTY_STUCK_PROGRESS_DISTANCE = 0.45;
|
|
||||||
|
|
||||||
/** Tests whether an expected move has made enough planar progress to reset the stuck timer. */
|
|
||||||
export function partyMadeStuckProgress(
|
|
||||||
previous: PartyWorldPosition,
|
|
||||||
current: PartyWorldPosition,
|
|
||||||
minimumDistance = PARTY_STUCK_PROGRESS_DISTANCE,
|
|
||||||
): boolean {
|
|
||||||
const minimum = Math.max(0, Number.isFinite(minimumDistance) ? minimumDistance : 0);
|
|
||||||
const distance = Math.hypot(current[0] - previous[0], current[2] - previous[2]);
|
|
||||||
return Number.isFinite(distance) && distance >= minimum;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A distant ally is stuck only while it should be moving and its progress
|
|
||||||
* timer has expired. Intentional ranged/melee holds never enter this state.
|
|
||||||
*/
|
|
||||||
export function partyShouldRecallForStuck(
|
|
||||||
current: PartyWorldPosition,
|
|
||||||
player: PartyWorldPosition,
|
|
||||||
movementExpected: boolean,
|
|
||||||
nowMs: number,
|
|
||||||
lastProgressAtMs: number,
|
|
||||||
recallDistance = PARTY_STUCK_RECALL_DISTANCE,
|
|
||||||
stuckDelayMs = PARTY_STUCK_RECALL_DELAY_MS,
|
|
||||||
): boolean {
|
|
||||||
if (!movementExpected || !Number.isFinite(nowMs) || !Number.isFinite(lastProgressAtMs)) return false;
|
|
||||||
const distance = Math.hypot(current[0] - player[0], current[2] - player[2]);
|
|
||||||
const minimumDistance = Math.max(0, Number.isFinite(recallDistance) ? recallDistance : 0);
|
|
||||||
const delay = Math.max(0, Number.isFinite(stuckDelayMs) ? stuckDelayMs : 0);
|
|
||||||
return Number.isFinite(distance)
|
|
||||||
&& distance >= minimumDistance
|
|
||||||
&& nowMs - lastProgressAtMs >= delay;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createPartyBreadcrumbTrail(): PartyBreadcrumbTrail {
|
export function createPartyBreadcrumbTrail(): PartyBreadcrumbTrail {
|
||||||
return { points: [], nextId: 0 };
|
return { points: [], nextId: 0 };
|
||||||
}
|
}
|
||||||
@@ -427,44 +388,6 @@ export function personalizedPartyCombatRangeBand(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Adds a bounded local repulsion so allies do not occupy the same point. */
|
|
||||||
export function partySeparationGoal(
|
|
||||||
current: PartyWorldPosition,
|
|
||||||
desired: PartyWorldPosition,
|
|
||||||
peers: readonly PartyWorldPosition[],
|
|
||||||
fallbackAngle: number,
|
|
||||||
out: MutablePartyWorldPosition,
|
|
||||||
separationDistance = 0.85,
|
|
||||||
maximumCorrection = 0.35,
|
|
||||||
): boolean {
|
|
||||||
let offsetX = 0;
|
|
||||||
let offsetZ = 0;
|
|
||||||
const safeSeparation = Math.max(EPSILON, separationDistance);
|
|
||||||
for (const peer of peers) {
|
|
||||||
const dx = current[0] - peer[0];
|
|
||||||
const dz = current[2] - peer[2];
|
|
||||||
const distance = Math.hypot(dx, dz);
|
|
||||||
if (!Number.isFinite(distance) || distance >= safeSeparation) continue;
|
|
||||||
const weight = (safeSeparation - distance) / safeSeparation;
|
|
||||||
if (distance <= EPSILON) {
|
|
||||||
offsetX += Math.sin(fallbackAngle) * weight;
|
|
||||||
offsetZ += Math.cos(fallbackAngle) * weight;
|
|
||||||
} else {
|
|
||||||
offsetX += dx / distance * weight;
|
|
||||||
offsetZ += dz / distance * weight;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const length = Math.hypot(offsetX, offsetZ);
|
|
||||||
out[0] = desired[0];
|
|
||||||
out[1] = desired[1];
|
|
||||||
out[2] = desired[2];
|
|
||||||
if (length <= EPSILON) return false;
|
|
||||||
const correction = Math.min(Math.max(0, maximumCorrection), length);
|
|
||||||
out[0] += offsetX / length * correction;
|
|
||||||
out[2] += offsetZ / length * correction;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Chooses an individual combat destination. Melee only closes distance;
|
* Chooses an individual combat destination. Melee only closes distance;
|
||||||
* ranged attackers also back out when a target collapses their preferred band.
|
* ranged attackers also back out when a target collapses their preferred band.
|
||||||
@@ -524,7 +447,6 @@ export function partyPersonalizedCombatMovementGoal(
|
|||||||
targetVisible: boolean,
|
targetVisible: boolean,
|
||||||
rangeBand: PartyCombatRangeLike,
|
rangeBand: PartyCombatRangeLike,
|
||||||
preferredBearing: number,
|
preferredBearing: number,
|
||||||
forceReposition: boolean,
|
|
||||||
out: MutablePartyWorldPosition,
|
out: MutablePartyWorldPosition,
|
||||||
): PartyCombatMovementIntent {
|
): PartyCombatMovementIntent {
|
||||||
const intent = partyCombatMovementGoal(
|
const intent = partyCombatMovementGoal(
|
||||||
@@ -538,10 +460,10 @@ export function partyPersonalizedCombatMovementGoal(
|
|||||||
Math.sin(preferredBearing),
|
Math.sin(preferredBearing),
|
||||||
Math.cos(preferredBearing),
|
Math.cos(preferredBearing),
|
||||||
);
|
);
|
||||||
if (!targetVisible || (intent === "hold" && !forceReposition)) return intent;
|
if (!targetVisible || intent === "hold") return intent;
|
||||||
|
|
||||||
out[0] = target[0] + Math.sin(preferredBearing) * rangeBand.preferred;
|
out[0] = target[0] + Math.sin(preferredBearing) * rangeBand.preferred;
|
||||||
out[1] = target[1];
|
out[1] = target[1];
|
||||||
out[2] = target[2] + Math.cos(preferredBearing) * rangeBand.preferred;
|
out[2] = target[2] + Math.cos(preferredBearing) * rangeBand.preferred;
|
||||||
return intent === "hold" ? "reposition" : intent;
|
return intent;
|
||||||
}
|
}
|
||||||
@@ -126,6 +126,10 @@ describe("party navigation graph", () => {
|
|||||||
|
|
||||||
expect(graph).toMatchObject({ componentCount: 1, discardedNodeCount: 0 });
|
expect(graph).toMatchObject({ componentCount: 1, discardedNodeCount: 0 });
|
||||||
expect(graph.triangles).toBeUndefined();
|
expect(graph.triangles).toBeUndefined();
|
||||||
|
expect(findNearestPartyNavigationSurface(graph, [0.25, 0.5, 0.25], 1)).toMatchObject({
|
||||||
|
triangleId: -1,
|
||||||
|
distance: expect.any(Number),
|
||||||
|
});
|
||||||
expect(findPartyNavigationPath(graph, [0, 0, 0], [11, 0, 10])).not.toBeNull();
|
expect(findPartyNavigationPath(graph, [0, 0, 0], [11, 0, 10])).not.toBeNull();
|
||||||
expect(findPartyNavigationPath(graph, [11, 0, 10], [0, 0, 0])).not.toBeNull();
|
expect(findPartyNavigationPath(graph, [11, 0, 10], [0, 0, 0])).not.toBeNull();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -570,7 +570,16 @@ export function findNearestPartyNavigationSurface(
|
|||||||
maxDistance = Number.POSITIVE_INFINITY,
|
maxDistance = Number.POSITIVE_INFINITY,
|
||||||
): PartyNavigationSurfaceMatch | null {
|
): PartyNavigationSurfaceMatch | null {
|
||||||
const triangles = graph.triangles;
|
const triangles = graph.triangles;
|
||||||
if (!triangles?.length) return null;
|
if (!triangles?.length) {
|
||||||
|
const nearest = findNearestPartyNavigationNode(graph, point, maxDistance);
|
||||||
|
return nearest
|
||||||
|
? Object.freeze({
|
||||||
|
position: nearest.node.position,
|
||||||
|
triangleId: -1,
|
||||||
|
distance: nearest.distance,
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
}
|
||||||
const target = pointComponents(point);
|
const target = pointComponents(point);
|
||||||
const limit = Number.isFinite(maxDistance) ? Math.max(0, maxDistance) : Number.POSITIVE_INFINITY;
|
const limit = Number.isFinite(maxDistance) ? Math.max(0, maxDistance) : Number.POSITIVE_INFINITY;
|
||||||
let nearestPosition: PartyNavigationPosition | null = null;
|
let nearestPosition: PartyNavigationPosition | null = null;
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
import { BufferGeometry, Float32BufferAttribute, Group, Mesh } from "three";
|
import {
|
||||||
|
BufferAttribute,
|
||||||
|
BufferGeometry,
|
||||||
|
Float32BufferAttribute,
|
||||||
|
Group,
|
||||||
|
Mesh,
|
||||||
|
} from "three";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { preparePartyNavigationGraph } from "./partyNavigationAssetCache";
|
import { preparePartyNavigationGraph } from "./partyNavigationAssetCache";
|
||||||
|
|
||||||
@@ -33,4 +39,26 @@ describe("party navigation asset cache", () => {
|
|||||||
}]);
|
}]);
|
||||||
expect(second).not.toBe(first);
|
expect(second).not.toBe(first);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("expands normalized quantized positions before baking the node transform", () => {
|
||||||
|
const geometry = new BufferGeometry();
|
||||||
|
geometry.setAttribute("position", new BufferAttribute(new Int16Array([
|
||||||
|
0, 0, 0,
|
||||||
|
32_767, 0, 0,
|
||||||
|
0, 0, 32_767,
|
||||||
|
]), 3, true));
|
||||||
|
const mesh = new Mesh(geometry);
|
||||||
|
mesh.position.set(10, 2, -5);
|
||||||
|
mesh.scale.setScalar(100);
|
||||||
|
const scene = new Group();
|
||||||
|
scene.add(mesh);
|
||||||
|
|
||||||
|
const graph = preparePartyNavigationGraph("/navigation/quantized-transform.glb", scene, []);
|
||||||
|
|
||||||
|
expect(graph.nodes.map((node) => node.position)).toEqual([
|
||||||
|
[10, 2, -5],
|
||||||
|
[110, 2, -5],
|
||||||
|
[10, 2, 95],
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { BufferGeometry, type Group, type Mesh } from "three";
|
import { BufferGeometry, Float32BufferAttribute, type Group, type Mesh } from "three";
|
||||||
import type { DungeonNavigationLink } from "./dungeonTypes";
|
import type { DungeonNavigationLink, DungeonTraversalRamp } from "./dungeonTypes";
|
||||||
|
import { bridgeDungeonStairNavigation, smoothDungeonStairGeometry } from "./dungeonTraversalRamps";
|
||||||
import {
|
import {
|
||||||
buildPartyNavigationGraph,
|
buildPartyNavigationGraph,
|
||||||
type PartyNavigationGraph,
|
type PartyNavigationGraph,
|
||||||
@@ -9,14 +10,18 @@ import { MAX_SYNCHRONOUS_PARTY_NAVIGATION_TRIANGLES } from "./partyNavigationPol
|
|||||||
const MAX_CACHED_NAVIGATION_GRAPHS = 8;
|
const MAX_CACHED_NAVIGATION_GRAPHS = 8;
|
||||||
const graphCache = new Map<string, PartyNavigationGraph>();
|
const graphCache = new Map<string, PartyNavigationGraph>();
|
||||||
|
|
||||||
function graphCacheKey(url: string, offMeshLinks: readonly DungeonNavigationLink[]): string {
|
function graphCacheKey(
|
||||||
|
url: string,
|
||||||
|
offMeshLinks: readonly DungeonNavigationLink[],
|
||||||
|
ramps: readonly DungeonTraversalRamp[],
|
||||||
|
): string {
|
||||||
const links = offMeshLinks.map((link) => [
|
const links = offMeshLinks.map((link) => [
|
||||||
link.id,
|
link.id,
|
||||||
link.start,
|
link.start,
|
||||||
link.end,
|
link.end,
|
||||||
link.bidirectional,
|
link.bidirectional,
|
||||||
]);
|
]);
|
||||||
return `${url}:${JSON.stringify(links)}`;
|
return `${url}:${JSON.stringify([links, ramps])}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function navigationGeometry(scene: Group): BufferGeometry {
|
function navigationGeometry(scene: Group): BufferGeometry {
|
||||||
@@ -33,6 +38,19 @@ function navigationGeometry(scene: Group): BufferGeometry {
|
|||||||
});
|
});
|
||||||
if (!sourceMesh) throw new Error("The party navigation GLB has no triangle geometry.");
|
if (!sourceMesh) throw new Error("The party navigation GLB has no triangle geometry.");
|
||||||
const geometry = (sourceMesh as Mesh).geometry.clone();
|
const geometry = (sourceMesh as Mesh).geometry.clone();
|
||||||
|
const position = geometry.getAttribute("position");
|
||||||
|
// BufferAttribute.applyMatrix4 writes back through the source component
|
||||||
|
// type. Quantized normalized integers therefore clamp world coordinates to
|
||||||
|
// [-1, 1] while baking their node transform. Expand them to floats first.
|
||||||
|
if (position.normalized) {
|
||||||
|
const expanded = new Float32Array(position.count * 3);
|
||||||
|
for (let index = 0; index < position.count; index += 1) {
|
||||||
|
expanded[index * 3] = position.getX(index);
|
||||||
|
expanded[index * 3 + 1] = position.getY(index);
|
||||||
|
expanded[index * 3 + 2] = position.getZ(index);
|
||||||
|
}
|
||||||
|
geometry.setAttribute("position", new Float32BufferAttribute(expanded, 3));
|
||||||
|
}
|
||||||
geometry.applyMatrix4((sourceMesh as Mesh).matrixWorld);
|
geometry.applyMatrix4((sourceMesh as Mesh).matrixWorld);
|
||||||
return geometry;
|
return geometry;
|
||||||
}
|
}
|
||||||
@@ -41,8 +59,9 @@ export function preparePartyNavigationGraph(
|
|||||||
url: string,
|
url: string,
|
||||||
scene: Group,
|
scene: Group,
|
||||||
offMeshLinks: readonly DungeonNavigationLink[],
|
offMeshLinks: readonly DungeonNavigationLink[],
|
||||||
|
ramps: readonly DungeonTraversalRamp[] = [],
|
||||||
): PartyNavigationGraph {
|
): PartyNavigationGraph {
|
||||||
const cacheKey = graphCacheKey(url, offMeshLinks);
|
const cacheKey = graphCacheKey(url, offMeshLinks, ramps);
|
||||||
const cached = graphCache.get(cacheKey);
|
const cached = graphCache.get(cacheKey);
|
||||||
if (cached) {
|
if (cached) {
|
||||||
graphCache.delete(cacheKey);
|
graphCache.delete(cacheKey);
|
||||||
@@ -53,9 +72,15 @@ export function preparePartyNavigationGraph(
|
|||||||
const geometry = navigationGeometry(scene);
|
const geometry = navigationGeometry(scene);
|
||||||
let graph: PartyNavigationGraph;
|
let graph: PartyNavigationGraph;
|
||||||
try {
|
try {
|
||||||
|
if (ramps.length) {
|
||||||
|
// Recast normally lifts its walkable surface slightly above collision.
|
||||||
|
smoothDungeonStairGeometry(geometry, ramps, 0.05);
|
||||||
|
bridgeDungeonStairNavigation(geometry, ramps, 0.05);
|
||||||
|
}
|
||||||
|
const bridgedLinks = new Set(ramps.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();
|
||||||
|
|||||||
@@ -102,7 +102,10 @@ describe("party navigation fleet safety", () => {
|
|||||||
|
|
||||||
if (shouldLoadPartyNavigation("dungeon", dungeon, null)) {
|
if (shouldLoadPartyNavigation("dungeon", dungeon, null)) {
|
||||||
enabled.push(dungeon.id);
|
enabled.push(dungeon.id);
|
||||||
expect(dungeon.validation.status, dungeon.id).toBe("green");
|
expect(
|
||||||
|
dungeon.validation.status === "green" || dungeon.validation.navigation === "green",
|
||||||
|
dungeon.id,
|
||||||
|
).toBe(true);
|
||||||
expect(actualTriangles, dungeon.id)
|
expect(actualTriangles, dungeon.id)
|
||||||
.toBeLessThanOrEqual(MAX_SYNCHRONOUS_PARTY_NAVIGATION_TRIANGLES);
|
.toBeLessThanOrEqual(MAX_SYNCHRONOUS_PARTY_NAVIGATION_TRIANGLES);
|
||||||
} else {
|
} else {
|
||||||
@@ -113,8 +116,11 @@ describe("party navigation fleet safety", () => {
|
|||||||
// Raksha Temple and The Origin now ship their full source-assembled
|
// Raksha Temple and The Origin now ship their full source-assembled
|
||||||
// navigation geometry, which correctly moves both above the synchronous
|
// navigation geometry, which correctly moves both above the synchronous
|
||||||
// graph-build ceiling and onto the breadcrumb fallback path.
|
// graph-build ceiling and onto the breadcrumb fallback path.
|
||||||
expect(enabled).toHaveLength(29);
|
// Stockades has a reviewed navigation override without claiming that its
|
||||||
expect(fallback).toHaveLength(55);
|
// broader encounter/reset validation is complete.
|
||||||
|
expect(enabled).toContain("stormwind-stockade");
|
||||||
|
expect(enabled).toHaveLength(30);
|
||||||
|
expect(fallback).toHaveLength(54);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("covers every Manastorm package and keeps actual synchronous geometry under budget", () => {
|
it("covers every Manastorm package and keeps actual synchronous geometry under budget", () => {
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ function manastormPackageWithNavigationTriangles(
|
|||||||
function dungeonWithNavigation(
|
function dungeonWithNavigation(
|
||||||
status: DungeonDefinition["validation"]["status"],
|
status: DungeonDefinition["validation"]["status"],
|
||||||
triangleCount: number,
|
triangleCount: number,
|
||||||
|
navigation?: DungeonDefinition["validation"]["navigation"],
|
||||||
): Pick<DungeonDefinition, "assets" | "validation"> {
|
): Pick<DungeonDefinition, "assets" | "validation"> {
|
||||||
return {
|
return {
|
||||||
assets: {
|
assets: {
|
||||||
@@ -32,7 +33,7 @@ function dungeonWithNavigation(
|
|||||||
collision: [],
|
collision: [],
|
||||||
navigation: { id: "navigation", fileName: "navigation.glb", triangleCount },
|
navigation: { id: "navigation", fileName: "navigation.glb", triangleCount },
|
||||||
},
|
},
|
||||||
validation: { status, blockers: [], warnings: [] },
|
validation: { status, navigation, blockers: [], warnings: [] },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,6 +62,14 @@ describe("party navigation loading policy", () => {
|
|||||||
)).toBe(false);
|
)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("loads an independently reviewed dungeon navigation graph", () => {
|
||||||
|
expect(shouldLoadPartyNavigation(
|
||||||
|
"dungeon",
|
||||||
|
dungeonWithNavigation("unvalidated", 1_000, "green"),
|
||||||
|
null,
|
||||||
|
)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it("protects Manastorm from oversized or unmeasured synchronous graphs", () => {
|
it("protects Manastorm from oversized or unmeasured synchronous graphs", () => {
|
||||||
const fallbackDungeon = requireDungeonDefinition("wailing-caverns");
|
const fallbackDungeon = requireDungeonDefinition("wailing-caverns");
|
||||||
|
|
||||||
|
|||||||
@@ -21,9 +21,13 @@ export function shouldLoadPartyNavigation(
|
|||||||
: dungeon.assets.navigation;
|
: dungeon.assets.navigation;
|
||||||
if (!navigation) return false;
|
if (!navigation) return false;
|
||||||
|
|
||||||
// Imported dungeon packs already use local/breadcrumb following until their
|
// Navigation can be reviewed independently from encounter/checkpoint content.
|
||||||
// routes are reviewed, so building their navigation graph provides no value.
|
// Do not make safe melee routing wait for unrelated dungeon validation work.
|
||||||
if (gameMode === "dungeon" && dungeon.validation.status !== "green") return false;
|
if (
|
||||||
|
gameMode === "dungeon"
|
||||||
|
&& dungeon.validation.status !== "green"
|
||||||
|
&& dungeon.validation.navigation !== "green"
|
||||||
|
) return false;
|
||||||
|
|
||||||
const triangleCount = navigation.triangleCount;
|
const triangleCount = navigation.triangleCount;
|
||||||
return typeof triangleCount === "number"
|
return typeof triangleCount === "number"
|
||||||
|
|||||||
@@ -92,6 +92,19 @@ describe("party path controller", () => {
|
|||||||
expect(route.blocked).toBe(true);
|
expect(route.blocked).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("accepts an in-range surface finish only when combat sight approves it", () => {
|
||||||
|
const options = { maxSnapDistance: 1, localFinishDistance: 1, directSegmentAllowed: () => false };
|
||||||
|
const visible = planPartyRoute(corridorGraph, "mob", [0, 0, 0], [2.5, 0, 2], 100, {
|
||||||
|
...options, destinationReached: (surface, target) => Math.hypot(surface[0] - target[0], surface[2] - target[2]) <= 1,
|
||||||
|
});
|
||||||
|
expect(visible.blocked).toBe(false);
|
||||||
|
expect(visible.waypoints.at(-1)).toEqual([2, 0, 2]);
|
||||||
|
const obstructed = planPartyRoute(corridorGraph, "mob", [0, 0, 0], [2.5, 0, 2], 100, {
|
||||||
|
...options, destinationReached: () => false,
|
||||||
|
});
|
||||||
|
expect(obstructed.blocked).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it("advances only one close-spaced waypoint at a corner", () => {
|
it("advances only one close-spaced waypoint at a corner", () => {
|
||||||
const route = {
|
const route = {
|
||||||
...emptyPartyRouteState(),
|
...emptyPartyRouteState(),
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ export interface PartyRouteOptions {
|
|||||||
start: PartyWorldPosition,
|
start: PartyWorldPosition,
|
||||||
end: PartyWorldPosition,
|
end: PartyWorldPosition,
|
||||||
) => boolean;
|
) => boolean;
|
||||||
|
/** Combat may finish on the retained surface only when range AND sight allow a hit. */
|
||||||
|
readonly destinationReached?: (surface: PartyWorldPosition, destination: PartyWorldPosition) => boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_MAX_SNAP_DISTANCE = 3;
|
const DEFAULT_MAX_SNAP_DISTANCE = 3;
|
||||||
@@ -117,7 +119,7 @@ export function planPartyRoute(
|
|||||||
) return blocked(waypoints);
|
) return blocked(waypoints);
|
||||||
const finishDistance = fullDistance(waypoints[waypoints.length - 1], destination);
|
const finishDistance = fullDistance(waypoints[waypoints.length - 1], destination);
|
||||||
if (finishDistance > localFinishDistance) return blocked(waypoints);
|
if (finishDistance > localFinishDistance) return blocked(waypoints);
|
||||||
if (finishDistance > reachedDistance * 0.5) {
|
if (finishDistance > reachedDistance * 0.5 && !options.destinationReached?.(waypoints[waypoints.length - 1], destination)) {
|
||||||
if (!options.directSegmentAllowed?.(waypoints[waypoints.length - 1], destination)) {
|
if (!options.directSegmentAllowed?.(waypoints[waypoints.length - 1], destination)) {
|
||||||
return blocked(waypoints);
|
return blocked(waypoints);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -611,7 +611,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 {
|
||||||
@@ -1148,6 +1148,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;
|
||||||
|
|||||||
@@ -0,0 +1,181 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
applyPartyStuckRecoveryLanding,
|
||||||
|
choosePartyStuckRecoveryPosition,
|
||||||
|
createPartyStuckRecoveryState,
|
||||||
|
markPartyStuckRecovered,
|
||||||
|
resetPartyStuckRecoveryProgress,
|
||||||
|
updatePartyStuckRecovery,
|
||||||
|
type PartyStuckRecoverySample,
|
||||||
|
} from "./partyStuckRecovery";
|
||||||
|
import type { MutablePartyWorldPosition, PartyWorldPosition } from "./partyRuntimeRegistry";
|
||||||
|
|
||||||
|
const origin: PartyWorldPosition = [0, 0, 0];
|
||||||
|
const sample = (nowMs: number, overrides: Partial<PartyStuckRecoverySample> = {}): PartyStuckRecoverySample => ({
|
||||||
|
position: origin, waypoint: [10, 0, 0], goalKey: "follow:leader", movementExpected: true,
|
||||||
|
health: 100, paused: false, stopped: false, casting: false, controlled: false, nowMs, ...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("per-teammate stuck navigation", () => {
|
||||||
|
it("replans at two seconds and recovers at five, without a player-distance gate", () => {
|
||||||
|
const state = createPartyStuckRecoveryState(origin, 0);
|
||||||
|
expect(updatePartyStuckRecovery(state, sample(0))).toBe("none");
|
||||||
|
expect(updatePartyStuckRecovery(state, sample(1_999))).toBe("none");
|
||||||
|
expect(updatePartyStuckRecovery(state, sample(2_000))).toBe("replan");
|
||||||
|
expect(updatePartyStuckRecovery(state, sample(2_500))).toBe("none");
|
||||||
|
expect(updatePartyStuckRecovery(state, sample(4_999))).toBe("none");
|
||||||
|
expect(updatePartyStuckRecovery(state, sample(5_000))).toBe("recover");
|
||||||
|
expect(updatePartyStuckRecovery(state, sample(5_100))).toBe("none");
|
||||||
|
expect(updatePartyStuckRecovery(state, sample(6_000))).toBe("recover");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not restart the timer when a replan changes waypoints or yields no path", () => {
|
||||||
|
const state = createPartyStuckRecoveryState(origin, 0);
|
||||||
|
updatePartyStuckRecovery(state, sample(0));
|
||||||
|
expect(updatePartyStuckRecovery(state, sample(2_000))).toBe("replan");
|
||||||
|
updatePartyStuckRecovery(state, sample(3_000, { waypoint: [0, 0, 10] }));
|
||||||
|
updatePartyStuckRecovery(state, sample(4_000, { waypoint: null }));
|
||||||
|
expect(updatePartyStuckRecovery(state, sample(5_000, { waypoint: null }))).toBe("recover");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cancels back-and-forth doorway shuffling instead of treating it as repeated progress", () => {
|
||||||
|
const state = createPartyStuckRecoveryState(origin, 0);
|
||||||
|
updatePartyStuckRecovery(state, sample(0));
|
||||||
|
updatePartyStuckRecovery(state, sample(500, { position: [0.6, 0, 0] }));
|
||||||
|
const actions: string[] = [];
|
||||||
|
for (let tick = 1; tick <= 10; tick += 1) {
|
||||||
|
actions.push(updatePartyStuckRecovery(state, sample(500 + tick * 500, {
|
||||||
|
position: tick % 2 ? origin : [0.6, 0, 0],
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
expect(actions).toContain("replan");
|
||||||
|
expect(actions.at(-1)).toBe("recover");
|
||||||
|
expect(state.lastProgressAtMs).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("counts movement toward route waypoints even when the route goes away from the player", () => {
|
||||||
|
const state = createPartyStuckRecoveryState(origin, 0);
|
||||||
|
updatePartyStuckRecovery(state, sample(0));
|
||||||
|
for (let tick = 1; tick <= 12; tick += 1) {
|
||||||
|
expect(updatePartyStuckRecovery(state, sample(tick * 500, { position: [tick * 0.5, 0, 0] }))).toBe("none");
|
||||||
|
}
|
||||||
|
expect(state.lastProgressAtMs).toBe(6_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not mistake target movement or sideways motion for useful actor progress", () => {
|
||||||
|
const state = createPartyStuckRecoveryState(origin, 0);
|
||||||
|
updatePartyStuckRecovery(state, sample(0));
|
||||||
|
for (let tick = 1; tick <= 5; tick += 1) {
|
||||||
|
updatePartyStuckRecovery(state, sample(tick * 1_000, { position: [0, 0, tick % 2], waypoint: [10 + tick, 0, 0] }));
|
||||||
|
}
|
||||||
|
expect(state.lastProgressAtMs).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("advances through dense waypoint chains and corners without false recovery", () => {
|
||||||
|
const state = createPartyStuckRecoveryState(origin, 0);
|
||||||
|
updatePartyStuckRecovery(state, sample(0, { waypoint: [0.08, 0, 0] }));
|
||||||
|
for (let frame = 1; frame <= 400; frame += 1) {
|
||||||
|
const point = (step: number): PartyWorldPosition => step <= 200
|
||||||
|
? [step * 0.08, 0, 0] : [16, 0, (step - 200) * 0.08];
|
||||||
|
expect(updatePartyStuckRecovery(state, sample(frame * 1000 / 60, {
|
||||||
|
position: point(frame), waypoint: point(frame + 1),
|
||||||
|
}))).toBe("none");
|
||||||
|
}
|
||||||
|
expect(state.lastProgressAtMs).toBeGreaterThan(6_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
{ movementExpected: false }, { health: 0 }, { paused: true }, { stopped: true }, { casting: true }, { controlled: true },
|
||||||
|
])("excludes intentional immobility %j and starts a fresh timer afterward", (excluded) => {
|
||||||
|
const state = createPartyStuckRecoveryState(origin, 0);
|
||||||
|
updatePartyStuckRecovery(state, sample(0));
|
||||||
|
expect(updatePartyStuckRecovery(state, sample(10_000, excluded))).toBe("none");
|
||||||
|
expect(updatePartyStuckRecovery(state, sample(11_000))).toBe("none");
|
||||||
|
expect(updatePartyStuckRecovery(state, sample(12_999))).toBe("none");
|
||||||
|
expect(updatePartyStuckRecovery(state, sample(13_000))).toBe("replan");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a ten-second teleport cooldown across pauses, holds, and goal changes", () => {
|
||||||
|
const state = createPartyStuckRecoveryState(origin, 0);
|
||||||
|
markPartyStuckRecovered(state, origin, 5_000);
|
||||||
|
updatePartyStuckRecovery(state, sample(5_100));
|
||||||
|
expect(updatePartyStuckRecovery(state, sample(7_100))).toBe("replan");
|
||||||
|
expect(updatePartyStuckRecovery(state, sample(10_100))).toBe("none");
|
||||||
|
resetPartyStuckRecoveryProgress(state);
|
||||||
|
updatePartyStuckRecovery(state, sample(10_200, { goalKey: "mob:new-target" }));
|
||||||
|
expect(updatePartyStuckRecovery(state, sample(12_200, { goalKey: "mob:new-target" }))).toBe("replan");
|
||||||
|
expect(updatePartyStuckRecovery(state, sample(14_999, { goalKey: "mob:new-target" }))).toBe("none");
|
||||||
|
expect(updatePartyStuckRecovery(state, sample(15_200, { goalKey: "mob:new-target" }))).toBe("recover");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps independent teammate timers", () => {
|
||||||
|
const stuck = createPartyStuckRecoveryState(origin, 0);
|
||||||
|
const moving = createPartyStuckRecoveryState(origin, 0);
|
||||||
|
updatePartyStuckRecovery(stuck, sample(0));
|
||||||
|
updatePartyStuckRecovery(moving, sample(0));
|
||||||
|
updatePartyStuckRecovery(stuck, sample(2_000));
|
||||||
|
for (let tick = 1; tick <= 10; tick += 1) updatePartyStuckRecovery(moving, sample(tick * 500, { position: [tick * 0.5, 0, 0] }));
|
||||||
|
expect(updatePartyStuckRecovery(stuck, sample(5_000))).toBe("recover");
|
||||||
|
expect(updatePartyStuckRecovery(moving, sample(5_001, { position: [5, 0, 0] }))).toBe("none");
|
||||||
|
expect(moving.cooldownUntilMs).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("moves only the affected teammate and starts only its teleport cooldown", () => {
|
||||||
|
const positions = new Map<string, MutablePartyWorldPosition>([
|
||||||
|
["tank", [0, 0, 0]], ["rogue", [2, 0, 1]], ["mage", [3, 0, 2]],
|
||||||
|
]);
|
||||||
|
const states = new Map([...positions].map(([id, position]) => [id, createPartyStuckRecoveryState(position, 0)]));
|
||||||
|
expect(applyPartyStuckRecoveryLanding(positions, "rogue", [10, 0, 0], states.get("rogue")!, 5_000)).toBe(true);
|
||||||
|
expect(positions.get("rogue")).toEqual([10, 0, 0]);
|
||||||
|
expect(positions.get("tank")).toEqual([0, 0, 0]);
|
||||||
|
expect(positions.get("mage")).toEqual([3, 0, 2]);
|
||||||
|
expect(states.get("rogue")!.cooldownUntilMs).toBe(15_000);
|
||||||
|
expect(states.get("tank")!.cooldownUntilMs).toBe(0);
|
||||||
|
expect(states.get("mage")!.cooldownUntilMs).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not apply missing or malformed landings", () => {
|
||||||
|
const positions = new Map<string, MutablePartyWorldPosition>([["tank", [0, 0, 0]]]);
|
||||||
|
const state = createPartyStuckRecoveryState(origin, 0);
|
||||||
|
expect(applyPartyStuckRecoveryLanding(positions, "missing", [10, 0, 0], state, 5_000)).toBe(false);
|
||||||
|
expect(applyPartyStuckRecoveryLanding(positions, "tank", [NaN, 0, 0], state, 5_000)).toBe(false);
|
||||||
|
expect(positions.get("tank")).toEqual(origin);
|
||||||
|
expect(state.cooldownUntilMs).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles malformed samples without poisoning later progress", () => {
|
||||||
|
const state = createPartyStuckRecoveryState(origin, 0);
|
||||||
|
expect(updatePartyStuckRecovery(state, sample(NaN))).toBe("none");
|
||||||
|
expect(updatePartyStuckRecovery(state, sample(1, { position: [NaN, 0, 0] }))).toBe("none");
|
||||||
|
expect(updatePartyStuckRecovery(state, sample(10_000))).toBe("none");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("safe stuck-recovery landings", () => {
|
||||||
|
const player: PartyWorldPosition = [10, 0, 0];
|
||||||
|
const choose = (trailPoints: readonly PartyWorldPosition[],
|
||||||
|
validate: (point: PartyWorldPosition) => PartyWorldPosition | null = (point) => point) => choosePartyStuckRecoveryPosition({
|
||||||
|
current: origin, player, groupAnchor: player, trailPoints, validate,
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers a visited trail point near the group over returning to the player", () => {
|
||||||
|
expect(choose([[2, 0, 0], [9, 0, 0]])).toEqual({ position: [9, 0, 0], source: "trail" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips wrong floors, distant points, current position, and blocked/unsafe landings", () => {
|
||||||
|
const result = choose([[0.2, 0, 0], [10, 5, 0], [50, 0, 0], [9, 0, 0], [8, 0, 0]],
|
||||||
|
(point) => point[0] === 9 ? null : point);
|
||||||
|
expect(result).toEqual({ position: [8, 0, 0], source: "trail" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to a validated player point when no safe local trail exists", () => {
|
||||||
|
expect(choose([], (point) => point)).toEqual({ position: player, source: "player" });
|
||||||
|
expect(choose([[9, 0, 0]], (point) => point[0] === 10 ? point : null)).toEqual({ position: player, source: "player" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never blindly teleports to an invalid player point or a far-away projected room", () => {
|
||||||
|
expect(choose([], () => null)).toBeNull();
|
||||||
|
expect(choose([[9, 0, 0]], () => [10, 0, 5])).toBeNull();
|
||||||
|
expect(choose([], () => [10, -5, 0])).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
import type { MutablePartyWorldPosition, PartyWorldPosition } from "./partyRuntimeRegistry";
|
||||||
|
|
||||||
|
export const PARTY_STUCK_REPLAN_MS = 2_000;
|
||||||
|
export const PARTY_STUCK_RECOVER_MS = 5_000;
|
||||||
|
export const PARTY_STUCK_RECOVERY_COOLDOWN_MS = 10_000;
|
||||||
|
const PROGRESS_DISTANCE = 0.45;
|
||||||
|
const RECOVERY_RETRY_MS = 1_000;
|
||||||
|
const MAX_TRAIL_GROUP_DISTANCE = 12;
|
||||||
|
const MAX_TRAIL_HEIGHT_DIFFERENCE = 1.25;
|
||||||
|
const MIN_RECOVERY_DISTANCE = 1;
|
||||||
|
|
||||||
|
export interface PartyStuckRecoveryState {
|
||||||
|
goalKey: string | null;
|
||||||
|
readonly previousPosition: MutablePartyWorldPosition;
|
||||||
|
waypoint: MutablePartyWorldPosition | null;
|
||||||
|
netProgress: number;
|
||||||
|
lastProgressAtMs: number;
|
||||||
|
replanned: boolean;
|
||||||
|
cooldownUntilMs: number;
|
||||||
|
retryAtMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PartyStuckRecoverySample {
|
||||||
|
readonly position: PartyWorldPosition;
|
||||||
|
readonly waypoint: PartyWorldPosition | null;
|
||||||
|
readonly goalKey: string;
|
||||||
|
readonly movementExpected: boolean;
|
||||||
|
readonly health: number;
|
||||||
|
readonly paused: boolean;
|
||||||
|
readonly stopped: boolean;
|
||||||
|
readonly casting: boolean;
|
||||||
|
readonly controlled: boolean;
|
||||||
|
readonly nowMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function distance(a: PartyWorldPosition, b: PartyWorldPosition): number {
|
||||||
|
return Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function finitePosition(position: PartyWorldPosition): boolean {
|
||||||
|
return position.every(Number.isFinite);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPartyStuckRecoveryState(position: PartyWorldPosition, nowMs: number): PartyStuckRecoveryState {
|
||||||
|
return {
|
||||||
|
goalKey: null,
|
||||||
|
previousPosition: [...position],
|
||||||
|
waypoint: null,
|
||||||
|
netProgress: 0,
|
||||||
|
lastProgressAtMs: nowMs,
|
||||||
|
replanned: false,
|
||||||
|
cooldownUntilMs: 0,
|
||||||
|
retryAtMs: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clear navigation timing without letting pauses/holds bypass a teleport cooldown. */
|
||||||
|
export function resetPartyStuckRecoveryProgress(state: PartyStuckRecoveryState): void {
|
||||||
|
state.goalKey = null;
|
||||||
|
state.waypoint = null;
|
||||||
|
state.netProgress = 0;
|
||||||
|
state.replanned = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Signed progress toward the previous waypoint cancels doorway back-and-forth. */
|
||||||
|
export function updatePartyStuckRecovery(
|
||||||
|
state: PartyStuckRecoveryState,
|
||||||
|
sample: PartyStuckRecoverySample,
|
||||||
|
): "none" | "replan" | "recover" {
|
||||||
|
const { position, waypoint, nowMs } = sample;
|
||||||
|
if (!Number.isFinite(nowMs) || !finitePosition(position) || (waypoint && !finitePosition(waypoint))) {
|
||||||
|
resetPartyStuckRecoveryProgress(state);
|
||||||
|
return "none";
|
||||||
|
}
|
||||||
|
const excluded = !sample.movementExpected || sample.health <= 0 || sample.paused
|
||||||
|
|| sample.stopped || sample.casting || sample.controlled;
|
||||||
|
if (excluded || state.goalKey !== sample.goalKey || nowMs < state.lastProgressAtMs) {
|
||||||
|
resetPartyStuckRecoveryProgress(state);
|
||||||
|
state.lastProgressAtMs = nowMs;
|
||||||
|
if (!excluded) state.goalKey = sample.goalKey;
|
||||||
|
} else if (state.waypoint) {
|
||||||
|
// Target/waypoint movement itself is not actor progress. Count only the
|
||||||
|
// actor's displacement relative to the last sampled route waypoint.
|
||||||
|
// Once the old waypoint was reached, movement belongs to the next leg.
|
||||||
|
// Otherwise dense waypoint chains would count every forward step as
|
||||||
|
// moving away from the just-completed point.
|
||||||
|
const progressWaypoint = waypoint && distance(state.previousPosition, state.waypoint) <= 0.06
|
||||||
|
&& distance(waypoint, state.waypoint) > 0.04 ? waypoint : state.waypoint;
|
||||||
|
state.netProgress += distance(state.previousPosition, progressWaypoint) - distance(position, progressWaypoint);
|
||||||
|
if (state.netProgress >= PROGRESS_DISTANCE) {
|
||||||
|
state.netProgress = 0;
|
||||||
|
state.lastProgressAtMs = nowMs;
|
||||||
|
state.replanned = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state.previousPosition[0] = position[0];
|
||||||
|
state.previousPosition[1] = position[1];
|
||||||
|
state.previousPosition[2] = position[2];
|
||||||
|
if (waypoint && !excluded) {
|
||||||
|
state.waypoint ??= [0, 0, 0];
|
||||||
|
state.waypoint[0] = waypoint[0];
|
||||||
|
state.waypoint[1] = waypoint[1];
|
||||||
|
state.waypoint[2] = waypoint[2];
|
||||||
|
} else state.waypoint = null;
|
||||||
|
if (excluded) return "none";
|
||||||
|
|
||||||
|
const elapsed = nowMs - state.lastProgressAtMs;
|
||||||
|
if (!state.replanned && elapsed >= PARTY_STUCK_REPLAN_MS) {
|
||||||
|
state.replanned = true;
|
||||||
|
return "replan";
|
||||||
|
}
|
||||||
|
if (elapsed >= PARTY_STUCK_RECOVER_MS && nowMs >= state.cooldownUntilMs && nowMs >= state.retryAtMs) {
|
||||||
|
state.retryAtMs = nowMs + RECOVERY_RETRY_MS;
|
||||||
|
return "recover";
|
||||||
|
}
|
||||||
|
return "none";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function markPartyStuckRecovered(state: PartyStuckRecoveryState, position: PartyWorldPosition, nowMs: number): void {
|
||||||
|
resetPartyStuckRecoveryProgress(state);
|
||||||
|
state.previousPosition[0] = position[0];
|
||||||
|
state.previousPosition[1] = position[1];
|
||||||
|
state.previousPosition[2] = position[2];
|
||||||
|
state.lastProgressAtMs = nowMs;
|
||||||
|
state.cooldownUntilMs = nowMs + PARTY_STUCK_RECOVERY_COOLDOWN_MS;
|
||||||
|
state.retryAtMs = state.cooldownUntilMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Position-only recovery: no squad recall, healing, threat reset, or resurrection. */
|
||||||
|
export function applyPartyStuckRecoveryLanding(
|
||||||
|
positions: ReadonlyMap<string, MutablePartyWorldPosition>,
|
||||||
|
memberId: string,
|
||||||
|
landing: PartyWorldPosition,
|
||||||
|
state: PartyStuckRecoveryState,
|
||||||
|
nowMs: number,
|
||||||
|
): boolean {
|
||||||
|
const current = positions.get(memberId);
|
||||||
|
if (!current || !finitePosition(landing) || !Number.isFinite(nowMs)) return false;
|
||||||
|
current[0] = landing[0];
|
||||||
|
current[1] = landing[1];
|
||||||
|
current[2] = landing[2];
|
||||||
|
markPartyStuckRecovered(state, current, nowMs);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Only visited trail positions are local candidates; never jump to an arbitrary nearest room. */
|
||||||
|
export function choosePartyStuckRecoveryPosition({
|
||||||
|
current, player, groupAnchor, trailPoints, validate,
|
||||||
|
}: {
|
||||||
|
readonly current: PartyWorldPosition;
|
||||||
|
readonly player: PartyWorldPosition;
|
||||||
|
readonly groupAnchor: PartyWorldPosition;
|
||||||
|
readonly trailPoints: readonly PartyWorldPosition[];
|
||||||
|
readonly validate: (position: PartyWorldPosition, anchor: PartyWorldPosition) => PartyWorldPosition | null;
|
||||||
|
}): { readonly position: PartyWorldPosition; readonly source: "trail" | "player" } | null {
|
||||||
|
if (!finitePosition(current) || !finitePosition(player) || !finitePosition(groupAnchor)) return null;
|
||||||
|
const candidates = trailPoints.filter((point) => finitePosition(point)
|
||||||
|
&& distance(point, current) >= MIN_RECOVERY_DISTANCE
|
||||||
|
&& distance(point, groupAnchor) <= MAX_TRAIL_GROUP_DISTANCE
|
||||||
|
&& Math.abs(point[1] - groupAnchor[1]) <= MAX_TRAIL_HEIGHT_DIFFERENCE)
|
||||||
|
.sort((a, b) => distance(a, groupAnchor) - distance(b, groupAnchor));
|
||||||
|
// Bound expensive path/physics checks even when a looping trail has many samples.
|
||||||
|
for (const candidate of candidates.slice(0, 16)) {
|
||||||
|
const position = validate(candidate, groupAnchor);
|
||||||
|
if (position && finitePosition(position) && distance(position, current) >= MIN_RECOVERY_DISTANCE
|
||||||
|
&& distance(position, candidate) <= 0.85
|
||||||
|
&& Math.abs(position[1] - groupAnchor[1]) <= MAX_TRAIL_HEIGHT_DIFFERENCE) {
|
||||||
|
return { position: [...position], source: "trail" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const position = validate(player, player);
|
||||||
|
return position && finitePosition(position) && distance(position, player) <= 0.85
|
||||||
|
? { position: [...position], source: "player" }
|
||||||
|
: null;
|
||||||
|
}
|
||||||
@@ -145,7 +145,7 @@ describe("player movement", () => {
|
|||||||
expect(translation.y).toBeLessThan(PLAYER_CENTER_HEIGHT + 0.1);
|
expect(translation.y).toBeLessThan(PLAYER_CENTER_HEIGHT + 0.1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("walks back up the real Stockades entrance stairs without a jump impulse", () => {
|
it("walks back up the Stockades interior stairs without a jump impulse", () => {
|
||||||
const world = new RAPIER.World({ x: 0, y: -18, z: 0 });
|
const world = new RAPIER.World({ x: 0, y: -18, z: 0 });
|
||||||
const dungeonBody = world.createRigidBody(RAPIER.RigidBodyDesc.fixed());
|
const dungeonBody = world.createRigidBody(RAPIER.RigidBodyDesc.fixed());
|
||||||
world.createCollider(
|
world.createCollider(
|
||||||
|
|||||||
@@ -0,0 +1,486 @@
|
|||||||
|
import { NodeIO } from "@gltf-transform/core";
|
||||||
|
import RAPIER from "@dimforge/rapier3d-compat";
|
||||||
|
import { ALL_EXTENSIONS } from "@gltf-transform/extensions";
|
||||||
|
import { MeshoptDecoder } from "meshoptimizer";
|
||||||
|
import { BufferAttribute, BufferGeometry, Group, Mesh, Ray, Vector3 } from "three";
|
||||||
|
import { beforeAll, describe, expect, it } from "vitest";
|
||||||
|
import { requireDungeonDefinition } from "./dungeonRegistry";
|
||||||
|
import {
|
||||||
|
findNearestPartyNavigationNode,
|
||||||
|
findNearestPartyNavigationSurface,
|
||||||
|
findPartyNavigationPath,
|
||||||
|
type PartyNavigationGraph,
|
||||||
|
} from "./partyNavigation";
|
||||||
|
import { preparePartyNavigationGraph } from "./partyNavigationAssetCache";
|
||||||
|
import { shouldLoadPartyNavigation } from "./partyNavigationPolicy";
|
||||||
|
import { planPartyRoute } from "./partyPathing";
|
||||||
|
import { partyCombatMovementGoal, stepPartyPosition } from "./partyMovement";
|
||||||
|
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 TANK_RANGE = Object.freeze({ minimum: 0, preferred: 2.9, maximum: 3.15 });
|
||||||
|
|
||||||
|
const stockades = requireDungeonDefinition("stormwind-stockade");
|
||||||
|
let graph: PartyNavigationGraph;
|
||||||
|
let sourceCollision: Group;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await RAPIER.init();
|
||||||
|
await MeshoptDecoder.ready;
|
||||||
|
const document = await new NodeIO()
|
||||||
|
.registerExtensions(ALL_EXTENSIONS)
|
||||||
|
.registerDependencies({ "meshopt.decoder": MeshoptDecoder })
|
||||||
|
.read(STOCKADES_NAVIGATION);
|
||||||
|
const meshNode = document.getRoot().listNodes().find((node) => node.getMesh());
|
||||||
|
const primitive = meshNode?.getMesh()?.listPrimitives()[0];
|
||||||
|
const accessor = primitive?.getAttribute("POSITION");
|
||||||
|
const sourcePositions = accessor?.getArray();
|
||||||
|
if (!meshNode || !accessor || !sourcePositions) {
|
||||||
|
throw new Error("Stormwind Stockade navigation GLB is missing its triangle surface.");
|
||||||
|
}
|
||||||
|
if (!(sourcePositions instanceof Int16Array) || !accessor.getNormalized()) {
|
||||||
|
throw new Error("Stormwind Stockade navigation regression expects normalized Int16 positions.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const geometry = new BufferGeometry();
|
||||||
|
geometry.setAttribute("position", new BufferAttribute(sourcePositions, 3, accessor.getNormalized()));
|
||||||
|
const sourceIndices = primitive?.getIndices()?.getArray();
|
||||||
|
if (sourceIndices) geometry.setIndex(Array.from(sourceIndices));
|
||||||
|
const mesh = new Mesh(geometry);
|
||||||
|
mesh.matrixAutoUpdate = false;
|
||||||
|
mesh.matrix.fromArray(meshNode.getWorldMatrix());
|
||||||
|
const scene = new Group();
|
||||||
|
scene.add(mesh);
|
||||||
|
// Exercise the same quantized-buffer and node-transform path as GLTFLoader
|
||||||
|
// in the shipping runtime, not a separately pre-expanded test geometry.
|
||||||
|
graph = preparePartyNavigationGraph(
|
||||||
|
"/test/stockades-navigation.glb", scene, stockades.navigationLinks, stockades.traversalRamps,
|
||||||
|
);
|
||||||
|
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 {
|
||||||
|
return Math.hypot(left[0] - right[0], left[1] - right[1], left[2] - right[2]);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Stormwind Stockade party navigation", () => {
|
||||||
|
it("blocks combat sight across shipping room walls in both directions while preserving clear stair sight", () => {
|
||||||
|
const world = collisionWorld(true, true);
|
||||||
|
try {
|
||||||
|
const prepared = prepareCollision(sourceCollision, stockades.traversalRamps);
|
||||||
|
let checkedWalls = 0;
|
||||||
|
for (const mesh of collisionLineOfSightTrimeshes(prepared)) {
|
||||||
|
for (let offset = 0; offset < mesh.indices.length && checkedWalls < 12; offset += 3) {
|
||||||
|
const a = Number(mesh.indices[offset]) * 3;
|
||||||
|
const b = Number(mesh.indices[offset + 1]) * 3;
|
||||||
|
const c = Number(mesh.indices[offset + 2]) * 3;
|
||||||
|
const v = mesh.vertices;
|
||||||
|
const ab = [Number(v[b]) - Number(v[a]), Number(v[b + 1]) - Number(v[a + 1]), Number(v[b + 2]) - Number(v[a + 2])];
|
||||||
|
const ac = [Number(v[c]) - Number(v[a]), Number(v[c + 1]) - Number(v[a + 1]), Number(v[c + 2]) - Number(v[a + 2])];
|
||||||
|
const normalX = ab[1] * ac[2] - ab[2] * ac[1];
|
||||||
|
const normalZ = ab[0] * ac[1] - ab[1] * ac[0];
|
||||||
|
const length = Math.hypot(normalX, normalZ);
|
||||||
|
if (length < 0.1) continue;
|
||||||
|
const center = [0, 1, 2].map((axis) => (Number(v[a + axis]) + Number(v[b + axis]) + Number(v[c + axis])) / 3);
|
||||||
|
const first = findNearestPartyNavigationSurface(graph,
|
||||||
|
[center[0] + normalX / length, center[1] - 1.05, center[2] + normalZ / length], 2.5)?.position;
|
||||||
|
const second = findNearestPartyNavigationSurface(graph,
|
||||||
|
[center[0] - normalX / length, center[1] - 1.05, center[2] - normalZ / length], 2.5)?.position;
|
||||||
|
if (!first || !second || Math.abs(first[1] - second[1]) > 0.4 || distance(first, second) < 1.5
|
||||||
|
|| Math.abs((first[1] + second[1]) / 2 + 1.05 - center[1]) > 0.15
|
||||||
|
|| Math.hypot(first[0] - center[0], first[2] - center[2]) > 1.3
|
||||||
|
|| Math.hypot(second[0] - center[0], second[2] - center[2]) > 1.3) continue;
|
||||||
|
const sideA = (first[0] - center[0]) * normalX + (first[2] - center[2]) * normalZ;
|
||||||
|
const sideB = (second[0] - center[0]) * normalX + (second[2] - center[2]) * normalZ;
|
||||||
|
if (sideA * sideB >= 0) continue;
|
||||||
|
// Nearby decorative faces/door-frame triangles are not room walls
|
||||||
|
// unless the actors' chest ray actually crosses this source face.
|
||||||
|
const origin = new Vector3(first[0], first[1] + 1.05, first[2]);
|
||||||
|
const destination = new Vector3(second[0], second[1] + 1.05, second[2]);
|
||||||
|
const ray = new Ray(origin, destination.clone().sub(origin).normalize());
|
||||||
|
const intersection = ray.intersectTriangle(
|
||||||
|
new Vector3(Number(v[a]), Number(v[a + 1]), Number(v[a + 2])),
|
||||||
|
new Vector3(Number(v[b]), Number(v[b + 1]), Number(v[b + 2])),
|
||||||
|
new Vector3(Number(v[c]), Number(v[c + 1]), Number(v[c + 2])), false, new Vector3(),
|
||||||
|
);
|
||||||
|
if (!intersection || intersection.distanceTo(origin) >= destination.distanceTo(origin)) continue;
|
||||||
|
expect(hasDungeonLineOfSight(RAPIER, world, first, second), JSON.stringify({ center, first, second })).toBe(false);
|
||||||
|
expect(hasDungeonLineOfSight(RAPIER, world, second, first), JSON.stringify({ center, first, second })).toBe(false);
|
||||||
|
checkedWalls += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const child of prepared.children) (child as Mesh).geometry.dispose();
|
||||||
|
expect(checkedWalls).toBeGreaterThanOrEqual(8);
|
||||||
|
for (const ramp of stockades.traversalRamps ?? []) {
|
||||||
|
expect(hasDungeonLineOfSight(RAPIER, world, ramp.start, ramp.end)).toBe(true);
|
||||||
|
expect(hasDungeonLineOfSight(RAPIER, world, ramp.end, ramp.start)).toBe(true);
|
||||||
|
}
|
||||||
|
} finally { world.free(); }
|
||||||
|
}, 20_000);
|
||||||
|
|
||||||
|
it("moves pursuing mobs up and down all shipping ramps without a false teleport or reset", () => {
|
||||||
|
const world = collisionWorld(true, true);
|
||||||
|
try {
|
||||||
|
const directAllowed = createPartyDirectSegmentValidator(RAPIER, world);
|
||||||
|
const routeAllowed = createPartyRouteSegmentValidator(RAPIER, world);
|
||||||
|
const navigator = createMobPursuit({ graph: () => graph, directAllowed, routeAllowed,
|
||||||
|
hasLineOfSight: (a, b) => hasDungeonLineOfSight(RAPIER, world, a, b),
|
||||||
|
validateLanding: createPartyRecoveryPointValidator(RAPIER, world, graph) });
|
||||||
|
for (const ramp of stockades.traversalRamps ?? []) {
|
||||||
|
for (const [start, target] of [[ramp.start, ramp.end], [ramp.end, ramp.start]]) {
|
||||||
|
const initialRoute = planPartyRoute(graph, "probe", start, target, 0, {
|
||||||
|
maxOriginSnapDistance: 3, maxDestinationSnapDistance: 3,
|
||||||
|
directSegmentAllowed: directAllowed, routeSegmentAllowed: routeAllowed,
|
||||||
|
destinationReached: (surface, destination) => distance(surface, destination) <= 1.5
|
||||||
|
&& hasDungeonLineOfSight(RAPIER, world, surface, destination),
|
||||||
|
});
|
||||||
|
expect(initialRoute.blocked, JSON.stringify({ ramp: ramp.id, waypoints: initialRoute.waypoints })).toBe(false);
|
||||||
|
const position: [number, number, number] = [...start];
|
||||||
|
for (let frame = 0; frame < 500 && distance(position, target) > 1.5; frame += 1) {
|
||||||
|
const before = [...position];
|
||||||
|
expect(navigator.step({ id: `${ramp.id}:${start[1]}`, targetId: "target", position,
|
||||||
|
target, home: start, phase: "chasing", health: 100, engaged: true, active: true,
|
||||||
|
casting: false, controlled: false, attackRange: 1.5, leashRange: 40,
|
||||||
|
maximumStep: 0.175, nowMs: frame * 50 }), JSON.stringify({ ramp: ramp.id, start, target, before, frame })).toBe("none");
|
||||||
|
}
|
||||||
|
expect(distance(position, target)).toBeLessThanOrEqual(1.5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally { world.free(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the reviewed warm indoor lighting profile", () => {
|
||||||
|
expect(stockades.presentation).toMatchObject({
|
||||||
|
background: "#171815",
|
||||||
|
fog: { color: "#1b1c18", near: 44 },
|
||||||
|
ambientLight: { color: "#d8c9aa", intensity: 0.58 },
|
||||||
|
hemisphereLight: { skyColor: "#cfc3a5", groundColor: "#292820", intensity: 0.86 },
|
||||||
|
directionalLight: { color: "#ffe0a8", intensity: 1.1, offset: [10, 18, -5] },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("loads its independently reviewed, budgeted navigation graph", () => {
|
||||||
|
expect(stockades.validation).toMatchObject({ status: "unvalidated", navigation: "green" });
|
||||||
|
expect(stockades.navigationLinks).toHaveLength(3);
|
||||||
|
expect(shouldLoadPartyNavigation("dungeon", stockades, null)).toBe(true);
|
||||||
|
expect(graph).toMatchObject({
|
||||||
|
sourceTriangleCount: 3_544,
|
||||||
|
componentCount: 31,
|
||||||
|
});
|
||||||
|
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", () => {
|
||||||
|
const slots = derivePartySpawnSlots({
|
||||||
|
footPosition: stockades.entrance.footPosition,
|
||||||
|
yaw: stockades.entrance.yaw,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(slots).toHaveLength(5);
|
||||||
|
expect(findNearestPartyNavigationNode(graph, stockades.entrance.footPosition)?.distance)
|
||||||
|
.toBeLessThanOrEqual(6);
|
||||||
|
for (const slot of slots) {
|
||||||
|
expect(findNearestPartyNavigationSurface(graph, slot.footPosition, 6)).not.toBeNull();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("routes from the shipping entrance to every authored boss objective", () => {
|
||||||
|
const world = collisionWorld();
|
||||||
|
const routeSegmentAllowed = createPartyRouteSegmentValidator(RAPIER, world);
|
||||||
|
const entrance = findNearestPartyNavigationSurface(
|
||||||
|
graph,
|
||||||
|
stockades.entrance.footPosition,
|
||||||
|
6,
|
||||||
|
);
|
||||||
|
expect(entrance).not.toBeNull();
|
||||||
|
|
||||||
|
for (const objective of stockades.objectiveOrder) {
|
||||||
|
const approach = findNearestPartyNavigationSurface(graph, objective.position, 15);
|
||||||
|
expect(approach, objective.name).not.toBeNull();
|
||||||
|
expect(findPartyNavigationPath(
|
||||||
|
graph,
|
||||||
|
entrance!.position,
|
||||||
|
approach!.position,
|
||||||
|
{ maxStartSnapDistance: 0.1, maxEndSnapDistance: 0.1, routeSegmentAllowed },
|
||||||
|
), objective.name).not.toBeNull();
|
||||||
|
}
|
||||||
|
world.free();
|
||||||
|
});
|
||||||
|
|
||||||
|
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({
|
||||||
|
footPosition: stockades.entrance.footPosition,
|
||||||
|
yaw: stockades.entrance.yaw,
|
||||||
|
}, 2)[1];
|
||||||
|
const tankStart = findNearestPartyNavigationSurface(graph, tankSlot.footPosition, 6);
|
||||||
|
expect(tankStart).not.toBeNull();
|
||||||
|
const firstEnemy = [...stockades.staticSpawns].sort((left, right) => (
|
||||||
|
distance(left.position, stockades.entrance.footPosition)
|
||||||
|
- distance(right.position, stockades.entrance.footPosition)
|
||||||
|
))[0];
|
||||||
|
const desired: [number, number, number] = [0, 0, 0];
|
||||||
|
expect(partyCombatMovementGoal(
|
||||||
|
tankStart!.position,
|
||||||
|
firstEnemy.position,
|
||||||
|
true,
|
||||||
|
TANK_RANGE.minimum,
|
||||||
|
TANK_RANGE.preferred,
|
||||||
|
TANK_RANGE.maximum,
|
||||||
|
desired,
|
||||||
|
)).toBe("approach");
|
||||||
|
|
||||||
|
const route = planPartyRoute(
|
||||||
|
graph,
|
||||||
|
`tank:${firstEnemy.id}`,
|
||||||
|
tankStart!.position,
|
||||||
|
desired,
|
||||||
|
0,
|
||||||
|
{
|
||||||
|
maxOriginSnapDistance: 3,
|
||||||
|
maxDestinationSnapDistance: 5,
|
||||||
|
localFinishDistance: TANK_RANGE.maximum,
|
||||||
|
directSegmentAllowed: segmentAllowed,
|
||||||
|
routeSegmentAllowed: createPartyRouteSegmentValidator(RAPIER, world),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(route.blocked).toBe(false);
|
||||||
|
expect(route.waypoints.length).toBeGreaterThan(1);
|
||||||
|
|
||||||
|
let current: [number, number, number] = [...tankStart!.position];
|
||||||
|
const next: [number, number, number] = [0, 0, 0];
|
||||||
|
for (const waypoint of route.waypoints) {
|
||||||
|
while (distance(current, waypoint) > 0.01) {
|
||||||
|
stepPartyPosition(current, waypoint, 0.5, next);
|
||||||
|
current = [...next];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(distance(current, firstEnemy.position)).toBeLessThanOrEqual(TANK_RANGE.maximum);
|
||||||
|
expect(partyCombatMovementGoal(
|
||||||
|
current,
|
||||||
|
firstEnemy.position,
|
||||||
|
true,
|
||||||
|
TANK_RANGE.minimum,
|
||||||
|
TANK_RANGE.preferred,
|
||||||
|
TANK_RANGE.maximum,
|
||||||
|
desired,
|
||||||
|
)).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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
+268
-200
@@ -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,
|
||||||
@@ -84,19 +90,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_OBJECTIVE_APPROACH_SNAP_DISTANCE = 15;
|
||||||
const PARTY_COLLISION_HALF_HEIGHT = 0.48;
|
const PARTY_NAVIGATION_SNAP_DISTANCE = 6;
|
||||||
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 = 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;
|
||||||
|
|
||||||
@@ -113,11 +120,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;
|
||||||
@@ -200,16 +202,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]);
|
||||||
@@ -288,16 +292,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],
|
||||||
@@ -308,92 +302,34 @@ 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;
|
// Authored-link graphs use their eroded Recast node edges directly. Their
|
||||||
if (distance <= 0.04) return true;
|
// boundary vertices are already agent-radius safe, while a center ray can
|
||||||
const queryFlags = rapier.QueryFilterFlags.EXCLUDE_SENSORS
|
// falsely read the stair floor as a wall across a short vertical seam.
|
||||||
| rapier.QueryFilterFlags.EXCLUDE_DYNAMIC;
|
const navigationRouteSegmentAllowed = navigationGraph?.triangles?.length
|
||||||
// Portal-centered paths already stay inside walkable triangles. One
|
? routeSurfaceSegmentAllowed
|
||||||
// body-center ray catches a true wall/overhang without treating the floor
|
: undefined;
|
||||||
// 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]);
|
|
||||||
const actorRefs = useRef<Array<Group | null>>([]);
|
const actorRefs = useRef<Array<Group | null>>([]);
|
||||||
const bodyRefs = useRef<Array<Group | null>>([]);
|
const bodyRefs = useRef<Array<Group | null>>([]);
|
||||||
const positionsRef = useRef(new Map<string, MutablePartyWorldPosition>());
|
const positionsRef = useRef(new Map<string, MutablePartyWorldPosition>());
|
||||||
@@ -415,7 +351,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>());
|
||||||
@@ -452,6 +388,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];
|
||||||
@@ -465,21 +402,30 @@ 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;
|
||||||
},
|
},
|
||||||
}), [hasStaticLineOfSight, memberIdKey, rapier, world]);
|
}), [hasStaticLineOfSight, memberIdKey, rapier, world]);
|
||||||
const teleportPartyToPlayer = useCallback((player: PartyWorldPosition, now: number): void => {
|
const teleportPartyToPlayer = useCallback((player: PartyWorldPosition, now: number): void => {
|
||||||
const party = usePartyStore.getState();
|
const party = usePartyStore.getState();
|
||||||
|
const navigationRecall = navigationGraphRef.current
|
||||||
|
? findNearestPartyNavigationSurface(
|
||||||
|
navigationGraphRef.current,
|
||||||
|
player,
|
||||||
|
PARTY_NAVIGATION_SNAP_DISTANCE,
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
const recallPosition = navigationRecall?.position ?? player;
|
||||||
party.members.forEach((member, index) => {
|
party.members.forEach((member, index) => {
|
||||||
const position = positionsRef.current.get(member.id) ?? initialMemberPosition(index);
|
const position = positionsRef.current.get(member.id) ?? initialMemberPosition(index);
|
||||||
// The player's exact point is the only location guaranteed to be on the
|
// Attach a recalled squad to the reviewed surface just as initial
|
||||||
// same side of a shortcut, doorway, or ledge. The breadcrumb system
|
// spawns do. An exact visual-floor point can sit between Recast stair
|
||||||
// spreads the party back into a trail once the player moves.
|
// vertices and leave the first route connector falsely blocked.
|
||||||
position[0] = player[0];
|
position[0] = recallPosition[0];
|
||||||
position[1] = player[1];
|
position[1] = recallPosition[1];
|
||||||
position[2] = player[2];
|
position[2] = recallPosition[2];
|
||||||
positionsRef.current.set(member.id, position);
|
positionsRef.current.set(member.id, position);
|
||||||
updatePartyRuntimePosition(member.id, position[0], position[1], position[2]);
|
updatePartyRuntimePosition(member.id, position[0], position[1], position[2]);
|
||||||
actorRefs.current[index]?.position.set(position[0], presentationY(position), position[2]);
|
actorRefs.current[index]?.position.set(position[0], presentationY(position), position[2]);
|
||||||
@@ -487,15 +433,14 @@ 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);
|
||||||
leaderPlayerCursorRef.current.breadcrumbId = null;
|
leaderPlayerCursorRef.current.breadcrumbId = null;
|
||||||
resetPartyBreadcrumbTrail(leaderTrailRef.current, player);
|
resetPartyBreadcrumbTrail(leaderTrailRef.current, recallPosition);
|
||||||
trailLeaderIdRef.current = firstLiving?.id ?? null;
|
trailLeaderIdRef.current = firstLiving?.id ?? null;
|
||||||
routeRef.current = emptyPartyRouteState();
|
routeRef.current = emptyPartyRouteState();
|
||||||
combatRoutesRef.current.clear();
|
combatRoutesRef.current.clear();
|
||||||
@@ -631,7 +576,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, []);
|
||||||
@@ -659,6 +604,7 @@ 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();
|
||||||
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];
|
||||||
@@ -699,11 +645,9 @@ function PartyActors({
|
|||||||
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}`;
|
||||||
@@ -714,13 +658,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;
|
||||||
@@ -756,12 +693,14 @@ function PartyActors({
|
|||||||
const leader = leaderMember;
|
const leader = leaderMember;
|
||||||
let goalKey: string;
|
let goalKey: string;
|
||||||
let hasDestination = true;
|
let hasDestination = true;
|
||||||
|
let leaderLocalFinishDistance = 3;
|
||||||
if (targetPosition && leaderTargetId) {
|
if (targetPosition && leaderTargetId) {
|
||||||
const profile = movementProfileFor(leader.id, leaderIndex);
|
const profile = movementProfileFor(leader.id, leaderIndex);
|
||||||
const rangeBand = personalizedPartyCombatRangeBand(
|
const rangeBand = personalizedPartyCombatRangeBand(
|
||||||
partyCombatRangeBand(leader),
|
partyCombatRangeBand(leader),
|
||||||
profile,
|
profile,
|
||||||
);
|
);
|
||||||
|
leaderLocalFinishDistance = Math.max(leaderLocalFinishDistance, rangeBand.maximum);
|
||||||
let combatAnchor = combatAnchorsRef.current.get(leader.id);
|
let combatAnchor = combatAnchorsRef.current.get(leader.id);
|
||||||
if (!combatAnchor || combatAnchor.targetId !== leaderTargetId) {
|
if (!combatAnchor || combatAnchor.targetId !== leaderTargetId) {
|
||||||
combatAnchor = {
|
combatAnchor = {
|
||||||
@@ -773,15 +712,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") {
|
||||||
@@ -817,7 +753,6 @@ function PartyActors({
|
|||||||
&& fullDistance(leaderPosition, leaderDestination) > 0.18;
|
&& fullDistance(leaderPosition, leaderDestination) > 0.18;
|
||||||
|
|
||||||
if (leaderFollowingPlayer) {
|
if (leaderFollowingPlayer) {
|
||||||
routeRef.current = { ...emptyPartyRouteState(), goalKey };
|
|
||||||
const cursor = leaderPlayerCursorRef.current;
|
const cursor = leaderPlayerCursorRef.current;
|
||||||
const profile = movementProfileFor(leader.id, leaderIndex);
|
const profile = movementProfileFor(leader.id, leaderIndex);
|
||||||
const baseTrailingDistance = PARTY_PLAYER_TRAILING_DISTANCE[0];
|
const baseTrailingDistance = PARTY_PLAYER_TRAILING_DISTANCE[0];
|
||||||
@@ -835,6 +770,7 @@ function PartyActors({
|
|||||||
leaderDestination,
|
leaderDestination,
|
||||||
);
|
);
|
||||||
if (directSegmentAllowed(leaderPosition, leaderDestination)) {
|
if (directSegmentAllowed(leaderPosition, leaderDestination)) {
|
||||||
|
routeRef.current = { ...emptyPartyRouteState(), goalKey };
|
||||||
const wasMoving = directFollowMovingRef.current.get(leader.id) ?? false;
|
const wasMoving = directFollowMovingRef.current.get(leader.id) ?? false;
|
||||||
leaderHasWaypoint = partyShouldMoveToComfortTarget(
|
leaderHasWaypoint = partyShouldMoveToComfortTarget(
|
||||||
leaderPosition,
|
leaderPosition,
|
||||||
@@ -877,9 +813,63 @@ function PartyActors({
|
|||||||
leaderHasWaypoint = false;
|
leaderHasWaypoint = false;
|
||||||
leaderPlayerTrailBlocked = true;
|
leaderPlayerTrailBlocked = true;
|
||||||
}
|
}
|
||||||
|
if (!leaderHasWaypoint && navigationGraph) {
|
||||||
|
const navigationFollowDestination = findNearestPartyNavigationSurface(
|
||||||
|
navigationGraph,
|
||||||
|
leaderDestination,
|
||||||
|
PARTY_NAVIGATION_SNAP_DISTANCE,
|
||||||
|
)?.position ?? leaderDestination;
|
||||||
|
if (partyRouteNeedsPlanning(
|
||||||
|
routeRef.current,
|
||||||
|
goalKey,
|
||||||
|
leaderPosition,
|
||||||
|
navigationFollowDestination,
|
||||||
|
routeNow,
|
||||||
|
{
|
||||||
|
replanDistance: 1.25,
|
||||||
|
replanIntervalMs: 350,
|
||||||
|
blockedRetryMs: 800,
|
||||||
|
},
|
||||||
|
)) {
|
||||||
|
routeRef.current = planPartyRoute(
|
||||||
|
navigationGraph,
|
||||||
|
goalKey,
|
||||||
|
leaderPosition,
|
||||||
|
navigationFollowDestination,
|
||||||
|
routeNow,
|
||||||
|
{
|
||||||
|
maxOriginSnapDistance: PARTY_NAVIGATION_SNAP_DISTANCE,
|
||||||
|
maxDestinationSnapDistance: 5,
|
||||||
|
localFinishDistance: 3,
|
||||||
|
replanDistance: 1.25,
|
||||||
|
replanIntervalMs: 350,
|
||||||
|
blockedRetryMs: 800,
|
||||||
|
directSegmentAllowed,
|
||||||
|
routeSegmentAllowed: navigationRouteSegmentAllowed,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
leaderHasWaypoint = nextPartyRouteWaypoint(
|
||||||
|
routeRef.current,
|
||||||
|
leaderPosition,
|
||||||
|
desired,
|
||||||
|
);
|
||||||
|
if (!leaderHasWaypoint && routeRef.current.blocked) {
|
||||||
|
leaderHasWaypoint = nextPartyRouteRecoveryWaypoint(
|
||||||
|
routeRef.current,
|
||||||
|
leaderPosition,
|
||||||
|
desired,
|
||||||
|
directSegmentAllowed,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
leaderPlayerTrailBlocked = !leaderHasWaypoint && routeRef.current.blocked;
|
||||||
|
}
|
||||||
leaderMovementExpected = leaderHasWaypoint
|
leaderMovementExpected = leaderHasWaypoint
|
||||||
|| planarDistance(leaderPosition, playerNavigation)
|
|| (
|
||||||
> personalizedTrailingDistance + profile.comfortSlack;
|
(!navigationGraph || leaderPlayerTrailBlocked)
|
||||||
|
&& planarDistance(leaderPosition, playerNavigation)
|
||||||
|
> personalizedTrailingDistance + profile.comfortSlack
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} else if (!hasDestination || fullDistance(leaderPosition, leaderDestination) <= 0.18) {
|
} else if (!hasDestination || fullDistance(leaderPosition, leaderDestination) <= 0.18) {
|
||||||
if (
|
if (
|
||||||
@@ -902,11 +892,18 @@ function PartyActors({
|
|||||||
leaderDestination,
|
leaderDestination,
|
||||||
routeNow,
|
routeNow,
|
||||||
{
|
{
|
||||||
maxOriginSnapDistance: 3,
|
maxOriginSnapDistance: PARTY_NAVIGATION_SNAP_DISTANCE,
|
||||||
maxDestinationSnapDistance: 5,
|
maxDestinationSnapDistance: 5,
|
||||||
localFinishDistance: 3,
|
localFinishDistance: leaderLocalFinishDistance,
|
||||||
directSegmentAllowed,
|
directSegmentAllowed,
|
||||||
routeSegmentAllowed: routeSurfaceSegmentAllowed,
|
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,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -971,7 +968,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];
|
||||||
@@ -1018,6 +1014,8 @@ function PartyActors({
|
|||||||
if (signal) signal.current = false;
|
if (signal) signal.current = false;
|
||||||
} 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 (member.activeCast || member.controlledUntil > combatNow) {
|
||||||
|
// Casting and crowd control are intentional immobility, not stuck navigation.
|
||||||
} else if (index === leaderIndex) {
|
} else if (index === leaderIndex) {
|
||||||
hasDesired = leaderHasWaypoint;
|
hasDesired = leaderHasWaypoint;
|
||||||
movementExpected = leaderMovementExpected;
|
movementExpected = leaderMovementExpected;
|
||||||
@@ -1046,15 +1044,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";
|
||||||
@@ -1082,14 +1077,19 @@ function PartyActors({
|
|||||||
desired,
|
desired,
|
||||||
routeNow,
|
routeNow,
|
||||||
{
|
{
|
||||||
maxOriginSnapDistance: 3,
|
maxOriginSnapDistance: PARTY_NAVIGATION_SNAP_DISTANCE,
|
||||||
maxDestinationSnapDistance: 5,
|
maxDestinationSnapDistance: 5,
|
||||||
localFinishDistance: 3,
|
localFinishDistance: Math.max(3, rangeBand.maximum),
|
||||||
replanDistance: 1.25,
|
replanDistance: 1.25,
|
||||||
replanIntervalMs: 350,
|
replanIntervalMs: 350,
|
||||||
blockedRetryMs: 800,
|
blockedRetryMs: 800,
|
||||||
directSegmentAllowed,
|
directSegmentAllowed,
|
||||||
routeSegmentAllowed: routeSurfaceSegmentAllowed,
|
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);
|
||||||
@@ -1112,7 +1112,6 @@ function PartyActors({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
combatRoutesRef.current.delete(member.id);
|
|
||||||
let cursor = followerCursorsRef.current.get(member.id);
|
let cursor = followerCursorsRef.current.get(member.id);
|
||||||
if (!cursor) {
|
if (!cursor) {
|
||||||
cursor = createPartyBreadcrumbCursor();
|
cursor = createPartyBreadcrumbCursor();
|
||||||
@@ -1137,6 +1136,7 @@ function PartyActors({
|
|||||||
naturalFollowTarget,
|
naturalFollowTarget,
|
||||||
);
|
);
|
||||||
if (directSegmentAllowed(current, naturalFollowTarget)) {
|
if (directSegmentAllowed(current, naturalFollowTarget)) {
|
||||||
|
combatRoutesRef.current.delete(member.id);
|
||||||
const wasMoving = directFollowMovingRef.current.get(member.id) ?? false;
|
const wasMoving = directFollowMovingRef.current.get(member.id) ?? false;
|
||||||
hasDesired = partyShouldMoveToComfortTarget(
|
hasDesired = partyShouldMoveToComfortTarget(
|
||||||
current,
|
current,
|
||||||
@@ -1158,6 +1158,7 @@ function PartyActors({
|
|||||||
movementExpected = hasDesired;
|
movementExpected = hasDesired;
|
||||||
} else {
|
} else {
|
||||||
directFollowMovingRef.current.set(member.id, false);
|
directFollowMovingRef.current.set(member.id, false);
|
||||||
|
let followerTrailBlocked = false;
|
||||||
const previousBreadcrumbId = cursor.breadcrumbId;
|
const previousBreadcrumbId = cursor.breadcrumbId;
|
||||||
const requiresValidatedRejoin = cursor.breadcrumbId === null
|
const requiresValidatedRejoin = cursor.breadcrumbId === null
|
||||||
|| !leaderTrailRef.current.points.some((point) => point.id === cursor!.breadcrumbId);
|
|| !leaderTrailRef.current.points.some((point) => point.id === cursor!.breadcrumbId);
|
||||||
@@ -1177,11 +1178,68 @@ function PartyActors({
|
|||||||
) {
|
) {
|
||||||
cursor.breadcrumbId = previousBreadcrumbId;
|
cursor.breadcrumbId = previousBreadcrumbId;
|
||||||
hasDesired = false;
|
hasDesired = false;
|
||||||
frameRouteBlocked = true;
|
followerTrailBlocked = true;
|
||||||
}
|
}
|
||||||
|
if (!hasDesired && navigationGraph) {
|
||||||
|
const goalKey = `member:${member.id}|leader-follow`;
|
||||||
|
const navigationFollowDestination = findNearestPartyNavigationSurface(
|
||||||
|
navigationGraph,
|
||||||
|
naturalFollowTarget,
|
||||||
|
PARTY_NAVIGATION_SNAP_DISTANCE,
|
||||||
|
)?.position ?? naturalFollowTarget;
|
||||||
|
let route = combatRoutesRef.current.get(member.id) ?? emptyPartyRouteState();
|
||||||
|
if (partyRouteNeedsPlanning(
|
||||||
|
route,
|
||||||
|
goalKey,
|
||||||
|
current,
|
||||||
|
navigationFollowDestination,
|
||||||
|
routeNow,
|
||||||
|
{
|
||||||
|
replanDistance: 1.25,
|
||||||
|
replanIntervalMs: 350,
|
||||||
|
blockedRetryMs: 800,
|
||||||
|
},
|
||||||
|
)) {
|
||||||
|
route = planPartyRoute(
|
||||||
|
navigationGraph,
|
||||||
|
goalKey,
|
||||||
|
current,
|
||||||
|
navigationFollowDestination,
|
||||||
|
routeNow,
|
||||||
|
{
|
||||||
|
maxOriginSnapDistance: PARTY_NAVIGATION_SNAP_DISTANCE,
|
||||||
|
maxDestinationSnapDistance: 5,
|
||||||
|
localFinishDistance: 3,
|
||||||
|
replanDistance: 1.25,
|
||||||
|
replanIntervalMs: 350,
|
||||||
|
blockedRetryMs: 800,
|
||||||
|
directSegmentAllowed,
|
||||||
|
routeSegmentAllowed: navigationRouteSegmentAllowed,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
combatRoutesRef.current.set(member.id, route);
|
||||||
|
}
|
||||||
|
hasDesired = nextPartyRouteWaypoint(route, current, desired);
|
||||||
|
const routePrefixRecovery = !hasDesired && route.blocked
|
||||||
|
? nextPartyRouteRecoveryWaypoint(
|
||||||
|
route,
|
||||||
|
current,
|
||||||
|
desired,
|
||||||
|
directSegmentAllowed,
|
||||||
|
)
|
||||||
|
: false;
|
||||||
|
if (routePrefixRecovery) hasDesired = true;
|
||||||
|
followerTrailBlocked = !hasDesired && route.blocked;
|
||||||
|
} else if (hasDesired) {
|
||||||
|
combatRoutesRef.current.delete(member.id);
|
||||||
|
}
|
||||||
|
if (followerTrailBlocked) frameRouteBlocked = true;
|
||||||
movementExpected = hasDesired
|
movementExpected = hasDesired
|
||||||
|| planarDistance(current, leaderPosition ?? playerNavigation)
|
|| (
|
||||||
> leaderTrailingDistance + profile.comfortSlack;
|
(!navigationGraph || followerTrailBlocked)
|
||||||
|
&& planarDistance(current, leaderPosition ?? playerNavigation)
|
||||||
|
> leaderTrailingDistance + profile.comfortSlack
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (memberTargetPosition) {
|
if (memberTargetPosition) {
|
||||||
@@ -1194,23 +1252,6 @@ 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" && hasDesired) {
|
||||||
@@ -1243,30 +1284,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 = 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",
|
||||||
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: 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) {
|
||||||
@@ -1285,12 +1355,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);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1382,6 +1446,8 @@ export function PartyPopulation({
|
|||||||
const wailingPhase = useWailingEncounterStore((state) => state.phase);
|
const wailingPhase = useWailingEncounterStore((state) => state.phase);
|
||||||
const manastormEncounter = useManastormStore((state) => state.currentEncounter);
|
const manastormEncounter = useManastormStore((state) => state.currentEncounter);
|
||||||
const dungeon = requireDungeonDefinition(activeDungeonId);
|
const dungeon = requireDungeonDefinition(activeDungeonId);
|
||||||
|
const navigationReviewed = dungeon.validation.status === "green"
|
||||||
|
|| dungeon.validation.navigation === "green";
|
||||||
const manastormPackage = gameMode === "manastorm"
|
const manastormPackage = gameMode === "manastorm"
|
||||||
? activeManastormStageAssetPackage()
|
? activeManastormStageAssetPackage()
|
||||||
: null;
|
: null;
|
||||||
@@ -1393,9 +1459,9 @@ export function PartyPopulation({
|
|||||||
}]
|
}]
|
||||||
// Generated packages preserve complete objective metadata, but their
|
// Generated packages preserve complete objective metadata, but their
|
||||||
// fragmented Recast islands do not claim autonomous route fidelity. Until
|
// fragmented Recast islands do not claim autonomous route fidelity. Until
|
||||||
// reviewed off-mesh links make the pack green, the player leads and the
|
// reviewed links make navigation green, the player leads and the party
|
||||||
// party follows/engages locally instead of displaying a false blocked route.
|
// follows/engages locally instead of displaying a false blocked route.
|
||||||
: dungeon.validation.status === "green"
|
: navigationReviewed
|
||||||
? activeDungeonId === "wailing-caverns"
|
? activeDungeonId === "wailing-caverns"
|
||||||
&& wailingPhase !== "mutanus"
|
&& wailingPhase !== "mutanus"
|
||||||
&& wailingPhase !== "complete"
|
&& wailingPhase !== "complete"
|
||||||
@@ -1411,6 +1477,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;
|
||||||
@@ -1450,7 +1517,7 @@ export function PartyPopulation({
|
|||||||
navigationGraph={navigationGraph}
|
navigationGraph={navigationGraph}
|
||||||
navigationFailed={navigationFailed || !navigationEnabled}
|
navigationFailed={navigationFailed || !navigationEnabled}
|
||||||
objectives={partyObjectivesForNavigation(Boolean(navigationGraph), objectives)}
|
objectives={partyObjectivesForNavigation(Boolean(navigationGraph), objectives)}
|
||||||
stackAtEntrance={gameMode === "manastorm" || dungeon.validation.status !== "green"}
|
stackAtEntrance={gameMode === "manastorm" || !navigationReviewed}
|
||||||
/>
|
/>
|
||||||
{navigationEnabled && assets.navigation ? (
|
{navigationEnabled && assets.navigation ? (
|
||||||
<NavigationAssetBoundary key={assets.navigation} onFailure={rejectNavigationGraph}>
|
<NavigationAssetBoundary key={assets.navigation} onFailure={rejectNavigationGraph}>
|
||||||
@@ -1458,6 +1525,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
@@ -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,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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(() => () => {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { useRapier } from "@react-three/rapier";
|
||||||
|
import { useEffect, useMemo } from "react";
|
||||||
|
import { combatNavigationGraph } from "../game/combatSpatial";
|
||||||
|
import { createMobPursuit } from "../game/mobPursuit";
|
||||||
|
import { registerDungeonRuntimeReset } from "../game/dungeonRuntimeReset";
|
||||||
|
import { hasDungeonLineOfSight } from "./dungeonLineOfSight";
|
||||||
|
import { createPartyDirectSegmentValidator, createPartyRecoveryPointValidator, createPartyRouteSegmentValidator } from "./partyTraversal";
|
||||||
|
|
||||||
|
/** Uses terrain only; actors remain non-blocking and the graph is loaded only once. */
|
||||||
|
export function useMobNavigation() {
|
||||||
|
const { rapier, world } = useRapier();
|
||||||
|
const navigator = useMemo(() => {
|
||||||
|
let graph = combatNavigationGraph();
|
||||||
|
let landing = createPartyRecoveryPointValidator(rapier, world, graph);
|
||||||
|
return createMobPursuit({
|
||||||
|
graph: combatNavigationGraph,
|
||||||
|
hasLineOfSight: (from, to) => hasDungeonLineOfSight(rapier, world, from, to),
|
||||||
|
directAllowed: createPartyDirectSegmentValidator(rapier, world),
|
||||||
|
routeAllowed: createPartyRouteSegmentValidator(rapier, world),
|
||||||
|
validateLanding: (point, anchor) => {
|
||||||
|
const currentGraph = combatNavigationGraph();
|
||||||
|
if (graph !== currentGraph) {
|
||||||
|
graph = currentGraph;
|
||||||
|
landing = createPartyRecoveryPointValidator(rapier, world, graph);
|
||||||
|
}
|
||||||
|
return landing(point, anchor);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [rapier, world]);
|
||||||
|
useEffect(() => registerDungeonRuntimeReset(navigator.clear), [navigator]);
|
||||||
|
return navigator;
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import RAPIER from "@dimforge/rapier3d-compat";
|
||||||
|
import { BufferAttribute, BufferGeometry } from "three";
|
||||||
|
import { beforeAll, describe, expect, it } from "vitest";
|
||||||
|
import { buildPartyNavigationGraph } from "../game/partyNavigation";
|
||||||
|
import { ACTOR_COLLISION_GROUPS } from "./actorCollision";
|
||||||
|
import { DUNGEON_PHYSICAL_COLLISION_GROUPS, DUNGEON_WALL_COLLISION_GROUPS } from "./dungeonLineOfSight";
|
||||||
|
import { createPartyRecoveryPointValidator } from "./partyTraversal";
|
||||||
|
|
||||||
|
beforeAll(async () => { await RAPIER.init(); });
|
||||||
|
|
||||||
|
function floorWorld() {
|
||||||
|
const world = new RAPIER.World({ x: 0, y: 0, z: 0 });
|
||||||
|
world.createCollider(RAPIER.ColliderDesc.cuboid(5, 0.1, 5).setTranslation(0, -0.1, 0)
|
||||||
|
.setCollisionGroups(DUNGEON_PHYSICAL_COLLISION_GROUPS));
|
||||||
|
return world;
|
||||||
|
}
|
||||||
|
|
||||||
|
function floorGraph() {
|
||||||
|
const geometry = new BufferGeometry();
|
||||||
|
geometry.setAttribute("position", new BufferAttribute(new Float32Array([
|
||||||
|
-4, 0.05, -4, 4, 0.05, -4, -4, 0.05, 4, 4, 0.05, 4,
|
||||||
|
]), 3));
|
||||||
|
geometry.setIndex([0, 2, 1, 1, 2, 3]);
|
||||||
|
const graph = buildPartyNavigationGraph(geometry);
|
||||||
|
geometry.dispose();
|
||||||
|
return graph;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("safe recovery terrain", () => {
|
||||||
|
it("accepts walkable, unobstructed landings connected to the group", () => {
|
||||||
|
const world = floorWorld();
|
||||||
|
world.step();
|
||||||
|
const allowed = createPartyRecoveryPointValidator(RAPIER, world, floorGraph());
|
||||||
|
expect(allowed([-2, 0.05, 0], [2, 0.05, 0])).not.toBeNull();
|
||||||
|
world.free();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a landing inside a wall and a route cut off by a wall", () => {
|
||||||
|
const world = floorWorld();
|
||||||
|
world.createCollider(RAPIER.ColliderDesc.cuboid(0.1, 2, 5).setTranslation(0, 2, 0)
|
||||||
|
.setCollisionGroups(DUNGEON_WALL_COLLISION_GROUPS));
|
||||||
|
world.step();
|
||||||
|
const allowed = createPartyRecoveryPointValidator(RAPIER, world, floorGraph());
|
||||||
|
expect(allowed([0, 0.05, 0], [-2, 0.05, 0])).toBeNull();
|
||||||
|
expect(allowed([-2, 0.05, 0], [2, 0.05, 0])).toBeNull();
|
||||||
|
expect(allowed([-2, 0.05, 0], [-3, 0.05, 0])).not.toBeNull();
|
||||||
|
world.free();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects low ceilings, missing floors, and points on the wrong vertical level", () => {
|
||||||
|
const world = floorWorld();
|
||||||
|
world.createCollider(RAPIER.ColliderDesc.cuboid(0.5, 0.1, 0.5).setTranslation(-2, 1.4, 0)
|
||||||
|
.setCollisionGroups(DUNGEON_PHYSICAL_COLLISION_GROUPS));
|
||||||
|
world.step();
|
||||||
|
const allowed = createPartyRecoveryPointValidator(RAPIER, world, floorGraph());
|
||||||
|
expect(allowed([-2, 0.05, 0], [2, 0.05, 0])).toBeNull();
|
||||||
|
expect(allowed([0, 5, 0], [2, 0.05, 0])).toBeNull();
|
||||||
|
const noFloor = new RAPIER.World({ x: 0, y: 0, z: 0 });
|
||||||
|
noFloor.step();
|
||||||
|
expect(createPartyRecoveryPointValidator(RAPIER, noFloor, floorGraph())([0, 0.05, 0], [0, 0.05, 0])).toBeNull();
|
||||||
|
noFloor.free();
|
||||||
|
world.free();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows a grounded player fallback without a navigation asset and ignores actor occupancy", () => {
|
||||||
|
const world = floorWorld();
|
||||||
|
const body = world.createRigidBody(RAPIER.RigidBodyDesc.kinematicPositionBased().setTranslation(0, 0.84, 0));
|
||||||
|
world.createCollider(RAPIER.ColliderDesc.capsule(0.48, 0.36).setCollisionGroups(ACTOR_COLLISION_GROUPS), body);
|
||||||
|
world.step();
|
||||||
|
expect(createPartyRecoveryPointValidator(RAPIER, world, null)([0, 0, 0], [0, 0, 0])).toEqual([0, 0, 0]);
|
||||||
|
world.free();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,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;
|
||||||
|
|||||||
Reference in new issue
Block a user