forked from phenom/healer-man
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
361734bc41 | ||
|
|
03a4e3f51c | ||
|
|
40d9f346d8 |
No files matched your search
@@ -2305,3 +2305,256 @@ Vite's existing large-chunk advisory remains informational.
|
||||
compression, and release-bundle verification passed. Existing build-size
|
||||
advisories and deprecated physics initialization warnings remain. No hosted
|
||||
release was published.
|
||||
|
||||
## 2026-09-12 - Non-blocking players and combat actors
|
||||
|
||||
- Removed party soft separation and crowd-triggered combat repositioning.
|
||||
Companions may overlap or cross one another without push-apart corrections
|
||||
changing their routes. Normal follow formations and role-specific attack
|
||||
ranges remain; a tank already in melee range does not move just because an
|
||||
ally occupies the same position.
|
||||
- Added a shared actor collision layer that interacts only with physical
|
||||
terrain. The player capsule uses this layer. Player controller movement,
|
||||
ground probes, camera sweeps, party direct/route validation, party grounding,
|
||||
and combat ground-position queries use terrain-only filters. Actor body type
|
||||
cannot turn an actor into a floor or movement obstruction. Party members,
|
||||
remote players, mobs, and bosses currently render without physical colliders;
|
||||
any future actor capsule must use the shared actor layer.
|
||||
- Walls, floors, ceilings, dungeon objects, and the Stockades walking ramps
|
||||
retain their terrain collision. Target-selection meshes, combat visibility,
|
||||
damage, and attack-range checks were not removed.
|
||||
- Ten new Rapier regressions cover overlapping actors with no contacts or
|
||||
displacement, movement through fixed/kinematic/dynamic actors (including an
|
||||
overlapped starting position), continued wall/floor collision, and ignoring
|
||||
actors in ground, party-route, and camera queries. A terrain-layer control
|
||||
confirms real actor/terrain contacts are still detected. Stockades shipping-
|
||||
asset regressions now use the live player collision policy and still pass
|
||||
down/up all three ramps without jumping.
|
||||
- Browser smoke pass at 1280 x 720 used offline level-15 Priest Rolecheck with
|
||||
Blood DK, Combat Rogue, Unholy DK, and Frost Mage companions. Inline roster
|
||||
and dungeon screenshots show character bodies, weapons, unchanged visible
|
||||
stairs, and the existing warm lighting. Attack moved companions down from
|
||||
the entrance and out of its view without ROUTE BLOCKED; the tank took aggro
|
||||
and melee/caster combat events continued. Experience rose from 11,098 to
|
||||
13,359. Stop followed by Recall returned the party to the nearby player.
|
||||
Selecting Morvane and casting Power Word: Shield applied the buff successfully.
|
||||
- Live QA was an entrance/trash/recall smoke pass, not a full dungeon clear or
|
||||
a manual traversal of both wings. Actor overlap and passage guarantees are
|
||||
backed by the physics regressions; physical Android and multiplayer QA remain
|
||||
open.
|
||||
- Validation: all 225 Vitest files passed, 1,390 tests passed, and six existing
|
||||
tests remained skipped. The final focused checks passed again after cleanup.
|
||||
Production TypeScript/Vite build, texture/performance audits, static bundle
|
||||
compression, and release-bundle verification passed. Existing chunk-size
|
||||
advisories and deprecated physics initialization warnings remain. No hosted
|
||||
release was published.
|
||||
|
||||
## 2026-09-12 - Individual companion navigation recovery
|
||||
|
||||
- Replaced the old distant, whole-party stuck Recall with an individual
|
||||
navigation watchdog in a separately tested game module. Two seconds without
|
||||
meaningful waypoint progress invalidates that companion's route; five seconds
|
||||
requests a safe recovery landing. Failed replans do not restart the timer,
|
||||
back-and-forth movement cancels forward progress, and dense waypoint chains
|
||||
still count genuine movement around corners.
|
||||
- Recovery prefers a recently visited party/player trail point near the group
|
||||
on the same floor, then falls back to a validated player position. Actual
|
||||
terrain checks require walkable ground, clear companion body space, and a
|
||||
route back to the group. Blocked or invalid landings are not applied. Each
|
||||
companion has an independent ten-second teleport cooldown.
|
||||
- Intentional attack-range holds, casting/channeling, crowd control, Stop,
|
||||
death, inactive simulation, and UI pauses do not accumulate stuck time.
|
||||
Automatic recovery changes only the affected companion's position and routing
|
||||
state; it does not heal, resurrect, reset threat, or teleport other companions.
|
||||
Manual Recall keeps its existing whole-party behavior and party order.
|
||||
- Added regressions for recovery timing, oscillation, replanning, independent
|
||||
cooldowns and member isolation, exclusions, safe-trail/player selection, wall
|
||||
and ceiling rejection, missing floors, and actor-independent landing checks.
|
||||
Shipping Stockades navigation/collision assets validate recovery landings on
|
||||
all three ramps and along approaches to all five boss objectives.
|
||||
- Browser smoke pass at 1280 x 720 used offline Priest Rolecheck and Blood DK,
|
||||
Combat Rogue, Unholy DK, and Frost Mage companions. Inline roster and dungeon
|
||||
screenshots show unchanged character bodies, equipment, stairs, and warm
|
||||
lighting. The first catalog load needed a retry. Attack moved companions down
|
||||
from the entrance without ROUTE BLOCKED, and combat continued with the player
|
||||
stationary. The unattended party eventually died; Stop remained selected
|
||||
after Recall returned the fallen companions without resurrecting them.
|
||||
- The intermittent side-room trap and its automatic recovery were not reproduced
|
||||
live. Recovery triggers and terrain safety are backed by automated tests;
|
||||
extended side-room playtesting, a healed full clear, and physical Android QA
|
||||
remain open. This pass did not modify existing ramp or actor-collision policy.
|
||||
- Validation: all 227 Vitest files passed, 1,416 tests passed, and six existing
|
||||
tests remained skipped. The final 37 focused checks passed after correcting a
|
||||
test-helper type annotation. Production TypeScript/Vite compilation, texture
|
||||
and performance audits, static compression, and bundle verification passed.
|
||||
Existing chunk-size advisories and deprecated physics initialization warnings
|
||||
remain. No hosted release was published.
|
||||
|
||||
## 2026-09-12 - Wall-aware combat pursuit and individual mob recovery
|
||||
|
||||
- Hostile mob actions now require both attack range and terrain line of sight.
|
||||
Losing either cancels a pending hostile cast so pursuit can resume; companion
|
||||
hostile casts also cancel when sight is lost. Self buffs and friendly heals
|
||||
cannot make an otherwise obstructed mob hold an attack position. Proximity
|
||||
aggro also checks sight instead of pulling enemies through nearby walls.
|
||||
- Static enemies, roaming packs, and summoned enemies share separately tested
|
||||
imperative pursuit rules over the already-loaded dungeon navigation surface.
|
||||
Blocked direct approaches use approved routes through doorways. Routes may
|
||||
finish at a reachable attack position only when that position is both in
|
||||
range and visible; off-surface connectors still require body clearance.
|
||||
- Two seconds without meaningful waypoint progress requests a replan. After
|
||||
five seconds, a stuck mob within ten meters of its target may recover to a
|
||||
validated nearby landing on the same floor and within its home leash. Ground,
|
||||
body clearance, route connectivity, attack range, and sight must all pass.
|
||||
This teleport preserves health and threat and has a ten-second cooldown.
|
||||
- Distant, wrong-floor, out-of-leash, or unsafe near-target recovery requests
|
||||
instead reset only that mob's combat state and restore its health. A validated
|
||||
home landing is preferred; otherwise it evades and navigates home without a
|
||||
blind teleport. Ten seconds of evade prevents immediate proximity, damage,
|
||||
or taunt re-engagement. Dead enemies are never resurrected. Casting, control,
|
||||
pauses, inactive simulation, and non-authoritative online clients do not
|
||||
accumulate navigation failures or trigger automatic recovery.
|
||||
- Regression coverage includes range-plus-sight attack/cast gating, immediate
|
||||
cast cancellation, doorway detours, recovery timing and safety, reset/member
|
||||
isolation, cooldown bypass protection, and near-teleport health/threat
|
||||
preservation. Shipping Stockades meshes independently verify real wall
|
||||
occlusion in both directions and clear sight across all three stair ramps.
|
||||
Pursuing mobs traverse each actual ramp uphill and downhill without false
|
||||
teleport/reset recovery. Existing party and player ramp checks still pass.
|
||||
- Browser smoke pass at 1280 x 720 used offline level-16 Priest Rolecheck and
|
||||
Blood DK, Combat Rogue, Unholy DK, and Frost Mage companions. Power Word:
|
||||
Shield applied to Morvane; Attack moved companions down from the entrance
|
||||
and out of its view without ROUTE BLOCKED, and Defias melee/cast events and
|
||||
tank damage followed. Stop plus Recall returned all four living companions
|
||||
to the nearby player while retaining Stop. Inline screenshots show character
|
||||
bodies, equipment, unchanged visible steps, and the existing warm lighting.
|
||||
The browser console had no errors and the existing physics initialization
|
||||
deprecation warning only.
|
||||
- Live QA was a short entrance/combat/recall smoke test, not a healed full clear
|
||||
or a manually reproduced side-room wall trap. Wall-blocked pursuit and mob
|
||||
recovery triggers are backed by automated tests; extended side-room combat,
|
||||
physical Android, and live multiplayer QA remain open. Existing lighting,
|
||||
ramp assets, and non-blocking actor collision policy were not changed.
|
||||
- Validation: all 229 Vitest files passed, 1,444 tests passed, and six existing
|
||||
tests remained skipped. Final production TypeScript/Vite compilation, texture
|
||||
and performance audits, static compression, and bundle verification passed.
|
||||
Existing chunk-size advisories remain. No hosted release was published.
|
||||
|
||||
## 2026-09-12 - HoTs and regeneration cannot heal corpses
|
||||
|
||||
- Reproduced the reported death-with-HoT state through lethal damage followed
|
||||
by overdue Rejuvenation ticks; the original implementation restored health
|
||||
while retaining the death animation. Separate failures confirmed passive
|
||||
spirit/equipment regeneration, ground healing, triggered healing procs, and
|
||||
a lingering damage tick's level-up refill could also restore corpse health.
|
||||
- Kept the fix in combat simulation rather than scene movement. Ordinary
|
||||
player healing in mutable combat work now uses a shared living-target guard;
|
||||
passive regeneration preserves zero health. Timed effects validate their
|
||||
target before ticks and procs, discard HoTs on dead targets even before the
|
||||
next tick, and retain HoTs on living allies when their caster dies. Newly
|
||||
applied actor HoTs also reject dead targets. Ground healing skips corpses
|
||||
while continuing to heal living teammates. Experience may still be awarded
|
||||
after death, but its level-up refill no longer implicitly resurrects.
|
||||
- Explicit player and actor-based party resurrection remain available and
|
||||
synchronize restored actor health/alive state. Party resurrection also
|
||||
clears the death animation using the existing ability-cancel event. Normal
|
||||
checkpoint respawn still restores health and clears lingering effects.
|
||||
The existing player movement gate remains unchanged and blocks zero health.
|
||||
- Fifteen new regressions cover the original self-HoT failure, actual Priest
|
||||
Renew, overdue/future ticks, explicit companion-to-player HoTs and their
|
||||
healing logs, dead allies, regeneration, ground healing, proc healing,
|
||||
post-death level rewards, living-target healing, resurrection, and respawn.
|
||||
The final focused combat/death/support checks passed 101 tests; the full
|
||||
suite passed all 230 files and 1,459 tests with six existing skips.
|
||||
- No live corpse-movement replay or physical Android check was performed in
|
||||
this pass. Evidence is combat-state regression coverage, not a new visual
|
||||
claim. Character rendering, camera/input, dungeon assets, lighting, and
|
||||
navigation policy were not modified. No hosted release was published.
|
||||
|
||||
## 2026-09-12 - Selectable resurrection checkpoints and fallen NPC recovery
|
||||
|
||||
- Normal dungeons now offer the entrance plus every defeated boss as named
|
||||
resurrection destinations. Choices use authored boss locations, not corpse
|
||||
coordinates, and unlock from actual defeated actors or durable encounter
|
||||
objective completion. Alive/script-despawned bosses and invalid destination
|
||||
IDs cannot grant a checkpoint. Boss-wipe resets retain completed checkpoints;
|
||||
a fresh dungeon run clears its progress.
|
||||
- The player defeat prompt includes a destination chooser. Respawning at the
|
||||
entrance or a cleared boss retains defeated enemies and run progress while
|
||||
resetting the current pull and restoring the party through the existing
|
||||
respawn lifecycle. Legacy callers without a choice retain saved-spawn behavior.
|
||||
- A contextual `Rez dead members` button and matching destination chooser appear
|
||||
below the party frames whenever an NPC teammate is dead. Only fallen NPCs are
|
||||
revived, at full health, into their formation slots at the selected checkpoint.
|
||||
Living NPCs, the player's health/location, current enemies, and party orders
|
||||
are not reset. Lingering timed effects on revived NPCs are removed without
|
||||
removing living allies' HoTs. Manastorm charges and remote human resurrection
|
||||
behavior are unchanged; non-authority online clients cannot mutate NPC revival.
|
||||
- Living, engaged bosses disable the NPC chooser/button with an explanation;
|
||||
the same restriction is checked again when the action executes. Trash combat
|
||||
does not block it. Individual return tasks use approved navigation toward the
|
||||
player's current location, including while the party is on Stop, then resume
|
||||
the current order. Returning members do not cast/taunt from the checkpoint or
|
||||
skew the engaged squad's target-selection center. Existing validated stuck
|
||||
recovery remains available. Recall, death, actor unmount, and run reset cancel
|
||||
pending return tasks. Scene relocation clears the corpse animation explicitly.
|
||||
- Live localhost Stockades screenshots at 1280x720 showed the panel with a dead
|
||||
Morvane tank, an enabled entrance chooser/button during trash combat, and the
|
||||
tank restored to 1707 health at the entrance after clicking while Stop remained
|
||||
selected. The button disappeared when no NPC was dead; living Thorne continued
|
||||
taking damage rather than receiving a party-wide refill. At 384x844 both native
|
||||
chooser and button remained within the viewport, about 176px wide and 44px high.
|
||||
The narrow screenshot capture was backend-scaled, so DOM bounds supplemented
|
||||
the visual check. Normal viewport sizing was restored and the temporary tab
|
||||
and development server were closed.
|
||||
- Twenty-nine new regressions cover checkpoint unlocking/retention, invalid and
|
||||
locked choices, player checkpoint respawn, boss/trash restrictions, selective
|
||||
NPC revival/effect cleanup, authority/Manastorm isolation, Stop/rejoin behavior,
|
||||
wall-aware return navigation, registry lifecycle, and UI visibility/placement.
|
||||
The shipping Stockades navigation/collision test also simulates all four NPC
|
||||
formation slots walking from the entrance and every boss checkpoint back to
|
||||
the entrance through approved corridors, without requiring stuck recovery.
|
||||
Final Vitest validation passed all 234 files and 1,488 tests, with six existing
|
||||
skips. No live boss-kill destination-selection, distant-player return replay,
|
||||
multiplayer session, or physical Android check is claimed in this pass.
|
||||
- Browser logs contained no errors; Rapier initialization and KTX2-loader warnings
|
||||
occurred during development hot reloads. Character assets, camera/input,
|
||||
dungeon geometry, and lighting were not edited. Final production TypeScript/Vite
|
||||
compilation, static compression, and release-bundle verification passed; texture
|
||||
and environment-performance audits also passed during prebuild. Existing build
|
||||
chunk advisories remain. No hosted release was published.
|
||||
|
||||
## Optional DPS meter — September 12, 2026
|
||||
|
||||
- Added a compact party DPS meter with current/last-fight damage, class-colored
|
||||
rankings, and a shared fight timer. It consumes the lossless combat event
|
||||
stream and counts effective damage, including pets/summons under their owner.
|
||||
Completed fights remain visible until the next pull; encounter resets clear
|
||||
the meter. Hiding it does not stop recording. Local menu/background pauses
|
||||
do not dilute DPS.
|
||||
- Automatic placement uses the PC lower-left corner and the Thor companion
|
||||
display. The UI editor supports visibility, dragging/keyboard nudges, width,
|
||||
and screen selection. These settings participate in saved layouts, all six
|
||||
template slots, and export/import, including migration of older UI files.
|
||||
Dedicated companion snapshots carry meter data and draft placement; lower
|
||||
display movement commands update the authoritative editor draft.
|
||||
- Live localhost Stockades checks at 1280x720 showed increasing party damage
|
||||
and readable bars in the PC lower-left corner. Resizing from 230 to 240 pixels
|
||||
worked; dragging moved the handle from approximately (9, 509) to (269, 400).
|
||||
Saving visibility off hid the meter, and reopening the editor retained that
|
||||
setting while damage continued accumulating. Default position/width and
|
||||
visibility were restored after this check.
|
||||
- In Thor preview, the meter appeared only inside the 620x540 companion surface.
|
||||
Populated bars remained clear of the party strip, vital controls, and action
|
||||
bar. With the main editor open, dragging on the bottom screen moved its handle
|
||||
from approximately (526, 322) to (661, 369) within companion bounds. Those
|
||||
preview movement edits were canceled. Screenshots are saved in
|
||||
`artifacts/dps-meter/pc-default.png`, `thor-default.png`, and
|
||||
`thor-editor-moved.png`. Full-page capture scaled incorrectly in the browser
|
||||
backend, so the Thor screenshots use the scrolled normal viewport.
|
||||
- All 47 targeted tests passed across seven files, including damage accounting,
|
||||
ownership, reset/pause behavior, UI migration/persistence, and dedicated
|
||||
companion publication/unsubscription. The production build and release
|
||||
checks passed. Browser error logs were empty. Physical Thor hardware and a
|
||||
multiplayer session were not exercised; the new per-party totals consume
|
||||
this client's combat stream. No hosted release was published.
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "healer-man",
|
||||
"version": "0.1.34",
|
||||
"version": "0.1.37",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "healer-man",
|
||||
"version": "0.1.34",
|
||||
"version": "0.1.37",
|
||||
"dependencies": {
|
||||
"@capacitor/android": "8.4.1",
|
||||
"@capacitor/core": "8.4.1",
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "healer-man",
|
||||
"private": true,
|
||||
"version": "0.1.34",
|
||||
"version": "0.1.37",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"versionName": "0.1.34",
|
||||
"versionCode": 1034
|
||||
"versionName": "0.1.37",
|
||||
"versionCode": 1037
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import "./ui/uiLayout.css";
|
||||
import { DualDisplayFrame } from "./components/DualDisplayFrame";
|
||||
import { installInput, releasePointerLock } from "./game/inputManager";
|
||||
import { CombatBridge } from "./game/CombatBridge";
|
||||
import { connectDpsMeter } from "./game/dpsMeterStore";
|
||||
import { useCombatStore } from "./game/combatStore";
|
||||
import { releaseDungeonGltfCache } from "./game/dungeonAssetCache";
|
||||
import { clearDungeonRuntimeCaches } from "./game/dungeonRuntimeReset";
|
||||
@@ -93,6 +94,7 @@ function InputBridge() {
|
||||
}
|
||||
|
||||
export default function GameRuntime() {
|
||||
useEffect(connectDpsMeter, []);
|
||||
const editingControls = useGameStore((state) => state.overlay === "keybinds" || state.overlay === "ui-layout");
|
||||
useEffect(() => () => {
|
||||
const activeDungeon = requireDungeonDefinition(useGameStore.getState().activeDungeonId);
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { PartyNavigationGraph } from "./partyNavigation";
|
||||
|
||||
export type CombatSpatialPosition = readonly [x: number, y: number, z: number];
|
||||
|
||||
export interface CombatSpatialActorSnapshot {
|
||||
@@ -8,6 +10,8 @@ export interface CombatSpatialActorSnapshot {
|
||||
}
|
||||
|
||||
export interface CombatSpatialProvider {
|
||||
/** The already-loaded, reviewed dungeon surface shared by actor navigation. */
|
||||
readonly navigationGraph?: () => PartyNavigationGraph | null;
|
||||
readonly actor: (actorId: string) => CombatSpatialActorSnapshot | null;
|
||||
readonly hasLineOfSight: (
|
||||
source: CombatSpatialPosition,
|
||||
@@ -45,6 +49,10 @@ export function combatHasLineOfSight(
|
||||
return provider?.hasLineOfSight(source, target) ?? true;
|
||||
}
|
||||
|
||||
export function combatNavigationGraph(): PartyNavigationGraph | null {
|
||||
return provider?.navigationGraph?.() ?? null;
|
||||
}
|
||||
|
||||
export function projectCombatGroundPoint(
|
||||
requested: CombatSpatialPosition,
|
||||
maximumDistance: number,
|
||||
|
||||
+143
-70
@@ -372,6 +372,7 @@ export interface MobCombatState {
|
||||
readonly lastDamageAt: number;
|
||||
readonly engaged: boolean;
|
||||
readonly combatPhase: "idle" | "chasing" | "attacking" | "returning";
|
||||
readonly navigationEvadeUntil?: number;
|
||||
readonly homePosition: CombatPosition | null;
|
||||
readonly nextAttackAt: number;
|
||||
readonly abilityReadyAt: Readonly<Record<string, number>>;
|
||||
@@ -723,6 +724,7 @@ export interface CombatState {
|
||||
cancelCast: (message?: string) => boolean;
|
||||
engageNearbyMobs: (now?: number, playerPosition?: CombatPosition) => number;
|
||||
advanceMobCombat: (now?: number, playerPosition?: CombatPosition) => number;
|
||||
resetMobForNavigation: (id: string, atHome: boolean, now?: number) => boolean;
|
||||
tick: (deltaSeconds: number, now?: number) => void;
|
||||
resetEncounter: (options?: {
|
||||
readonly preserveDefeated?: boolean;
|
||||
@@ -2173,10 +2175,7 @@ function executeTriggeredAuraProcsInWork(
|
||||
const maximum = work.maxResourcePools[resource] ?? defaultResourceMaximum(resource);
|
||||
work.resourcePools[resource] = clamp((work.resourcePools[resource] ?? 0) + action.amount, 0, maximum);
|
||||
} else if (action.kind === "heal" && actionTarget === PLAYER_AURA_ENTITY_ID) {
|
||||
const healed = Math.min(Math.max(0, action.amount), work.maxHealth - work.health);
|
||||
work.health += healed;
|
||||
work.progression.health = work.health;
|
||||
work.totalHealing += healed;
|
||||
work.totalHealing += healPlayerInWork(work, action.amount);
|
||||
} else if (action.kind === "heal" && actionTarget && !work.mobs[actionTarget]) {
|
||||
work.totalHealing += healCompanionActorInWork(work, actionTarget, Math.max(0, action.amount));
|
||||
} else if (action.kind === "damage" && actionTarget && work.mobs[actionTarget]) {
|
||||
@@ -2377,7 +2376,8 @@ function grantMobExperience(
|
||||
work.secondaryClassId,
|
||||
work.auras,
|
||||
);
|
||||
progression.health = progression.maxHealth;
|
||||
// A lingering DoT may grant experience after death, but leveling is not resurrection.
|
||||
progression.health = work.health > 0 ? progression.maxHealth : 0;
|
||||
work.health = progression.health;
|
||||
work.maxHealth = progression.maxHealth;
|
||||
work.maxResource = talentAdjustedMaxResource(
|
||||
@@ -2418,6 +2418,7 @@ function engageMobGroup(
|
||||
let changed = false;
|
||||
for (const [id, mob] of Object.entries(mobs)) {
|
||||
if (mob.dead || mob.despawned || mobGroupId(id) !== groupId) continue;
|
||||
if (mob.combatPhase === "returning" || (mob.navigationEvadeUntil ?? 0) > now) continue;
|
||||
const threatByActor = groupWasEngaged
|
||||
? mob.threatByActor
|
||||
: seedActorThreat(mob.threatByActor, sourceActorId);
|
||||
@@ -2459,7 +2460,7 @@ function nearestAggroActorId(
|
||||
let nearestDistanceSquared = aggroRange * aggroRange;
|
||||
if (state.health > 0) {
|
||||
const playerDistanceSquared = distanceSquared(mobPosition, state.playerPosition);
|
||||
if (playerDistanceSquared <= nearestDistanceSquared) {
|
||||
if (playerDistanceSquared <= nearestDistanceSquared && combatHasLineOfSight(mobPosition, state.playerPosition)) {
|
||||
nearestId = PLAYER_AGGRO_ID;
|
||||
nearestDistanceSquared = playerDistanceSquared;
|
||||
}
|
||||
@@ -2469,7 +2470,7 @@ function nearestAggroActorId(
|
||||
const position = getPartyRuntimePosition(member.id);
|
||||
if (!position) continue;
|
||||
const memberDistanceSquared = distanceSquared(mobPosition, position);
|
||||
if (memberDistanceSquared < nearestDistanceSquared) {
|
||||
if (memberDistanceSquared < nearestDistanceSquared && combatHasLineOfSight(mobPosition, position)) {
|
||||
nearestId = member.id;
|
||||
nearestDistanceSquared = memberDistanceSquared;
|
||||
}
|
||||
@@ -2479,7 +2480,7 @@ function nearestAggroActorId(
|
||||
const position = getPartyRuntimePosition(actor.id);
|
||||
if (!position) continue;
|
||||
const actorDistanceSquared = distanceSquared(mobPosition, position);
|
||||
if (actorDistanceSquared < nearestDistanceSquared) {
|
||||
if (actorDistanceSquared < nearestDistanceSquared && combatHasLineOfSight(mobPosition, position)) {
|
||||
nearestId = actor.id;
|
||||
nearestDistanceSquared = actorDistanceSquared;
|
||||
}
|
||||
@@ -2487,7 +2488,7 @@ function nearestAggroActorId(
|
||||
for (const summon of state.summons) {
|
||||
if (summon.health <= 0) continue;
|
||||
const summonDistanceSquared = distanceSquared(mobPosition, summon.position);
|
||||
if (summonDistanceSquared < nearestDistanceSquared) {
|
||||
if (summonDistanceSquared < nearestDistanceSquared && combatHasLineOfSight(mobPosition, summon.position)) {
|
||||
nearestId = summon.id;
|
||||
nearestDistanceSquared = summonDistanceSquared;
|
||||
}
|
||||
@@ -2533,7 +2534,9 @@ function damageMobInWork(
|
||||
combatResult?: CombatResult,
|
||||
): DamageResult {
|
||||
const mob = work.mobs[id];
|
||||
if (!mob || mob.dead || amount <= 0) return { amount: 0, killed: false, xpAwarded: 0, levelsGained: 0 };
|
||||
if (!mob || mob.dead || amount <= 0 || (mob.navigationEvadeUntil ?? 0) > now) {
|
||||
return { amount: 0, killed: false, xpAwarded: 0, levelsGained: 0 };
|
||||
}
|
||||
const mitigatedBeforeAuras = combatResult?.finalDamage ?? damageAfterMobResistance(
|
||||
amount,
|
||||
mob,
|
||||
@@ -2678,7 +2681,7 @@ function resolveAndDamageMobInWork(
|
||||
rules: CombatResultRules = {},
|
||||
): DamageResult {
|
||||
const mob = work.mobs[id];
|
||||
if (!mob || mob.dead || amount <= 0) {
|
||||
if (!mob || mob.dead || amount <= 0 || (mob.navigationEvadeUntil ?? 0) > now) {
|
||||
return { amount: 0, killed: false, xpAwarded: 0, levelsGained: 0 };
|
||||
}
|
||||
const sourceAuras = aurasForEntity(work.auras, source.actorId);
|
||||
@@ -2872,10 +2875,7 @@ function executeTriggeredSpellInWork(
|
||||
"taken",
|
||||
));
|
||||
if (recipient === PLAYER_AURA_ENTITY_ID) {
|
||||
const healed = Math.min(amount, work.maxHealth - work.health);
|
||||
work.health += healed;
|
||||
work.progression.health = work.health;
|
||||
work.totalHealing += healed;
|
||||
work.totalHealing += healPlayerInWork(work, amount);
|
||||
} else {
|
||||
const healed = healCompanionActorInWork(work, recipient, amount);
|
||||
work.totalHealing += healed;
|
||||
@@ -3183,6 +3183,18 @@ function applyAuraHealthTransitionInWork(
|
||||
mirrorCompanionActor(nextActor);
|
||||
}
|
||||
|
||||
/** Ordinary healing never transitions a corpse back to a living actor. */
|
||||
function healPlayerInWork(work: MutableCastWork, amount: number): number {
|
||||
const requested = Math.max(0, Math.round(Number.isFinite(amount) ? amount : 0));
|
||||
if (work.health <= 0 || requested <= 0) return 0;
|
||||
const healed = Math.min(requested, Math.max(0, work.maxHealth - work.health));
|
||||
work.health += healed;
|
||||
work.progression.health = work.health;
|
||||
const actor = work.actors[PLAYER_AURA_ENTITY_ID];
|
||||
if (actor) work.actors[PLAYER_AURA_ENTITY_ID] = { ...actor, health: work.health, alive: true };
|
||||
return healed;
|
||||
}
|
||||
|
||||
function healCompanionActorInWork(work: MutableCastWork, id: string, amount: number): number {
|
||||
const actor = ensureCompanionActorInWork(work, id);
|
||||
const requested = Math.max(0, Math.round(Number.isFinite(amount) ? amount : 0));
|
||||
@@ -4695,7 +4707,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
? Math.min(resolution.rawAmount, friendlySummon.maxHealth - friendlySummon.health)
|
||||
: friendlyOnline
|
||||
? Math.min(resolution.rawAmount, friendlyOnline.maxHealth - friendlyOnline.health)
|
||||
: resolution.effectiveAmount;
|
||||
: healPlayerInWork(work, resolution.rawAmount);
|
||||
if (friendlySummon && healed > 0) {
|
||||
work.summons = work.summons.map((summon) => summon.id === healingTargetId
|
||||
? { ...summon, health: summon.health + healed }
|
||||
@@ -4705,9 +4717,6 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
} else if (friendlyOnline && healed > 0) {
|
||||
const actor = work.actors[healingTargetId] ?? friendlyOnline;
|
||||
work.actors[healingTargetId] = { ...actor, health: actor.health + healed, alive: true };
|
||||
} else if (!friendlyTarget) {
|
||||
work.health += healed;
|
||||
work.progression.health = work.health;
|
||||
}
|
||||
recordCombatEventInWork(work, {
|
||||
occurredAt: now,
|
||||
@@ -4739,10 +4748,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
&& aura.definition.utilityTags?.includes("beacon-of-light")
|
||||
));
|
||||
if (beacon?.targetId === PLAYER_AURA_ENTITY_ID) {
|
||||
const beaconHealing = Math.min(directHealing, work.maxHealth - work.health);
|
||||
work.health += beaconHealing;
|
||||
work.progression.health = work.health;
|
||||
work.totalHealing += beaconHealing;
|
||||
work.totalHealing += healPlayerInWork(work, directHealing);
|
||||
} else if (beacon) {
|
||||
work.totalHealing += healCompanionActorInWork(work, beacon.targetId, directHealing);
|
||||
}
|
||||
@@ -5326,10 +5332,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
work.resource = clamp(work.resource + restored, 0, work.maxResource);
|
||||
work.resourcePools[activeResourceType] = work.resource;
|
||||
} else if (/food|refreshment|health|firestone/i.test(ability.name)) {
|
||||
const healed = Math.min(Math.max(1, Math.round(work.maxHealth * 0.25)), work.maxHealth - work.health);
|
||||
work.health += healed;
|
||||
work.progression.health = work.health;
|
||||
work.totalHealing += healed;
|
||||
work.totalHealing += healPlayerInWork(work, Math.max(1, Math.round(work.maxHealth * 0.25)));
|
||||
}
|
||||
} else if (effect.action === "portal") {
|
||||
summonSequence += 1;
|
||||
@@ -5358,13 +5361,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
aura.targetId === PLAYER_AURA_ENTITY_ID
|
||||
&& aura.definition.utilityTags?.includes("rom-revival-charge")
|
||||
));
|
||||
const restored = Math.min(
|
||||
work.maxHealth - work.health,
|
||||
Math.round(work.maxHealth * 0.05 * (revival?.stacks ?? 0)),
|
||||
);
|
||||
work.health += restored;
|
||||
work.progression.health = work.health;
|
||||
work.totalHealing += restored;
|
||||
work.totalHealing += healPlayerInWork(work, Math.round(work.maxHealth * 0.05 * (revival?.stacks ?? 0)));
|
||||
if (revival) work.auras = work.auras.filter((aura) => aura.instanceId !== revival.instanceId);
|
||||
}
|
||||
}
|
||||
@@ -5407,9 +5404,8 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
|
||||
// Judgement of Light restores 2% health when the marked target is struck.
|
||||
if (work.totalDamage > 0 && targetId && work.mobs[targetId]?.statuses.some((entry) => entry.kind === "marked")) {
|
||||
const healed = Math.min(talentAdjustedAmount(Math.round(work.maxHealth * 0.02), modifiers, "healing-percent", ability), work.maxHealth - work.health);
|
||||
work.health += healed;
|
||||
work.progression.health = work.health;
|
||||
const healed = healPlayerInWork(work,
|
||||
talentAdjustedAmount(Math.round(work.maxHealth * 0.02), modifiers, "healing-percent", ability));
|
||||
work.totalHealing += healed;
|
||||
addHealingThreatInWork(work, castThreatSource, healed);
|
||||
}
|
||||
@@ -5656,7 +5652,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
|
||||
tauntMob: (id, source, durationMs, now = combatNow()) => {
|
||||
const target = get().mobs[id];
|
||||
if (!target || target.dead || durationMs <= 0) return false;
|
||||
if (!target || target.dead || durationMs <= 0 || (target.navigationEvadeUntil ?? 0) > now) return false;
|
||||
const previous = target.tauntDiminishingReturns;
|
||||
const applications = target.boss && previous && previous.resetAt > now ? previous.applications : 0;
|
||||
const multiplier = target.boss ? [1, 0.65, 0.4225, 0][Math.min(3, applications)] : 1;
|
||||
@@ -5676,6 +5672,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
));
|
||||
for (const [mobId, mob] of Object.entries(mobs)) {
|
||||
if (mob.dead || mobGroupId(mobId) !== groupId) continue;
|
||||
if ((mob.navigationEvadeUntil ?? 0) > now) continue;
|
||||
const seeded = groupWasEngaged
|
||||
? mob.threatByActor
|
||||
: seedActorThreat(mob.threatByActor, source.actorId);
|
||||
@@ -5868,10 +5865,15 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
if (id === PLAYER_AURA_ENTITY_ID) {
|
||||
const restored = state.health > 0 ? 0 : Math.max(1, Math.round(state.maxHealth * fraction));
|
||||
if (restored <= 0) return 0;
|
||||
const playerActor = state.actors[id];
|
||||
set({
|
||||
health: restored,
|
||||
controlledUntil: 0,
|
||||
controlMechanic: null,
|
||||
playerAnimationEvent: nextAnimationEvent("ability-cancel"),
|
||||
actors: playerActor ? { ...state.actors, [id]: {
|
||||
...playerActor, health: restored, alive: true, controlledUntil: 0, cast: null,
|
||||
} } : state.actors,
|
||||
feedback: nextFeedback("heal", `Resurrected with ${restored} health.`, { amount: restored, abilityId }),
|
||||
combatEvents: recordCombatEvent(state.combatEvents, {
|
||||
occurredAt: now,
|
||||
@@ -6095,7 +6097,12 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
const state = get();
|
||||
if (!state.actors[sourceActorId]) return false;
|
||||
if (effect.kind === "dot" && (!state.mobs[targetActorId] || state.mobs[targetActorId].dead)) return false;
|
||||
if (effect.kind === "hot" && !state.actors[targetActorId]) return false;
|
||||
if (effect.kind === "hot") {
|
||||
const targetHealth = targetActorId === PLAYER_AURA_ENTITY_ID ? state.health
|
||||
: state.summons.find((summon) => summon.id === targetActorId)?.health
|
||||
?? state.actors[targetActorId]?.health ?? 0;
|
||||
if (!state.actors[targetActorId] || targetHealth <= 0) return false;
|
||||
}
|
||||
const sourceActor = state.actors[sourceActorId];
|
||||
const sourceMember = usePartyStore.getState().members.find((member) => member.id === sourceActorId);
|
||||
const amount = sourceMember
|
||||
@@ -6396,11 +6403,15 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
const restored = Math.max(1, Math.round(
|
||||
state.maxHealth * Math.max(0.01, Math.min(1, percentMaxHealth)),
|
||||
));
|
||||
const playerActor = state.actors[PLAYER_AURA_ENTITY_ID];
|
||||
set({
|
||||
health: restored,
|
||||
controlledUntil: 0,
|
||||
controlMechanic: null,
|
||||
playerAnimationEvent: nextAnimationEvent("ability-cancel"),
|
||||
actors: playerActor ? { ...state.actors, [PLAYER_AURA_ENTITY_ID]: {
|
||||
...playerActor, health: restored, alive: true, controlledUntil: 0, cast: null,
|
||||
} } : state.actors,
|
||||
feedback: nextFeedback("heal", `Resurrected with ${restored} health.`, { amount: restored }),
|
||||
});
|
||||
return restored;
|
||||
@@ -6441,7 +6452,8 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
let engagedGroups = 0;
|
||||
for (const id of Object.keys(mobs)) {
|
||||
const mob = mobs[id];
|
||||
if (mob.dead || mob.despawned || mob.engaged || mob.aggroRange <= 0) continue;
|
||||
if (mob.dead || mob.despawned || mob.engaged || mob.aggroRange <= 0
|
||||
|| mob.combatPhase === "returning" || (mob.navigationEvadeUntil ?? 0) > now) continue;
|
||||
const position = getMobPosition(id);
|
||||
if (!position) continue;
|
||||
const actorId = nearestAggroActorId(runtimeState, position, mob.aggroRange);
|
||||
@@ -6452,6 +6464,38 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
return engagedGroups;
|
||||
},
|
||||
|
||||
resetMobForNavigation: (id, atHome, now = combatNow()) => {
|
||||
const state = get();
|
||||
const mob = state.mobs[id];
|
||||
if (!mob || mob.dead || mob.despawned || !Number.isFinite(now)) return false;
|
||||
set({
|
||||
mobs: { ...state.mobs, [id]: {
|
||||
...mob,
|
||||
engaged: false,
|
||||
combatPhase: atHome ? "idle" : "returning",
|
||||
navigationEvadeUntil: Math.max(mob.navigationEvadeUntil ?? 0, now + 10_000),
|
||||
health: mob.maxHealth,
|
||||
statuses: [],
|
||||
controlDiminishingReturns: {},
|
||||
tauntDiminishingReturns: { applications: 0, resetAt: 0 },
|
||||
auras: [],
|
||||
abilityReadyAt: {},
|
||||
abilityCastCounts: {},
|
||||
activeCast: null,
|
||||
schoolLockedUntil: {},
|
||||
nextAttackAt: 0,
|
||||
nextAttackIndex: 0,
|
||||
threatByActor: {},
|
||||
targetActorId: null,
|
||||
forcedTarget: null,
|
||||
} },
|
||||
effects: state.effects.filter((effect) => effect.targetId !== id && effect.sourceActorId !== id),
|
||||
auras: state.auras.filter((aura) => aura.targetId !== id && aura.sourceId !== id),
|
||||
areaEffects: state.areaEffects.filter((area) => area.sourceActorId !== id),
|
||||
});
|
||||
return true;
|
||||
},
|
||||
|
||||
advanceMobCombat: (now = combatNow(), playerPosition) => {
|
||||
const state = get();
|
||||
const runtimeState = playerPosition
|
||||
@@ -6527,6 +6571,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
const dy = position[1] - targetPosition[1];
|
||||
const dz = position[2] - targetPosition[2];
|
||||
const distanceSquared = dx * dx + dy * dy + dz * dz;
|
||||
const targetVisible = combatHasLineOfSight(position, targetPosition);
|
||||
const abilityReadyAt = Object.keys(mob.abilityReadyAt).length
|
||||
? { ...mob.abilityReadyAt }
|
||||
: { ...initialEnemyAbilityReadyAt(mob, now) };
|
||||
@@ -6537,6 +6582,20 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
let enemyActiveCast = cannotAttack || (silenced && mob.activeCast?.school !== "physical")
|
||||
? null
|
||||
: mob.activeCast;
|
||||
if (enemyActiveCast) {
|
||||
const pendingAttack = mob.attacks.find((attack) => attack.id === enemyActiveCast!.attackId);
|
||||
const pendingPosition = enemyActiveCast.targetActorId
|
||||
? actorPosition(runtimeState, enemyActiveCast.targetActorId) : null;
|
||||
if (pendingAttack && enemyAbilityNeedsHostileRange(pendingAttack)
|
||||
&& (!pendingPosition || !combatHasLineOfSight(position, pendingPosition)
|
||||
|| Math.hypot(position[0] - pendingPosition[0], position[1] - pendingPosition[1],
|
||||
position[2] - pendingPosition[2]) > pendingAttack.range)) {
|
||||
emitEnemyAttackPresentation(mob, pendingAttack, "cast-cancel", now, position,
|
||||
enemyActiveCast.targetActorId, pendingPosition);
|
||||
abilityReadyAt[pendingAttack.id] = now + 250;
|
||||
enemyActiveCast = null;
|
||||
}
|
||||
}
|
||||
let completingEnemyCast = false;
|
||||
let selectedAttackIndex = -1;
|
||||
let selectedAttack: EnemyAttackDefinition | null = null;
|
||||
@@ -6551,7 +6610,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
const attack = mob.attacks[index];
|
||||
const attackKind = enemyAttackKind(attack);
|
||||
const inAbilityRange = !enemyAbilityNeedsHostileRange(attack)
|
||||
|| distanceSquared <= attack.range * attack.range;
|
||||
|| (targetVisible && distanceSquared <= attack.range * attack.range);
|
||||
if (
|
||||
inAbilityRange
|
||||
&& !(silenced && attackKind === "spell")
|
||||
@@ -6567,10 +6626,24 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
}
|
||||
}
|
||||
}
|
||||
const inRange = mob.attacks.some((attack) => (
|
||||
!enemyAbilityNeedsHostileRange(attack)
|
||||
|| distanceSquared <= attack.range * attack.range
|
||||
));
|
||||
// A self buff/heal must not pin an otherwise blocked attacker in place.
|
||||
const hostileAttacks = mob.attacks.filter(enemyAbilityNeedsHostileRange);
|
||||
const inRange = targetVisible && (hostileAttacks.length
|
||||
? hostileAttacks.some((attack) => distanceSquared <= attack.range * attack.range)
|
||||
: distanceSquared <= mob.attackRange * mob.attackRange);
|
||||
if (selectedAttack && completingEnemyCast && enemyAbilityNeedsHostileRange(selectedAttack)) {
|
||||
const castTargetPosition = enemyActiveCast?.targetActorId
|
||||
? actorPosition(runtimeState, enemyActiveCast.targetActorId) : null;
|
||||
if (!castTargetPosition || !combatHasLineOfSight(position, castTargetPosition)
|
||||
|| Math.hypot(position[0] - castTargetPosition[0], position[1] - castTargetPosition[1],
|
||||
position[2] - castTargetPosition[2]) > selectedAttack.range) {
|
||||
emitEnemyAttackPresentation(mob, selectedAttack, "cast-cancel", now, position,
|
||||
enemyActiveCast?.targetActorId ?? resolution.targetActorId, castTargetPosition);
|
||||
abilityReadyAt[selectedAttack.id] = now + 250;
|
||||
enemyActiveCast = null;
|
||||
selectedAttack = null;
|
||||
}
|
||||
}
|
||||
const phase: MobCombatState["combatPhase"] = inRange ? "attacking" : rooted ? "idle" : "chasing";
|
||||
let nextAttackAt = Math.min(...Object.values(abilityReadyAt), Number.POSITIVE_INFINITY);
|
||||
let attackRevision = mob.attackRevision;
|
||||
@@ -7510,7 +7583,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
combatEvents: [...state.combatEvents],
|
||||
actors: { ...state.actors },
|
||||
cooldowns: { ...state.cooldowns },
|
||||
health: Math.min(
|
||||
health: state.health <= 0 ? 0 : Math.min(
|
||||
state.maxHealth,
|
||||
state.health + resolveAuraStatValue(
|
||||
state.gearStats.healthPer5 / 5 + state.gearStats.spirit * 0.01,
|
||||
@@ -7639,10 +7712,24 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
}
|
||||
}
|
||||
|
||||
const timedEffectTargetAlive = (effect: TimedCombatEffect): boolean => {
|
||||
if (effect.kind === "dot") {
|
||||
const mob = effect.targetId ? work.mobs[effect.targetId] : null;
|
||||
return Boolean(mob && !mob.dead && mob.health > 0);
|
||||
}
|
||||
const targetId = effect.targetId ?? PLAYER_AURA_ENTITY_ID;
|
||||
if (targetId === PLAYER_AURA_ENTITY_ID) return work.health > 0;
|
||||
const summon = work.summons.find((candidate) => candidate.id === targetId);
|
||||
if (summon) return summon.health > 0;
|
||||
const actor = ensureCompanionActorInWork(work, targetId) ?? work.actors[targetId];
|
||||
return Boolean(actor?.alive && actor.health > 0);
|
||||
};
|
||||
for (const effect of state.effects) {
|
||||
// Check before due ticks/procs, not only after applying their health changes.
|
||||
if (!timedEffectTargetAlive(effect)) continue;
|
||||
let remainingTicks = effect.remainingTicks;
|
||||
let nextTickAt = effect.nextTickAt;
|
||||
while (remainingTicks > 0 && nextTickAt <= now) {
|
||||
while (remainingTicks > 0 && nextTickAt <= now && timedEffectTargetAlive(effect)) {
|
||||
const ability = abilityById(effect.sourceAbilityId);
|
||||
const baseAmount = effect.fixedAmount ?? (ability
|
||||
? equipmentAdjustedCombatAmount(
|
||||
@@ -7696,10 +7783,10 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
"taken",
|
||||
));
|
||||
if (effect.targetId) {
|
||||
const target = work.actors[effect.targetId]
|
||||
?? ensureCompanionActorInWork(work, effect.targetId)
|
||||
?? (work.summons.some((summon) => summon.id === effect.targetId)
|
||||
? combatActorFromSummon(work.summons.find((summon) => summon.id === effect.targetId)!, progression.level, work.auras)
|
||||
const target = ensureCompanionActorInWork(work, hotTargetId)
|
||||
?? work.actors[hotTargetId]
|
||||
?? (work.summons.some((summon) => summon.id === hotTargetId)
|
||||
? combatActorFromSummon(work.summons.find((summon) => summon.id === hotTargetId)!, progression.level, work.auras)
|
||||
: null);
|
||||
if (target?.alive) partyHotTicks.push({
|
||||
memberId: target.id,
|
||||
@@ -7708,9 +7795,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
sourceAbilityId: effect.sourceAbilityId,
|
||||
});
|
||||
} else {
|
||||
const healed = Math.min(modifiedHealing, work.maxHealth - work.health);
|
||||
work.health += healed;
|
||||
work.progression.health = work.health;
|
||||
const healed = healPlayerInWork(work, modifiedHealing);
|
||||
work.totalHealing += healed;
|
||||
addHealingThreatInWork(
|
||||
work,
|
||||
@@ -7755,12 +7840,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
remainingTicks -= 1;
|
||||
nextTickAt += effect.intervalMs;
|
||||
}
|
||||
const targetAlive = effect.kind === "dot"
|
||||
? Boolean(effect.targetId && !work.mobs[effect.targetId]?.dead)
|
||||
: !effect.targetId || usePartyStore.getState().members.some(
|
||||
(member) => member.id === effect.targetId && member.health > 0,
|
||||
);
|
||||
if (remainingTicks > 0 && targetAlive) {
|
||||
if (remainingTicks > 0 && timedEffectTargetAlive(effect)) {
|
||||
work.effects.push(
|
||||
remainingTicks === effect.remainingTicks && nextTickAt === effect.nextTickAt
|
||||
? effect
|
||||
@@ -7816,13 +7896,10 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
|| Math.sqrt(distanceSquared(area.position, position)) > area.radius
|
||||
|| !combatHasLineOfSight(area.position, position)) continue;
|
||||
const member = partyMembers.find((candidate) => candidate.id === friendlyId);
|
||||
if (member ? member.health <= 0 : work.health <= 0) continue;
|
||||
const healed = member
|
||||
? healCompanionActorInWork(work, friendlyId, amount)
|
||||
: Math.min(amount, work.maxHealth - work.health);
|
||||
if (!member) {
|
||||
work.health += healed;
|
||||
work.progression.health = work.health;
|
||||
}
|
||||
: healPlayerInWork(work, amount);
|
||||
work.totalHealing += healed;
|
||||
recordCombatEventInWork(work, {
|
||||
occurredAt: nextTickAt,
|
||||
@@ -7873,12 +7950,8 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
const target = injured[0];
|
||||
if (target) {
|
||||
const healed = target.id === PLAYER_AURA_ENTITY_ID
|
||||
? Math.min(summon.attackDamage, work.maxHealth - work.health)
|
||||
? healPlayerInWork(work, summon.attackDamage)
|
||||
: healCompanionActorInWork(work, target.id, summon.attackDamage);
|
||||
if (target.id === PLAYER_AURA_ENTITY_ID) {
|
||||
work.health += healed;
|
||||
work.progression.health = work.health;
|
||||
}
|
||||
work.totalHealing += healed;
|
||||
addHealingThreatInWork(work, { actorId: summon.ownerActorId, role: "healer", abilityId: summon.sourceAbilityId }, healed);
|
||||
recordCombatEventInWork(work, {
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { abilityAtLevel } from "./abilityCatalog";
|
||||
import { applyAura } from "./combatAuras";
|
||||
import { PLAYER_AURA_ENTITY_ID, useCombatStore, type TimedCombatEffect } from "./combatStore";
|
||||
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
|
||||
import { clearPartyRuntimeRegistry, registerPartyRuntimePosition } from "./partyRuntimeRegistry";
|
||||
import { usePartyStore } from "./partyStore";
|
||||
import { respawnDefeatedPlayer } from "./deathRespawn";
|
||||
import { useGameStore } from "./store";
|
||||
|
||||
function killPlayer(): void {
|
||||
useCombatStore.getState().damagePlayer(useCombatStore.getState().maxHealth * 10);
|
||||
expect(useCombatStore.getState().health).toBe(0);
|
||||
expect(useCombatStore.getState().playerAnimationEvent?.kind).toBe("death");
|
||||
}
|
||||
|
||||
function selfHot(): TimedCombatEffect {
|
||||
expect(useCombatStore.getState().castAbility("druid-rejuvenation", undefined, 1_000).ok).toBe(true);
|
||||
const effect = useCombatStore.getState().effects.find((entry) => entry.kind === "hot" && entry.targetId === null);
|
||||
expect(effect).toBeDefined();
|
||||
return effect!;
|
||||
}
|
||||
|
||||
function setMemberHealth(id: string, health: number): void {
|
||||
const member = usePartyStore.getState().members.find((candidate) => candidate.id === id)!;
|
||||
useCombatStore.getState().commitCompanionActors([{ ...member, health, status: health <= 0 ? "down" : "ready" }]);
|
||||
}
|
||||
|
||||
describe("healing must not implicitly resurrect defeated players", () => {
|
||||
beforeEach(() => {
|
||||
clearMobRuntimeRegistry();
|
||||
clearPartyRuntimeRegistry();
|
||||
useCombatStore.getState().initializeCharacter({ classId: "druid", level: 1, xp: 0 });
|
||||
usePartyStore.getState().initializeParty("death-healing", 1, useCombatStore.getState().health);
|
||||
usePartyStore.getState().selectMember(null);
|
||||
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
useGameStore.getState().resetAtEntrance();
|
||||
});
|
||||
|
||||
it("reproduces death with a self HoT and rejects every overdue tick", () => {
|
||||
const effect = selfHot();
|
||||
killPlayer();
|
||||
const deathEvent = useCombatStore.getState().playerAnimationEvent;
|
||||
useCombatStore.getState().tick(0.1, effect.nextTickAt + effect.intervalMs * 10);
|
||||
expect(useCombatStore.getState().health).toBe(0);
|
||||
expect(useCombatStore.getState().playerAnimationEvent).toBe(deathEvent);
|
||||
expect(useCombatStore.getState().effects).toHaveLength(0);
|
||||
expect(useCombatStore.getState().castAbility("druid-rejuvenation", undefined, 100_000))
|
||||
.toMatchObject({ ok: false, reason: "player-dead" });
|
||||
});
|
||||
|
||||
it("removes a dead player's HoT even before its next tick is due", () => {
|
||||
const effect = selfHot();
|
||||
killPlayer();
|
||||
useCombatStore.getState().tick(0, effect.nextTickAt - 1);
|
||||
expect(useCombatStore.getState().health).toBe(0);
|
||||
expect(useCombatStore.getState().effects).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("rejects the actual Priest Renew tick after lethal damage", () => {
|
||||
useCombatStore.getState().initializeCharacter({ classId: "priest", level: 14 });
|
||||
const renew = useCombatStore.getState().abilities.find((ability) => ability.name === "Renew"
|
||||
&& ability.effects.some((effect) => effect.kind === "hot"));
|
||||
expect(renew).toBeDefined();
|
||||
expect(useCombatStore.getState().castAbility(renew!.id, undefined, 1_000).ok).toBe(true);
|
||||
const effect = useCombatStore.getState().effects.find((entry) => entry.kind === "hot")!;
|
||||
killPlayer();
|
||||
useCombatStore.getState().tick(0.1, effect.nextTickAt);
|
||||
expect(useCombatStore.getState().health).toBe(0);
|
||||
expect(useCombatStore.getState().effects).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("does not let passive spirit or equipment regeneration heal a corpse", () => {
|
||||
const state = useCombatStore.getState();
|
||||
useCombatStore.setState({ gearStats: { ...state.gearStats, healthPer5: 500, spirit: 500 } });
|
||||
killPlayer();
|
||||
for (let frame = 0; frame < 30; frame += 1) {
|
||||
useCombatStore.getState().tick(0.1, 10_000 + frame * 100);
|
||||
expect(useCombatStore.getState().health).toBe(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects companion HoTs with an explicit player target after death", () => {
|
||||
const effect = selfHot();
|
||||
const source = usePartyStore.getState().members[0].id;
|
||||
useCombatStore.setState({ effects: [{ ...effect, sourceActorId: source, targetId: PLAYER_AURA_ENTITY_ID }] });
|
||||
killPlayer();
|
||||
useCombatStore.getState().tick(0.1, effect.nextTickAt);
|
||||
expect(useCombatStore.getState().health).toBe(0);
|
||||
expect(useCombatStore.getState().effects).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("living explicit player HoTs retain future ticks and healing combat events", () => {
|
||||
const effect = selfHot();
|
||||
const source = usePartyStore.getState().members[0].id;
|
||||
useCombatStore.setState({ effects: [{ ...effect, sourceActorId: source, targetId: PLAYER_AURA_ENTITY_ID }] });
|
||||
useCombatStore.getState().damagePlayer(50);
|
||||
const before = useCombatStore.getState().health;
|
||||
useCombatStore.getState().tick(0, effect.nextTickAt);
|
||||
expect(useCombatStore.getState().health).toBeGreaterThan(before);
|
||||
expect(useCombatStore.getState().effects).toHaveLength(1);
|
||||
expect(useCombatStore.getState().combatEvents.some((event) => event.kind === "healing"
|
||||
&& event.sourceActorId === source && event.targetActorId === PLAYER_AURA_ENTITY_ID
|
||||
&& event.effectiveAmount > 0)).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects newly applied teammate HoTs on dead targets", () => {
|
||||
const ability = abilityAtLevel(useCombatStore.getState().abilities.find((entry) => entry.id === "druid-rejuvenation")!, 1);
|
||||
const hot = ability.effects.find((effect) => effect.kind === "hot")!;
|
||||
expect(hot.kind).toBe("hot");
|
||||
if (hot.kind !== "hot") throw new Error("Expected Rejuvenation HoT");
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
const source = usePartyStore.getState().members[0].id;
|
||||
killPlayer();
|
||||
expect(useCombatStore.getState().applyTimedActorEffect(source, PLAYER_AURA_ENTITY_ID, ability, hot, 1_000)).toBe(false);
|
||||
const member = usePartyStore.getState().members[1];
|
||||
setMemberHealth(member.id, 0);
|
||||
expect(useCombatStore.getState().applyTimedActorEffect(source, member.id, ability, hot, 1_000)).toBe(false);
|
||||
expect(useCombatStore.getState().effects).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("removes a fallen ally's HoT without changing other party members", () => {
|
||||
const member = usePartyStore.getState().members[0];
|
||||
registerPartyRuntimePosition(member.id, [2, 0, 0]);
|
||||
usePartyStore.getState().selectMember(member.id);
|
||||
expect(useCombatStore.getState().castAbility("druid-rejuvenation", undefined, 1_000).ok).toBe(true);
|
||||
const effect = useCombatStore.getState().effects[0];
|
||||
setMemberHealth(member.id, 0);
|
||||
const others = usePartyStore.getState().members.slice(1);
|
||||
useCombatStore.getState().tick(0, effect.nextTickAt);
|
||||
expect(usePartyStore.getState().members[0].health).toBe(0);
|
||||
expect(usePartyStore.getState().members.slice(1)).toEqual(others);
|
||||
expect(useCombatStore.getState().effects).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("ground healing skips the dead player while continuing to heal living allies", () => {
|
||||
const effect = selfHot();
|
||||
const member = usePartyStore.getState().members[0];
|
||||
registerPartyRuntimePosition(member.id, [0, 0, 0]);
|
||||
setMemberHealth(member.id, 1);
|
||||
useCombatStore.setState({ effects: [], areaEffects: [{
|
||||
id: "healing-ground", sourceActorId: member.id, sourceAbilityId: effect.sourceAbilityId,
|
||||
position: [0, 0, 0], radius: 10, createdAt: 1_000, expiresAt: 20_000,
|
||||
nextTickAt: 4_000, tickIntervalMs: 3_000, fixedHealingPerTick: 50,
|
||||
}] });
|
||||
killPlayer();
|
||||
useCombatStore.getState().tick(0.1, 4_000);
|
||||
expect(useCombatStore.getState().health).toBe(0);
|
||||
expect(usePartyStore.getState().members[0].health).toBeGreaterThan(1);
|
||||
expect(useCombatStore.getState().combatEvents.some((event) => event.kind === "healing"
|
||||
&& event.targetActorId === PLAYER_AURA_ENTITY_ID && event.effectiveAmount > 0)).toBe(false);
|
||||
});
|
||||
|
||||
it("a HoT on a living ally continues even when its player caster dies", () => {
|
||||
const member = usePartyStore.getState().members[0];
|
||||
registerPartyRuntimePosition(member.id, [2, 0, 0]);
|
||||
setMemberHealth(member.id, 1);
|
||||
usePartyStore.getState().selectMember(member.id);
|
||||
expect(useCombatStore.getState().castAbility("druid-rejuvenation", undefined, 1_000).ok).toBe(true);
|
||||
const effect = useCombatStore.getState().effects[0];
|
||||
killPlayer();
|
||||
useCombatStore.getState().tick(0.1, effect.nextTickAt);
|
||||
expect(useCombatStore.getState().health).toBe(0);
|
||||
expect(usePartyStore.getState().members[0].health).toBeGreaterThan(1);
|
||||
expect(useCombatStore.getState().effects).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("periodic damage cannot proc an ordinary self heal after its caster dies", () => {
|
||||
useCombatStore.getState().registerMob("dot-target", { name: "Target", maxHealth: 1_000, xpReward: 0 });
|
||||
setMobPosition("dot-target", [2, 0, 0]);
|
||||
const aura = applyAura([], { sourceId: PLAYER_AURA_ENTITY_ID, targetId: PLAYER_AURA_ENTITY_ID,
|
||||
now: 1_000, definition: {
|
||||
id: "periodic-heal-proc", name: "Periodic heal", disposition: "buff", durationMs: null,
|
||||
procs: [{ id: "heal", triggers: "periodic", chance: 1,
|
||||
action: { kind: "heal", target: "aura-target", amount: 100 } }],
|
||||
},
|
||||
}).auras;
|
||||
useCombatStore.setState({ auras: [...aura], effects: [{
|
||||
id: "dot", kind: "dot", sourceActorId: PLAYER_AURA_ENTITY_ID,
|
||||
sourceAbilityId: "druid-rejuvenation", targetId: "dot-target", coefficient: 1,
|
||||
fixedAmount: 1, remainingTicks: 3, intervalMs: 3_000, nextTickAt: 4_000,
|
||||
}] });
|
||||
killPlayer();
|
||||
useCombatStore.getState().tick(0, 4_000);
|
||||
expect(useCombatStore.getState().mobs["dot-target"].health).toBeLessThan(1_000);
|
||||
expect(useCombatStore.getState().health).toBe(0);
|
||||
});
|
||||
|
||||
it("a lingering damage tick can award a level without resurrecting its dead caster", () => {
|
||||
useCombatStore.getState().registerMob("dot-target", { name: "Target", maxHealth: 1, xpReward: 100_000 });
|
||||
setMobPosition("dot-target", [2, 0, 0]);
|
||||
useCombatStore.setState({ effects: [{
|
||||
id: "dot", kind: "dot", sourceActorId: PLAYER_AURA_ENTITY_ID,
|
||||
sourceAbilityId: "druid-rejuvenation", targetId: "dot-target", coefficient: 1,
|
||||
fixedAmount: 100, remainingTicks: 1, intervalMs: 3_000, nextTickAt: 4_000,
|
||||
}] });
|
||||
killPlayer();
|
||||
useCombatStore.getState().tick(0, 4_000);
|
||||
expect(useCombatStore.getState().level).toBeGreaterThan(1);
|
||||
expect(useCombatStore.getState().health).toBe(0);
|
||||
});
|
||||
|
||||
it("living self HoTs and regeneration still restore health", () => {
|
||||
useCombatStore.getState().damagePlayer(50);
|
||||
const effect = selfHot();
|
||||
const before = useCombatStore.getState().health;
|
||||
useCombatStore.getState().tick(0.1, effect.nextTickAt);
|
||||
expect(useCombatStore.getState().health).toBeGreaterThan(before);
|
||||
expect(useCombatStore.getState().health).toBeLessThanOrEqual(useCombatStore.getState().maxHealth);
|
||||
});
|
||||
|
||||
it("explicit resurrection and normal respawn still work after discarding corpse HoTs", () => {
|
||||
const effect = selfHot();
|
||||
killPlayer();
|
||||
useCombatStore.getState().tick(0.1, effect.nextTickAt);
|
||||
const restored = useCombatStore.getState().revivePlayer(0.35);
|
||||
expect(restored).toBeGreaterThan(0);
|
||||
expect(useCombatStore.getState().playerAnimationEvent?.kind).toBe("ability-cancel");
|
||||
expect(useCombatStore.getState().castAbility("druid-rejuvenation", undefined, 20_000).ok).toBe(true);
|
||||
killPlayer();
|
||||
expect(respawnDefeatedPlayer()).toBe(true);
|
||||
expect(useCombatStore.getState().health).toBe(useCombatStore.getState().maxHealth);
|
||||
expect(useCombatStore.getState().effects).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("actor-based party resurrection clears the corpse animation and actor death state", () => {
|
||||
const effect = selfHot();
|
||||
killPlayer();
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
useCombatStore.getState().tick(0.1, effect.nextTickAt);
|
||||
const restored = useCombatStore.getState().reviveActor(PLAYER_AURA_ENTITY_ID, 0.35,
|
||||
usePartyStore.getState().members[0].id, "party-resurrection", 10_000);
|
||||
expect(restored).toBeGreaterThan(0);
|
||||
expect(useCombatStore.getState().playerAnimationEvent?.kind).toBe("ability-cancel");
|
||||
expect(useCombatStore.getState().actors[PLAYER_AURA_ENTITY_ID]).toMatchObject({ alive: true, health: restored });
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCombatStore } from "./combatStore";
|
||||
import { requireDungeonDefinition } from "./dungeonRegistry";
|
||||
import { useGameStore } from "./store";
|
||||
import { resolveResurrectionCheckpoint } from "./resurrectionCheckpoints";
|
||||
import {
|
||||
activeDungeonCompletedObjectiveIds,
|
||||
resetDungeonEncounters,
|
||||
@@ -11,9 +12,11 @@ import {
|
||||
* current pull. Defeated enemies and persisted dungeon progress remain clear,
|
||||
* matching a wipe instead of silently starting a brand-new dungeon.
|
||||
*/
|
||||
export function respawnDefeatedPlayer(): boolean {
|
||||
export function respawnDefeatedPlayer(checkpointId?: string): boolean {
|
||||
const combat = useCombatStore.getState();
|
||||
if (combat.health > 0) return false;
|
||||
const checkpoint = checkpointId === undefined ? null : resolveResurrectionCheckpoint(checkpointId);
|
||||
if (checkpointId !== undefined && !checkpoint) return false;
|
||||
|
||||
const dungeonId = useGameStore.getState().activeDungeonId;
|
||||
const completedObjectiveIds = new Set(activeDungeonCompletedObjectiveIds(dungeonId));
|
||||
@@ -31,6 +34,8 @@ export function respawnDefeatedPlayer(): boolean {
|
||||
if (typeof document !== "undefined" && document.pointerLockElement) {
|
||||
document.exitPointerLock();
|
||||
}
|
||||
// Select the saved return spawn without relocating first or resetting twice.
|
||||
if (checkpoint) useGameStore.setState({ activeSpawn: checkpoint.spawn });
|
||||
useGameStore.getState().resetAfterDefeat();
|
||||
resetDungeonEncounters("boss-wipe", dungeonId);
|
||||
const respawnPosition = useGameStore.getState().playerPosition;
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { advanceDpsMeter, dpsMeterSnapshot, emptyDpsMeter, recordDpsDamage } from "./dpsMeter";
|
||||
import type { StructuredCombatEvent } from "./combatEvents";
|
||||
|
||||
const actor = { id: "player", name: "You", color: "#fff" };
|
||||
function damage(id: number, effectiveAmount = 10): StructuredCombatEvent {
|
||||
return { id, occurredAt: 1_000 + id * 10, kind: "damage", sourceActorId: actor.id,
|
||||
targetActorId: "enemy", rawAmount: 100, effectiveAmount, overheal: 0, absorbed: 30, critical: false };
|
||||
}
|
||||
|
||||
describe("DPS fight recorder", () => {
|
||||
it("keeps effective damage from more than 200 events and ranks everyone over the same fight duration", () => {
|
||||
let state = advanceDpsMeter(emptyDpsMeter(), 1_000, true);
|
||||
for (let id = 1; id <= 1_000; id++) state = recordDpsDamage(state, damage(id), actor);
|
||||
const ally = { id: "ally", name: "Ally", color: "#f00" };
|
||||
state = recordDpsDamage(state, { ...damage(1_000), effectiveAmount: 5_000 }, ally);
|
||||
const snapshot = dpsMeterSnapshot(state, [{ id: "healer", name: "Healer", color: "#0f0" }]);
|
||||
expect(snapshot).toMatchObject({ totalDamage: 15_000, totalDps: 1_500, durationSeconds: 10 });
|
||||
expect(snapshot.rows.map((row) => [row.id, row.dps, row.percent])).toEqual([
|
||||
["player", 1_000, 100], ["ally", 500, 50], ["healer", 0, 0],
|
||||
]);
|
||||
});
|
||||
it("freezes the last fight, starts fresh on the next pull, and excludes menu time", () => {
|
||||
let state = recordDpsDamage(emptyDpsMeter(), damage(0, 100), actor);
|
||||
state = advanceDpsMeter(state, 2_000, true);
|
||||
state = advanceDpsMeter(state, 12_000, true, true);
|
||||
state = advanceDpsMeter(state, 13_000, false);
|
||||
expect(dpsMeterSnapshot(state)).toMatchObject({ active: false, durationSeconds: 2, totalDps: 50 });
|
||||
expect(advanceDpsMeter(state, 30_000, false)).toBe(state);
|
||||
state = recordDpsDamage(state, { ...damage(0, 20), occurredAt: 31_000 }, actor);
|
||||
expect(dpsMeterSnapshot(state)).toMatchObject({ active: true, durationSeconds: 0, totalDamage: 20, totalDps: 20 });
|
||||
});
|
||||
it("ignores misses, healing, invalid damage, and clock regressions", () => {
|
||||
const empty = emptyDpsMeter();
|
||||
expect(recordDpsDamage(empty, damage(0, 0), actor)).toBe(empty);
|
||||
expect(recordDpsDamage(empty, damage(0, NaN), actor)).toBe(empty);
|
||||
expect(recordDpsDamage(empty, { ...damage(0), kind: "healing" }, actor)).toBe(empty);
|
||||
let state = recordDpsDamage(empty, damage(0), actor);
|
||||
state = advanceDpsMeter(state, 900, true);
|
||||
expect(state.elapsedMs).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { StructuredCombatEvent } from "./combatEvents";
|
||||
|
||||
export interface DpsMeterActor {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly color: string;
|
||||
}
|
||||
|
||||
export interface DpsMeterState {
|
||||
readonly startedAt: number | null;
|
||||
readonly endedAt: number | null;
|
||||
readonly updatedAt: number | null;
|
||||
readonly elapsedMs: number;
|
||||
readonly damage: Readonly<Record<string, DpsMeterActor & { total: number }>>;
|
||||
}
|
||||
|
||||
export interface DpsMeterRow extends DpsMeterActor {
|
||||
readonly total: number;
|
||||
readonly dps: number;
|
||||
readonly percent: number;
|
||||
}
|
||||
|
||||
export interface DpsMeterSnapshot {
|
||||
readonly active: boolean;
|
||||
readonly durationSeconds: number;
|
||||
readonly totalDamage: number;
|
||||
readonly totalDps: number;
|
||||
readonly rows: readonly DpsMeterRow[];
|
||||
}
|
||||
|
||||
export function emptyDpsMeter(): DpsMeterState {
|
||||
return { startedAt: null, endedAt: null, updatedAt: null, elapsedMs: 0, damage: {} };
|
||||
}
|
||||
|
||||
/** Keep the last pull visible until the next starts; menus do not add combat time. */
|
||||
export function advanceDpsMeter(state: DpsMeterState, now: number, inCombat: boolean, suspended = false): DpsMeterState {
|
||||
if (!Number.isFinite(now)) return state;
|
||||
if (state.startedAt === null || state.endedAt !== null) {
|
||||
return inCombat ? { ...emptyDpsMeter(), startedAt: now, updatedAt: now } : state;
|
||||
}
|
||||
const updatedAt = Math.max(state.updatedAt ?? now, now);
|
||||
return { ...state, updatedAt,
|
||||
elapsedMs: state.elapsedMs + (suspended ? 0 : updatedAt - (state.updatedAt ?? updatedAt)),
|
||||
endedAt: inCombat ? null : updatedAt };
|
||||
}
|
||||
|
||||
/** Consume the lossless stream, never the truncated combat-log history. */
|
||||
export function recordDpsDamage(state: DpsMeterState, event: StructuredCombatEvent, actor: DpsMeterActor): DpsMeterState {
|
||||
if (event.kind !== "damage" || !Number.isFinite(event.effectiveAmount) || event.effectiveAmount <= 0) return state;
|
||||
const next = advanceDpsMeter(state, event.occurredAt, true);
|
||||
return { ...next, damage: { ...next.damage,
|
||||
[actor.id]: { ...actor, total: (next.damage[actor.id]?.total ?? 0) + event.effectiveAmount } } };
|
||||
}
|
||||
|
||||
export function dpsMeterSnapshot(state: DpsMeterState, actors: readonly DpsMeterActor[] = []): DpsMeterSnapshot {
|
||||
const durationSeconds = state.elapsedMs / 1_000;
|
||||
const seconds = Math.max(1, durationSeconds);
|
||||
const damage = state.startedAt === null ? {} : {
|
||||
...Object.fromEntries(actors.map((actor) => [actor.id, { ...actor, total: 0 }])), ...state.damage,
|
||||
};
|
||||
const ordered = Object.values(damage).sort((a, b) => b.total - a.total || a.name.localeCompare(b.name));
|
||||
const maximum = Math.max(1, ordered[0]?.total ?? 0);
|
||||
const totalDamage = ordered.reduce((sum, actor) => sum + actor.total, 0);
|
||||
return { active: state.startedAt !== null && state.endedAt === null, durationSeconds, totalDamage,
|
||||
totalDps: totalDamage / seconds,
|
||||
rows: ordered.map((actor) => ({ ...actor, dps: actor.total / seconds, percent: actor.total / maximum * 100 })) };
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { PLAYER_AGGRO_ID } from "./aggro";
|
||||
import { useCombatStore } from "./combatStore";
|
||||
import { appendCombatEvent } from "./combatEvents";
|
||||
import { connectDpsMeter, currentDpsMeterSnapshot, useDpsMeterStore } from "./dpsMeterStore";
|
||||
import { useGameStore } from "./store";
|
||||
import { useUiLayoutStore } from "./uiLayoutStore";
|
||||
import { currentCompanionSnapshot, executeCompanionCommand } from "../platform/dualScreenSync";
|
||||
|
||||
describe("live DPS tracking and companion editor", () => {
|
||||
let disconnect: () => void;
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers(); vi.setSystemTime(1_000);
|
||||
useGameStore.setState({ overlay: null, paused: false, companionOpen: false });
|
||||
useCombatStore.getState().initializeCharacter({ classId: "mage", level: 5 });
|
||||
useCombatStore.getState().registerMob("dps-target", { name: "Target", maxHealth: 1_000_000 });
|
||||
disconnect = connectDpsMeter();
|
||||
});
|
||||
afterEach(() => { disconnect(); useUiLayoutStore.getState().finish(false); vi.useRealTimers(); });
|
||||
|
||||
it("tracks real damage while hidden, caps overkill, freezes on defeat, and clears on reset", () => {
|
||||
useUiLayoutStore.getState().begin();
|
||||
useUiLayoutStore.getState().updateDraft((layout) => ({ ...layout, elements: { ...layout.elements, dps: { ...layout.elements.dps, visible: false } } }));
|
||||
useCombatStore.getState().damageMob("dps-target", 100, Date.now());
|
||||
vi.advanceTimersByTime(2_000);
|
||||
expect(currentDpsMeterSnapshot()).toMatchObject({ totalDamage: 100, totalDps: 50 });
|
||||
const remaining = useCombatStore.getState().mobs["dps-target"].health;
|
||||
useCombatStore.getState().damageMob("dps-target", 2_000_000, Date.now());
|
||||
expect(currentDpsMeterSnapshot()).toMatchObject({ active: false, totalDamage: 100 + remaining });
|
||||
vi.advanceTimersByTime(10_000);
|
||||
expect(currentDpsMeterSnapshot().durationSeconds).toBe(2);
|
||||
useCombatStore.getState().resetEncounter();
|
||||
expect(useDpsMeterStore.getState().startedAt).toBeNull();
|
||||
});
|
||||
it("merges summon damage into its owner and excludes hostile and friendly-target damage", () => {
|
||||
const actors = useCombatStore.getState().actors;
|
||||
useCombatStore.setState({ actors: { ...actors, "meter-pet": { ...actors[PLAYER_AGGRO_ID], id: "meter-pet", kind: "pet", ownerId: PLAYER_AGGRO_ID } } });
|
||||
for (const [id, sourceActorId, targetActorId] of [[1, "meter-pet", "dps-target"], [2, "dps-target", PLAYER_AGGRO_ID], [3, PLAYER_AGGRO_ID, PLAYER_AGGRO_ID]] as const) {
|
||||
appendCombatEvent([], { occurredAt: Date.now(), kind: "damage", sourceActorId, targetActorId,
|
||||
rawAmount: 100, effectiveAmount: 80, overheal: 0, absorbed: 20, critical: false }, id);
|
||||
}
|
||||
expect(currentDpsMeterSnapshot().totalDamage).toBe(80);
|
||||
expect(currentDpsMeterSnapshot().rows.find((row) => row.total > 0)?.id).toBe(PLAYER_AGGRO_ID);
|
||||
});
|
||||
it("does not dilute DPS while a local game menu pauses combat", () => {
|
||||
useCombatStore.getState().damageMob("dps-target", 100, Date.now());
|
||||
vi.advanceTimersByTime(1_000);
|
||||
useGameStore.setState({ paused: true, overlay: "ui-layout" });
|
||||
vi.advanceTimersByTime(10_000);
|
||||
expect(currentDpsMeterSnapshot()).toMatchObject({ durationSeconds: 1, totalDps: 100 });
|
||||
useGameStore.setState({ paused: false, overlay: null });
|
||||
vi.advanceTimersByTime(1_000);
|
||||
expect(currentDpsMeterSnapshot()).toMatchObject({ durationSeconds: 2, totalDps: 50 });
|
||||
});
|
||||
it("publishes draft visibility and accepts lower-screen movement only while editing", () => {
|
||||
useUiLayoutStore.getState().begin();
|
||||
const command = { name: "editDpsMeter", update: { position: { x: .7, y: .8 }, width: 300 } } as const;
|
||||
executeCompanionCommand(command);
|
||||
expect(useUiLayoutStore.getState().draft?.elements.dps.position).toBeNull();
|
||||
useGameStore.setState({ overlay: "ui-layout" });
|
||||
executeCompanionCommand(command);
|
||||
expect(currentCompanionSnapshot().game.dpsMeter).toMatchObject({ editing: true, layout: { position: { x: .7, y: .8 }, width: 300 } });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import { create } from "zustand";
|
||||
import { classById, CLASSES } from "../app/characterCatalog";
|
||||
import { useShellStore } from "../app/shellStore";
|
||||
import { useOnlineSessionStore } from "../app/onlineSessionStore";
|
||||
import { PLAYER_AGGRO_ID } from "./aggro";
|
||||
import { useCombatStore } from "./combatStore";
|
||||
import { subscribeCombatEvents } from "./combatEvents";
|
||||
import { advanceDpsMeter, dpsMeterSnapshot, emptyDpsMeter, recordDpsDamage, type DpsMeterActor } from "./dpsMeter";
|
||||
import { onlinePlayerActorId } from "./onlineSessionRuntime";
|
||||
import { usePartyStore } from "./partyStore";
|
||||
import { useGameStore } from "./store";
|
||||
|
||||
export const useDpsMeterStore = create(() => emptyDpsMeter());
|
||||
|
||||
function meterActors(): DpsMeterActor[] {
|
||||
const combat = useCombatStore.getState();
|
||||
const online = useOnlineSessionStore.getState().snapshot;
|
||||
return [
|
||||
{ id: PLAYER_AGGRO_ID, name: useShellStore.getState().activeCharacter?.name ?? "You", color: classById(combat.classId).color },
|
||||
...usePartyStore.getState().members.map((member) => ({ id: member.id, name: member.name, color: classById(member.classId).color })),
|
||||
...(online?.players ?? []).filter((player) => player.accountId !== online?.localAccountId).map((player) => ({
|
||||
id: onlinePlayerActorId(player.accountId), name: player.character?.name ?? player.username,
|
||||
color: CLASSES.find((entry) => entry.id === player.character?.classId)?.color ?? "#d5c28b",
|
||||
})),
|
||||
];
|
||||
}
|
||||
|
||||
export function dpsSourceActor(sourceId: string): DpsMeterActor | null {
|
||||
const combat = useCombatStore.getState();
|
||||
const localAccount = useOnlineSessionStore.getState().snapshot?.localAccountId;
|
||||
const visited = new Set<string>();
|
||||
let id = sourceId;
|
||||
while (!visited.has(id)) {
|
||||
visited.add(id);
|
||||
if (localAccount && id === onlinePlayerActorId(localAccount)) id = PLAYER_AGGRO_ID;
|
||||
const known = meterActors().find((actor) => actor.id === id);
|
||||
if (known) return known;
|
||||
const ownerId = combat.actors[id]?.ownerId ?? combat.summons.find((summon) => summon.id === id)?.ownerActorId;
|
||||
if (!ownerId) return null;
|
||||
id = ownerId;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function currentDpsMeterSnapshot() {
|
||||
return dpsMeterSnapshot(useDpsMeterStore.getState(), meterActors());
|
||||
}
|
||||
|
||||
/** Owned by the gameplay runtime so hiding or moving the meter never loses damage. */
|
||||
export function connectDpsMeter(): () => void {
|
||||
useDpsMeterStore.setState(emptyDpsMeter(), true);
|
||||
let suspendedPreviously = false;
|
||||
const sample = () => {
|
||||
const combat = useCombatStore.getState();
|
||||
const game = useGameStore.getState();
|
||||
const inCombat = Object.values(combat.mobs).some((mob) => mob.engaged && !mob.dead && !mob.despawned);
|
||||
const sharedOnline = Boolean(useOnlineSessionStore.getState().snapshot);
|
||||
const suspended = !sharedOnline && (game.paused || game.companionOpen || (typeof document !== "undefined" && document.hidden));
|
||||
const state = useDpsMeterStore.getState();
|
||||
const next = advanceDpsMeter(state, Date.now(), inCombat, suspendedPreviously);
|
||||
suspendedPreviously = suspended;
|
||||
if (next !== state) useDpsMeterStore.setState(next, true);
|
||||
};
|
||||
const unsubscribeEvents = subscribeCombatEvents((event) => {
|
||||
if (event.kind !== "damage" || !useCombatStore.getState().mobs[event.targetActorId]) return;
|
||||
const actor = dpsSourceActor(event.sourceActorId);
|
||||
if (actor) useDpsMeterStore.setState(recordDpsDamage(useDpsMeterStore.getState(), event, actor), true);
|
||||
});
|
||||
const unsubscribeCombat = useCombatStore.subscribe((state, previous) => {
|
||||
if (state.combatEvents !== previous.combatEvents && state.combatEvents.length === 0) {
|
||||
useDpsMeterStore.setState(emptyDpsMeter(), true);
|
||||
} else sample();
|
||||
});
|
||||
const unsubscribeGame = useGameStore.subscribe(sample);
|
||||
if (typeof document !== "undefined") document.addEventListener("visibilitychange", sample);
|
||||
const timer = setInterval(sample, 250);
|
||||
sample();
|
||||
return () => {
|
||||
clearInterval(timer);
|
||||
unsubscribeEvents(); unsubscribeCombat(); unsubscribeGame();
|
||||
if (typeof document !== "undefined") document.removeEventListener("visibilitychange", sample);
|
||||
useDpsMeterStore.setState(emptyDpsMeter(), true);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { useCombatStore } from "./combatStore";
|
||||
import { registerCombatSpatialProvider, resetCombatSpatialProvider } from "./combatSpatial";
|
||||
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
|
||||
import { clearPartyRuntimeRegistry } from "./partyRuntimeRegistry";
|
||||
import { usePartyStore } from "./partyStore";
|
||||
import { advanceDungeonPartyCombat, resetPartyRuntimeClock } from "./partyRuntime";
|
||||
import { registerPartyRuntimePosition, updatePartyRuntimeTargetVisibility } from "./partyRuntimeRegistry";
|
||||
import { DEFAULT_ENEMY_ATTACK, type EnemyAttackDefinition } from "./enemyAttacks";
|
||||
import { createMobPursuit } from "./mobPursuit";
|
||||
import { subscribeCombatPresentation, type CombatPresentationEvent } from "./combatPresentation";
|
||||
|
||||
let visible = true;
|
||||
const melee = { ...DEFAULT_ENEMY_ATTACK, id: "swing", range: 4, initialCooldownMs: [0, 0] as const };
|
||||
const spell: EnemyAttackDefinition = { ...melee, id: "bolt", range: 30, school: "shadow", animation: "cast", castTimeMs: 1_000 };
|
||||
function register(id = "enemy", attacks: readonly EnemyAttackDefinition[] = [melee]) {
|
||||
setMobPosition(id, [2, 0, 0]);
|
||||
useCombatStore.getState().registerMob(id, { name: id, attacks, maxHealth: 1_000, xpReward: 0 });
|
||||
useCombatStore.getState().damageMob(id, 1, 1_000);
|
||||
}
|
||||
|
||||
describe("dungeon wall combat authorization", () => {
|
||||
beforeEach(() => {
|
||||
resetCombatSpatialProvider(); clearMobRuntimeRegistry(); clearPartyRuntimeRegistry(); resetPartyRuntimeClock();
|
||||
useCombatStore.getState().initializeCharacter({ classId: "priest", level: 20 });
|
||||
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
|
||||
usePartyStore.getState().initializeParty("walls", 20, useCombatStore.getState().health);
|
||||
visible = true;
|
||||
registerCombatSpatialProvider({ actor: () => null, hasLineOfSight: () => visible, projectGroundPoint: (point) => point });
|
||||
});
|
||||
afterEach(resetCombatSpatialProvider);
|
||||
|
||||
it("does not swing or consume an attack cooldown through a wall despite melee distance", () => {
|
||||
register(); visible = false;
|
||||
const health = useCombatStore.getState().health;
|
||||
const events: CombatPresentationEvent[] = [];
|
||||
const unsubscribe = subscribeCombatPresentation((message) => { if (message.kind === "event") events.push(message.event); });
|
||||
expect(useCombatStore.getState().advanceMobCombat(2_000)).toBe(0);
|
||||
expect(useCombatStore.getState().mobs.enemy).toMatchObject({ combatPhase: "chasing", attackRevision: 0 });
|
||||
expect(useCombatStore.getState().health).toBe(health);
|
||||
expect(events.filter((event) => event.sourceActorId === "enemy")).toHaveLength(0);
|
||||
visible = true;
|
||||
expect(useCombatStore.getState().advanceMobCombat(2_100)).toBeGreaterThan(0);
|
||||
expect(useCombatStore.getState().mobs.enemy.combatPhase).toBe("attacking");
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
it("does not start an obstructed cast and cancels a pending cast when sight is lost", () => {
|
||||
register("enemy", [spell]); visible = false;
|
||||
useCombatStore.getState().advanceMobCombat(1_000);
|
||||
expect(useCombatStore.getState().mobs.enemy.activeCast).toBeNull();
|
||||
visible = true;
|
||||
useCombatStore.getState().advanceMobCombat(1_100);
|
||||
expect(useCombatStore.getState().mobs.enemy.activeCast).not.toBeNull();
|
||||
const health = useCombatStore.getState().health;
|
||||
visible = false;
|
||||
expect(useCombatStore.getState().advanceMobCombat(1_200)).toBe(0);
|
||||
expect(useCombatStore.getState().mobs.enemy).toMatchObject({ activeCast: null, combatPhase: "chasing", attackRevision: 0 });
|
||||
expect(useCombatStore.getState().health).toBe(health);
|
||||
});
|
||||
|
||||
it("does not let a self buff keep a wall-blocked melee mob in attack mode", () => {
|
||||
register("enemy", [melee, { ...melee, id: "buff", target: "self", effect: { kind: "aura", aura: "enrage", durationMs: 10_000 } }]);
|
||||
visible = false;
|
||||
useCombatStore.getState().advanceMobCombat(2_000);
|
||||
expect(useCombatStore.getState().mobs.enemy.combatPhase).toBe("chasing");
|
||||
});
|
||||
|
||||
it("does not proximity-pull a visible-distance mob through a wall", () => {
|
||||
setMobPosition("idle", [2, 0, 0]);
|
||||
useCombatStore.getState().registerMob("idle", { name: "idle", attacks: [melee] });
|
||||
visible = false;
|
||||
expect(useCombatStore.getState().engageNearbyMobs(1_000)).toBe(0);
|
||||
visible = true;
|
||||
expect(useCombatStore.getState().engageNearbyMobs(1_001)).toBe(1);
|
||||
});
|
||||
|
||||
it("resets only the stuck living mob, clears its threat/cast, restores health, and delays repulls", () => {
|
||||
register("enemy", [spell]); register("other");
|
||||
useCombatStore.getState().advanceMobCombat(1_000);
|
||||
const other = useCombatStore.getState().mobs.other;
|
||||
expect(useCombatStore.getState().resetMobForNavigation("enemy", true, 2_000)).toBe(true);
|
||||
expect(useCombatStore.getState().mobs.enemy).toMatchObject({ health: 1_000, engaged: false,
|
||||
combatPhase: "idle", activeCast: null, targetActorId: null, threatByActor: {}, navigationEvadeUntil: 12_000,
|
||||
dead: false, experienceGranted: false });
|
||||
expect(useCombatStore.getState().mobs.other).toBe(other);
|
||||
expect(useCombatStore.getState().engageNearbyMobs(11_999)).toBe(0);
|
||||
expect(useCombatStore.getState().engageNearbyMobs(12_000)).toBe(1);
|
||||
});
|
||||
|
||||
it("does not recover or resurrect a dead mob", () => {
|
||||
register();
|
||||
useCombatStore.getState().damageMob("enemy", 10_000, 2_000);
|
||||
expect(useCombatStore.getState().resetMobForNavigation("enemy", true, 3_000)).toBe(false);
|
||||
expect(useCombatStore.getState().mobs.enemy.dead).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects damage against a navigation-reset mob during its evade cooldown", () => {
|
||||
register();
|
||||
useCombatStore.getState().resetMobForNavigation("enemy", true, 2_000);
|
||||
expect(useCombatStore.getState().damageMob("enemy", 500, 3_000).amount).toBe(0);
|
||||
expect(useCombatStore.getState().tauntMob("enemy", { actorId: "__player__", role: "healer" }, 3_000, 3_000)).toBe(false);
|
||||
expect(useCombatStore.getState().mobs.enemy.health).toBe(1_000);
|
||||
});
|
||||
|
||||
it("cancels an NPC teammate's hostile cast immediately on lost sight so it can pursue", () => {
|
||||
register();
|
||||
const member = usePartyStore.getState().members[0];
|
||||
registerPartyRuntimePosition(member.id, [0, 0, 0]);
|
||||
updatePartyRuntimeTargetVisibility(member.id, "enemy", true);
|
||||
usePartyStore.setState((state) => ({ members: state.members.map((candidate) => candidate.id === member.id
|
||||
? { ...candidate, activeCastTargetId: "enemy", activeCast: {
|
||||
abilityId: "mage-frostbolt", school: "frost", mode: "cast", startedAt: 1_000,
|
||||
completesAt: 5_000, originalDurationMs: 4_000, pushbackHits: 0,
|
||||
origin: [0, 0, 0] as const, targetId: "enemy",
|
||||
} } : candidate) }));
|
||||
visible = false;
|
||||
advanceDungeonPartyCombat(1_100);
|
||||
expect(usePartyStore.getState().members[0]).toMatchObject({ activeCast: null, status: "advancing" });
|
||||
});
|
||||
|
||||
it("nearby teleport recovery leaves mob health, threat, target, and other mobs unchanged", () => {
|
||||
register(); register("other");
|
||||
const before = useCombatStore.getState().mobs;
|
||||
const navigator = createMobPursuit({ graph: () => null, hasLineOfSight: (from) => from[0] > 2,
|
||||
directAllowed: () => false, routeAllowed: () => true, validateLanding: (point) => point });
|
||||
const position: [number, number, number] = [0, 0, 0];
|
||||
const sample = { id: "enemy", targetId: "tank", position, target: [4, 0, 0] as const,
|
||||
home: [0, 0, 0] as const, phase: "chasing" as const, health: before.enemy.health,
|
||||
engaged: true, active: true, casting: false, controlled: false, attackRange: 1.5,
|
||||
leashRange: 40, maximumStep: 0, nowMs: 0 };
|
||||
navigator.step(sample);
|
||||
navigator.step({ ...sample, nowMs: 2_000 });
|
||||
expect(navigator.step({ ...sample, nowMs: 5_000 })).toBe("teleported");
|
||||
expect(position[0]).toBeGreaterThan(2);
|
||||
expect(useCombatStore.getState().mobs.enemy).toBe(before.enemy);
|
||||
expect(useCombatStore.getState().mobs.other).toBe(before.other);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,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,
|
||||
partyCombatMovementGoal,
|
||||
partyFormationPosition,
|
||||
partyMadeStuckProgress,
|
||||
partyMovementProfile,
|
||||
partyNaturalFollowPosition,
|
||||
partyPersonalizedCombatMovementGoal,
|
||||
partyPresentationY,
|
||||
partySeparationGoal,
|
||||
partyShouldRecallForStuck,
|
||||
partyShouldMoveToComfortTarget,
|
||||
PARTY_STUCK_RECALL_DISTANCE,
|
||||
personalizedPartyCombatRangeBand,
|
||||
resetPartyBreadcrumbTrail,
|
||||
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];
|
||||
expect(partyPersonalizedCombatMovementGoal(
|
||||
[20, 0, 0],
|
||||
@@ -110,34 +106,20 @@ describe("party world movement", () => {
|
||||
true,
|
||||
{ minimum: 10, preferred: 14, maximum: 18 },
|
||||
Math.PI / 2,
|
||||
false,
|
||||
out,
|
||||
)).toBe("approach");
|
||||
expect(out[0]).toBeCloseTo(14);
|
||||
expect(out[1]).toBe(0);
|
||||
expect(out[2]).toBeCloseTo(0);
|
||||
|
||||
const separated: [number, number, number] = [0, 0, 0];
|
||||
expect(partySeparationGoal(
|
||||
expect(partyPersonalizedCombatMovementGoal(
|
||||
[1, 0, 1],
|
||||
[2, 0, 2],
|
||||
[[1, 0, 1]],
|
||||
[0, 0, 0],
|
||||
true,
|
||||
{ minimum: 0, preferred: 2.9, maximum: 3.15 },
|
||||
Math.PI / 2,
|
||||
separated,
|
||||
)).toBe(true);
|
||||
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);
|
||||
out,
|
||||
)).toBe("hold");
|
||||
});
|
||||
|
||||
it("makes melee approach a fleeing target and hold once in range", () => {
|
||||
|
||||
@@ -19,7 +19,7 @@ export interface PartyBreadcrumbCursor {
|
||||
breadcrumbId: number | null;
|
||||
}
|
||||
|
||||
export type PartyCombatMovementIntent = "hold" | "approach" | "retreat" | "reposition";
|
||||
export type PartyCombatMovementIntent = "hold" | "approach" | "retreat";
|
||||
|
||||
export interface PartyMovementProfile {
|
||||
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 {
|
||||
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;
|
||||
* ranged attackers also back out when a target collapses their preferred band.
|
||||
@@ -524,7 +447,6 @@ export function partyPersonalizedCombatMovementGoal(
|
||||
targetVisible: boolean,
|
||||
rangeBand: PartyCombatRangeLike,
|
||||
preferredBearing: number,
|
||||
forceReposition: boolean,
|
||||
out: MutablePartyWorldPosition,
|
||||
): PartyCombatMovementIntent {
|
||||
const intent = partyCombatMovementGoal(
|
||||
@@ -538,10 +460,10 @@ export function partyPersonalizedCombatMovementGoal(
|
||||
Math.sin(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[1] = target[1];
|
||||
out[2] = target[2] + Math.cos(preferredBearing) * rangeBand.preferred;
|
||||
return intent === "hold" ? "reposition" : intent;
|
||||
return intent;
|
||||
}
|
||||
@@ -92,6 +92,19 @@ describe("party path controller", () => {
|
||||
expect(route.blocked).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts an in-range surface finish only when combat sight approves it", () => {
|
||||
const options = { maxSnapDistance: 1, localFinishDistance: 1, directSegmentAllowed: () => false };
|
||||
const visible = planPartyRoute(corridorGraph, "mob", [0, 0, 0], [2.5, 0, 2], 100, {
|
||||
...options, destinationReached: (surface, target) => Math.hypot(surface[0] - target[0], surface[2] - target[2]) <= 1,
|
||||
});
|
||||
expect(visible.blocked).toBe(false);
|
||||
expect(visible.waypoints.at(-1)).toEqual([2, 0, 2]);
|
||||
const obstructed = planPartyRoute(corridorGraph, "mob", [0, 0, 0], [2.5, 0, 2], 100, {
|
||||
...options, destinationReached: () => false,
|
||||
});
|
||||
expect(obstructed.blocked).toBe(true);
|
||||
});
|
||||
|
||||
it("advances only one close-spaced waypoint at a corner", () => {
|
||||
const route = {
|
||||
...emptyPartyRouteState(),
|
||||
|
||||
@@ -34,6 +34,8 @@ export interface PartyRouteOptions {
|
||||
start: PartyWorldPosition,
|
||||
end: PartyWorldPosition,
|
||||
) => boolean;
|
||||
/** Combat may finish on the retained surface only when range AND sight allow a hit. */
|
||||
readonly destinationReached?: (surface: PartyWorldPosition, destination: PartyWorldPosition) => boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_MAX_SNAP_DISTANCE = 3;
|
||||
@@ -117,7 +119,7 @@ export function planPartyRoute(
|
||||
) return blocked(waypoints);
|
||||
const finishDistance = fullDistance(waypoints[waypoints.length - 1], destination);
|
||||
if (finishDistance > localFinishDistance) return blocked(waypoints);
|
||||
if (finishDistance > reachedDistance * 0.5) {
|
||||
if (finishDistance > reachedDistance * 0.5 && !options.destinationReached?.(waypoints[waypoints.length - 1], destination)) {
|
||||
if (!options.directSegmentAllowed?.(waypoints[waypoints.length - 1], destination)) {
|
||||
return blocked(waypoints);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { partyRejoinMovement } from "./partyRejoin";
|
||||
import { emptyPartyRouteState } from "./partyPathing";
|
||||
import type { PartyNavigationGraph } from "./partyNavigation";
|
||||
|
||||
const graph: PartyNavigationGraph = {
|
||||
nodes: [
|
||||
{ id: 0, position: [0, 0, 0], neighbors: [{ to: 1, cost: 6 }] },
|
||||
{ id: 1, position: [0, 0, 6], neighbors: [{ to: 0, cost: 6 }, { to: 2, cost: 2 }] },
|
||||
{ id: 2, position: [2, 0, 6], neighbors: [{ to: 1, cost: 2 }, { to: 3, cost: 6 }] },
|
||||
{ id: 3, position: [2, 0, 0], neighbors: [{ to: 2, cost: 6 }] },
|
||||
],
|
||||
weldTolerance: .001, sourceVertexCount: 4, sourceTriangleCount: 2,
|
||||
weldedNodeCount: 4, componentCount: 1, discardedNodeCount: 0,
|
||||
};
|
||||
|
||||
describe("individual resurrection return navigation", () => {
|
||||
it("finishes only when near the player on a clear traversable segment", () => {
|
||||
expect(partyRejoinMovement("tank", [0, 0, 0], [2, 0, 0], 0, null, emptyPartyRouteState(),
|
||||
{ directSegmentAllowed: () => true }).rejoined).toBe(true);
|
||||
expect(partyRejoinMovement("tank", [0, 0, 0], [0, 5, 0], 0, null, emptyPartyRouteState(),
|
||||
{ directSegmentAllowed: () => true }).rejoined).toBe(false);
|
||||
});
|
||||
|
||||
it("routes out of a room even when the player is close on the other side of a wall", () => {
|
||||
const result = partyRejoinMovement("tank", [0, 0, 0], [2, 0, 0], 0, graph, emptyPartyRouteState(), {
|
||||
directSegmentAllowed: (start, end) => start[0] === end[0] || (start[2] >= 5 && end[2] >= 5),
|
||||
routeSegmentAllowed: () => true,
|
||||
});
|
||||
expect(result.rejoined).toBe(false);
|
||||
expect(result.blocked).toBe(false);
|
||||
expect(result.waypoint).toEqual([0, 0, 6]);
|
||||
});
|
||||
|
||||
it("follows the current player position, not a fixed death location or boss", () => {
|
||||
const result = partyRejoinMovement("tank", [0, 0, 0], [20, 0, 0], 0, null, emptyPartyRouteState(), {
|
||||
directSegmentAllowed: () => true,
|
||||
});
|
||||
const moved = partyRejoinMovement("tank", [4, 0, 0], [20, 0, 8], 50, null, result.route, {
|
||||
directSegmentAllowed: () => true,
|
||||
});
|
||||
expect(moved.waypoint).toEqual([20, 0, 8]);
|
||||
expect(moved.rejoined).toBe(false);
|
||||
});
|
||||
|
||||
it("reports blocked navigation to the existing watchdog instead of walking through walls", () => {
|
||||
const result = partyRejoinMovement("tank", [0, 0, 0], [20, 0, 0], 0, null, emptyPartyRouteState(), {
|
||||
directSegmentAllowed: () => false,
|
||||
});
|
||||
expect(result.blocked).toBe(true);
|
||||
expect(result.waypoint).toBeNull();
|
||||
expect(result.rejoined).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { findNearestPartyNavigationSurface, type PartyNavigationGraph } from "./partyNavigation";
|
||||
import { emptyPartyRouteState, nextPartyRouteRecoveryWaypoint, nextPartyRouteWaypoint, partyRouteNeedsPlanning, planPartyRoute, type PartyRouteOptions, type PartyRouteState } from "./partyPathing";
|
||||
import type { PartyWorldPosition } from "./partyRuntimeRegistry";
|
||||
|
||||
export const PARTY_REJOIN_DISTANCE = 3;
|
||||
|
||||
/** Returning resurrected members target the player, not a distant boss or leader. */
|
||||
export function partyRejoinMovement(
|
||||
id: string, current: PartyWorldPosition, player: PartyWorldPosition, nowMs: number,
|
||||
graph: PartyNavigationGraph | null, route: PartyRouteState,
|
||||
options: Pick<PartyRouteOptions, "directSegmentAllowed" | "routeSegmentAllowed">,
|
||||
) {
|
||||
const reachedPlayer = (point: PartyWorldPosition) => Math.hypot(
|
||||
point[0] - player[0], point[1] - player[1], point[2] - player[2],
|
||||
) <= PARTY_REJOIN_DISTANCE && Boolean(options.directSegmentAllowed?.(point, player));
|
||||
if (reachedPlayer(current)) return { rejoined: true, route: emptyPartyRouteState(), waypoint: null, blocked: false };
|
||||
if (options.directSegmentAllowed?.(current, player)) {
|
||||
return { rejoined: false, route: emptyPartyRouteState(), waypoint: player, blocked: false };
|
||||
}
|
||||
const destination = graph ? findNearestPartyNavigationSurface(graph, player, 5)?.position ?? player : player;
|
||||
const goalKey = `member:${id}|resurrection-rejoin`;
|
||||
if (partyRouteNeedsPlanning(route, goalKey, current, destination, nowMs, { replanIntervalMs: 350, blockedRetryMs: 800 })) {
|
||||
route = planPartyRoute(graph, goalKey, current, destination, nowMs, {
|
||||
...options, maxOriginSnapDistance: 6, maxDestinationSnapDistance: 5,
|
||||
localFinishDistance: PARTY_REJOIN_DISTANCE,
|
||||
destinationReached: reachedPlayer,
|
||||
});
|
||||
}
|
||||
const waypoint: [number, number, number] = [0, 0, 0];
|
||||
const hasWaypoint = nextPartyRouteWaypoint(route, current, waypoint)
|
||||
|| nextPartyRouteRecoveryWaypoint(route, current, waypoint, options.directSegmentAllowed ?? (() => false));
|
||||
return { rejoined: false, route, waypoint: hasWaypoint ? waypoint : null, blocked: route.blocked && !hasWaypoint };
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { PLAYER_AGGRO_ID } from "./aggro";
|
||||
import { useCombatStore } from "./combatStore";
|
||||
import { activateDungeonEncounterDefinitions, resetDungeonEncounters } from "./dungeonEncounterRuntime";
|
||||
import { requireDungeonDefinition } from "./dungeonRegistry";
|
||||
import { configureOnlineSessionRuntime } from "./onlineSessionRuntime";
|
||||
import { clearPartyRuntimeRegistry, consumePartyRuntimeRejoin, getPartyRuntimePosition, partyRuntimeIsRejoining, registerPartyRuntimePosition } from "./partyRuntimeRegistry";
|
||||
import { resurrectDeadPartyMembers } from "./partyResurrection";
|
||||
import { derivePartySpawnSlots } from "./partySpawnSlots";
|
||||
import { usePartyStore } from "./partyStore";
|
||||
import { useGameStore } from "./store";
|
||||
|
||||
const dungeon = requireDungeonDefinition("stormwind-stockade");
|
||||
|
||||
function killMember(index: number) {
|
||||
const member = usePartyStore.getState().members[index];
|
||||
useCombatStore.getState().commitCompanionActors([{ ...member, health: 0, status: "down" }]);
|
||||
return member.id;
|
||||
}
|
||||
|
||||
describe("fallen NPC checkpoint resurrection", () => {
|
||||
beforeEach(() => {
|
||||
configureOnlineSessionRuntime({ active: false, authority: true, localAccountId: null });
|
||||
clearPartyRuntimeRegistry();
|
||||
useGameStore.getState().activateDungeon("stormwind-stockade");
|
||||
activateDungeonEncounterDefinitions(dungeon.id, dungeon.encounters ?? []);
|
||||
resetDungeonEncounters("dungeon", dungeon.id);
|
||||
useCombatStore.getState().initializeCharacter({ classId: "priest", level: 16 });
|
||||
useCombatStore.setState({ mobs: {} });
|
||||
usePartyStore.getState().initializeParty("resurrection-party", 16, useCombatStore.getState().health);
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
for (const member of usePartyStore.getState().members) registerPartyRuntimePosition(member.id, [99, 0, 99]);
|
||||
});
|
||||
|
||||
it("restores only fallen members at the original entrance, not the current saved spawn", () => {
|
||||
const deadId = killMember(0);
|
||||
usePartyStore.getState().setCommand("stop");
|
||||
const living = usePartyStore.getState().members.slice(1);
|
||||
const livingActors = living.map((member) => useCombatStore.getState().actors[member.id]);
|
||||
const gameBefore = useGameStore.getState();
|
||||
const playerBefore = useCombatStore.getState().health;
|
||||
useGameStore.setState({ activeSpawn: { footPosition: [80, 5, 80], yaw: 1 } });
|
||||
expect(resurrectDeadPartyMembers("entrance", 1_000)).toBe(1);
|
||||
expect(usePartyStore.getState().members[0]).toMatchObject({ health: usePartyStore.getState().members[0].maxHealth, status: "advancing" });
|
||||
expect(getPartyRuntimePosition(deadId)).toEqual(derivePartySpawnSlots(dungeon.entrance, 5)[1].footPosition);
|
||||
expect(partyRuntimeIsRejoining(deadId)).toBe(true);
|
||||
expect(consumePartyRuntimeRejoin(deadId)).not.toBeNull();
|
||||
expect(consumePartyRuntimeRejoin(deadId)).toBeNull();
|
||||
expect(partyRuntimeIsRejoining(deadId)).toBe(true);
|
||||
expect(usePartyStore.getState().members.slice(1)).toEqual(living);
|
||||
expect(living.map((member) => useCombatStore.getState().actors[member.id])).toEqual(livingActors);
|
||||
expect(usePartyStore.getState().command).toBe("stop");
|
||||
expect(useCombatStore.getState().health).toBe(playerBefore);
|
||||
expect(useGameStore.getState().playerPosition).toEqual(gameBefore.playerPosition);
|
||||
expect(useGameStore.getState().resetRevision).toBe(gameBefore.resetRevision);
|
||||
expect(useCombatStore.getState().combatEvents.at(-1)).toMatchObject({ kind: "resurrection", targetActorId: deadId });
|
||||
expect(resurrectDeadPartyMembers("entrance", 1_001)).toBe(0);
|
||||
});
|
||||
|
||||
it("restores all fallen members at a killed boss without resetting any living encounter", () => {
|
||||
const boss = dungeon.bosses[0];
|
||||
useCombatStore.getState().registerMob(boss.id, { name: boss.name, maxHealth: 1, boss: true });
|
||||
useCombatStore.getState().damageMob(boss.id, 1, 100);
|
||||
const mobsBefore = useCombatStore.getState().mobs;
|
||||
const ids = usePartyStore.getState().members.map((_, index) => killMember(index));
|
||||
expect(resurrectDeadPartyMembers(`boss:${boss.id}`, 1_000)).toBe(ids.length);
|
||||
const slots = derivePartySpawnSlots({ footPosition: boss.position, yaw: dungeon.entrance.yaw }, ids.length + 1);
|
||||
ids.forEach((id, index) => expect(getPartyRuntimePosition(id)).toEqual(slots[index + 1].footPosition));
|
||||
expect(useCombatStore.getState().mobs).toBe(mobsBefore);
|
||||
});
|
||||
|
||||
it("enforces boss combat at action time and remains usable during trash combat", () => {
|
||||
const id = killMember(0);
|
||||
useCombatStore.getState().registerMob("active-boss", { name: "Boss", maxHealth: 100, boss: true });
|
||||
useCombatStore.getState().damageMob("active-boss", 1, 100);
|
||||
const before = useCombatStore.getState();
|
||||
expect(resurrectDeadPartyMembers("entrance", 1_000)).toBe(0);
|
||||
expect(useCombatStore.getState()).toBe(before);
|
||||
expect(partyRuntimeIsRejoining(id)).toBe(false);
|
||||
useCombatStore.getState().damageMob("active-boss", 99, 101);
|
||||
useCombatStore.getState().registerMob("trash", { name: "Trash", maxHealth: 100 });
|
||||
useCombatStore.getState().damageMob("trash", 1, 102);
|
||||
expect(resurrectDeadPartyMembers("entrance", 1_001)).toBe(1);
|
||||
});
|
||||
|
||||
it("rejects locked, stale, or arbitrary checkpoint IDs without any mutation", () => {
|
||||
killMember(0);
|
||||
const before = useCombatStore.getState();
|
||||
for (const id of [`boss:${dungeon.bosses[0].id}`, "boss:other-dungeon", "20,30,40"]) {
|
||||
expect(resurrectDeadPartyMembers(id, 1_000)).toBe(0);
|
||||
expect(useCombatStore.getState()).toBe(before);
|
||||
}
|
||||
});
|
||||
|
||||
it("cannot spend or bypass Manastorm revival charges or run on a non-authority client", () => {
|
||||
killMember(0);
|
||||
useGameStore.setState({ gameMode: "manastorm" });
|
||||
expect(resurrectDeadPartyMembers()).toBe(0);
|
||||
useGameStore.setState({ gameMode: "dungeon" });
|
||||
configureOnlineSessionRuntime({ active: true, authority: false, localAccountId: "guest" });
|
||||
expect(resurrectDeadPartyMembers()).toBe(0);
|
||||
configureOnlineSessionRuntime({ active: false, authority: true, localAccountId: null });
|
||||
expect(resurrectDeadPartyMembers("entrance", NaN)).toBe(0);
|
||||
});
|
||||
|
||||
it("does not resurrect the dead player with the NPC button", () => {
|
||||
killMember(0);
|
||||
useCombatStore.getState().damagePlayer(useCombatStore.getState().maxHealth * 10);
|
||||
expect(resurrectDeadPartyMembers("entrance", 1_000)).toBe(1);
|
||||
expect(useCombatStore.getState().health).toBe(0);
|
||||
expect(useCombatStore.getState().combatEvents.at(-1)?.targetActorId).not.toBe(PLAYER_AGGRO_ID);
|
||||
});
|
||||
|
||||
it("removes lingering effects only on the resurrected member, leaving living allies' HoTs intact", () => {
|
||||
const members = usePartyStore.getState().members;
|
||||
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
|
||||
for (const member of members) registerPartyRuntimePosition(member.id, [0, 0, 0]);
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
const renew = useCombatStore.getState().abilities.find((ability) => ability.name === "Renew")!;
|
||||
usePartyStore.getState().selectMember(members[0].id);
|
||||
expect(useCombatStore.getState().castAbility(renew.id, undefined, 1_000)).toMatchObject({ ok: true });
|
||||
usePartyStore.getState().selectMember(members[1].id);
|
||||
expect(useCombatStore.getState().castAbility(renew.id, undefined, 5_000).ok).toBe(true);
|
||||
const livingEffects = useCombatStore.getState().effects.filter((effect) => effect.targetId === members[1].id);
|
||||
expect(livingEffects.length).toBeGreaterThan(0);
|
||||
killMember(0);
|
||||
expect(resurrectDeadPartyMembers("entrance", 6_000)).toBe(1);
|
||||
expect(useCombatStore.getState().effects.some((effect) => effect.targetId === members[0].id)).toBe(false);
|
||||
expect(useCombatStore.getState().effects.filter((effect) => effect.targetId === members[1].id)).toEqual(livingEffects);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { PLAYER_AGGRO_ID } from "./aggro";
|
||||
import { useCombatStore, type MobCombatState } from "./combatStore";
|
||||
import { onlineSessionOwnsWorldSimulation } from "./onlineSessionRuntime";
|
||||
import { beginPartyRuntimeRejoin } from "./partyRuntimeRegistry";
|
||||
import { derivePartySpawnSlots } from "./partySpawnSlots";
|
||||
import { usePartyStore } from "./partyStore";
|
||||
import { useGameStore } from "./store";
|
||||
import { resolveResurrectionCheckpoint } from "./resurrectionCheckpoints";
|
||||
|
||||
export function partyResurrectionBlockedByBoss(mobs: Readonly<Record<string, MobCombatState>>): boolean {
|
||||
return Object.values(mobs).some((mob) => mob.boss && mob.engaged && !mob.dead && !mob.despawned);
|
||||
}
|
||||
|
||||
/** Normal-dungeon recovery only; never bypass Manastorm charges or online authority. */
|
||||
export function resurrectDeadPartyMembers(checkpointId = "entrance", now = Date.now()): number {
|
||||
const game = useGameStore.getState();
|
||||
const combat = useCombatStore.getState();
|
||||
if (game.gameMode !== "dungeon" || !Number.isFinite(now) || !onlineSessionOwnsWorldSimulation()
|
||||
|| partyResurrectionBlockedByBoss(combat.mobs)) return 0;
|
||||
const checkpoint = resolveResurrectionCheckpoint(checkpointId);
|
||||
if (!checkpoint) return 0;
|
||||
const party = usePartyStore.getState();
|
||||
const fallen = party.members.filter((member) => (combat.actors[member.id]?.health ?? member.health) <= 0);
|
||||
if (!fallen.length) return 0;
|
||||
const ids = new Set(fallen.map((member) => member.id));
|
||||
const slots = derivePartySpawnSlots(checkpoint.spawn, party.members.length + 1);
|
||||
useCombatStore.setState({ effects: combat.effects.filter((effect) => !effect.targetId || !ids.has(effect.targetId)) });
|
||||
let restoredCount = 0;
|
||||
for (const member of fallen) {
|
||||
if (useCombatStore.getState().reviveActor(member.id, 1, PLAYER_AGGRO_ID, "party-checkpoint-resurrection", now) <= 0) continue;
|
||||
const index = party.members.findIndex((candidate) => candidate.id === member.id);
|
||||
beginPartyRuntimeRejoin(member.id, (slots[index + 1] ?? slots[0]).footPosition);
|
||||
usePartyStore.getState().updateMemberCombat(member.id, {
|
||||
status: "advancing", activeCast: null, activeCastTargetId: null, controlledUntil: 0, controlMechanic: null,
|
||||
});
|
||||
restoredCount += 1;
|
||||
}
|
||||
return restoredCount;
|
||||
}
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
registerPartyRuntimePosition,
|
||||
updatePartyRuntimeAttackCorridor,
|
||||
updatePartyRuntimeTargetVisibility,
|
||||
beginPartyRuntimeRejoin,
|
||||
finishPartyRuntimeRejoin,
|
||||
} from "./partyRuntimeRegistry";
|
||||
import {
|
||||
activePartyTargetId,
|
||||
@@ -47,6 +49,36 @@ describe("party combat runtime", () => {
|
||||
expect(activePartyTargetId(mobs, null)).toBe("pack:a");
|
||||
});
|
||||
|
||||
it("lets a resurrected tank rejoin independently instead of taunting or casting from its checkpoint", () => {
|
||||
registerEngagedMob();
|
||||
const tank = usePartyStore.getState().members.find((member) => member.role === "tank")!;
|
||||
for (const member of usePartyStore.getState().members) registerPartyRuntimePosition(member.id, [0, 0, 0]);
|
||||
beginPartyRuntimeRejoin(tank.id, [1_000, 0, 0]);
|
||||
const beforeHealth = useCombatStore.getState().mobs["pack:a"].health;
|
||||
advanceDungeonPartyCombat(1_000);
|
||||
expect(usePartyStore.getState().members.find((member) => member.id === tank.id))
|
||||
.toMatchObject({ status: "advancing", activeCast: null, activeCastTargetId: null });
|
||||
expect(usePartyStore.getState().memberTargetIds[tank.id]).toBeUndefined();
|
||||
expect(useCombatStore.getState().mobs["pack:a"].forcedTarget?.actorId).not.toBe(tank.id);
|
||||
expect(useCombatStore.getState().mobs["pack:a"].health).toBeLessThan(beforeHealth);
|
||||
finishPartyRuntimeRejoin(tank.id);
|
||||
registerPartyRuntimePosition(tank.id, [0, 0, 0]);
|
||||
advanceDungeonPartyCombat(2_000);
|
||||
expect(usePartyStore.getState().memberTargetIds[tank.id]).toBe("pack:a");
|
||||
});
|
||||
|
||||
it("keeps returning teammates advancing during Stop, then resumes the party order when they arrive", () => {
|
||||
const tank = usePartyStore.getState().members.find((member) => member.role === "tank")!;
|
||||
usePartyStore.getState().setCommand("stop");
|
||||
beginPartyRuntimeRejoin(tank.id, [0, 0, 0]);
|
||||
advanceDungeonPartyCombat(1_000);
|
||||
expect(usePartyStore.getState().members.find((member) => member.id === tank.id)?.status).toBe("advancing");
|
||||
finishPartyRuntimeRejoin(tank.id);
|
||||
advanceDungeonPartyCombat(2_000);
|
||||
expect(usePartyStore.getState().command).toBe("stop");
|
||||
expect(usePartyStore.getState().members.find((member) => member.id === tank.id)?.status).toBe("holding");
|
||||
});
|
||||
|
||||
it("has the tank taunt the active mob while all damage allies attack it", () => {
|
||||
registerEngagedMob();
|
||||
for (const member of usePartyStore.getState().members) {
|
||||
|
||||
@@ -21,6 +21,7 @@ import { requireDungeonDefinition } from "./dungeonRegistry";
|
||||
import { getMobPosition } from "./mobRuntimeRegistry";
|
||||
import {
|
||||
getPartyRuntimePosition,
|
||||
partyRuntimeIsRejoining,
|
||||
partyRuntimeCenter,
|
||||
partyRuntimePositionAlongAttackCorridor,
|
||||
partyRuntimeTargetVisible,
|
||||
@@ -611,7 +612,7 @@ function emitPartyAbility(
|
||||
member: PartyMember,
|
||||
ability: AbilityDefinition,
|
||||
targetId: string,
|
||||
phase: "cast-start" | "release" | "impact",
|
||||
phase: "cast-start" | "cast-cancel" | "release" | "impact",
|
||||
now: number,
|
||||
instantCast = false,
|
||||
): void {
|
||||
@@ -1058,7 +1059,7 @@ export function advanceDungeonPartyCombat(now = Date.now()): void {
|
||||
));
|
||||
let members: readonly PartyMember[] = authoritativeMembers;
|
||||
const partyOrigin = partyRuntimeCenter(
|
||||
members.map((member) => member.id),
|
||||
members.filter((member) => !partyRuntimeIsRejoining(member.id)).map((member) => member.id),
|
||||
combat.playerPosition,
|
||||
);
|
||||
let targetId = partyCommandTargetId(
|
||||
@@ -1122,6 +1123,10 @@ export function advanceDungeonPartyCombat(now = Date.now()): void {
|
||||
if (member.status !== "down") nextMembers[index] = { ...member, status: "down" };
|
||||
continue;
|
||||
}
|
||||
if (partyRuntimeIsRejoining(member.id)) {
|
||||
nextMembers[index] = { ...member, status: "advancing", activeCast: null, activeCastTargetId: null };
|
||||
continue;
|
||||
}
|
||||
if (member.controlledUntil > now) {
|
||||
nextMembers[index] = member.status === "holding"
|
||||
? member
|
||||
@@ -1148,6 +1153,16 @@ export function advanceDungeonPartyCombat(now = Date.now()): void {
|
||||
const desiredStatus = hostileCast
|
||||
? member.role === "tank" ? "guarding" : "attacking"
|
||||
: "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) {
|
||||
nextMembers[index] = member.status === desiredStatus ? member : { ...member, status: desiredStatus };
|
||||
if (hostileCast) memberTargetIds[member.id] = castTargetId;
|
||||
|
||||
@@ -11,11 +11,41 @@ import {
|
||||
updatePartyRuntimeTargetVisibility,
|
||||
updatePartyRuntimeAttackCorridor,
|
||||
updatePartyRuntimePosition,
|
||||
beginPartyRuntimeRejoin,
|
||||
consumePartyRuntimeRejoin,
|
||||
partyRuntimeIsRejoining,
|
||||
finishPartyRuntimeRejoin,
|
||||
} from "./partyRuntimeRegistry";
|
||||
|
||||
describe("party runtime position registry", () => {
|
||||
beforeEach(clearPartyRuntimeRegistry);
|
||||
|
||||
it("consumes a copied relocation once and keeps the individual return task until completed", () => {
|
||||
const origin: [number, number, number] = [1, 2, 3];
|
||||
beginPartyRuntimeRejoin("tank", origin);
|
||||
origin[0] = 999;
|
||||
const result = consumePartyRuntimeRejoin("tank")! as [number, number, number];
|
||||
expect(result).toEqual([1, 2, 3]);
|
||||
result[0] = 888;
|
||||
expect(getPartyRuntimePosition("tank")).toEqual([1, 2, 3]);
|
||||
expect(consumePartyRuntimeRejoin("tank")).toBeNull();
|
||||
expect(partyRuntimeIsRejoining("tank")).toBe(true);
|
||||
expect(partyRuntimeIsRejoining("healer")).toBe(false);
|
||||
finishPartyRuntimeRejoin("tank");
|
||||
expect(partyRuntimeIsRejoining("tank")).toBe(false);
|
||||
});
|
||||
|
||||
it("clears pending resurrection tasks on actor unmount and dungeon switch", () => {
|
||||
const unregister = registerPartyRuntimePosition("tank", [0, 0, 0]);
|
||||
beginPartyRuntimeRejoin("tank", [1, 0, 0]);
|
||||
unregister();
|
||||
expect(partyRuntimeIsRejoining("tank")).toBe(false);
|
||||
beginPartyRuntimeRejoin("tank", [1, 0, 0]);
|
||||
clearPartyRuntimeRegistry();
|
||||
expect(consumePartyRuntimeRejoin("tank")).toBeNull();
|
||||
expect(partyRuntimeIsRejoining("tank")).toBe(false);
|
||||
});
|
||||
|
||||
it("tracks world actors without exposing mutable registry storage", () => {
|
||||
const unregister = registerPartyRuntimePosition("tank", [1, 2, 3]);
|
||||
updatePartyRuntimePosition("tank", 4, 5, 6);
|
||||
|
||||
@@ -3,6 +3,29 @@ export type MutablePartyWorldPosition = [x: number, y: number, z: number];
|
||||
|
||||
const positions = new Map<string, MutablePartyWorldPosition>();
|
||||
const targetVisibility = new Map<string, { targetId: string | null; visible: boolean }>();
|
||||
const rejoins = new Map<string, { position: PartyWorldPosition; pending: boolean }>();
|
||||
|
||||
/** A one-shot scene relocation followed by an individual return-to-player task. */
|
||||
export function beginPartyRuntimeRejoin(id: string, position: PartyWorldPosition): void {
|
||||
rejoins.set(id, { position: [...position], pending: true });
|
||||
updatePartyRuntimePosition(id, position[0], position[1], position[2]);
|
||||
targetVisibility.delete(id);
|
||||
}
|
||||
|
||||
export function consumePartyRuntimeRejoin(id: string): PartyWorldPosition | null {
|
||||
const rejoin = rejoins.get(id);
|
||||
if (!rejoin?.pending) return null;
|
||||
rejoin.pending = false;
|
||||
return [...rejoin.position];
|
||||
}
|
||||
|
||||
export function partyRuntimeIsRejoining(id: string): boolean {
|
||||
return rejoins.has(id);
|
||||
}
|
||||
|
||||
export function finishPartyRuntimeRejoin(id: string): void {
|
||||
rejoins.delete(id);
|
||||
}
|
||||
let attackCorridor: {
|
||||
readonly origin: MutablePartyWorldPosition;
|
||||
readonly waypoints: readonly PartyWorldPosition[];
|
||||
@@ -21,6 +44,7 @@ export function registerPartyRuntimePosition(
|
||||
return () => {
|
||||
positions.delete(id);
|
||||
targetVisibility.delete(id);
|
||||
rejoins.delete(id);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -111,6 +135,7 @@ export function updatePartyRuntimePosition(
|
||||
export function removePartyRuntimePosition(id: string): void {
|
||||
positions.delete(id);
|
||||
targetVisibility.delete(id);
|
||||
rejoins.delete(id);
|
||||
}
|
||||
|
||||
export function getPartyRuntimePosition(id: string): PartyWorldPosition | null {
|
||||
@@ -166,5 +191,6 @@ export function partyRuntimeCenter(
|
||||
export function clearPartyRuntimeRegistry(): void {
|
||||
positions.clear();
|
||||
targetVisibility.clear();
|
||||
rejoins.clear();
|
||||
attackCorridor = null;
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
applyPartyStuckRecoveryLanding,
|
||||
choosePartyStuckRecoveryPosition,
|
||||
createPartyStuckRecoveryState,
|
||||
markPartyStuckRecovered,
|
||||
resetPartyStuckRecoveryProgress,
|
||||
updatePartyStuckRecovery,
|
||||
type PartyStuckRecoverySample,
|
||||
} from "./partyStuckRecovery";
|
||||
import type { MutablePartyWorldPosition, PartyWorldPosition } from "./partyRuntimeRegistry";
|
||||
|
||||
const origin: PartyWorldPosition = [0, 0, 0];
|
||||
const sample = (nowMs: number, overrides: Partial<PartyStuckRecoverySample> = {}): PartyStuckRecoverySample => ({
|
||||
position: origin, waypoint: [10, 0, 0], goalKey: "follow:leader", movementExpected: true,
|
||||
health: 100, paused: false, stopped: false, casting: false, controlled: false, nowMs, ...overrides,
|
||||
});
|
||||
|
||||
describe("per-teammate stuck navigation", () => {
|
||||
it("replans at two seconds and recovers at five, without a player-distance gate", () => {
|
||||
const state = createPartyStuckRecoveryState(origin, 0);
|
||||
expect(updatePartyStuckRecovery(state, sample(0))).toBe("none");
|
||||
expect(updatePartyStuckRecovery(state, sample(1_999))).toBe("none");
|
||||
expect(updatePartyStuckRecovery(state, sample(2_000))).toBe("replan");
|
||||
expect(updatePartyStuckRecovery(state, sample(2_500))).toBe("none");
|
||||
expect(updatePartyStuckRecovery(state, sample(4_999))).toBe("none");
|
||||
expect(updatePartyStuckRecovery(state, sample(5_000))).toBe("recover");
|
||||
expect(updatePartyStuckRecovery(state, sample(5_100))).toBe("none");
|
||||
expect(updatePartyStuckRecovery(state, sample(6_000))).toBe("recover");
|
||||
});
|
||||
|
||||
it("does not restart the timer when a replan changes waypoints or yields no path", () => {
|
||||
const state = createPartyStuckRecoveryState(origin, 0);
|
||||
updatePartyStuckRecovery(state, sample(0));
|
||||
expect(updatePartyStuckRecovery(state, sample(2_000))).toBe("replan");
|
||||
updatePartyStuckRecovery(state, sample(3_000, { waypoint: [0, 0, 10] }));
|
||||
updatePartyStuckRecovery(state, sample(4_000, { waypoint: null }));
|
||||
expect(updatePartyStuckRecovery(state, sample(5_000, { waypoint: null }))).toBe("recover");
|
||||
});
|
||||
|
||||
it("cancels back-and-forth doorway shuffling instead of treating it as repeated progress", () => {
|
||||
const state = createPartyStuckRecoveryState(origin, 0);
|
||||
updatePartyStuckRecovery(state, sample(0));
|
||||
updatePartyStuckRecovery(state, sample(500, { position: [0.6, 0, 0] }));
|
||||
const actions: string[] = [];
|
||||
for (let tick = 1; tick <= 10; tick += 1) {
|
||||
actions.push(updatePartyStuckRecovery(state, sample(500 + tick * 500, {
|
||||
position: tick % 2 ? origin : [0.6, 0, 0],
|
||||
})));
|
||||
}
|
||||
expect(actions).toContain("replan");
|
||||
expect(actions.at(-1)).toBe("recover");
|
||||
expect(state.lastProgressAtMs).toBe(500);
|
||||
});
|
||||
|
||||
it("counts movement toward route waypoints even when the route goes away from the player", () => {
|
||||
const state = createPartyStuckRecoveryState(origin, 0);
|
||||
updatePartyStuckRecovery(state, sample(0));
|
||||
for (let tick = 1; tick <= 12; tick += 1) {
|
||||
expect(updatePartyStuckRecovery(state, sample(tick * 500, { position: [tick * 0.5, 0, 0] }))).toBe("none");
|
||||
}
|
||||
expect(state.lastProgressAtMs).toBe(6_000);
|
||||
});
|
||||
|
||||
it("does not mistake target movement or sideways motion for useful actor progress", () => {
|
||||
const state = createPartyStuckRecoveryState(origin, 0);
|
||||
updatePartyStuckRecovery(state, sample(0));
|
||||
for (let tick = 1; tick <= 5; tick += 1) {
|
||||
updatePartyStuckRecovery(state, sample(tick * 1_000, { position: [0, 0, tick % 2], waypoint: [10 + tick, 0, 0] }));
|
||||
}
|
||||
expect(state.lastProgressAtMs).toBe(0);
|
||||
});
|
||||
|
||||
it("advances through dense waypoint chains and corners without false recovery", () => {
|
||||
const state = createPartyStuckRecoveryState(origin, 0);
|
||||
updatePartyStuckRecovery(state, sample(0, { waypoint: [0.08, 0, 0] }));
|
||||
for (let frame = 1; frame <= 400; frame += 1) {
|
||||
const point = (step: number): PartyWorldPosition => step <= 200
|
||||
? [step * 0.08, 0, 0] : [16, 0, (step - 200) * 0.08];
|
||||
expect(updatePartyStuckRecovery(state, sample(frame * 1000 / 60, {
|
||||
position: point(frame), waypoint: point(frame + 1),
|
||||
}))).toBe("none");
|
||||
}
|
||||
expect(state.lastProgressAtMs).toBeGreaterThan(6_000);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ movementExpected: false }, { health: 0 }, { paused: true }, { stopped: true }, { casting: true }, { controlled: true },
|
||||
])("excludes intentional immobility %j and starts a fresh timer afterward", (excluded) => {
|
||||
const state = createPartyStuckRecoveryState(origin, 0);
|
||||
updatePartyStuckRecovery(state, sample(0));
|
||||
expect(updatePartyStuckRecovery(state, sample(10_000, excluded))).toBe("none");
|
||||
expect(updatePartyStuckRecovery(state, sample(11_000))).toBe("none");
|
||||
expect(updatePartyStuckRecovery(state, sample(12_999))).toBe("none");
|
||||
expect(updatePartyStuckRecovery(state, sample(13_000))).toBe("replan");
|
||||
});
|
||||
|
||||
it("keeps a ten-second teleport cooldown across pauses, holds, and goal changes", () => {
|
||||
const state = createPartyStuckRecoveryState(origin, 0);
|
||||
markPartyStuckRecovered(state, origin, 5_000);
|
||||
updatePartyStuckRecovery(state, sample(5_100));
|
||||
expect(updatePartyStuckRecovery(state, sample(7_100))).toBe("replan");
|
||||
expect(updatePartyStuckRecovery(state, sample(10_100))).toBe("none");
|
||||
resetPartyStuckRecoveryProgress(state);
|
||||
updatePartyStuckRecovery(state, sample(10_200, { goalKey: "mob:new-target" }));
|
||||
expect(updatePartyStuckRecovery(state, sample(12_200, { goalKey: "mob:new-target" }))).toBe("replan");
|
||||
expect(updatePartyStuckRecovery(state, sample(14_999, { goalKey: "mob:new-target" }))).toBe("none");
|
||||
expect(updatePartyStuckRecovery(state, sample(15_200, { goalKey: "mob:new-target" }))).toBe("recover");
|
||||
});
|
||||
|
||||
it("keeps independent teammate timers", () => {
|
||||
const stuck = createPartyStuckRecoveryState(origin, 0);
|
||||
const moving = createPartyStuckRecoveryState(origin, 0);
|
||||
updatePartyStuckRecovery(stuck, sample(0));
|
||||
updatePartyStuckRecovery(moving, sample(0));
|
||||
updatePartyStuckRecovery(stuck, sample(2_000));
|
||||
for (let tick = 1; tick <= 10; tick += 1) updatePartyStuckRecovery(moving, sample(tick * 500, { position: [tick * 0.5, 0, 0] }));
|
||||
expect(updatePartyStuckRecovery(stuck, sample(5_000))).toBe("recover");
|
||||
expect(updatePartyStuckRecovery(moving, sample(5_001, { position: [5, 0, 0] }))).toBe("none");
|
||||
expect(moving.cooldownUntilMs).toBe(0);
|
||||
});
|
||||
|
||||
it("moves only the affected teammate and starts only its teleport cooldown", () => {
|
||||
const positions = new Map<string, MutablePartyWorldPosition>([
|
||||
["tank", [0, 0, 0]], ["rogue", [2, 0, 1]], ["mage", [3, 0, 2]],
|
||||
]);
|
||||
const states = new Map([...positions].map(([id, position]) => [id, createPartyStuckRecoveryState(position, 0)]));
|
||||
expect(applyPartyStuckRecoveryLanding(positions, "rogue", [10, 0, 0], states.get("rogue")!, 5_000)).toBe(true);
|
||||
expect(positions.get("rogue")).toEqual([10, 0, 0]);
|
||||
expect(positions.get("tank")).toEqual([0, 0, 0]);
|
||||
expect(positions.get("mage")).toEqual([3, 0, 2]);
|
||||
expect(states.get("rogue")!.cooldownUntilMs).toBe(15_000);
|
||||
expect(states.get("tank")!.cooldownUntilMs).toBe(0);
|
||||
expect(states.get("mage")!.cooldownUntilMs).toBe(0);
|
||||
});
|
||||
|
||||
it("does not apply missing or malformed landings", () => {
|
||||
const positions = new Map<string, MutablePartyWorldPosition>([["tank", [0, 0, 0]]]);
|
||||
const state = createPartyStuckRecoveryState(origin, 0);
|
||||
expect(applyPartyStuckRecoveryLanding(positions, "missing", [10, 0, 0], state, 5_000)).toBe(false);
|
||||
expect(applyPartyStuckRecoveryLanding(positions, "tank", [NaN, 0, 0], state, 5_000)).toBe(false);
|
||||
expect(positions.get("tank")).toEqual(origin);
|
||||
expect(state.cooldownUntilMs).toBe(0);
|
||||
});
|
||||
|
||||
it("handles malformed samples without poisoning later progress", () => {
|
||||
const state = createPartyStuckRecoveryState(origin, 0);
|
||||
expect(updatePartyStuckRecovery(state, sample(NaN))).toBe("none");
|
||||
expect(updatePartyStuckRecovery(state, sample(1, { position: [NaN, 0, 0] }))).toBe("none");
|
||||
expect(updatePartyStuckRecovery(state, sample(10_000))).toBe("none");
|
||||
});
|
||||
});
|
||||
|
||||
describe("safe stuck-recovery landings", () => {
|
||||
const player: PartyWorldPosition = [10, 0, 0];
|
||||
const choose = (trailPoints: readonly PartyWorldPosition[],
|
||||
validate: (point: PartyWorldPosition) => PartyWorldPosition | null = (point) => point) => choosePartyStuckRecoveryPosition({
|
||||
current: origin, player, groupAnchor: player, trailPoints, validate,
|
||||
});
|
||||
|
||||
it("prefers a visited trail point near the group over returning to the player", () => {
|
||||
expect(choose([[2, 0, 0], [9, 0, 0]])).toEqual({ position: [9, 0, 0], source: "trail" });
|
||||
});
|
||||
|
||||
it("skips wrong floors, distant points, current position, and blocked/unsafe landings", () => {
|
||||
const result = choose([[0.2, 0, 0], [10, 5, 0], [50, 0, 0], [9, 0, 0], [8, 0, 0]],
|
||||
(point) => point[0] === 9 ? null : point);
|
||||
expect(result).toEqual({ position: [8, 0, 0], source: "trail" });
|
||||
});
|
||||
|
||||
it("falls back to a validated player point when no safe local trail exists", () => {
|
||||
expect(choose([], (point) => point)).toEqual({ position: player, source: "player" });
|
||||
expect(choose([[9, 0, 0]], (point) => point[0] === 10 ? point : null)).toEqual({ position: player, source: "player" });
|
||||
});
|
||||
|
||||
it("never blindly teleports to an invalid player point or a far-away projected room", () => {
|
||||
expect(choose([], () => null)).toBeNull();
|
||||
expect(choose([[9, 0, 0]], () => [10, 0, 5])).toBeNull();
|
||||
expect(choose([], () => [10, -5, 0])).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,175 @@
|
||||
import type { MutablePartyWorldPosition, PartyWorldPosition } from "./partyRuntimeRegistry";
|
||||
|
||||
export const PARTY_STUCK_REPLAN_MS = 2_000;
|
||||
export const PARTY_STUCK_RECOVER_MS = 5_000;
|
||||
export const PARTY_STUCK_RECOVERY_COOLDOWN_MS = 10_000;
|
||||
const PROGRESS_DISTANCE = 0.45;
|
||||
const RECOVERY_RETRY_MS = 1_000;
|
||||
const MAX_TRAIL_GROUP_DISTANCE = 12;
|
||||
const MAX_TRAIL_HEIGHT_DIFFERENCE = 1.25;
|
||||
const MIN_RECOVERY_DISTANCE = 1;
|
||||
|
||||
export interface PartyStuckRecoveryState {
|
||||
goalKey: string | null;
|
||||
readonly previousPosition: MutablePartyWorldPosition;
|
||||
waypoint: MutablePartyWorldPosition | null;
|
||||
netProgress: number;
|
||||
lastProgressAtMs: number;
|
||||
replanned: boolean;
|
||||
cooldownUntilMs: number;
|
||||
retryAtMs: number;
|
||||
}
|
||||
|
||||
export interface PartyStuckRecoverySample {
|
||||
readonly position: PartyWorldPosition;
|
||||
readonly waypoint: PartyWorldPosition | null;
|
||||
readonly goalKey: string;
|
||||
readonly movementExpected: boolean;
|
||||
readonly health: number;
|
||||
readonly paused: boolean;
|
||||
readonly stopped: boolean;
|
||||
readonly casting: boolean;
|
||||
readonly controlled: boolean;
|
||||
readonly nowMs: number;
|
||||
}
|
||||
|
||||
function distance(a: PartyWorldPosition, b: PartyWorldPosition): number {
|
||||
return Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]);
|
||||
}
|
||||
|
||||
function finitePosition(position: PartyWorldPosition): boolean {
|
||||
return position.every(Number.isFinite);
|
||||
}
|
||||
|
||||
export function createPartyStuckRecoveryState(position: PartyWorldPosition, nowMs: number): PartyStuckRecoveryState {
|
||||
return {
|
||||
goalKey: null,
|
||||
previousPosition: [...position],
|
||||
waypoint: null,
|
||||
netProgress: 0,
|
||||
lastProgressAtMs: nowMs,
|
||||
replanned: false,
|
||||
cooldownUntilMs: 0,
|
||||
retryAtMs: 0,
|
||||
};
|
||||
}
|
||||
|
||||
/** Clear navigation timing without letting pauses/holds bypass a teleport cooldown. */
|
||||
export function resetPartyStuckRecoveryProgress(state: PartyStuckRecoveryState): void {
|
||||
state.goalKey = null;
|
||||
state.waypoint = null;
|
||||
state.netProgress = 0;
|
||||
state.replanned = false;
|
||||
}
|
||||
|
||||
/** Signed progress toward the previous waypoint cancels doorway back-and-forth. */
|
||||
export function updatePartyStuckRecovery(
|
||||
state: PartyStuckRecoveryState,
|
||||
sample: PartyStuckRecoverySample,
|
||||
): "none" | "replan" | "recover" {
|
||||
const { position, waypoint, nowMs } = sample;
|
||||
if (!Number.isFinite(nowMs) || !finitePosition(position) || (waypoint && !finitePosition(waypoint))) {
|
||||
resetPartyStuckRecoveryProgress(state);
|
||||
return "none";
|
||||
}
|
||||
const excluded = !sample.movementExpected || sample.health <= 0 || sample.paused
|
||||
|| sample.stopped || sample.casting || sample.controlled;
|
||||
if (excluded || state.goalKey !== sample.goalKey || nowMs < state.lastProgressAtMs) {
|
||||
resetPartyStuckRecoveryProgress(state);
|
||||
state.lastProgressAtMs = nowMs;
|
||||
if (!excluded) state.goalKey = sample.goalKey;
|
||||
} else if (state.waypoint) {
|
||||
// Target/waypoint movement itself is not actor progress. Count only the
|
||||
// actor's displacement relative to the last sampled route waypoint.
|
||||
// Once the old waypoint was reached, movement belongs to the next leg.
|
||||
// Otherwise dense waypoint chains would count every forward step as
|
||||
// moving away from the just-completed point.
|
||||
const progressWaypoint = waypoint && distance(state.previousPosition, state.waypoint) <= 0.06
|
||||
&& distance(waypoint, state.waypoint) > 0.04 ? waypoint : state.waypoint;
|
||||
state.netProgress += distance(state.previousPosition, progressWaypoint) - distance(position, progressWaypoint);
|
||||
if (state.netProgress >= PROGRESS_DISTANCE) {
|
||||
state.netProgress = 0;
|
||||
state.lastProgressAtMs = nowMs;
|
||||
state.replanned = false;
|
||||
}
|
||||
}
|
||||
state.previousPosition[0] = position[0];
|
||||
state.previousPosition[1] = position[1];
|
||||
state.previousPosition[2] = position[2];
|
||||
if (waypoint && !excluded) {
|
||||
state.waypoint ??= [0, 0, 0];
|
||||
state.waypoint[0] = waypoint[0];
|
||||
state.waypoint[1] = waypoint[1];
|
||||
state.waypoint[2] = waypoint[2];
|
||||
} else state.waypoint = null;
|
||||
if (excluded) return "none";
|
||||
|
||||
const elapsed = nowMs - state.lastProgressAtMs;
|
||||
if (!state.replanned && elapsed >= PARTY_STUCK_REPLAN_MS) {
|
||||
state.replanned = true;
|
||||
return "replan";
|
||||
}
|
||||
if (elapsed >= PARTY_STUCK_RECOVER_MS && nowMs >= state.cooldownUntilMs && nowMs >= state.retryAtMs) {
|
||||
state.retryAtMs = nowMs + RECOVERY_RETRY_MS;
|
||||
return "recover";
|
||||
}
|
||||
return "none";
|
||||
}
|
||||
|
||||
export function markPartyStuckRecovered(state: PartyStuckRecoveryState, position: PartyWorldPosition, nowMs: number): void {
|
||||
resetPartyStuckRecoveryProgress(state);
|
||||
state.previousPosition[0] = position[0];
|
||||
state.previousPosition[1] = position[1];
|
||||
state.previousPosition[2] = position[2];
|
||||
state.lastProgressAtMs = nowMs;
|
||||
state.cooldownUntilMs = nowMs + PARTY_STUCK_RECOVERY_COOLDOWN_MS;
|
||||
state.retryAtMs = state.cooldownUntilMs;
|
||||
}
|
||||
|
||||
/** Position-only recovery: no squad recall, healing, threat reset, or resurrection. */
|
||||
export function applyPartyStuckRecoveryLanding(
|
||||
positions: ReadonlyMap<string, MutablePartyWorldPosition>,
|
||||
memberId: string,
|
||||
landing: PartyWorldPosition,
|
||||
state: PartyStuckRecoveryState,
|
||||
nowMs: number,
|
||||
): boolean {
|
||||
const current = positions.get(memberId);
|
||||
if (!current || !finitePosition(landing) || !Number.isFinite(nowMs)) return false;
|
||||
current[0] = landing[0];
|
||||
current[1] = landing[1];
|
||||
current[2] = landing[2];
|
||||
markPartyStuckRecovered(state, current, nowMs);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Only visited trail positions are local candidates; never jump to an arbitrary nearest room. */
|
||||
export function choosePartyStuckRecoveryPosition({
|
||||
current, player, groupAnchor, trailPoints, validate,
|
||||
}: {
|
||||
readonly current: PartyWorldPosition;
|
||||
readonly player: PartyWorldPosition;
|
||||
readonly groupAnchor: PartyWorldPosition;
|
||||
readonly trailPoints: readonly PartyWorldPosition[];
|
||||
readonly validate: (position: PartyWorldPosition, anchor: PartyWorldPosition) => PartyWorldPosition | null;
|
||||
}): { readonly position: PartyWorldPosition; readonly source: "trail" | "player" } | null {
|
||||
if (!finitePosition(current) || !finitePosition(player) || !finitePosition(groupAnchor)) return null;
|
||||
const candidates = trailPoints.filter((point) => finitePosition(point)
|
||||
&& distance(point, current) >= MIN_RECOVERY_DISTANCE
|
||||
&& distance(point, groupAnchor) <= MAX_TRAIL_GROUP_DISTANCE
|
||||
&& Math.abs(point[1] - groupAnchor[1]) <= MAX_TRAIL_HEIGHT_DIFFERENCE)
|
||||
.sort((a, b) => distance(a, groupAnchor) - distance(b, groupAnchor));
|
||||
// Bound expensive path/physics checks even when a looping trail has many samples.
|
||||
for (const candidate of candidates.slice(0, 16)) {
|
||||
const position = validate(candidate, groupAnchor);
|
||||
if (position && finitePosition(position) && distance(position, current) >= MIN_RECOVERY_DISTANCE
|
||||
&& distance(position, candidate) <= 0.85
|
||||
&& Math.abs(position[1] - groupAnchor[1]) <= MAX_TRAIL_HEIGHT_DIFFERENCE) {
|
||||
return { position: [...position], source: "trail" };
|
||||
}
|
||||
}
|
||||
const position = validate(player, player);
|
||||
return position && finitePosition(position) && distance(position, player) <= 0.85
|
||||
? { position: [...position], source: "player" }
|
||||
: null;
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { useCombatStore } from "./combatStore";
|
||||
import { activateDungeonEncounterDefinitions, activeDungeonCompletedObjectiveIds, advanceDungeonEncounters, dispatchActiveDungeonEntityEvent, resetDungeonEncounters } from "./dungeonEncounterRuntime";
|
||||
import { requireDungeonDefinition } from "./dungeonRegistry";
|
||||
import { respawnDefeatedPlayer } from "./deathRespawn";
|
||||
import { availableResurrectionCheckpoints, dungeonResurrectionCheckpoints } from "./resurrectionCheckpoints";
|
||||
import { useGameStore } from "./store";
|
||||
|
||||
const dungeon = requireDungeonDefinition("stormwind-stockade");
|
||||
|
||||
describe("boss resurrection checkpoints", () => {
|
||||
beforeEach(() => {
|
||||
useGameStore.getState().activateDungeon("stormwind-stockade");
|
||||
activateDungeonEncounterDefinitions(dungeon.id, dungeon.encounters ?? []);
|
||||
resetDungeonEncounters("dungeon", dungeon.id);
|
||||
useCombatStore.getState().initializeCharacter({ classId: "priest", level: 16 });
|
||||
useCombatStore.setState({ mobs: {} });
|
||||
});
|
||||
|
||||
it("always offers the entrance and unlocks every killed boss, including optional bosses", () => {
|
||||
expect(dungeonResurrectionCheckpoints(dungeon, {})).toHaveLength(1);
|
||||
const result = dungeonResurrectionCheckpoints(dungeon, {}, dungeon.bosses.map((boss) => boss.id));
|
||||
expect(result.map((checkpoint) => checkpoint.id)).toEqual(["entrance", ...dungeon.bosses.map((boss) => `boss:${boss.id}`)]);
|
||||
dungeon.bosses.forEach((boss, index) => expect(result[index + 1].spawn.footPosition).toEqual(boss.position));
|
||||
});
|
||||
|
||||
it("uses durable encounter completion after hydration when no corpse is registered", () => {
|
||||
const boss = dungeon.bosses[0];
|
||||
dispatchActiveDungeonEntityEvent({ kind: "entity-death", entityId: boss.id, now: 100 });
|
||||
advanceDungeonEncounters(dungeon.id, { kind: "tick", now: 100, facts: { deadEntityIds: [boss.id], registeredEntityIds: [boss.id] } });
|
||||
expect(activeDungeonCompletedObjectiveIds(dungeon.id)).toContain(boss.id);
|
||||
expect(availableResurrectionCheckpoints().map((checkpoint) => checkpoint.id)).toContain(`boss:${boss.id}`);
|
||||
resetDungeonEncounters("boss-wipe", dungeon.id);
|
||||
expect(availableResurrectionCheckpoints().map((checkpoint) => checkpoint.id)).toContain(`boss:${boss.id}`);
|
||||
resetDungeonEncounters("dungeon", dungeon.id);
|
||||
expect(availableResurrectionCheckpoints()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("does not award a checkpoint for an alive or script-despawned boss", () => {
|
||||
const boss = dungeon.bosses[0];
|
||||
useCombatStore.getState().registerMob(boss.id, { name: boss.name, maxHealth: 100, boss: true });
|
||||
expect(availableResurrectionCheckpoints()).toHaveLength(1);
|
||||
const mob = useCombatStore.getState().mobs[boss.id];
|
||||
useCombatStore.setState({ mobs: { [boss.id]: { ...mob, dead: true, health: 0, despawned: true } } });
|
||||
expect(availableResurrectionCheckpoints()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("returns the defeated player and party reset signal to the selected boss, preserving the kill", () => {
|
||||
const boss = dungeon.bosses[0];
|
||||
useCombatStore.getState().registerMob(boss.id, { name: boss.name, maxHealth: 1, boss: true });
|
||||
useCombatStore.getState().damageMob(boss.id, 1, 100);
|
||||
useCombatStore.getState().damagePlayer(useCombatStore.getState().maxHealth * 10);
|
||||
const before = useGameStore.getState().resetRevision;
|
||||
expect(respawnDefeatedPlayer(`boss:${boss.id}`)).toBe(true);
|
||||
expect(useGameStore.getState().playerPosition).toEqual(boss.position);
|
||||
expect(useGameStore.getState().resetRevision).toBe(before + 1);
|
||||
expect(useCombatStore.getState().playerPosition).toEqual(boss.position);
|
||||
expect(useCombatStore.getState().health).toBe(useCombatStore.getState().maxHealth);
|
||||
expect(useCombatStore.getState().mobs[boss.id].dead).toBe(true);
|
||||
expect(availableResurrectionCheckpoints().map((checkpoint) => checkpoint.id)).toContain(`boss:${boss.id}`);
|
||||
});
|
||||
|
||||
it("lets a defeated player explicitly choose the entrance instead of a previously saved boss spawn", () => {
|
||||
useGameStore.setState({ activeSpawn: { footPosition: dungeon.bosses[0].position, yaw: 1 } });
|
||||
useCombatStore.getState().damagePlayer(useCombatStore.getState().maxHealth * 10);
|
||||
expect(respawnDefeatedPlayer("entrance")).toBe(true);
|
||||
expect(useGameStore.getState().playerPosition).toEqual(dungeon.entrance.footPosition);
|
||||
});
|
||||
|
||||
it("rejects locked boss choices and cannot teleport a living player", () => {
|
||||
const before = useGameStore.getState();
|
||||
expect(respawnDefeatedPlayer("entrance")).toBe(false);
|
||||
useCombatStore.getState().damagePlayer(useCombatStore.getState().maxHealth * 10);
|
||||
expect(respawnDefeatedPlayer(`boss:${dungeon.bosses[0].id}`)).toBe(false);
|
||||
expect(useCombatStore.getState().health).toBe(0);
|
||||
expect(useGameStore.getState()).toBe(before);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useCombatStore, type MobCombatState } from "./combatStore";
|
||||
import { activeDungeonCompletedObjectiveIds } from "./dungeonEncounterRuntime";
|
||||
import { requireDungeonDefinition } from "./dungeonRegistry";
|
||||
import type { DungeonDefinition, WorldSpawn } from "./dungeonTypes";
|
||||
import { useGameStore } from "./store";
|
||||
|
||||
export interface ResurrectionCheckpoint {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly spawn: WorldSpawn;
|
||||
}
|
||||
|
||||
/** Authored boss positions only: never accept a corpse position or arbitrary UI coordinates. */
|
||||
export function dungeonResurrectionCheckpoints(
|
||||
dungeon: DungeonDefinition,
|
||||
mobs: Readonly<Record<string, MobCombatState>>,
|
||||
completedObjectiveIds: readonly string[] = [],
|
||||
): ResurrectionCheckpoint[] {
|
||||
const completed = new Set(completedObjectiveIds);
|
||||
return [
|
||||
{ id: "entrance", name: "Dungeon entrance", spawn: dungeon.entrance },
|
||||
...dungeon.bosses.filter((boss) => completed.has(boss.id)
|
||||
|| (mobs[boss.id]?.dead && !mobs[boss.id]?.despawned)).map((boss) => ({
|
||||
id: `boss:${boss.id}`, name: boss.name,
|
||||
spawn: { footPosition: boss.position, yaw: dungeon.entrance.yaw },
|
||||
})),
|
||||
];
|
||||
}
|
||||
|
||||
export function availableResurrectionCheckpoints(): ResurrectionCheckpoint[] {
|
||||
const game = useGameStore.getState();
|
||||
return dungeonResurrectionCheckpoints(requireDungeonDefinition(game.activeDungeonId),
|
||||
useCombatStore.getState().mobs, activeDungeonCompletedObjectiveIds(game.activeDungeonId));
|
||||
}
|
||||
|
||||
export function resolveResurrectionCheckpoint(id: string): ResurrectionCheckpoint | null {
|
||||
if (useGameStore.getState().gameMode !== "dungeon") return null;
|
||||
return availableResurrectionCheckpoints().find((checkpoint) => checkpoint.id === id) ?? null;
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { NodeIO } from "@gltf-transform/core";
|
||||
import RAPIER from "@dimforge/rapier3d-compat";
|
||||
import { ALL_EXTENSIONS } from "@gltf-transform/extensions";
|
||||
import { MeshoptDecoder } from "meshoptimizer";
|
||||
import { BufferAttribute, BufferGeometry, Group, Mesh } from "three";
|
||||
import { BufferAttribute, BufferGeometry, Group, Mesh, Ray, Vector3 } from "three";
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
import { requireDungeonDefinition } from "./dungeonRegistry";
|
||||
import {
|
||||
@@ -13,12 +13,17 @@ import {
|
||||
} from "./partyNavigation";
|
||||
import { preparePartyNavigationGraph } from "./partyNavigationAssetCache";
|
||||
import { shouldLoadPartyNavigation } from "./partyNavigationPolicy";
|
||||
import { planPartyRoute } from "./partyPathing";
|
||||
import { emptyPartyRouteState, planPartyRoute } from "./partyPathing";
|
||||
import { partyRejoinMovement } from "./partyRejoin";
|
||||
import { partyCombatMovementGoal, stepPartyPosition } from "./partyMovement";
|
||||
import { derivePartySpawnSlots } from "./partySpawnSlots";
|
||||
import { collisionTrimeshes, prepareCollision } from "../scene/productionDungeonCollision";
|
||||
import { createPartyDirectSegmentValidator, createPartyRouteSegmentValidator } from "../scene/partyTraversal";
|
||||
import { collisionTrimeshes, collisionLineOfSightTrimeshes, prepareCollision } from "../scene/productionDungeonCollision";
|
||||
import { DUNGEON_PHYSICAL_COLLISION_GROUPS, DUNGEON_SIGHT_BLOCKER_COLLISION_GROUPS, hasDungeonLineOfSight } from "../scene/dungeonLineOfSight";
|
||||
import { createPartyDirectSegmentValidator, createPartyRouteSegmentValidator, createPartyRecoveryPointValidator } from "../scene/partyTraversal";
|
||||
import { configurePlayerCharacterController, PLAYER_CHARACTER_CONTROLLER_OFFSET } from "./playerMovement";
|
||||
import { ACTOR_COLLISION_GROUPS, ACTOR_TERRAIN_QUERY_GROUPS } from "../scene/actorCollision";
|
||||
import { choosePartyStuckRecoveryPosition } from "./partyStuckRecovery";
|
||||
import { createMobPursuit } from "./mobPursuit";
|
||||
|
||||
const STOCKADES_NAVIGATION = "public/assets/game/manastorm/34-stormwindjail/navigation.glb";
|
||||
const TANK_RANGE = Object.freeze({ minimum: 0, preferred: 2.9, maximum: 3.15 });
|
||||
@@ -82,13 +87,20 @@ beforeAll(async () => {
|
||||
}
|
||||
});
|
||||
|
||||
function collisionWorld(useRamps = true): RAPIER.World {
|
||||
function collisionWorld(useRamps = true, includeSight = false): RAPIER.World {
|
||||
const world = new RAPIER.World({ x: 0, y: -18, z: 0 });
|
||||
const prepared = prepareCollision(sourceCollision, useRamps ? stockades.traversalRamps : []);
|
||||
for (const trimesh of collisionTrimeshes(prepared)) {
|
||||
world.createCollider(RAPIER.ColliderDesc.trimesh(
|
||||
Float32Array.from(trimesh.vertices), Uint32Array.from(trimesh.indices),
|
||||
));
|
||||
).setCollisionGroups(DUNGEON_PHYSICAL_COLLISION_GROUPS));
|
||||
}
|
||||
if (includeSight) {
|
||||
for (const trimesh of collisionLineOfSightTrimeshes(prepared)) {
|
||||
world.createCollider(RAPIER.ColliderDesc.trimesh(
|
||||
Float32Array.from(trimesh.vertices), Uint32Array.from(trimesh.indices),
|
||||
).setCollisionGroups(DUNGEON_SIGHT_BLOCKER_COLLISION_GROUPS).setSensor(true));
|
||||
}
|
||||
}
|
||||
for (const child of prepared.children) (child as Mesh).geometry.dispose();
|
||||
world.step();
|
||||
@@ -100,6 +112,127 @@ function distance(left: readonly number[], right: readonly number[]): number {
|
||||
}
|
||||
|
||||
describe("Stormwind Stockade party navigation", () => {
|
||||
it("returns resurrected teammates from the entrance and every boss checkpoint through approved shipping corridors", () => {
|
||||
const world = collisionWorld(true, true);
|
||||
try {
|
||||
const directSegmentAllowed = createPartyDirectSegmentValidator(RAPIER, world);
|
||||
const routeSegmentAllowed = createPartyRouteSegmentValidator(RAPIER, world);
|
||||
const checkpoints = [stockades.entrance, ...stockades.bosses.map((boss) => ({ footPosition: boss.position, yaw: stockades.entrance.yaw }))];
|
||||
for (const checkpoint of checkpoints) {
|
||||
const player = findNearestPartyNavigationSurface(graph, stockades.entrance.footPosition, 6)!.position;
|
||||
for (const slot of derivePartySpawnSlots(checkpoint, 5).slice(1)) {
|
||||
const origin = findNearestPartyNavigationSurface(graph, slot.footPosition, 6)?.position;
|
||||
expect(origin, JSON.stringify(slot)).toBeDefined();
|
||||
let result = partyRejoinMovement("returning-member", origin!, player, 0, graph, emptyPartyRouteState(), {
|
||||
directSegmentAllowed, routeSegmentAllowed,
|
||||
});
|
||||
expect(result.blocked, JSON.stringify({ checkpoint, origin, waypoints: result.route.waypoints })).toBe(false);
|
||||
expect(result.rejoined || result.waypoint !== null).toBe(true);
|
||||
for (let index = 1; index < result.route.waypoints.length; index += 1) {
|
||||
expect(routeSegmentAllowed(result.route.waypoints[index - 1], result.route.waypoints[index])).toBe(true);
|
||||
}
|
||||
const current: [number, number, number] = [...origin!];
|
||||
const next: [number, number, number] = [0, 0, 0];
|
||||
for (let frame = 1; frame <= 4_000 && !result.rejoined; frame += 1) {
|
||||
if (result.waypoint) {
|
||||
stepPartyPosition(current, result.waypoint, .175, next);
|
||||
current[0] = next[0]; current[1] = next[1]; current[2] = next[2];
|
||||
}
|
||||
result = partyRejoinMovement("returning-member", current, player, frame * 50, graph, result.route, {
|
||||
directSegmentAllowed, routeSegmentAllowed,
|
||||
});
|
||||
}
|
||||
expect(result.rejoined, JSON.stringify({ checkpoint, origin, current, result })).toBe(true);
|
||||
}
|
||||
}
|
||||
} finally { world.free(); }
|
||||
}, 60_000);
|
||||
|
||||
it("blocks combat sight across shipping room walls in both directions while preserving clear stair sight", () => {
|
||||
const world = collisionWorld(true, true);
|
||||
try {
|
||||
const prepared = prepareCollision(sourceCollision, stockades.traversalRamps);
|
||||
let checkedWalls = 0;
|
||||
for (const mesh of collisionLineOfSightTrimeshes(prepared)) {
|
||||
for (let offset = 0; offset < mesh.indices.length && checkedWalls < 12; offset += 3) {
|
||||
const a = Number(mesh.indices[offset]) * 3;
|
||||
const b = Number(mesh.indices[offset + 1]) * 3;
|
||||
const c = Number(mesh.indices[offset + 2]) * 3;
|
||||
const v = mesh.vertices;
|
||||
const ab = [Number(v[b]) - Number(v[a]), Number(v[b + 1]) - Number(v[a + 1]), Number(v[b + 2]) - Number(v[a + 2])];
|
||||
const ac = [Number(v[c]) - Number(v[a]), Number(v[c + 1]) - Number(v[a + 1]), Number(v[c + 2]) - Number(v[a + 2])];
|
||||
const normalX = ab[1] * ac[2] - ab[2] * ac[1];
|
||||
const normalZ = ab[0] * ac[1] - ab[1] * ac[0];
|
||||
const length = Math.hypot(normalX, normalZ);
|
||||
if (length < 0.1) continue;
|
||||
const center = [0, 1, 2].map((axis) => (Number(v[a + axis]) + Number(v[b + axis]) + Number(v[c + axis])) / 3);
|
||||
const first = findNearestPartyNavigationSurface(graph,
|
||||
[center[0] + normalX / length, center[1] - 1.05, center[2] + normalZ / length], 2.5)?.position;
|
||||
const second = findNearestPartyNavigationSurface(graph,
|
||||
[center[0] - normalX / length, center[1] - 1.05, center[2] - normalZ / length], 2.5)?.position;
|
||||
if (!first || !second || Math.abs(first[1] - second[1]) > 0.4 || distance(first, second) < 1.5
|
||||
|| Math.abs((first[1] + second[1]) / 2 + 1.05 - center[1]) > 0.15
|
||||
|| Math.hypot(first[0] - center[0], first[2] - center[2]) > 1.3
|
||||
|| Math.hypot(second[0] - center[0], second[2] - center[2]) > 1.3) continue;
|
||||
const sideA = (first[0] - center[0]) * normalX + (first[2] - center[2]) * normalZ;
|
||||
const sideB = (second[0] - center[0]) * normalX + (second[2] - center[2]) * normalZ;
|
||||
if (sideA * sideB >= 0) continue;
|
||||
// Nearby decorative faces/door-frame triangles are not room walls
|
||||
// unless the actors' chest ray actually crosses this source face.
|
||||
const origin = new Vector3(first[0], first[1] + 1.05, first[2]);
|
||||
const destination = new Vector3(second[0], second[1] + 1.05, second[2]);
|
||||
const ray = new Ray(origin, destination.clone().sub(origin).normalize());
|
||||
const intersection = ray.intersectTriangle(
|
||||
new Vector3(Number(v[a]), Number(v[a + 1]), Number(v[a + 2])),
|
||||
new Vector3(Number(v[b]), Number(v[b + 1]), Number(v[b + 2])),
|
||||
new Vector3(Number(v[c]), Number(v[c + 1]), Number(v[c + 2])), false, new Vector3(),
|
||||
);
|
||||
if (!intersection || intersection.distanceTo(origin) >= destination.distanceTo(origin)) continue;
|
||||
expect(hasDungeonLineOfSight(RAPIER, world, first, second), JSON.stringify({ center, first, second })).toBe(false);
|
||||
expect(hasDungeonLineOfSight(RAPIER, world, second, first), JSON.stringify({ center, first, second })).toBe(false);
|
||||
checkedWalls += 1;
|
||||
}
|
||||
}
|
||||
for (const child of prepared.children) (child as Mesh).geometry.dispose();
|
||||
expect(checkedWalls).toBeGreaterThanOrEqual(8);
|
||||
for (const ramp of stockades.traversalRamps ?? []) {
|
||||
expect(hasDungeonLineOfSight(RAPIER, world, ramp.start, ramp.end)).toBe(true);
|
||||
expect(hasDungeonLineOfSight(RAPIER, world, ramp.end, ramp.start)).toBe(true);
|
||||
}
|
||||
} finally { world.free(); }
|
||||
}, 20_000);
|
||||
|
||||
it("moves pursuing mobs up and down all shipping ramps without a false teleport or reset", () => {
|
||||
const world = collisionWorld(true, true);
|
||||
try {
|
||||
const directAllowed = createPartyDirectSegmentValidator(RAPIER, world);
|
||||
const routeAllowed = createPartyRouteSegmentValidator(RAPIER, world);
|
||||
const navigator = createMobPursuit({ graph: () => graph, directAllowed, routeAllowed,
|
||||
hasLineOfSight: (a, b) => hasDungeonLineOfSight(RAPIER, world, a, b),
|
||||
validateLanding: createPartyRecoveryPointValidator(RAPIER, world, graph) });
|
||||
for (const ramp of stockades.traversalRamps ?? []) {
|
||||
for (const [start, target] of [[ramp.start, ramp.end], [ramp.end, ramp.start]]) {
|
||||
const initialRoute = planPartyRoute(graph, "probe", start, target, 0, {
|
||||
maxOriginSnapDistance: 3, maxDestinationSnapDistance: 3,
|
||||
directSegmentAllowed: directAllowed, routeSegmentAllowed: routeAllowed,
|
||||
destinationReached: (surface, destination) => distance(surface, destination) <= 1.5
|
||||
&& hasDungeonLineOfSight(RAPIER, world, surface, destination),
|
||||
});
|
||||
expect(initialRoute.blocked, JSON.stringify({ ramp: ramp.id, waypoints: initialRoute.waypoints })).toBe(false);
|
||||
const position: [number, number, number] = [...start];
|
||||
for (let frame = 0; frame < 500 && distance(position, target) > 1.5; frame += 1) {
|
||||
const before = [...position];
|
||||
expect(navigator.step({ id: `${ramp.id}:${start[1]}`, targetId: "target", position,
|
||||
target, home: start, phase: "chasing", health: 100, engaged: true, active: true,
|
||||
casting: false, controlled: false, attackRange: 1.5, leashRange: 40,
|
||||
maximumStep: 0.175, nowMs: frame * 50 }), JSON.stringify({ ramp: ramp.id, start, target, before, frame })).toBe("none");
|
||||
}
|
||||
expect(distance(position, target)).toBeLessThanOrEqual(1.5);
|
||||
}
|
||||
}
|
||||
} finally { world.free(); }
|
||||
});
|
||||
|
||||
it("uses the reviewed warm indoor lighting profile", () => {
|
||||
expect(stockades.presentation).toMatchObject({
|
||||
background: "#171815",
|
||||
@@ -302,13 +435,49 @@ describe("Stormwind Stockade party navigation", () => {
|
||||
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), body);
|
||||
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();
|
||||
@@ -321,7 +490,7 @@ describe("Stormwind Stockade party navigation", () => {
|
||||
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), body);
|
||||
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 = () => {
|
||||
@@ -338,7 +507,7 @@ describe("Stormwind Stockade party navigation", () => {
|
||||
x: dx / length * direction * 4.8 / 60,
|
||||
y: -0.5 / 60,
|
||||
z: dz / length * direction * 4.8 / 60,
|
||||
}, RAPIER.QueryFilterFlags.EXCLUDE_SENSORS | RAPIER.QueryFilterFlags.EXCLUDE_DYNAMIC);
|
||||
}, 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 });
|
||||
|
||||
@@ -1,10 +1,31 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
actionKeyForEvent, bindDesktopSlot, defaultUiLayout, desktopSlotForKey,
|
||||
exportUiPreferences, importUiPreferences, normalizeActionKey, normalizeUiPreferences,
|
||||
exportUiPreferences, importUiPreferences, normalizeActionKey, normalizeUiPreferences, resolveDpsMeterDisplay,
|
||||
} from "./uiLayout";
|
||||
|
||||
describe("personal UI layouts", () => {
|
||||
it("defaults DPS to PC lower-left flow and the Thor companion, with saved overrides", () => {
|
||||
const ui = normalizeUiPreferences(null);
|
||||
expect(ui.current.pc.elements.dps).toEqual({ visible: true, position: null, width: null });
|
||||
expect(resolveDpsMeterDisplay(ui.current.pc, false)).toBe("main");
|
||||
expect(resolveDpsMeterDisplay(ui.current.mobile, true)).toBe("companion");
|
||||
expect(resolveDpsMeterDisplay(ui.current.pc, true)).toBe("companion");
|
||||
ui.current.mobile.dpsMeterDisplay = "main";
|
||||
ui.current.mobile.elements.dps = { visible: false, position: { x: .6, y: .7 }, width: 300 };
|
||||
expect(importUiPreferences(exportUiPreferences(ui))).toEqual(ui);
|
||||
expect(resolveDpsMeterDisplay(ui.current.mobile, true)).toBe("main");
|
||||
});
|
||||
it("imports older UI templates without a DPS meter", () => {
|
||||
const exported = JSON.parse(exportUiPreferences(normalizeUiPreferences(null)));
|
||||
for (const device of ["pc", "mobile"]) {
|
||||
delete exported.current[device].elements.dps;
|
||||
delete exported.current[device].dpsMeterDisplay;
|
||||
}
|
||||
expect(importUiPreferences(JSON.stringify(exported)).current.pc.elements.dps.visible).toBe(true);
|
||||
exported.current.pc.dpsMeterDisplay = "invalid";
|
||||
expect(() => importUiPreferences(JSON.stringify(exported))).toThrow("DPS meter display");
|
||||
});
|
||||
it("migrates old accounts to independent Mobile and PC layouts with exactly six empty slots", () => {
|
||||
const ui = normalizeUiPreferences(undefined);
|
||||
expect(ui.slots.mobile).toEqual([null, null, null]);
|
||||
|
||||
+13
-2
@@ -7,7 +7,7 @@ export const BAR_COLUMNS = [1, 2, 3, 4, 6, 12] as const;
|
||||
export const HUD_ELEMENTS = {
|
||||
party: "Party frames", summons: "Pet and summon frames", minimap: "Minimap",
|
||||
player: "Player status", timeline: "Encounter timeline", target: "Enemy target",
|
||||
friendly: "Friendly target", threat: "Threat meter", cast: "Cast bar",
|
||||
friendly: "Friendly target", threat: "Threat meter", dps: "DPS meter", cast: "Cast bar",
|
||||
hints: "Control hints", actions: "Controller action bar", experience: "Experience bar",
|
||||
reticle: "Crosshair", loot: "Loot notifications", interaction: "Interaction prompt",
|
||||
manastorm: "Manastorm status", completion: "Dungeon completion", assets: "Asset notice",
|
||||
@@ -25,6 +25,7 @@ export interface DesktopActionSlot { abilityId: string | null; key: string | nul
|
||||
export interface DesktopActionBar { columns: number; slots: DesktopActionSlot[] }
|
||||
export interface UiLayout {
|
||||
elements: Record<HudElementId, HudElementLayout>;
|
||||
dpsMeterDisplay: "auto" | "main" | "companion";
|
||||
wowBars: boolean;
|
||||
barCount: number;
|
||||
bars: DesktopActionBar[];
|
||||
@@ -42,6 +43,7 @@ export function defaultUiLayout(): UiLayout {
|
||||
elements: Object.fromEntries(Object.keys(HUD_ELEMENTS).map((id) => [id, {
|
||||
visible: true, position: null, width: null,
|
||||
}])) as UiLayout["elements"],
|
||||
dpsMeterDisplay: "auto",
|
||||
wowBars: false,
|
||||
barCount: 1,
|
||||
bars: Array.from({ length: 5 }, (_, bar) => ({
|
||||
@@ -106,7 +108,13 @@ export function normalizeUiLayout(value: unknown): UiLayout {
|
||||
}),
|
||||
};
|
||||
});
|
||||
return { ...defaults, wowBars: source.wowBars === true, barCount: Math.floor(bounded(source.barCount, 1, 1, 5)) };
|
||||
return { ...defaults,
|
||||
dpsMeterDisplay: source.dpsMeterDisplay === "main" || source.dpsMeterDisplay === "companion" ? source.dpsMeterDisplay : "auto",
|
||||
wowBars: source.wowBars === true, barCount: Math.floor(bounded(source.barCount, 1, 1, 5)) };
|
||||
}
|
||||
|
||||
export function resolveDpsMeterDisplay(layout: UiLayout, dualScreen: boolean): "main" | "companion" {
|
||||
return layout.dpsMeterDisplay === "auto" ? dualScreen ? "companion" : "main" : layout.dpsMeterDisplay;
|
||||
}
|
||||
export function normalizeUiPreferences(value: unknown): UiPreferences {
|
||||
const source = record(value);
|
||||
@@ -146,9 +154,12 @@ export function importUiPreferences(text: string): UiPreferences {
|
||||
if (source.format !== "healer-man-ui" || source.version !== 1) throw new Error("Unsupported UI file format or version.");
|
||||
const validateLayout = (value: unknown) => {
|
||||
const layout = record(value);
|
||||
if (layout.dpsMeterDisplay !== undefined && !["auto", "main", "companion"].includes(String(layout.dpsMeterDisplay))) throw new Error("Invalid DPS meter display.");
|
||||
if (typeof layout.wowBars !== "boolean" || !Number.isInteger(layout.barCount) || Number(layout.barCount) < 1 || Number(layout.barCount) > 5
|
||||
|| !Array.isArray(layout.bars) || layout.bars.length !== 5) throw new Error("Invalid action bar configuration.");
|
||||
for (const id of Object.keys(HUD_ELEMENTS)) {
|
||||
// Version-one exports made before the DPS meter inherit its defaults.
|
||||
if (id === "dps" && record(layout.elements)[id] === undefined) continue;
|
||||
const element = record(record(layout.elements)[id]);
|
||||
const position = record(element.position);
|
||||
if (typeof element.visible !== "boolean" || (element.position !== null && [position.x, position.y].some((v) => typeof v !== "number" || !Number.isFinite(v) || v < 0 || v > 1))
|
||||
|
||||
@@ -5,6 +5,8 @@ import { useShellStore } from "../app/shellStore";
|
||||
import { emitControllerToken, setExternalControllerMovement } from "../input/controller";
|
||||
import { currentCompanionSnapshot, executeCompanionCommand, type DualScreenMessage } from "./dualScreenSync";
|
||||
import { createRateLimitedPublisher } from "./rateLimitedPublisher";
|
||||
import { useUiLayoutStore } from "../game/uiLayoutStore";
|
||||
import { useDpsMeterStore } from "../game/dpsMeterStore";
|
||||
|
||||
export function connectAuthoritativeDualScreen(
|
||||
channel: BroadcastChannel,
|
||||
@@ -30,6 +32,12 @@ export function connectAuthoritativeDualScreen(
|
||||
const unsubscribeParty = usePartyStore.subscribe(() => {
|
||||
if (companionReady) gamePublisher.request();
|
||||
});
|
||||
const unsubscribeUi = useUiLayoutStore.subscribe(() => {
|
||||
if (companionReady) gamePublisher.request();
|
||||
});
|
||||
const unsubscribeDps = useDpsMeterStore.subscribe(() => {
|
||||
if (companionReady) gamePublisher.request();
|
||||
});
|
||||
channel.onmessage = (event: MessageEvent<DualScreenMessage>) => {
|
||||
if (event.data.type === "companion-ready") {
|
||||
companionReady = true;
|
||||
@@ -52,7 +60,8 @@ export function connectAuthoritativeDualScreen(
|
||||
unsubscribeGame();
|
||||
unsubscribeCombat();
|
||||
unsubscribeParty();
|
||||
unsubscribeUi();
|
||||
unsubscribeDps();
|
||||
gamePublisher.dispose();
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { connectAuthoritativeDualScreen } from "./authoritativeDualScreenRuntime";
|
||||
import { useUiLayoutStore } from "../game/uiLayoutStore";
|
||||
import { useDpsMeterStore } from "../game/dpsMeterStore";
|
||||
import { emptyDpsMeter } from "../game/dpsMeter";
|
||||
import type { DualScreenMessage } from "./dualScreenSync";
|
||||
|
||||
describe("dedicated companion DPS synchronization", () => {
|
||||
afterEach(() => {
|
||||
useUiLayoutStore.getState().finish(false);
|
||||
useDpsMeterStore.setState(emptyDpsMeter(), true);
|
||||
vi.unstubAllGlobals();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("publishes meter ticks and layout drafts, then unsubscribes on disconnect", () => {
|
||||
vi.useFakeTimers(); vi.setSystemTime(1_000);
|
||||
vi.stubGlobal("window", { setTimeout, clearTimeout, location: { search: "?display=top" } });
|
||||
const postMessage = vi.fn<(message: DualScreenMessage) => void>();
|
||||
const channel = { postMessage, onmessage: null } as unknown as BroadcastChannel;
|
||||
const disconnect = connectAuthoritativeDualScreen(channel, { data: { type: "companion-ready" } } as MessageEvent<DualScreenMessage>);
|
||||
try {
|
||||
useUiLayoutStore.getState().begin();
|
||||
useUiLayoutStore.getState().updateDraft((layout) => ({ ...layout, dpsMeterDisplay: "companion", elements: {
|
||||
...layout.elements, dps: { visible: false, position: { x: .4, y: .6 }, width: 300 },
|
||||
} }));
|
||||
vi.advanceTimersByTime(100);
|
||||
let latest = postMessage.mock.lastCall?.[0];
|
||||
expect(latest?.type).toBe("app-state");
|
||||
if (latest?.type !== "app-state") throw new Error("Missing companion snapshot");
|
||||
expect(latest.snapshot.game.dpsMeter).toMatchObject({ display: "companion", layout: {
|
||||
visible: false, position: { x: .4, y: .6 }, width: 300,
|
||||
} });
|
||||
|
||||
useDpsMeterStore.setState({ startedAt: 1_000, endedAt: null, updatedAt: 3_000, elapsedMs: 2_000,
|
||||
damage: { test: { id: "test", name: "Test", color: "#fff", total: 200 } } }, true);
|
||||
vi.advanceTimersByTime(100);
|
||||
latest = postMessage.mock.lastCall?.[0];
|
||||
if (latest?.type !== "app-state") throw new Error("Missing DPS snapshot");
|
||||
expect(latest.snapshot.game.dpsMeter.values).toMatchObject({ totalDamage: 200, totalDps: 100, durationSeconds: 2 });
|
||||
} finally { disconnect(); }
|
||||
postMessage.mockClear();
|
||||
useUiLayoutStore.getState().finish(false);
|
||||
useDpsMeterStore.setState(emptyDpsMeter(), true);
|
||||
vi.advanceTimersByTime(100);
|
||||
expect(postMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -26,6 +26,12 @@ import { abilityAtLevel, resourceProfileForClass, type AbilityTarget } from "../
|
||||
import type { DungeonId } from "../game/dungeonCatalog";
|
||||
import { useManastormStore } from "../game/manastormStore";
|
||||
import { resolveStagePresentation } from "../game/manastormStagePresentation";
|
||||
import { currentDpsMeterSnapshot } from "../game/dpsMeterStore";
|
||||
import type { DpsMeterSnapshot } from "../game/dpsMeter";
|
||||
import { activeUiLayout, useUiLayoutStore } from "../game/uiLayoutStore";
|
||||
import { normalizeHudPosition } from "../game/hudLayout";
|
||||
import { resolveDpsMeterDisplay, type HudElementLayout } from "../game/uiLayout";
|
||||
import { currentDisplayLayout } from "./displayLayout";
|
||||
|
||||
export { createDualScreenChannel } from "./dualScreenChannel";
|
||||
|
||||
@@ -89,6 +95,12 @@ export interface CompanionPartyObjectiveSnapshot {
|
||||
}
|
||||
|
||||
export interface CompanionGameSnapshot {
|
||||
dpsMeter: {
|
||||
values: DpsMeterSnapshot;
|
||||
layout: HudElementLayout;
|
||||
display: "main" | "companion";
|
||||
editing: boolean;
|
||||
};
|
||||
gameMode: GameMode;
|
||||
dungeonId: string;
|
||||
dungeonTitle: string;
|
||||
@@ -159,7 +171,8 @@ export type CompanionCommand =
|
||||
| { name: "cyclePartyMember"; direction: 1 | -1 }
|
||||
| { name: "setPartyCommand"; command: PartyCommand }
|
||||
| { name: "recallParty" }
|
||||
| { name: "resetAtEntrance" };
|
||||
| { name: "resetAtEntrance" }
|
||||
| { name: "editDpsMeter"; update: Partial<HudElementLayout> };
|
||||
|
||||
export type DualScreenMessage =
|
||||
| { type: "companion-ready" }
|
||||
@@ -180,6 +193,7 @@ export function currentCompanionSnapshot(): CompanionSnapshot {
|
||||
const combat = useCombatStore.getState();
|
||||
const party = usePartyStore.getState();
|
||||
const manastorm = useManastormStore.getState();
|
||||
const uiLayout = activeUiLayout();
|
||||
const dungeon = requireDungeonDefinition(game.activeDungeonId);
|
||||
const stagePresentation = resolveStagePresentation(game.gameMode, manastorm.currentEncounter, {
|
||||
title: dungeon.title,
|
||||
@@ -251,6 +265,12 @@ export function currentCompanionSnapshot(): CompanionSnapshot {
|
||||
draft: shell.draft,
|
||||
notice: shell.notice,
|
||||
game: {
|
||||
dpsMeter: {
|
||||
values: currentDpsMeterSnapshot(),
|
||||
layout: uiLayout.elements.dps,
|
||||
display: resolveDpsMeterDisplay(uiLayout, typeof window !== "undefined" && currentDisplayLayout() !== "single"),
|
||||
editing: game.overlay === "ui-layout",
|
||||
},
|
||||
gameMode: game.gameMode,
|
||||
dungeonId: dungeon.id,
|
||||
dungeonTitle: stagePresentation.title,
|
||||
@@ -322,6 +342,17 @@ export function executeCompanionCommand(command: CompanionCommand): void {
|
||||
const game = useGameStore.getState();
|
||||
const combat = useCombatStore.getState();
|
||||
switch (command.name) {
|
||||
case "editDpsMeter": {
|
||||
if (game.overlay !== "ui-layout" || !useUiLayoutStore.getState().draft) break;
|
||||
const update: Partial<HudElementLayout> = {};
|
||||
if (command.update.position) update.position = normalizeHudPosition(command.update.position, { x: .5, y: .5 });
|
||||
if (typeof command.update.width === "number" && Number.isFinite(command.update.width)) update.width = Math.round(Math.max(160, Math.min(600, command.update.width)));
|
||||
useUiLayoutStore.getState().updateDraft((layout) => ({ ...layout, elements: {
|
||||
...layout.elements, dps: { ...layout.elements.dps, ...update },
|
||||
} }));
|
||||
useUiLayoutStore.setState({ selectedElement: "dps" });
|
||||
break;
|
||||
}
|
||||
case "selectCharacter": shell.selectCharacter(command.characterId); break;
|
||||
case "openCreator": shell.openCreator(); break;
|
||||
case "cancelCreator": shell.cancelCreator(); break;
|
||||
|
||||
+43
-34
@@ -27,7 +27,7 @@ import {
|
||||
sampleMobAnimation,
|
||||
} from "../game/mobAnimation";
|
||||
import type { MobAnimationState } from "../game/mobAnimation";
|
||||
import { useCombatStore } from "../game/combatStore";
|
||||
import { useCombatStore, type MobCombatState } from "../game/combatStore";
|
||||
import { PRIMARY_MOUSE_BUTTON } from "../game/inputManager";
|
||||
import { TimedEffectStrip } from "../ui/TimedEffectStrip";
|
||||
import { AuraStrip } from "../ui/AuraStrip";
|
||||
@@ -76,9 +76,10 @@ import {
|
||||
resolveMobAnimationInterval,
|
||||
} from "./mobAnimationCadence";
|
||||
import { useGameGLTF } from "./useGameGLTF";
|
||||
import { onlineSynchronizedMobTransform, requestOnlineInteraction } from "../game/onlineSessionRuntime";
|
||||
import { onlineSessionOwnsWorldSimulation, onlineSynchronizedMobTransform, requestOnlineInteraction } from "../game/onlineSessionRuntime";
|
||||
import { isMobAggroRangeVisible } from "./mobAggroRangeVisibility";
|
||||
import { DungeonOccludedHtml } from "./DungeonOccludedHtml";
|
||||
import { useMobNavigation } from "./mobNavigation";
|
||||
|
||||
const MOVEMENT_LOCKING_MOB_STATUSES: ReadonlySet<string> = new Set([
|
||||
"stun", "root", "sleep", "knockdown", "freeze", "charm", "confuse",
|
||||
@@ -119,8 +120,32 @@ function mobTargetPosition(
|
||||
playerPosition: MobVector3,
|
||||
): MobVector3 {
|
||||
if (!targetActorId || targetActorId === PLAYER_AGGRO_ID) return playerPosition;
|
||||
const summon = useCombatStore.getState().summons.find((candidate) => candidate.id === targetActorId);
|
||||
if (summon) return summon.position;
|
||||
return getPartyRuntimePosition(targetActorId) ?? playerPosition;
|
||||
}
|
||||
|
||||
function navigateMob(
|
||||
navigator: ReturnType<typeof useMobNavigation>, mob: MobCombatState,
|
||||
position: MutableMobVector3, target: MobVector3, home: MobVector3,
|
||||
maximumStep: number, active: boolean, nowMs: number, controlled = false,
|
||||
): void {
|
||||
const game = useGameStore.getState();
|
||||
const hostileRanges = mob.attacks.filter((attack) => attack.target === "primary"
|
||||
|| attack.target === "random-party" || attack.target === "nearby-party").map((attack) => attack.range);
|
||||
const result = navigator.step({
|
||||
id: mob.id, targetId: mob.targetActorId, position, target, home,
|
||||
phase: mob.combatPhase, health: mob.health, engaged: mob.engaged,
|
||||
active: active && onlineSessionOwnsWorldSimulation() && !game.paused && !game.mapOpen && !game.companionOpen,
|
||||
casting: mob.activeCast !== null, controlled,
|
||||
attackRange: hostileRanges.length ? Math.max(...hostileRanges) : mob.attackRange,
|
||||
leashRange: mob.leashRange, maximumStep, nowMs,
|
||||
});
|
||||
if (result === "reset" || result === "home") {
|
||||
useCombatStore.getState().resetMobForNavigation(mob.id,
|
||||
result === "home" || Math.hypot(position[0] - home[0], position[1] - home[1], position[2] - home[2]) <= 0.85, nowMs);
|
||||
}
|
||||
}
|
||||
const PROXIMITY_CHECK_INTERVAL = 0.3;
|
||||
const AGGRO_RANGE_VISIBILITY_CHECK_INTERVAL = 0.25;
|
||||
let preparedCreatureTemplates = new WeakMap<Object3D, Object3D>();
|
||||
@@ -771,23 +796,6 @@ function isPlayerOrPartyNear(points: readonly MobVector3[], maxDistance: number)
|
||||
});
|
||||
}
|
||||
|
||||
function stepToward(
|
||||
current: MutableMobVector3,
|
||||
target: MobVector3,
|
||||
maximumStep: number,
|
||||
): number {
|
||||
const dx = target[0] - current[0];
|
||||
const dy = target[1] - current[1];
|
||||
const dz = target[2] - current[2];
|
||||
const distance = Math.hypot(dx, dy, dz);
|
||||
if (distance <= 0.001) return 0;
|
||||
const step = Math.min(Math.max(0, maximumStep), distance);
|
||||
current[0] += dx / distance * step;
|
||||
current[1] += dy / distance * step;
|
||||
current[2] += dz / distance * step;
|
||||
return distance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Activates content when the player or an autonomous party member approaches.
|
||||
* The stores are read
|
||||
@@ -1159,6 +1167,7 @@ function RoamingPack({
|
||||
useOriginalModels,
|
||||
allowedRuntimeIds,
|
||||
}: RoamingPackProps) {
|
||||
const navigator = useMobNavigation();
|
||||
const path = useMemo(() => createLoopedPath(definition.waypoints), [definition.waypoints]);
|
||||
const distanceRef = useRef(definition.startDistance ?? 0);
|
||||
const { scratchPositions, scratchForwards, chasePositions } = useMemo(() => {
|
||||
@@ -1277,7 +1286,7 @@ function RoamingPack({
|
||||
const maximumStep = Math.min(delta, 0.05)
|
||||
* mob!.moveSpeed
|
||||
* Math.max(0.1, 1 - slow);
|
||||
if (!movementLocked && feared) {
|
||||
if (active && !movementLocked && feared) {
|
||||
stepFearedMobAway(
|
||||
chasePosition,
|
||||
targetPosition,
|
||||
@@ -1286,19 +1295,16 @@ function RoamingPack({
|
||||
mob!.leashRange,
|
||||
chasePosition,
|
||||
);
|
||||
} else if (!movementLocked && mob!.combatPhase === "chasing") {
|
||||
stepToward(
|
||||
chasePosition,
|
||||
targetPosition,
|
||||
maximumStep,
|
||||
);
|
||||
}
|
||||
navigateMob(navigator, mob!, chasePosition, targetPosition,
|
||||
mob!.homePosition ?? patrolPosition, maximumStep, active, now, movementLocked || feared);
|
||||
position = chasePosition;
|
||||
const facingSign = feared && !movementLocked ? -1 : 1;
|
||||
forward[0] = (targetPosition[0] - position[0]) * facingSign;
|
||||
forward[2] = (targetPosition[2] - position[2]) * facingSign;
|
||||
} else if (mob?.combatPhase === "returning") {
|
||||
stepToward(chasePosition, patrolPosition, Math.min(delta, 0.05) * mob.moveSpeed * 1.25);
|
||||
navigateMob(navigator, mob, chasePosition, targetPosition,
|
||||
mob.homePosition ?? patrolPosition, Math.min(delta, 0.05) * mob.moveSpeed * 1.25, active, now);
|
||||
position = chasePosition;
|
||||
} else {
|
||||
chasePosition[0] = patrolPosition[0];
|
||||
@@ -1360,6 +1366,7 @@ interface StaticPopulationProps {
|
||||
}
|
||||
|
||||
function StaticPopulationMember({ spawn, entities, active, showLabels, useOriginalModels }: StaticPopulationProps) {
|
||||
const navigator = useMobNavigation();
|
||||
const entity = entities[spawn.entityId];
|
||||
const groupRef = useRef<Group>(null);
|
||||
const positionRef = useRef<MutableMobVector3>([...spawn.position]);
|
||||
@@ -1398,7 +1405,7 @@ function StaticPopulationMember({ spawn, entities, active, showLabels, useOrigin
|
||||
feared = mob.statuses.some((status) => status.endsAt > now && status.kind === "fear");
|
||||
const slow = mob.statuses.find((status) => status.endsAt > now && status.kind === "slow")?.magnitude ?? 0;
|
||||
const maximumStep = Math.min(delta, 0.05) * mob.moveSpeed * Math.max(0.1, 1 - slow);
|
||||
if (!movementLocked && feared) {
|
||||
if (active && !movementLocked && feared) {
|
||||
stepFearedMobAway(
|
||||
position,
|
||||
targetPosition,
|
||||
@@ -1407,11 +1414,12 @@ function StaticPopulationMember({ spawn, entities, active, showLabels, useOrigin
|
||||
mob.leashRange,
|
||||
position,
|
||||
);
|
||||
} else if (!movementLocked && mob.combatPhase === "chasing") {
|
||||
stepToward(position, targetPosition, maximumStep);
|
||||
}
|
||||
navigateMob(navigator, mob, position, targetPosition, mob.homePosition ?? spawn.position,
|
||||
maximumStep, active, now, movementLocked || feared);
|
||||
} else if (mob?.combatPhase === "returning") {
|
||||
stepToward(position, spawn.position, Math.min(delta, 0.05) * mob.moveSpeed * 1.25);
|
||||
navigateMob(navigator, mob, position, targetPosition, mob.homePosition ?? spawn.position,
|
||||
Math.min(delta, 0.05) * mob.moveSpeed * 1.25, active, now);
|
||||
} else {
|
||||
position[0] = spawn.position[0];
|
||||
position[1] = spawn.position[1];
|
||||
@@ -1500,6 +1508,7 @@ function SummonedPopulationMember({
|
||||
readonly active: boolean;
|
||||
readonly useOriginalModels: boolean;
|
||||
}) {
|
||||
const navigator = useMobNavigation();
|
||||
const groupRef = useRef<Group>(null);
|
||||
const initial = getMobRuntimeTransform(instanceId)?.position ?? [0, 0, 0];
|
||||
const positionRef = useRef<MutableMobVector3>([...initial]);
|
||||
@@ -1515,7 +1524,7 @@ function SummonedPopulationMember({
|
||||
const feared = mob.statuses.some((status) => status.endsAt > now && status.kind === "fear");
|
||||
const slow = mob.statuses.find((status) => status.endsAt > now && status.kind === "slow")?.magnitude ?? 0;
|
||||
const maximumStep = Math.min(delta, 0.05) * mob.moveSpeed * Math.max(0.1, 1 - slow);
|
||||
if (!movementLocked && feared) {
|
||||
if (active && !movementLocked && feared) {
|
||||
stepFearedMobAway(
|
||||
position,
|
||||
targetPosition,
|
||||
@@ -1524,9 +1533,9 @@ function SummonedPopulationMember({
|
||||
mob.leashRange,
|
||||
position,
|
||||
);
|
||||
} else if (!movementLocked && mob.combatPhase === "chasing") {
|
||||
stepToward(position, targetPosition, maximumStep);
|
||||
}
|
||||
navigateMob(navigator, mob, position, targetPosition, mob.homePosition ?? initial,
|
||||
maximumStep, active, now, movementLocked || feared);
|
||||
const facingSign = feared && !movementLocked ? -1 : 1;
|
||||
const yaw = Math.atan2(
|
||||
(targetPosition[0] - position[0]) * facingSign,
|
||||
|
||||
+141
-80
@@ -33,13 +33,10 @@ import {
|
||||
createPartyBreadcrumbTrail,
|
||||
nextPartyBreadcrumbFollowWaypoint,
|
||||
partyFormationPosition,
|
||||
partyMadeStuckProgress,
|
||||
partyMovementProfile,
|
||||
partyNaturalFollowPosition,
|
||||
partyPersonalizedCombatMovementGoal,
|
||||
partyPresentationY,
|
||||
partySeparationGoal,
|
||||
partyShouldRecallForStuck,
|
||||
partyShouldMoveToComfortTarget,
|
||||
personalizedPartyCombatRangeBand,
|
||||
resetPartyBreadcrumbTrail,
|
||||
@@ -48,6 +45,15 @@ import {
|
||||
type PartyBreadcrumbCursor,
|
||||
type PartyMovementProfile,
|
||||
} from "../game/partyMovement";
|
||||
import {
|
||||
applyPartyStuckRecoveryLanding,
|
||||
choosePartyStuckRecoveryPosition,
|
||||
createPartyStuckRecoveryState,
|
||||
markPartyStuckRecovered,
|
||||
resetPartyStuckRecoveryProgress,
|
||||
updatePartyStuckRecovery,
|
||||
type PartyStuckRecoveryState,
|
||||
} from "../game/partyStuckRecovery";
|
||||
import {
|
||||
findNearestPartyNavigationSurface,
|
||||
type PartyNavigationGraph,
|
||||
@@ -66,10 +72,14 @@ import {
|
||||
type PartyRouteState,
|
||||
} from "../game/partyPathing";
|
||||
import { partyCombatRangeBand } from "../game/partyRuntime";
|
||||
import { partyRejoinMovement } from "../game/partyRejoin";
|
||||
import { useWailingEncounterStore } from "../game/wailingCavernsEncounter";
|
||||
import {
|
||||
clearPartyRuntimeAttackCorridor,
|
||||
consumePartyRuntimeRejoin,
|
||||
finishPartyRuntimeRejoin,
|
||||
getPartyRuntimePosition,
|
||||
partyRuntimeIsRejoining,
|
||||
registerPartyRuntimePosition,
|
||||
updatePartyRuntimeAttackCorridor,
|
||||
updatePartyRuntimePosition,
|
||||
@@ -84,12 +94,13 @@ import { registerCombatSpatialProvider } from "../game/combatSpatial";
|
||||
import { PLAYER_AGGRO_ID } from "../game/aggro";
|
||||
import { hasDungeonLineOfSight } from "./dungeonLineOfSight";
|
||||
import { registerCombatEffectAnchor } from "./combatEffectAnchorRegistry";
|
||||
import { ACTOR_TERRAIN_QUERY_GROUPS } from "./actorCollision";
|
||||
import {
|
||||
createPartyDirectSegmentValidator,
|
||||
createPartyRouteSegmentValidator,
|
||||
createPartyRecoveryPointValidator,
|
||||
PARTY_FLOOR_RAY_DEPTH,
|
||||
PARTY_FLOOR_RAY_HEIGHT,
|
||||
partyTerrainQueryFlags,
|
||||
} from "./partyTraversal";
|
||||
|
||||
const PARTY_MOVE_SPEED = 5.25;
|
||||
@@ -113,11 +124,6 @@ interface PartyAvatarMetrics {
|
||||
readonly hitCenterY: number;
|
||||
}
|
||||
|
||||
interface PartyStuckProgress {
|
||||
readonly position: MutablePartyWorldPosition;
|
||||
lastProgressAt: number;
|
||||
}
|
||||
|
||||
interface PartyCombatAnchor {
|
||||
readonly targetId: string;
|
||||
readonly bearing: number;
|
||||
@@ -300,7 +306,8 @@ function PartyActors({
|
||||
ray,
|
||||
PARTY_FLOOR_RAY_DEPTH,
|
||||
true,
|
||||
partyTerrainQueryFlags(rapier),
|
||||
rapier.QueryFilterFlags.EXCLUDE_SENSORS,
|
||||
ACTOR_TERRAIN_QUERY_GROUPS,
|
||||
);
|
||||
const floorY = floor ? ray.origin.y - floor.timeOfImpact : null;
|
||||
return partyPresentationY(position[1], floorY, PARTY_MAX_GROUND_CORRECTION);
|
||||
@@ -317,6 +324,10 @@ function PartyActors({
|
||||
() => createPartyRouteSegmentValidator(rapier, world),
|
||||
[rapier, world],
|
||||
);
|
||||
const recoveryPointAllowed = useMemo(
|
||||
() => createPartyRecoveryPointValidator(rapier, world, navigationGraph),
|
||||
[rapier, world, navigationGraph],
|
||||
);
|
||||
// Authored-link graphs use their eroded Recast node edges directly. Their
|
||||
// boundary vertices are already agent-radius safe, while a center ray can
|
||||
// falsely read the stair floor as a wall across a short vertical seam.
|
||||
@@ -344,7 +355,7 @@ function PartyActors({
|
||||
const lastLeaderWaypointRef = useRef<MutablePartyWorldPosition | null>(null);
|
||||
const lastCommandRef = useRef(usePartyStore.getState().command);
|
||||
const lastRecallRevisionRef = useRef(usePartyStore.getState().recallRevision);
|
||||
const stuckProgressRef = useRef(new Map<string, PartyStuckProgress>());
|
||||
const stuckProgressRef = useRef(new Map<string, PartyStuckRecoveryState>());
|
||||
const movementProfilesRef = useRef(new Map<string, PartyMovementProfile>());
|
||||
const directFollowMovingRef = useRef(new Map<string, boolean>());
|
||||
const combatAnchorsRef = useRef(new Map<string, PartyCombatAnchor>());
|
||||
@@ -381,6 +392,7 @@ function PartyActors({
|
||||
return { id: actorId, position: mobPosition, yaw };
|
||||
},
|
||||
hasLineOfSight: hasStaticLineOfSight,
|
||||
navigationGraph: () => navigationGraphRef.current,
|
||||
projectGroundPoint: (requested, maximumDistance, requestedOrigin) => {
|
||||
const origin = requestedOrigin ?? useGameStore.getState().playerPosition;
|
||||
const dx = requested[0] - origin[0];
|
||||
@@ -394,7 +406,8 @@ function PartyActors({
|
||||
ray,
|
||||
20,
|
||||
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;
|
||||
},
|
||||
@@ -410,6 +423,7 @@ function PartyActors({
|
||||
: null;
|
||||
const recallPosition = navigationRecall?.position ?? player;
|
||||
party.members.forEach((member, index) => {
|
||||
finishPartyRuntimeRejoin(member.id);
|
||||
const position = positionsRef.current.get(member.id) ?? initialMemberPosition(index);
|
||||
// Attach a recalled squad to the reviewed surface just as initial
|
||||
// spawns do. An exact visual-floor point can sit between Recast stair
|
||||
@@ -424,10 +438,9 @@ function PartyActors({
|
||||
if (movingSignal) movingSignal.current = false;
|
||||
const body = bodyRefs.current[index];
|
||||
if (body) body.position.y = 0;
|
||||
stuckProgressRef.current.set(member.id, {
|
||||
position: [position[0], position[1], position[2]],
|
||||
lastProgressAt: now,
|
||||
});
|
||||
const progress = stuckProgressRef.current.get(member.id) ?? createPartyStuckRecoveryState(position, now);
|
||||
markPartyStuckRecovered(progress, position, now);
|
||||
stuckProgressRef.current.set(member.id, progress);
|
||||
});
|
||||
const firstLiving = party.members.find((member) => member.health > 0);
|
||||
resetPartyBreadcrumbTrail(playerTrailRef.current, player);
|
||||
@@ -510,6 +523,7 @@ function PartyActors({
|
||||
members.forEach((member, index) => {
|
||||
const position = positionsRef.current.get(member.id) ?? initialMemberPosition(index);
|
||||
const slot = spawnSlots[index + 1] ?? spawnSlots[0];
|
||||
finishPartyRuntimeRejoin(member.id);
|
||||
position[0] = slot.footPosition[0];
|
||||
position[1] = slot.footPosition[1];
|
||||
position[2] = slot.footPosition[2];
|
||||
@@ -568,7 +582,7 @@ function PartyActors({
|
||||
|
||||
// Time spent in a pause/menu must not count toward the stuck watchdog.
|
||||
useEffect(() => {
|
||||
stuckProgressRef.current.clear();
|
||||
for (const state of stuckProgressRef.current.values()) resetPartyStuckRecoveryProgress(state);
|
||||
}, [active]);
|
||||
|
||||
useEffect(() => clearPartyRuntimeAttackCorridor, []);
|
||||
@@ -596,6 +610,30 @@ function PartyActors({
|
||||
const playerNavigation = game.playerNavigationPosition;
|
||||
appendPartyBreadcrumb(playerTrailRef.current, playerNavigation);
|
||||
const routeNow = clock.elapsedTime * 1_000;
|
||||
const combatNow = Date.now();
|
||||
for (const member of party.members) {
|
||||
if (member.health <= 0) {
|
||||
finishPartyRuntimeRejoin(member.id);
|
||||
continue;
|
||||
}
|
||||
const checkpoint = consumePartyRuntimeRejoin(member.id);
|
||||
if (!checkpoint) continue;
|
||||
const landing = navigationGraph
|
||||
? findNearestPartyNavigationSurface(navigationGraph, checkpoint, 6)?.position ?? checkpoint : checkpoint;
|
||||
const current = positionsRef.current.get(member.id) ?? [...landing];
|
||||
current[0] = landing[0]; current[1] = landing[1]; current[2] = landing[2];
|
||||
positionsRef.current.set(member.id, current);
|
||||
updatePartyRuntimePosition(member.id, current[0], current[1], current[2]);
|
||||
combatRoutesRef.current.delete(member.id);
|
||||
combatAnchorsRef.current.delete(member.id);
|
||||
followerCursorsRef.current.delete(member.id);
|
||||
directFollowMovingRef.current.delete(member.id);
|
||||
stuckProgressRef.current.delete(member.id);
|
||||
updatePartyRuntimeTargetVisibility(member.id, null, false);
|
||||
const animationSignal = animationEventSignalsRef.current.get(member.id);
|
||||
animationRevisionRef.current += 1;
|
||||
if (animationSignal) animationSignal.current = { revision: animationRevisionRef.current, kind: "ability-cancel" };
|
||||
}
|
||||
const playerTrail = playerTrailRef.current.points;
|
||||
const latestPlayerTrailPoint = playerTrail[playerTrail.length - 1];
|
||||
const previousPlayerTrailPoint = playerTrail[playerTrail.length - 2];
|
||||
@@ -630,17 +668,15 @@ function PartyActors({
|
||||
lastLeaderWaypointRef.current = null;
|
||||
}
|
||||
const objective = objectives.find((candidate) => candidate.id === party.objectiveBossId) ?? null;
|
||||
const leaderIndex = party.members.findIndex((member) => member.health > 0);
|
||||
const leaderIndex = party.members.findIndex((member) => member.health > 0 && !partyRuntimeIsRejoining(member.id));
|
||||
const safeDelta = Math.min(delta, 0.05);
|
||||
const desired: MutablePartyWorldPosition = [0, 0, 0];
|
||||
const nextPosition: MutablePartyWorldPosition = [0, 0, 0];
|
||||
const leaderDestination: MutablePartyWorldPosition = [0, 0, 0];
|
||||
const naturalFollowTarget: MutablePartyWorldPosition = [0, 0, 0];
|
||||
const separatedDesired: MutablePartyWorldPosition = [0, 0, 0];
|
||||
const livingIndices = party.members
|
||||
.map((member, index) => member.health > 0 ? index : -1)
|
||||
.filter((index) => index >= 0);
|
||||
const livingIds = new Set(livingIndices.map((index) => party.members[index].id));
|
||||
const movementProfileFor = (memberId: string, memberIndex: number): PartyMovementProfile => {
|
||||
const livingRank = Math.max(0, livingIndices.indexOf(memberIndex));
|
||||
const key = `${memberId}|${livingRank}`;
|
||||
@@ -651,13 +687,6 @@ function PartyActors({
|
||||
}
|
||||
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 leaderMovementExpected = false;
|
||||
let leaderFollowingPlayer = false;
|
||||
@@ -712,15 +741,12 @@ function PartyActors({
|
||||
};
|
||||
combatAnchorsRef.current.set(leader.id, combatAnchor);
|
||||
}
|
||||
const peers = peerPositionsFor(leader.id);
|
||||
const crowded = peers.some((peer) => planarDistance(leaderPosition, peer) < 0.85);
|
||||
const movementIntent = partyPersonalizedCombatMovementGoal(
|
||||
leaderPosition,
|
||||
targetPosition,
|
||||
hasStaticLineOfSight(leaderPosition, targetPosition),
|
||||
rangeBand,
|
||||
combatAnchor.bearing,
|
||||
crowded,
|
||||
leaderDestination,
|
||||
);
|
||||
if (movementIntent === "hold") {
|
||||
@@ -900,6 +926,13 @@ function PartyActors({
|
||||
localFinishDistance: leaderLocalFinishDistance,
|
||||
directSegmentAllowed,
|
||||
routeSegmentAllowed: navigationRouteSegmentAllowed,
|
||||
destinationReached: targetPosition ? (surface) => {
|
||||
const band = personalizedPartyCombatRangeBand(partyCombatRangeBand(leaderMember),
|
||||
movementProfileFor(leaderMember.id, leaderIndex));
|
||||
const distance = fullDistance(surface, targetPosition);
|
||||
return distance >= band.minimum && distance <= band.maximum
|
||||
&& hasStaticLineOfSight(surface, targetPosition);
|
||||
} : undefined,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -964,7 +997,6 @@ function PartyActors({
|
||||
: navigationFailed || Boolean(navigationGraph && routeRef.current.blocked)
|
||||
),
|
||||
);
|
||||
let autoRecallRequested = false;
|
||||
|
||||
for (let index = 0; index < party.members.length; index += 1) {
|
||||
const member = party.members[index];
|
||||
@@ -991,7 +1023,8 @@ function PartyActors({
|
||||
}
|
||||
lastHealthRef.current.set(member.id, member.health);
|
||||
if (!actor || !current) continue;
|
||||
const memberTargetId = party.memberTargetIds[member.id] ?? party.activeMobId;
|
||||
const rejoining = partyRuntimeIsRejoining(member.id);
|
||||
const memberTargetId = rejoining ? null : party.memberTargetIds[member.id] ?? party.activeMobId;
|
||||
const memberTargetPosition = memberTargetId ? getMobPosition(memberTargetId) : null;
|
||||
const canSeeActiveTarget = Boolean(
|
||||
memberTargetId
|
||||
@@ -1009,6 +1042,26 @@ function PartyActors({
|
||||
if (member.health <= 0) {
|
||||
const signal = movingSignalsRef.current.get(member.id);
|
||||
if (signal) signal.current = false;
|
||||
} else if (member.activeCast || member.controlledUntil > combatNow) {
|
||||
// Casting and crowd control are intentional immobility, not stuck navigation.
|
||||
} else if (rejoining) {
|
||||
const intent = partyRejoinMovement(member.id, current, playerNavigation, routeNow,
|
||||
navigationGraph, combatRoutesRef.current.get(member.id) ?? emptyPartyRouteState(),
|
||||
{ directSegmentAllowed, routeSegmentAllowed: navigationRouteSegmentAllowed });
|
||||
combatRoutesRef.current.set(member.id, intent.route);
|
||||
if (intent.rejoined) {
|
||||
finishPartyRuntimeRejoin(member.id);
|
||||
combatRoutesRef.current.delete(member.id);
|
||||
followerCursorsRef.current.delete(member.id);
|
||||
} else {
|
||||
movementExpected = true;
|
||||
frameRouteBlocked ||= intent.blocked;
|
||||
if (intent.waypoint) {
|
||||
desired[0] = intent.waypoint[0]; desired[1] = intent.waypoint[1]; desired[2] = intent.waypoint[2];
|
||||
hasDesired = true;
|
||||
faceX = desired[0] - current[0]; faceZ = desired[2] - current[2];
|
||||
}
|
||||
}
|
||||
} else if (party.command === "stop") {
|
||||
// Holding is represented by retaining the current world-space tuple.
|
||||
} else if (index === leaderIndex) {
|
||||
@@ -1039,15 +1092,12 @@ function PartyActors({
|
||||
};
|
||||
combatAnchorsRef.current.set(member.id, combatAnchor);
|
||||
}
|
||||
const peers = peerPositionsFor(member.id);
|
||||
const crowded = peers.some((peer) => planarDistance(current, peer) < 0.85);
|
||||
const movementIntent = partyPersonalizedCombatMovementGoal(
|
||||
current,
|
||||
memberTargetPosition,
|
||||
canSeeActiveTarget,
|
||||
rangeBand,
|
||||
combatAnchor.bearing,
|
||||
crowded,
|
||||
desired,
|
||||
);
|
||||
movementExpected = movementIntent !== "hold";
|
||||
@@ -1083,6 +1133,11 @@ function PartyActors({
|
||||
blockedRetryMs: 800,
|
||||
directSegmentAllowed,
|
||||
routeSegmentAllowed: navigationRouteSegmentAllowed,
|
||||
destinationReached: (surface) => {
|
||||
const distance = fullDistance(surface, memberTargetPosition);
|
||||
return distance >= rangeBand.minimum && distance <= rangeBand.maximum
|
||||
&& hasStaticLineOfSight(surface, memberTargetPosition);
|
||||
},
|
||||
},
|
||||
);
|
||||
combatRoutesRef.current.set(member.id, route);
|
||||
@@ -1245,26 +1300,9 @@ function PartyActors({
|
||||
}
|
||||
|
||||
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);
|
||||
if (member.health > 0 && party.command !== "stop" && hasDesired) {
|
||||
if (member.health > 0 && (party.command !== "stop" || rejoining) && hasDesired) {
|
||||
const speed = (currentPlanarDistance > 8 ? PARTY_CATCH_UP_SPEED : PARTY_MOVE_SPEED)
|
||||
* movementProfile.speedMultiplier;
|
||||
const moved = stepPartyPosition(current, desired, speed * safeDelta, nextPosition);
|
||||
@@ -1294,30 +1332,59 @@ function PartyActors({
|
||||
|
||||
let stuckProgress = stuckProgressRef.current.get(member.id);
|
||||
if (!stuckProgress) {
|
||||
stuckProgress = {
|
||||
position: [current[0], current[1], current[2]],
|
||||
lastProgressAt: routeNow,
|
||||
};
|
||||
stuckProgress = createPartyStuckRecoveryState(current, routeNow);
|
||||
stuckProgressRef.current.set(member.id, stuckProgress);
|
||||
}
|
||||
if (member.health <= 0 || party.command === "stop" || !movementExpected) {
|
||||
stuckProgress.position[0] = current[0];
|
||||
stuckProgress.position[1] = current[1];
|
||||
stuckProgress.position[2] = current[2];
|
||||
stuckProgress.lastProgressAt = routeNow;
|
||||
} else if (partyMadeStuckProgress(stuckProgress.position, current)) {
|
||||
stuckProgress.position[0] = current[0];
|
||||
stuckProgress.position[1] = current[1];
|
||||
stuckProgress.position[2] = current[2];
|
||||
stuckProgress.lastProgressAt = routeNow;
|
||||
} else if (partyShouldRecallForStuck(
|
||||
current,
|
||||
playerNavigation,
|
||||
const recoveryGoal = rejoining ? "resurrection-rejoin:player" : memberTargetPosition ? `mob:${memberTargetId}`
|
||||
: index === leaderIndex ? `objective:${party.objectiveBossId}` : `follow:${leaderMember?.id}`;
|
||||
const recovery = updatePartyStuckRecovery(stuckProgress, {
|
||||
position: current,
|
||||
waypoint: hasDesired ? desired : null,
|
||||
goalKey: `${party.command}|${recoveryGoal}`,
|
||||
movementExpected,
|
||||
routeNow,
|
||||
stuckProgress.lastProgressAt,
|
||||
)) {
|
||||
autoRecallRequested = true;
|
||||
health: member.health,
|
||||
paused: !active || game.paused || game.mapOpen || game.companionOpen,
|
||||
stopped: party.command === "stop" && !rejoining,
|
||||
casting: member.activeCast !== null,
|
||||
controlled: member.controlledUntil > combatNow,
|
||||
nowMs: routeNow,
|
||||
});
|
||||
if (recovery !== "none") {
|
||||
// Invalidate only this actor's route. Keep its watchdog running across
|
||||
// replans so an endlessly blocked connector cannot restart the timer.
|
||||
combatRoutesRef.current.delete(member.id);
|
||||
followerCursorsRef.current.delete(member.id);
|
||||
directFollowMovingRef.current.delete(member.id);
|
||||
if (index === leaderIndex) {
|
||||
routeRef.current = emptyPartyRouteState();
|
||||
leaderPlayerCursorRef.current.breadcrumbId = null;
|
||||
lastLeaderWaypointRef.current = null;
|
||||
}
|
||||
if (recovery === "recover") {
|
||||
const landing = choosePartyStuckRecoveryPosition({
|
||||
current,
|
||||
player: playerNavigation,
|
||||
groupAnchor: rejoining || index === leaderIndex ? playerNavigation : leaderPosition ?? playerNavigation,
|
||||
trailPoints: [...leaderTrailRef.current.points, ...playerTrailRef.current.points].map((point) => point.position),
|
||||
validate: recoveryPointAllowed,
|
||||
});
|
||||
if (landing && applyPartyStuckRecoveryLanding(positionsRef.current, member.id, landing.position, stuckProgress, routeNow)) {
|
||||
updatePartyRuntimePosition(member.id, current[0], current[1], current[2]);
|
||||
actor.position.set(current[0], presentationY(current), current[2]);
|
||||
const movingSignal = movingSignalsRef.current.get(member.id);
|
||||
if (movingSignal) movingSignal.current = false;
|
||||
shouldMove = false;
|
||||
combatAnchorsRef.current.delete(member.id);
|
||||
updatePartyRuntimeTargetVisibility(member.id, memberTargetId, Boolean(memberTargetPosition
|
||||
&& hasStaticLineOfSight(current, memberTargetPosition)));
|
||||
if (index === leaderIndex) {
|
||||
// Do not record a teleport as a straight, walkable breadcrumb segment.
|
||||
resetPartyBreadcrumbTrail(leaderTrailRef.current, current);
|
||||
followerCursorsRef.current.clear();
|
||||
clearPartyRuntimeAttackCorridor();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (Math.hypot(faceX, faceZ) > 0.001) {
|
||||
@@ -1336,12 +1403,6 @@ function PartyActors({
|
||||
body.rotation.x = 0;
|
||||
}
|
||||
}
|
||||
if (autoRecallRequested) {
|
||||
party.recallParty();
|
||||
lastRecallRevisionRef.current = usePartyStore.getState().recallRevision;
|
||||
teleportPartyToPlayer(playerNavigation, routeNow);
|
||||
return;
|
||||
}
|
||||
party.setRouteBlocked(frameRouteBlocked);
|
||||
});
|
||||
|
||||
|
||||
+13
-7
@@ -60,6 +60,7 @@ import {
|
||||
import { activeManastormStageAssetPackage } from "../game/manastormStageLoader";
|
||||
import { useGameStore } from "../game/store";
|
||||
import { registerCombatEffectAnchor } from "./combatEffectAnchorRegistry";
|
||||
import { ACTOR_COLLISION_GROUPS, ACTOR_TERRAIN_QUERY_GROUPS } from "./actorCollision";
|
||||
|
||||
const PLAYER_RADIUS = 0.36;
|
||||
const PLAYER_CAPSULE_HALF_HEIGHT = 0.48;
|
||||
@@ -212,8 +213,8 @@ function PlayerController({
|
||||
groundRay,
|
||||
groundProbeLength,
|
||||
true,
|
||||
rapier.QueryFilterFlags.EXCLUDE_SENSORS | rapier.QueryFilterFlags.EXCLUDE_DYNAMIC,
|
||||
undefined,
|
||||
rapier.QueryFilterFlags.EXCLUDE_SENSORS,
|
||||
ACTOR_TERRAIN_QUERY_GROUPS,
|
||||
undefined,
|
||||
body,
|
||||
);
|
||||
@@ -267,7 +268,8 @@ function PlayerController({
|
||||
y: verticalVelocity * 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 nextTranslation = {
|
||||
@@ -347,7 +349,11 @@ function PlayerController({
|
||||
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]}>
|
||||
<CharacterModel
|
||||
identity={identity}
|
||||
@@ -408,7 +414,7 @@ function ThirdPersonCamera({
|
||||
|
||||
// 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,
|
||||
// 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(
|
||||
target,
|
||||
shapeRotation,
|
||||
@@ -417,8 +423,8 @@ function ThirdPersonCamera({
|
||||
CAMERA_WALL_GAP,
|
||||
fullDistance,
|
||||
false,
|
||||
rapier.QueryFilterFlags.EXCLUDE_SENSORS | rapier.QueryFilterFlags.EXCLUDE_DYNAMIC,
|
||||
undefined,
|
||||
rapier.QueryFilterFlags.EXCLUDE_SENSORS,
|
||||
ACTOR_TERRAIN_QUERY_GROUPS,
|
||||
undefined,
|
||||
body,
|
||||
);
|
||||
|
||||
@@ -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 { TERRAIN_COLLISION_GROUP } from "./actorCollision";
|
||||
import type {
|
||||
QueryFilterFlags,
|
||||
Ray,
|
||||
@@ -17,9 +18,9 @@ interface RapierApi {
|
||||
type WorldPosition = readonly [number, number, number];
|
||||
|
||||
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_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(
|
||||
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();
|
||||
});
|
||||
});
|
||||
+44
-12
@@ -1,6 +1,9 @@
|
||||
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;
|
||||
@@ -11,14 +14,6 @@ export const PARTY_FLOOR_RAY_DEPTH = 2;
|
||||
const FLOOR_SAMPLE_SPACING = 0.6;
|
||||
const MAX_FLOOR_DELTA = 0.55;
|
||||
|
||||
export function partyTerrainQueryFlags(rapier: typeof RAPIER) {
|
||||
// Actor capsules are not terrain. Crowd spacing is handled separately by
|
||||
// party separation; counting the player here blocks recalls and replans.
|
||||
return rapier.QueryFilterFlags.EXCLUDE_SENSORS
|
||||
| rapier.QueryFilterFlags.EXCLUDE_DYNAMIC
|
||||
| rapier.QueryFilterFlags.EXCLUDE_KINEMATIC;
|
||||
}
|
||||
|
||||
/** Shared by live party routing and collision-backed traversal regressions. */
|
||||
export function createPartyDirectSegmentValidator(rapier: typeof RAPIER, world: World) {
|
||||
const capsule = new rapier.Capsule(
|
||||
@@ -26,7 +21,7 @@ export function createPartyDirectSegmentValidator(rapier: typeof RAPIER, world:
|
||||
COLLISION_RADIUS,
|
||||
);
|
||||
const rotation = { x: 0, y: 0, z: 0, w: 1 };
|
||||
const flags = partyTerrainQueryFlags(rapier);
|
||||
const flags = rapier.QueryFilterFlags.EXCLUDE_SENSORS;
|
||||
return (start: PartyWorldPosition, end: PartyWorldPosition): boolean => {
|
||||
const dx = end[0] - start[0];
|
||||
const dy = end[1] - start[1];
|
||||
@@ -43,6 +38,7 @@ export function createPartyDirectSegmentValidator(rapier: typeof RAPIER, world:
|
||||
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));
|
||||
@@ -54,7 +50,7 @@ export function createPartyDirectSegmentValidator(rapier: typeof RAPIER, world:
|
||||
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);
|
||||
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;
|
||||
@@ -63,7 +59,7 @@ export function createPartyDirectSegmentValidator(rapier: typeof RAPIER, world:
|
||||
|
||||
/** Triangle corridors need wall/overhang validation, not a second radius erosion. */
|
||||
export function createPartyRouteSegmentValidator(rapier: typeof RAPIER, world: World) {
|
||||
const flags = partyTerrainQueryFlags(rapier);
|
||||
const flags = rapier.QueryFilterFlags.EXCLUDE_SENSORS;
|
||||
return (start: PartyWorldPosition, end: PartyWorldPosition): boolean => {
|
||||
const dx = end[0] - start[0];
|
||||
const dy = end[1] - start[1];
|
||||
@@ -74,7 +70,43 @@ export function createPartyRouteSegmentValidator(rapier: typeof RAPIER, world: W
|
||||
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);
|
||||
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;
|
||||
};
|
||||
}
|
||||
@@ -731,6 +731,16 @@ kbd { color: var(--moss-bright); font: inherit; font-size: 0.64rem; }
|
||||
.party-frame.is-selected { border-color: rgba(229, 207, 110, .75); background: linear-gradient(100deg, rgba(39, 43, 20, .96), rgba(18, 38, 24, .88)); box-shadow: 0 0 0 1px rgba(229,207,110,.14), 0 6px 18px rgba(0,0,0,.32); }
|
||||
.party-frame.is-dispellable { border-color: var(--dispel-bright); box-shadow: 0 0 0 1px rgba(var(--dispel-rgb),.72), 0 0 12px rgba(var(--dispel-rgb),.62), 0 6px 18px rgba(0,0,0,.32); }
|
||||
.party-frame.is-down { filter: grayscale(.75); opacity: .58; }
|
||||
.party-rez-button { display: grid; width: 100%; min-height: 44px; gap: 3px; padding: 6px 8px; border: 1px solid var(--party-rez-border, rgba(224,197,101,.5)); border-radius: 4px; background: var(--party-rez-background, linear-gradient(110deg, rgba(41,45,23,.96), rgba(13,28,20,.94))); color: var(--party-rez-color, #fff0b0); cursor: pointer; }
|
||||
.party-rez-button strong { font-size: 10px; }
|
||||
.party-rez-button small { color: #b6c4a8; font-size: 8px; }
|
||||
.party-rez-button:hover:not(:disabled), .party-rez-button:focus-visible { border-color: #fff0b0; outline: 1px solid #fff0b0; outline-offset: 2px; }
|
||||
.party-rez-button:disabled { opacity: .6; cursor: not-allowed; }
|
||||
.party-resurrection { display: grid; gap: 4px; }
|
||||
.resurrection-destination { display: grid; width: 100%; gap: 3px; margin-bottom: 6px; color: #b6c4a8; font-size: 10px; text-align: left; }
|
||||
.resurrection-destination select { width: 100%; min-width: 0; min-height: 44px; padding: 4px 7px; border: 1px solid rgba(224,197,101,.5); border-radius: 4px; background: #142219; color: #fff0b0; font: inherit; }
|
||||
.resurrection-destination select:focus-visible { outline: 1px solid #fff0b0; outline-offset: 2px; }
|
||||
.resurrection-destination select:disabled { opacity: .6; }
|
||||
.party-frame--player { opacity: .9; }
|
||||
.party-frame--player.is-selected { opacity: 1; }
|
||||
.party-frame--online { border-color: rgba(79,184,154,.3); background: linear-gradient(100deg, rgba(9,35,31,.94), rgba(11,24,21,.9)); }
|
||||
|
||||
@@ -30,6 +30,10 @@ import { ExperienceBar } from "./ExperienceBar";
|
||||
import { BrandMark, FrontSurface } from "./FrontSurface";
|
||||
import { ShelvedCompanionPanel } from "@shelvedCompanionPanel";
|
||||
import { SHELVED_MODES_ENABLED } from "../app/releaseFeatures";
|
||||
import { EditableHudElement } from "./EditableHudElement";
|
||||
import { DpsMeterWidget } from "./DpsMeterWidget";
|
||||
import { useUiLayoutStore } from "../game/uiLayoutStore";
|
||||
import { useDpsMeterStore } from "../game/dpsMeterStore";
|
||||
|
||||
function safePercent(current: number, maximum: number): number {
|
||||
if (!Number.isFinite(current) || !Number.isFinite(maximum) || maximum <= 0) return 0;
|
||||
@@ -456,14 +460,21 @@ export function CompanionPanel({ snapshot, send, preferControllerBinds = false }
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
<section className="companion-map-card">
|
||||
<DungeonMapGraphic position={snapshot.game.playerPosition} yaw={snapshot.game.cameraYaw} compact />
|
||||
<div>
|
||||
<span>{snapshot.game.overlay ? "Upper display" : "Current area"}</span>
|
||||
<strong>{snapshot.game.overlay ? overlayName(snapshot.game.overlay, romSkills) : snapshot.game.areaName}</strong>
|
||||
<small>{Math.round(snapshot.game.distanceTravelled)} m explored / {heading} degrees</small>
|
||||
</div>
|
||||
</section>
|
||||
<div className="companion-tactical__overview">
|
||||
<section className="companion-map-card">
|
||||
<DungeonMapGraphic position={snapshot.game.playerPosition} yaw={snapshot.game.cameraYaw} compact />
|
||||
<div>
|
||||
<span>{snapshot.game.overlay ? "Upper display" : "Current area"}</span>
|
||||
<strong>{snapshot.game.overlay ? overlayName(snapshot.game.overlay, romSkills) : snapshot.game.areaName}</strong>
|
||||
<small>{Math.round(snapshot.game.distanceTravelled)} m explored / {heading} degrees</small>
|
||||
</div>
|
||||
</section>
|
||||
{snapshot.game.dpsMeter.display === "companion" && <EditableHudElement id="dps" hostSelector=".companion-panel"
|
||||
elementLayout={snapshot.game.dpsMeter.layout} editing={snapshot.game.dpsMeter.editing}
|
||||
onChange={(update) => send({ name: "editDpsMeter", update })}>
|
||||
<DpsMeterWidget compact snapshot={snapshot.game.dpsMeter.values} width={snapshot.game.dpsMeter.layout.width} />
|
||||
</EditableHudElement>}
|
||||
</div>
|
||||
<div className="companion-tactical__actions" style={{ gridColumn: "1 / 3", minWidth: 0 }}>
|
||||
<ActionBar
|
||||
items={actionItems}
|
||||
@@ -501,5 +512,7 @@ export function LocalCompanionPanel() {
|
||||
useGameStore();
|
||||
useCombatStore();
|
||||
usePartyStore();
|
||||
useUiLayoutStore();
|
||||
useDpsMeterStore();
|
||||
return <CompanionPanel snapshot={currentCompanionSnapshot()} send={executeCompanionCommand} />;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { currentDisplayLayout } from "../platform/displayLayout";
|
||||
import { currentUiDevice, useUiLayoutStore } from "../game/uiLayoutStore";
|
||||
import { resolveDpsMeterDisplay } from "../game/uiLayout";
|
||||
import { currentDpsMeterSnapshot, useDpsMeterStore } from "../game/dpsMeterStore";
|
||||
import { EditableHudElement } from "./EditableHudElement";
|
||||
import { DpsMeterWidget } from "./DpsMeterWidget";
|
||||
|
||||
export function DpsMeter() {
|
||||
useDpsMeterStore();
|
||||
const ui = useUiLayoutStore();
|
||||
const layout = ui.draft ?? ui.preferences.current[currentUiDevice()];
|
||||
if (resolveDpsMeterDisplay(layout, currentDisplayLayout() !== "single") !== "main") return null;
|
||||
return <EditableHudElement id="dps"><DpsMeterWidget snapshot={currentDpsMeterSnapshot()} width={layout.elements.dps.width} /></EditableHudElement>;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { CSSProperties } from "react";
|
||||
import type { DpsMeterSnapshot } from "../game/dpsMeter";
|
||||
import "./dpsMeter.css";
|
||||
|
||||
export function DpsMeterWidget({ snapshot, width, compact = false }: {
|
||||
snapshot: DpsMeterSnapshot; width?: number | null; compact?: boolean;
|
||||
}) {
|
||||
const number = (value: number) => Math.round(value).toLocaleString();
|
||||
return <section className={`dps-meter ${compact ? "dps-meter--compact" : ""}`} aria-label="DPS meter"
|
||||
style={{ "--dps-meter-width": `${width ?? 230}px` } as CSSProperties}>
|
||||
<header><strong>DPS</strong><small>{snapshot.rows.length === 0 ? "Waiting for combat" : snapshot.active ? "Current fight" : "Last fight"}</small>
|
||||
<span>{Math.floor(snapshot.durationSeconds / 60)}:{String(Math.floor(snapshot.durationSeconds % 60)).padStart(2, "0")}</span></header>
|
||||
{snapshot.rows.length > 0 ? <ol>{snapshot.rows.map((row) => <li key={row.id}
|
||||
title={`${row.name}: ${number(row.dps)} DPS · ${number(row.total)} damage`}
|
||||
style={{ "--dps-color": row.color } as CSSProperties}>
|
||||
<i style={{ width: `${row.percent}%` }} aria-hidden="true" /><span>{row.name}</span><b>{number(row.dps)}</b>
|
||||
</li>)}</ol> : <p>Damage dealt per second</p>}
|
||||
{snapshot.rows.length > 0 && <footer><span>Party · {number(snapshot.totalDamage)} damage</span><b>{number(snapshot.totalDps)} DPS</b></footer>}
|
||||
</section>;
|
||||
}
|
||||
@@ -1,12 +1,13 @@
|
||||
import { useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { HUD_ELEMENTS, type HudElementId } from "../game/uiLayout";
|
||||
import { HUD_ELEMENTS, type HudElementId, type HudElementLayout } from "../game/uiLayout";
|
||||
import { currentUiDevice, useUiLayoutStore } from "../game/uiLayoutStore";
|
||||
import { useGameStore } from "../game/store";
|
||||
import "./uiLayout.css";
|
||||
|
||||
const FALLBACKS: Partial<Record<HudElementId, [number, number, number]>> = {
|
||||
party: [.1, .3, 210], summons: [.27, .25, 150], minimap: [.9, .12, 160], player: [.9, .34, 160],
|
||||
timeline: [.9, .52, 160], target: [.5, .15, 230], friendly: [.3, .15, 230], threat: [.78, .25, 160],
|
||||
timeline: [.9, .52, 160], target: [.5, .15, 230], friendly: [.3, .15, 230], threat: [.78, .25, 160], dps: [.13, .8, 230],
|
||||
cast: [.5, .68, 330], actions: [.5, .86, 420], hints: [.5, .65, 420], experience: [.5, .99, 900],
|
||||
reticle: [.5, .5, 20], loot: [.5, .3, 320], interaction: [.5, .6, 320], manastorm: [.5, .05, 400],
|
||||
completion: [.5, .39, 360], assets: [.78, .6, 200], feedback: [.5, .71, 400],
|
||||
@@ -14,14 +15,17 @@ const FALLBACKS: Partial<Record<HudElementId, [number, number, number]>> = {
|
||||
};
|
||||
|
||||
/** Defaults remain in their original CSS flow until the player moves the widget. */
|
||||
export function EditableHudElement({ id, children, intrinsicWidth, bottomAnchor }: {
|
||||
export function EditableHudElement({ id, children, intrinsicWidth, bottomAnchor, elementLayout,
|
||||
editing: editingOverride, hostSelector = ".game-shell", onChange }: {
|
||||
id: HudElementId; children?: ReactNode; intrinsicWidth?: number; bottomAnchor?: number;
|
||||
elementLayout?: HudElementLayout; editing?: boolean; hostSelector?: string;
|
||||
onChange?: (update: Partial<HudElementLayout>) => void;
|
||||
}) {
|
||||
const overlay = useGameStore((state) => state.overlay);
|
||||
const editing = overlay === "ui-layout";
|
||||
const editing = editingOverride ?? overlay === "ui-layout";
|
||||
const state = useUiLayoutStore();
|
||||
const layout = state.draft ?? state.preferences.current[currentUiDevice()];
|
||||
const element = layout.elements[id];
|
||||
const element = elementLayout ?? layout.elements[id];
|
||||
const [origin, setOrigin] = useState<{ x: number; y: number; width: number } | null>(null);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [host, setHost] = useState<Element | null>(null);
|
||||
@@ -34,14 +38,14 @@ export function EditableHudElement({ id, children, intrinsicWidth, bottomAnchor
|
||||
: origin ?? { x: fallback[0], y: fallback[1] });
|
||||
const width = intrinsicWidth ?? element.width ?? origin?.width ?? fallback[2];
|
||||
|
||||
useLayoutEffect(() => { if (ref.current) setHost(ref.current.closest(".game-shell")); }, []);
|
||||
useLayoutEffect(() => { if (ref.current) setHost(ref.current.closest(hostSelector)); }, [hostSelector]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (positioned || !ref.current) return;
|
||||
const node = ref.current;
|
||||
const measure = () => {
|
||||
const child = node.firstElementChild?.getBoundingClientRect();
|
||||
const host = node.closest(".game-shell")?.getBoundingClientRect();
|
||||
const host = node.closest(hostSelector)?.getBoundingClientRect();
|
||||
if (child && host && child.width > 0 && child.height > 0) {
|
||||
setOrigin({ x: (child.left - host.left + child.width / 2) / host.width,
|
||||
y: (child.top - host.top + child.height / 2) / host.height, width: child.width });
|
||||
@@ -52,21 +56,23 @@ export function EditableHudElement({ id, children, intrinsicWidth, bottomAnchor
|
||||
if (node.firstElementChild) observer.observe(node.firstElementChild);
|
||||
window.addEventListener("resize", measure);
|
||||
return () => { observer.disconnect(); window.removeEventListener("resize", measure); };
|
||||
}, [positioned, children]);
|
||||
}, [positioned, children, hostSelector]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const node = ref.current;
|
||||
const host = node?.closest(".game-shell");
|
||||
const host = node?.closest(hostSelector);
|
||||
if (!positioned || !node || !host) return;
|
||||
const measure = () => setBounds({ width: host.clientWidth, height: host.clientHeight, panelWidth: node.offsetWidth, panelHeight: node.offsetHeight });
|
||||
const observer = new ResizeObserver(measure);
|
||||
observer.observe(node); observer.observe(host); measure();
|
||||
return () => observer.disconnect();
|
||||
}, [positioned, host]);
|
||||
}, [positioned, host, hostSelector]);
|
||||
|
||||
const updatePosition = (x: number, y: number) => state.updateDraft((draft) => ({ ...draft, elements: {
|
||||
...draft.elements, [id]: { ...draft.elements[id], position: { x: Math.max(0, Math.min(1, x)), y: Math.max(0, Math.min(1, y)) }, width },
|
||||
} }));
|
||||
const updatePosition = (x: number, y: number) => {
|
||||
const update = { position: { x: Math.max(0, Math.min(1, x)), y: Math.max(0, Math.min(1, y)) }, width };
|
||||
if (onChange) onChange(update);
|
||||
else state.updateDraft((draft) => ({ ...draft, elements: { ...draft.elements, [id]: { ...draft.elements[id], ...update } } }));
|
||||
};
|
||||
const left = Math.max(6, Math.min(Math.max(6, bounds.width - bounds.panelWidth - 6), position.x * bounds.width - bounds.panelWidth / 2));
|
||||
const top = Math.max(6, Math.min(Math.max(6, bounds.height - bounds.panelHeight - 6), position.y * bounds.height - bounds.panelHeight / 2));
|
||||
const style = positioned ? { left, top, width: `min(${width}px, calc(100% - 12px))` } as CSSProperties : undefined;
|
||||
@@ -85,7 +91,7 @@ export function EditableHudElement({ id, children, intrinsicWidth, bottomAnchor
|
||||
}}
|
||||
onPointerMove={(event) => {
|
||||
if (drag.current?.pointerId !== event.pointerId) return;
|
||||
const host = ref.current!.closest(".game-shell")!.getBoundingClientRect();
|
||||
const host = ref.current!.closest(hostSelector)!.getBoundingClientRect();
|
||||
updatePosition((event.clientX - host.left - drag.current.dx) / host.width, (event.clientY - host.top - drag.current.dy) / host.height);
|
||||
}}
|
||||
onPointerUp={() => { drag.current = null; }} onPointerCancel={() => { drag.current = null; }} onLostPointerCapture={() => { drag.current = null; }}
|
||||
|
||||
+23
-8
@@ -28,12 +28,15 @@ import { Minimap } from "./Minimap";
|
||||
import { PlayerStatusPanel } from "./PlayerStatusPanel";
|
||||
import { EncounterTimeline } from "./EncounterTimeline";
|
||||
import { PartyFrames } from "./PartyFrames";
|
||||
import { ResurrectionDestination, useResurrectionCheckpoints } from "./ResurrectionDestination";
|
||||
import type { ResurrectionCheckpoint } from "../game/resurrectionCheckpoints";
|
||||
import { TimedEffectStrip } from "./TimedEffectStrip";
|
||||
import { AuraStrip } from "./AuraStrip";
|
||||
import { ShelvedHud } from "@shelvedHud";
|
||||
import { ThreatMeterWidget } from "./ThreatMeterWidget";
|
||||
import { BossLootWindow } from "./BossLootWindow";
|
||||
import { EditableHudElement } from "./EditableHudElement";
|
||||
import { DpsMeter } from "./DpsMeter";
|
||||
import { DesktopActionBars } from "./DesktopActionBars";
|
||||
import { currentUiDevice, useUiLayoutStore } from "../game/uiLayoutStore";
|
||||
import { SummonFrames } from "./SummonFrames";
|
||||
@@ -82,12 +85,16 @@ export function DefeatedPrompt({
|
||||
resurrectionCharges,
|
||||
onReturn,
|
||||
sharedOnline = false,
|
||||
checkpoints = [],
|
||||
}: {
|
||||
gameMode: "dungeon" | "manastorm";
|
||||
resurrectionCharges: number;
|
||||
onReturn: () => void;
|
||||
onReturn: (checkpointId?: string) => void;
|
||||
sharedOnline?: boolean;
|
||||
checkpoints?: readonly ResurrectionCheckpoint[];
|
||||
}) {
|
||||
const [requestedCheckpoint, setRequestedCheckpoint] = useState("entrance");
|
||||
const checkpointId = checkpoints.some((checkpoint) => checkpoint.id === requestedCheckpoint) ? requestedCheckpoint : "entrance";
|
||||
const canReturn = !sharedOnline && (gameMode === "dungeon" || resurrectionCharges > 0);
|
||||
return (
|
||||
<section
|
||||
@@ -103,11 +110,16 @@ export function DefeatedPrompt({
|
||||
Stay down and wait for a party member to resurrect you, or choose to return now.
|
||||
</p>
|
||||
{canReturn ? (
|
||||
<button type="button" className="button button--primary" onClick={onReturn}>
|
||||
{gameMode === "manastorm"
|
||||
? `Resurrect now · ${resurrectionCharges} remaining`
|
||||
: "Respawn at entrance"}
|
||||
</button>
|
||||
<>
|
||||
{gameMode === "dungeon" && checkpoints.length > 0 ? (
|
||||
<ResurrectionDestination checkpoints={checkpoints} value={checkpointId} onChange={setRequestedCheckpoint} />
|
||||
) : null}
|
||||
<button type="button" className="button button--primary" onClick={() => onReturn(gameMode === "dungeon" ? checkpointId : undefined)}>
|
||||
{gameMode === "manastorm"
|
||||
? `Resurrect now · ${resurrectionCharges} remaining`
|
||||
: checkpointId === "entrance" ? "Respawn at entrance" : "Respawn at checkpoint"}
|
||||
</button>
|
||||
</>
|
||||
) : sharedOnline ? (
|
||||
<small>Your shared run is still active. A living player can resurrect you.</small>
|
||||
) : (
|
||||
@@ -159,6 +171,7 @@ export function Hud() {
|
||||
const assetStatus = useGameStore((state) => state.assetStatus);
|
||||
const assetMessage = useGameStore((state) => state.assetMessage);
|
||||
const activeDungeonId = useGameStore((state) => state.activeDungeonId);
|
||||
const resurrectionCheckpoints = useResurrectionCheckpoints();
|
||||
const dungeon = requireDungeonDefinition(activeDungeonId);
|
||||
const encounter = useManastormStore((state) => state.currentEncounter);
|
||||
const resurrectionCharges = useManastormStore((state) => state.resurrectionCharges);
|
||||
@@ -349,12 +362,12 @@ export function Hud() {
|
||||
mapId: dungeon.mapId,
|
||||
areaName,
|
||||
});
|
||||
const returnFromDefeat = () => {
|
||||
const returnFromDefeat = (checkpointId?: string) => {
|
||||
if (gameMode === "manastorm") {
|
||||
resurrectManastormSession({ kind: "player" });
|
||||
return;
|
||||
}
|
||||
respawnDefeatedPlayer();
|
||||
respawnDefeatedPlayer(checkpointId ?? "entrance");
|
||||
};
|
||||
const finishDungeon = () => {
|
||||
useShellStore.getState().saveActiveDungeonRunCheckpoint(null);
|
||||
@@ -364,6 +377,7 @@ export function Hud() {
|
||||
return (
|
||||
<div className="hud" style={{ "--game-ui-scale": settings.uiScale } as CSSProperties}>
|
||||
<ShelvedHud />
|
||||
<DpsMeter />
|
||||
<BossLootWindow />
|
||||
<EditableHudElement id="completion">{dungeonComplete && health > 0 ? (
|
||||
<DungeonCompletePrompt dungeonTitle={dungeon.title} onFinish={finishDungeon} />
|
||||
@@ -501,6 +515,7 @@ export function Hud() {
|
||||
gameMode={gameMode}
|
||||
resurrectionCharges={resurrectionCharges}
|
||||
onReturn={returnFromDefeat}
|
||||
checkpoints={resurrectionCheckpoints}
|
||||
sharedOnline={onlineSession !== null}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
+29
-2
@@ -1,4 +1,4 @@
|
||||
import type { CSSProperties } from "react";
|
||||
import { useState, type CSSProperties } from "react";
|
||||
import { classById, raceById } from "../app/characterCatalog";
|
||||
import { useShellStore } from "../app/shellStore";
|
||||
import { useCombatStore } from "../game/combatStore";
|
||||
@@ -15,8 +15,11 @@ import { useOnlineGroupStore } from "../app/onlineGroupStore";
|
||||
import type { OnlineGroupMember } from "../app/onlineGroupTypes";
|
||||
import { useOnlineSessionStore } from "../app/onlineSessionStore";
|
||||
import type { OnlineSessionPlayer } from "../app/onlineSessionTypes";
|
||||
import { onlinePlayerActorId } from "../game/onlineSessionRuntime";
|
||||
import { onlinePlayerActorId, onlineSessionOwnsWorldSimulation } from "../game/onlineSessionRuntime";
|
||||
import { auraDefinitionIsDispellableDebuff } from "./auraDisplay";
|
||||
import { partyResurrectionBlockedByBoss, resurrectDeadPartyMembers } from "../game/partyResurrection";
|
||||
import { ResurrectionDestination, useResurrectionCheckpoints } from "./ResurrectionDestination";
|
||||
import { useGameStore } from "../game/store";
|
||||
|
||||
const PARTY_COMMANDS: readonly {
|
||||
command: PartyCommand;
|
||||
@@ -230,6 +233,11 @@ export function PartyFrames() {
|
||||
.map((aura) => aura.definition.name)
|
||||
.join(", "));
|
||||
const members = usePartyStore((state) => state.members);
|
||||
const gameMode = useGameStore((state) => state.gameMode);
|
||||
const bossCombat = useCombatStore((state) => partyResurrectionBlockedByBoss(state.mobs));
|
||||
const checkpoints = useResurrectionCheckpoints();
|
||||
const [requestedCheckpoint, setRequestedCheckpoint] = useState("entrance");
|
||||
const checkpointId = checkpoints.some((checkpoint) => checkpoint.id === requestedCheckpoint) ? requestedCheckpoint : "entrance";
|
||||
const selectedMemberId = usePartyStore((state) => state.selectedMemberId);
|
||||
const selfSelected = usePartyStore((state) => state.selfSelected);
|
||||
const routeBlocked = usePartyStore((state) => state.routeBlocked);
|
||||
@@ -321,6 +329,25 @@ export function PartyFrames() {
|
||||
onSelect={() => selectMember(member.id)}
|
||||
/>
|
||||
))}
|
||||
{gameMode === "dungeon" && members.some((member) => member.health <= 0) ? (
|
||||
<div className="party-resurrection">
|
||||
<ResurrectionDestination checkpoints={checkpoints} value={checkpointId} onChange={setRequestedCheckpoint}
|
||||
disabled={bossCombat || !onlineSessionOwnsWorldSimulation()} />
|
||||
<button
|
||||
type="button"
|
||||
className="party-rez-button"
|
||||
disabled={bossCombat || !onlineSessionOwnsWorldSimulation()}
|
||||
title={bossCombat ? "Unavailable during boss combat." : !onlineSessionOwnsWorldSimulation()
|
||||
? "Only the party's simulation host can resurrect NPC teammates."
|
||||
: "Resurrect fallen NPC teammates at the selected checkpoint. They will return to you."}
|
||||
onClick={() => resurrectDeadPartyMembers(checkpointId)}
|
||||
>
|
||||
<strong>Rez dead members</strong>
|
||||
<small>{bossCombat ? "Unavailable during boss combat" : !onlineSessionOwnsWorldSimulation()
|
||||
? "Simulation host only" : "Then return to you"}</small>
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { useMemo, useSyncExternalStore } from "react";
|
||||
import { useCombatStore } from "../game/combatStore";
|
||||
import { activeDungeonCompletedObjectiveIds, activeDungeonEncounterRevision, subscribeDungeonEncounterSnapshots } from "../game/dungeonEncounterRuntime";
|
||||
import { requireDungeonDefinition } from "../game/dungeonRegistry";
|
||||
import { dungeonResurrectionCheckpoints, type ResurrectionCheckpoint } from "../game/resurrectionCheckpoints";
|
||||
import { useGameStore } from "../game/store";
|
||||
|
||||
export function useResurrectionCheckpoints() {
|
||||
const dungeonId = useGameStore((state) => state.activeDungeonId);
|
||||
const mobs = useCombatStore((state) => state.mobs);
|
||||
const revision = useSyncExternalStore(subscribeDungeonEncounterSnapshots,
|
||||
activeDungeonEncounterRevision, activeDungeonEncounterRevision);
|
||||
return useMemo(() => dungeonResurrectionCheckpoints(requireDungeonDefinition(dungeonId), mobs,
|
||||
activeDungeonCompletedObjectiveIds(dungeonId)), [dungeonId, mobs, revision]);
|
||||
}
|
||||
|
||||
export function ResurrectionDestination({ checkpoints, value, onChange, disabled = false }: {
|
||||
checkpoints: readonly ResurrectionCheckpoint[];
|
||||
value: string;
|
||||
onChange: (id: string) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<label className="resurrection-destination">
|
||||
<span>Resurrect at</span>
|
||||
<select value={value} onChange={(event) => onChange(event.target.value)} disabled={disabled}>
|
||||
{checkpoints.map((checkpoint) => <option key={checkpoint.id} value={checkpoint.id}>{checkpoint.name}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -138,6 +138,23 @@ export function UiLayoutEditor() {
|
||||
</div>)}
|
||||
<button type="button" onClick={() => ui.updateDraft((layout) => ({ ...layout, elements: defaultUiLayout().elements }))}>Reset all positions and visibility</button>
|
||||
</details>
|
||||
<details open className="ui-layout-editor__dps"><summary>DPS meter</summary>
|
||||
<label><input type="checkbox" checked={draft.elements.dps.visible} onChange={(event) => ui.updateDraft((layout) => ({ ...layout,
|
||||
elements: { ...layout.elements, dps: { ...layout.elements.dps, visible: event.target.checked } },
|
||||
}))} />Show DPS meter</label>
|
||||
<label>Screen<select aria-label="DPS meter screen" value={draft.dpsMeterDisplay} onChange={(event) => ui.updateDraft((layout) => ({ ...layout,
|
||||
dpsMeterDisplay: event.target.value as typeof layout.dpsMeterDisplay,
|
||||
elements: { ...layout.elements, dps: { ...layout.elements.dps, position: null } },
|
||||
}))}>
|
||||
<option value="auto">Automatic · PC lower left / Thor bottom</option>
|
||||
<option value="main">Main screen</option><option value="companion">Companion screen</option>
|
||||
</select></label>
|
||||
<label>Width<input type="range" aria-label="DPS meter width" min={160} max={600} step={10} value={draft.elements.dps.width ?? 230}
|
||||
onChange={(event) => ui.updateDraft((layout) => ({ ...layout, elements: { ...layout.elements,
|
||||
dps: { ...layout.elements.dps, width: Number(event.target.value) },
|
||||
} }))} /><output>{draft.elements.dps.width ?? 230}px</output></label>
|
||||
<p>Drag the DPS label on its screen to move it. Damage includes pets and summons. The last fight stays visible until the next pull.</p>
|
||||
</details>
|
||||
{ui.device === "pc" && <details open><summary>PC action bars</summary>
|
||||
<label className="ui-layout-editor__check"><input type="checkbox" checked={draft.wowBars} onChange={(event) => ui.updateDraft((layout) => {
|
||||
const combat = useCombatStore.getState();
|
||||
|
||||
@@ -2,6 +2,7 @@ import { createElement } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { DefeatedPrompt, DungeonCompletePrompt } from "./Hud";
|
||||
import { ResurrectionDestination } from "./ResurrectionDestination";
|
||||
|
||||
describe("defeated prompt", () => {
|
||||
it("offers an explicit dungeon respawn while explaining that waiting is allowed", () => {
|
||||
@@ -25,6 +26,29 @@ describe("defeated prompt", () => {
|
||||
expect(markup).toContain("No shared resurrection charges remain");
|
||||
expect(markup).not.toContain("Resurrect now");
|
||||
});
|
||||
|
||||
it("offers labeled entrance and killed-boss choices on the player defeat prompt", () => {
|
||||
const markup = renderToStaticMarkup(createElement(DefeatedPrompt, {
|
||||
gameMode: "dungeon", resurrectionCharges: 0, onReturn: () => undefined,
|
||||
checkpoints: [
|
||||
{ id: "entrance", name: "Dungeon entrance", spawn: { footPosition: [0, 0, 0], yaw: 0 } },
|
||||
{ id: "boss:bazil", name: "Bazil Thredd", spawn: { footPosition: [20, 0, 0], yaw: 0 } },
|
||||
],
|
||||
}));
|
||||
expect(markup).toContain("Resurrect at");
|
||||
expect(markup).toContain("value=\"boss:bazil\"");
|
||||
expect(markup).toContain("Dungeon entrance");
|
||||
});
|
||||
|
||||
it("retains a selected boss destination in the shared chooser", () => {
|
||||
const markup = renderToStaticMarkup(createElement(ResurrectionDestination, {
|
||||
checkpoints: [
|
||||
{ id: "entrance", name: "Dungeon entrance", spawn: { footPosition: [0, 0, 0], yaw: 0 } },
|
||||
{ id: "boss:bazil", name: "Bazil Thredd", spawn: { footPosition: [20, 0, 0], yaw: 0 } },
|
||||
], value: "boss:bazil", onChange: () => undefined,
|
||||
}));
|
||||
expect(markup).toContain("value=\"boss:bazil\" selected=\"\"");
|
||||
});
|
||||
});
|
||||
|
||||
describe("dungeon completion prompt", () => {
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
.dps-meter {
|
||||
position: absolute;
|
||||
z-index: 12;
|
||||
bottom: max(32px, env(safe-area-inset-bottom));
|
||||
left: max(14px, env(safe-area-inset-left));
|
||||
width: min(var(--dps-meter-width, 230px), calc(100% - 28px));
|
||||
max-height: 220px;
|
||||
overflow: auto;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--edge, #536344);
|
||||
border-radius: 6px;
|
||||
background: rgba(5, 17, 12, .9);
|
||||
color: var(--ink, #ece5d0);
|
||||
font-size: 11px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.dps-meter > header { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
|
||||
.dps-meter > header strong { color: var(--gold, #d5c28b); font: bold 13px Georgia, serif; }
|
||||
.dps-meter > header small { flex: 1; color: var(--muted, #a4b69a); font-size: 10px; }
|
||||
.dps-meter > header span { font-size: 10px; }
|
||||
.dps-meter > p { margin: 4px 0; color: var(--muted, #a4b69a); font-size: 10px; }
|
||||
.dps-meter > ol { display: grid; gap: 3px; margin: 0; padding: 0; list-style: none; }
|
||||
.dps-meter li { position: relative; display: flex; align-items: center; gap: 8px; min-height: 20px; padding: 2px 5px; overflow: hidden; border-radius: 3px; background: #ffffff06; }
|
||||
.dps-meter li > i { position: absolute; inset: 0 auto 0 0; background: var(--dps-color); opacity: .3; }
|
||||
.dps-meter li > span { position: relative; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.dps-meter li > b { position: relative; font-size: 10px; }
|
||||
.dps-meter > footer { display: flex; justify-content: space-between; gap: 5px; margin-top: 6px; color: var(--muted, #a4b69a); font-size: 9px; }
|
||||
.dps-meter > footer b { color: var(--ink, #ece5d0); white-space: nowrap; }
|
||||
.editable-hud--editing:has(> .dps-meter) { padding-top: 24px; }
|
||||
.dps-meter--compact { position: relative; inset: auto; width: min(var(--dps-meter-width, 230px), 100%); max-height: 150px; padding: 5px; font-size: 8px; }
|
||||
.dps-meter--compact > header { gap: 5px; margin-bottom: 3px; }
|
||||
.dps-meter--compact > header strong { font-size: 10px; }
|
||||
.dps-meter--compact > header :is(small, span), .dps-meter--compact > p { font-size: 7px; }
|
||||
.dps-meter--compact > ol { gap: 2px; }
|
||||
.dps-meter--compact li { min-height: 13px; padding: 1px 4px; }
|
||||
.dps-meter--compact li > b { font-size: 7px; }
|
||||
.dps-meter--compact > footer { margin-top: 3px; font-size: 6px; }
|
||||
.companion-tactical__overview { display: grid; grid-template-rows: minmax(0, 1fr) auto; gap: 6px; min-width: 0; min-height: 0; }
|
||||
.companion-tactical__overview > .companion-map-card { grid-template-columns: minmax(0, 1fr) 110px; min-height: 0; }
|
||||
.ui-layout-editor__dps { padding: 5px 0; }
|
||||
.ui-layout-editor__dps input[type="range"] { flex: 1; min-width: 0; }
|
||||
@@ -0,0 +1,87 @@
|
||||
import { createElement } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useCombatStore } from "../game/combatStore";
|
||||
import { activateDungeonEncounterDefinitions, resetDungeonEncounters } from "../game/dungeonEncounterRuntime";
|
||||
import { requireDungeonDefinition } from "../game/dungeonRegistry";
|
||||
import { usePartyStore } from "../game/partyStore";
|
||||
import { useGameStore } from "../game/store";
|
||||
import { PartyFrames } from "./PartyFrames";
|
||||
|
||||
// Exercise the client HUD against live stores rather than their initial SSR snapshots.
|
||||
vi.mock("../game/partyStore", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../game/partyStore")>();
|
||||
return { ...actual, usePartyStore: Object.assign(
|
||||
(select: (state: ReturnType<typeof actual.usePartyStore.getState>) => unknown) => select(actual.usePartyStore.getState()), actual.usePartyStore,
|
||||
) };
|
||||
});
|
||||
vi.mock("../game/combatStore", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../game/combatStore")>();
|
||||
return { ...actual, useCombatStore: Object.assign(
|
||||
(select: (state: ReturnType<typeof actual.useCombatStore.getState>) => unknown) => select(actual.useCombatStore.getState()), actual.useCombatStore,
|
||||
) };
|
||||
});
|
||||
vi.mock("../game/store", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../game/store")>();
|
||||
return { ...actual, useGameStore: Object.assign(
|
||||
(select: (state: ReturnType<typeof actual.useGameStore.getState>) => unknown) => select(actual.useGameStore.getState()), actual.useGameStore,
|
||||
) };
|
||||
});
|
||||
|
||||
const dungeon = requireDungeonDefinition("stormwind-stockade");
|
||||
const render = () => renderToStaticMarkup(createElement(PartyFrames));
|
||||
function killMember() {
|
||||
const member = usePartyStore.getState().members[0];
|
||||
useCombatStore.getState().commitCompanionActors([{ ...member, health: 0, status: "down" }]);
|
||||
}
|
||||
|
||||
describe("party resurrection UI", () => {
|
||||
beforeEach(() => {
|
||||
useGameStore.getState().activateDungeon("stormwind-stockade");
|
||||
activateDungeonEncounterDefinitions(dungeon.id, dungeon.encounters ?? []);
|
||||
resetDungeonEncounters("dungeon", dungeon.id);
|
||||
useCombatStore.getState().initializeCharacter({ classId: "priest", level: 16 });
|
||||
useCombatStore.setState({ mobs: {} });
|
||||
usePartyStore.getState().initializeParty("rez-ui", 16, useCombatStore.getState().health);
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
});
|
||||
|
||||
it("hides the control when all NPCs are alive, even if only the player is dead", () => {
|
||||
expect(render()).not.toContain("Rez dead members");
|
||||
useCombatStore.getState().damagePlayer(useCombatStore.getState().maxHealth * 10);
|
||||
expect(render()).not.toContain("Rez dead members");
|
||||
});
|
||||
|
||||
it("places the button and labeled destination selector below every party frame", () => {
|
||||
killMember();
|
||||
const markup = render();
|
||||
expect(markup).toContain("Rez dead members");
|
||||
expect(markup).toContain("Resurrect at");
|
||||
expect(markup).toContain("Dungeon entrance");
|
||||
expect(markup.indexOf("party-resurrection")).toBeGreaterThan(markup.lastIndexOf("party-frame__identity"));
|
||||
expect(markup).not.toMatch(/class="party-rez-button" disabled/);
|
||||
});
|
||||
|
||||
it("shows why resurrection is disabled during a living boss encounter", () => {
|
||||
killMember();
|
||||
useCombatStore.getState().registerMob("boss", { name: "Boss", boss: true, maxHealth: 100 });
|
||||
useCombatStore.getState().damageMob("boss", 1, 100);
|
||||
expect(render()).toMatch(/class="party-rez-button" disabled=""/);
|
||||
expect(render()).toContain("Unavailable during boss combat");
|
||||
});
|
||||
|
||||
it("offers killed boss destinations but not bosses that are still alive", () => {
|
||||
killMember();
|
||||
const boss = dungeon.bosses[0];
|
||||
expect(render()).not.toContain(`value="boss:${boss.id}"`);
|
||||
useCombatStore.getState().registerMob(boss.id, { name: boss.name, boss: true, maxHealth: 1 });
|
||||
useCombatStore.getState().damageMob(boss.id, 1, 100);
|
||||
expect(render()).toContain(`value="boss:${boss.id}"`);
|
||||
});
|
||||
|
||||
it("does not offer free resurrection in Manastorm", () => {
|
||||
killMember();
|
||||
useGameStore.setState({ gameMode: "manastorm" });
|
||||
expect(render()).not.toContain("Rez dead members");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user