From 03a4e3f51cdbdb8c1b2b65bf3afa54063114be33 Mon Sep 17 00:00:00 2001 From: phenom Date: Sat, 12 Sep 2026 19:16:01 -0400 Subject: [PATCH] Release Healer Man 0.1.36 --- PLAYTEST_NOTES.md | 91 ++++++++++++++ package-lock.json | 4 +- package.json | 2 +- release-version.json | 4 +- src/game/combatSpatial.ts | 8 ++ src/game/combatStore.ts | 94 ++++++++++++-- src/game/dungeonWallCombat.test.ts | 139 ++++++++++++++++++++ src/game/mobPursuit.test.ts | 133 ++++++++++++++++++++ src/game/mobPursuit.ts | 142 +++++++++++++++++++++ src/game/partyMovement.test.ts | 14 --- src/game/partyMovement.ts | 39 ------ src/game/partyPathing.test.ts | 13 ++ src/game/partyPathing.ts | 4 +- src/game/partyRuntime.ts | 12 +- src/game/partyStuckRecovery.test.ts | 181 +++++++++++++++++++++++++++ src/game/partyStuckRecovery.ts | 175 ++++++++++++++++++++++++++ src/game/stockadesNavigation.test.ts | 141 ++++++++++++++++++++- src/scene/MobPopulation.tsx | 77 +++++++----- src/scene/PartyPopulation.tsx | 126 +++++++++++++------ src/scene/mobNavigation.ts | 32 +++++ src/scene/partyTraversal.test.ts | 73 +++++++++++ src/scene/partyTraversal.ts | 38 ++++++ 22 files changed, 1389 insertions(+), 153 deletions(-) create mode 100644 src/game/dungeonWallCombat.test.ts create mode 100644 src/game/mobPursuit.test.ts create mode 100644 src/game/mobPursuit.ts create mode 100644 src/game/partyStuckRecovery.test.ts create mode 100644 src/game/partyStuckRecovery.ts create mode 100644 src/scene/mobNavigation.ts create mode 100644 src/scene/partyTraversal.test.ts diff --git a/PLAYTEST_NOTES.md b/PLAYTEST_NOTES.md index beccc7ec..6afaa210 100644 --- a/PLAYTEST_NOTES.md +++ b/PLAYTEST_NOTES.md @@ -2348,3 +2348,94 @@ Vite's existing large-chunk advisory remains informational. compression, and release-bundle verification passed. Existing chunk-size advisories and deprecated physics initialization warnings remain. No hosted release was published. + +## 2026-09-12 - Individual companion navigation recovery + +- Replaced the old distant, whole-party stuck Recall with an individual + navigation watchdog in a separately tested game module. Two seconds without + meaningful waypoint progress invalidates that companion's route; five seconds + requests a safe recovery landing. Failed replans do not restart the timer, + back-and-forth movement cancels forward progress, and dense waypoint chains + still count genuine movement around corners. +- Recovery prefers a recently visited party/player trail point near the group + on the same floor, then falls back to a validated player position. Actual + terrain checks require walkable ground, clear companion body space, and a + route back to the group. Blocked or invalid landings are not applied. Each + companion has an independent ten-second teleport cooldown. +- Intentional attack-range holds, casting/channeling, crowd control, Stop, + death, inactive simulation, and UI pauses do not accumulate stuck time. + Automatic recovery changes only the affected companion's position and routing + state; it does not heal, resurrect, reset threat, or teleport other companions. + Manual Recall keeps its existing whole-party behavior and party order. +- Added regressions for recovery timing, oscillation, replanning, independent + cooldowns and member isolation, exclusions, safe-trail/player selection, wall + and ceiling rejection, missing floors, and actor-independent landing checks. + Shipping Stockades navigation/collision assets validate recovery landings on + all three ramps and along approaches to all five boss objectives. +- Browser smoke pass at 1280 x 720 used offline Priest Rolecheck and Blood DK, + Combat Rogue, Unholy DK, and Frost Mage companions. Inline roster and dungeon + screenshots show unchanged character bodies, equipment, stairs, and warm + lighting. The first catalog load needed a retry. Attack moved companions down + from the entrance without ROUTE BLOCKED, and combat continued with the player + stationary. The unattended party eventually died; Stop remained selected + after Recall returned the fallen companions without resurrecting them. +- The intermittent side-room trap and its automatic recovery were not reproduced + live. Recovery triggers and terrain safety are backed by automated tests; + extended side-room playtesting, a healed full clear, and physical Android QA + remain open. This pass did not modify existing ramp or actor-collision policy. +- Validation: all 227 Vitest files passed, 1,416 tests passed, and six existing + tests remained skipped. The final 37 focused checks passed after correcting a + test-helper type annotation. Production TypeScript/Vite compilation, texture + and performance audits, static compression, and bundle verification passed. + Existing chunk-size advisories and deprecated physics initialization warnings + remain. No hosted release was published. + +## 2026-09-12 - Wall-aware combat pursuit and individual mob recovery + +- Hostile mob actions now require both attack range and terrain line of sight. + Losing either cancels a pending hostile cast so pursuit can resume; companion + hostile casts also cancel when sight is lost. Self buffs and friendly heals + cannot make an otherwise obstructed mob hold an attack position. Proximity + aggro also checks sight instead of pulling enemies through nearby walls. +- Static enemies, roaming packs, and summoned enemies share separately tested + imperative pursuit rules over the already-loaded dungeon navigation surface. + Blocked direct approaches use approved routes through doorways. Routes may + finish at a reachable attack position only when that position is both in + range and visible; off-surface connectors still require body clearance. +- Two seconds without meaningful waypoint progress requests a replan. After + five seconds, a stuck mob within ten meters of its target may recover to a + validated nearby landing on the same floor and within its home leash. Ground, + body clearance, route connectivity, attack range, and sight must all pass. + This teleport preserves health and threat and has a ten-second cooldown. +- Distant, wrong-floor, out-of-leash, or unsafe near-target recovery requests + instead reset only that mob's combat state and restore its health. A validated + home landing is preferred; otherwise it evades and navigates home without a + blind teleport. Ten seconds of evade prevents immediate proximity, damage, + or taunt re-engagement. Dead enemies are never resurrected. Casting, control, + pauses, inactive simulation, and non-authoritative online clients do not + accumulate navigation failures or trigger automatic recovery. +- Regression coverage includes range-plus-sight attack/cast gating, immediate + cast cancellation, doorway detours, recovery timing and safety, reset/member + isolation, cooldown bypass protection, and near-teleport health/threat + preservation. Shipping Stockades meshes independently verify real wall + occlusion in both directions and clear sight across all three stair ramps. + Pursuing mobs traverse each actual ramp uphill and downhill without false + teleport/reset recovery. Existing party and player ramp checks still pass. +- Browser smoke pass at 1280 x 720 used offline level-16 Priest Rolecheck and + Blood DK, Combat Rogue, Unholy DK, and Frost Mage companions. Power Word: + Shield applied to Morvane; Attack moved companions down from the entrance + and out of its view without ROUTE BLOCKED, and Defias melee/cast events and + tank damage followed. Stop plus Recall returned all four living companions + to the nearby player while retaining Stop. Inline screenshots show character + bodies, equipment, unchanged visible steps, and the existing warm lighting. + The browser console had no errors and the existing physics initialization + deprecation warning only. +- Live QA was a short entrance/combat/recall smoke test, not a healed full clear + or a manually reproduced side-room wall trap. Wall-blocked pursuit and mob + recovery triggers are backed by automated tests; extended side-room combat, + physical Android, and live multiplayer QA remain open. Existing lighting, + ramp assets, and non-blocking actor collision policy were not changed. +- Validation: all 229 Vitest files passed, 1,444 tests passed, and six existing + tests remained skipped. Final production TypeScript/Vite compilation, texture + and performance audits, static compression, and bundle verification passed. + Existing chunk-size advisories remain. No hosted release was published. diff --git a/package-lock.json b/package-lock.json index 3accadf7..96158822 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "healer-man", - "version": "0.1.35", + "version": "0.1.36", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "healer-man", - "version": "0.1.35", + "version": "0.1.36", "dependencies": { "@capacitor/android": "8.4.1", "@capacitor/core": "8.4.1", diff --git a/package.json b/package.json index 7718185b..25bf344b 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "healer-man", "private": true, - "version": "0.1.35", + "version": "0.1.36", "type": "module", "scripts": { "dev": "vite --host 0.0.0.0", diff --git a/release-version.json b/release-version.json index 0b84d7ed..7415a4da 100644 --- a/release-version.json +++ b/release-version.json @@ -1,4 +1,4 @@ { - "versionName": "0.1.35", - "versionCode": 1035 + "versionName": "0.1.36", + "versionCode": 1036 } diff --git a/src/game/combatSpatial.ts b/src/game/combatSpatial.ts index 1d7c0a10..25a5206f 100644 --- a/src/game/combatSpatial.ts +++ b/src/game/combatSpatial.ts @@ -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, diff --git a/src/game/combatStore.ts b/src/game/combatStore.ts index 06092e20..2854a3ec 100644 --- a/src/game/combatStore.ts +++ b/src/game/combatStore.ts @@ -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>; @@ -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; @@ -2418,6 +2420,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 +2462,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 +2472,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 +2482,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 +2490,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 +2536,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 +2683,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); @@ -5656,7 +5661,7 @@ export const useCombatStore = create((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 +5681,7 @@ export const useCombatStore = create((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); @@ -6441,7 +6447,8 @@ export const useCombatStore = create((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 +6459,38 @@ export const useCombatStore = create((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 +6566,7 @@ export const useCombatStore = create((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 +6577,20 @@ export const useCombatStore = create((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 +6605,7 @@ export const useCombatStore = create((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 +6621,24 @@ export const useCombatStore = create((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; diff --git a/src/game/dungeonWallCombat.test.ts b/src/game/dungeonWallCombat.test.ts new file mode 100644 index 00000000..680301e6 --- /dev/null +++ b/src/game/dungeonWallCombat.test.ts @@ -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); + }); +}); diff --git a/src/game/mobPursuit.test.ts b/src/game/mobPursuit.test.ts new file mode 100644 index 00000000..38a5b46c --- /dev/null +++ b/src/game/mobPursuit.test.ts @@ -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 { + 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 { + 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]); + }); +}); diff --git a/src/game/mobPursuit.ts b/src/game/mobPursuit.ts new file mode 100644 index 00000000..f649f9c3 --- /dev/null +++ b/src/game/mobPursuit.ts @@ -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; + 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"; + }, + }; +} diff --git a/src/game/partyMovement.test.ts b/src/game/partyMovement.test.ts index ccf3d90f..07328e97 100644 --- a/src/game/partyMovement.test.ts +++ b/src/game/partyMovement.test.ts @@ -7,14 +7,11 @@ import { nextPartyBreadcrumbWaypoint, partyCombatMovementGoal, partyFormationPosition, - partyMadeStuckProgress, partyMovementProfile, partyNaturalFollowPosition, partyPersonalizedCombatMovementGoal, partyPresentationY, - partyShouldRecallForStuck, partyShouldMoveToComfortTarget, - PARTY_STUCK_RECALL_DISTANCE, personalizedPartyCombatRangeBand, resetPartyBreadcrumbTrail, stepPartyPosition, @@ -125,17 +122,6 @@ describe("party world movement", () => { )).toBe("hold"); }); - it("recalls only distant allies that should be moving and stopped making progress", () => { - expect(partyMadeStuckProgress([0, 0, 0], [0.45, 4, 0])).toBe(true); - expect(partyMadeStuckProgress([0, 0, 0], [0.44, 0, 0])).toBe(false); - - expect(PARTY_STUCK_RECALL_DISTANCE).toBe(80); - expect(partyShouldRecallForStuck([80, 0, 0], [0, 0, 0], true, 4_500, 1_000)).toBe(true); - expect(partyShouldRecallForStuck([79.9, 0, 0], [0, 0, 0], true, 4_500, 1_000)).toBe(false); - expect(partyShouldRecallForStuck([80, 0, 0], [0, 0, 0], false, 4_500, 1_000)).toBe(false); - expect(partyShouldRecallForStuck([80, 0, 0], [0, 0, 0], true, 4_499, 1_000)).toBe(false); - }); - it("makes melee approach a fleeing target and hold once in range", () => { const out: [number, number, number] = [0, 0, 0]; expect(partyCombatMovementGoal( diff --git a/src/game/partyMovement.ts b/src/game/partyMovement.ts index 1c4679e8..a414a472 100644 --- a/src/game/partyMovement.ts +++ b/src/game/partyMovement.ts @@ -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 }; } diff --git a/src/game/partyPathing.test.ts b/src/game/partyPathing.test.ts index 2836c8c3..d4535660 100644 --- a/src/game/partyPathing.test.ts +++ b/src/game/partyPathing.test.ts @@ -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(), diff --git a/src/game/partyPathing.ts b/src/game/partyPathing.ts index 8ec063c9..0c68edf4 100644 --- a/src/game/partyPathing.ts +++ b/src/game/partyPathing.ts @@ -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); } diff --git a/src/game/partyRuntime.ts b/src/game/partyRuntime.ts index ab9a02f3..95559c5f 100644 --- a/src/game/partyRuntime.ts +++ b/src/game/partyRuntime.ts @@ -611,7 +611,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 { @@ -1148,6 +1148,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; diff --git a/src/game/partyStuckRecovery.test.ts b/src/game/partyStuckRecovery.test.ts new file mode 100644 index 00000000..cbd19518 --- /dev/null +++ b/src/game/partyStuckRecovery.test.ts @@ -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 => ({ + 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([ + ["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([["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(); + }); +}); diff --git a/src/game/partyStuckRecovery.ts b/src/game/partyStuckRecovery.ts new file mode 100644 index 00000000..6acb7ccf --- /dev/null +++ b/src/game/partyStuckRecovery.ts @@ -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, + 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; +} diff --git a/src/game/stockadesNavigation.test.ts b/src/game/stockadesNavigation.test.ts index d72ca49f..4c92331e 100644 --- a/src/game/stockadesNavigation.test.ts +++ b/src/game/stockadesNavigation.test.ts @@ -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 { @@ -16,10 +16,13 @@ import { shouldLoadPartyNavigation } from "./partyNavigationPolicy"; import { planPartyRoute } from "./partyPathing"; import { partyCombatMovementGoal, stepPartyPosition } from "./partyMovement"; import { derivePartySpawnSlots } from "./partySpawnSlots"; -import { collisionTrimeshes, prepareCollision } from "../scene/productionDungeonCollision"; -import { createPartyDirectSegmentValidator, createPartyRouteSegmentValidator } from "../scene/partyTraversal"; +import { collisionTrimeshes, collisionLineOfSightTrimeshes, prepareCollision } from "../scene/productionDungeonCollision"; +import { DUNGEON_PHYSICAL_COLLISION_GROUPS, DUNGEON_SIGHT_BLOCKER_COLLISION_GROUPS, hasDungeonLineOfSight } from "../scene/dungeonLineOfSight"; +import { createPartyDirectSegmentValidator, createPartyRouteSegmentValidator, createPartyRecoveryPointValidator } from "../scene/partyTraversal"; import { configurePlayerCharacterController, PLAYER_CHARACTER_CONTROLLER_OFFSET } from "./playerMovement"; import { ACTOR_COLLISION_GROUPS, ACTOR_TERRAIN_QUERY_GROUPS } from "../scene/actorCollision"; +import { choosePartyStuckRecoveryPosition } from "./partyStuckRecovery"; +import { createMobPursuit } from "./mobPursuit"; const STOCKADES_NAVIGATION = "public/assets/game/manastorm/34-stormwindjail/navigation.glb"; const TANK_RANGE = Object.freeze({ minimum: 0, preferred: 2.9, maximum: 3.15 }); @@ -83,13 +86,20 @@ beforeAll(async () => { } }); -function collisionWorld(useRamps = true): RAPIER.World { +function collisionWorld(useRamps = true, includeSight = false): RAPIER.World { const world = new RAPIER.World({ x: 0, y: -18, z: 0 }); const prepared = prepareCollision(sourceCollision, useRamps ? stockades.traversalRamps : []); for (const trimesh of collisionTrimeshes(prepared)) { world.createCollider(RAPIER.ColliderDesc.trimesh( Float32Array.from(trimesh.vertices), Uint32Array.from(trimesh.indices), - )); + ).setCollisionGroups(DUNGEON_PHYSICAL_COLLISION_GROUPS)); + } + if (includeSight) { + for (const trimesh of collisionLineOfSightTrimeshes(prepared)) { + world.createCollider(RAPIER.ColliderDesc.trimesh( + Float32Array.from(trimesh.vertices), Uint32Array.from(trimesh.indices), + ).setCollisionGroups(DUNGEON_SIGHT_BLOCKER_COLLISION_GROUPS).setSensor(true)); + } } for (const child of prepared.children) (child as Mesh).geometry.dispose(); world.step(); @@ -101,6 +111,91 @@ function distance(left: readonly number[], right: readonly number[]): number { } describe("Stormwind Stockade party navigation", () => { + it("blocks combat sight across shipping room walls in both directions while preserving clear stair sight", () => { + const world = collisionWorld(true, true); + try { + const prepared = prepareCollision(sourceCollision, stockades.traversalRamps); + let checkedWalls = 0; + for (const mesh of collisionLineOfSightTrimeshes(prepared)) { + for (let offset = 0; offset < mesh.indices.length && checkedWalls < 12; offset += 3) { + const a = Number(mesh.indices[offset]) * 3; + const b = Number(mesh.indices[offset + 1]) * 3; + const c = Number(mesh.indices[offset + 2]) * 3; + const v = mesh.vertices; + const ab = [Number(v[b]) - Number(v[a]), Number(v[b + 1]) - Number(v[a + 1]), Number(v[b + 2]) - Number(v[a + 2])]; + const ac = [Number(v[c]) - Number(v[a]), Number(v[c + 1]) - Number(v[a + 1]), Number(v[c + 2]) - Number(v[a + 2])]; + const normalX = ab[1] * ac[2] - ab[2] * ac[1]; + const normalZ = ab[0] * ac[1] - ab[1] * ac[0]; + const length = Math.hypot(normalX, normalZ); + if (length < 0.1) continue; + const center = [0, 1, 2].map((axis) => (Number(v[a + axis]) + Number(v[b + axis]) + Number(v[c + axis])) / 3); + const first = findNearestPartyNavigationSurface(graph, + [center[0] + normalX / length, center[1] - 1.05, center[2] + normalZ / length], 2.5)?.position; + const second = findNearestPartyNavigationSurface(graph, + [center[0] - normalX / length, center[1] - 1.05, center[2] - normalZ / length], 2.5)?.position; + if (!first || !second || Math.abs(first[1] - second[1]) > 0.4 || distance(first, second) < 1.5 + || Math.abs((first[1] + second[1]) / 2 + 1.05 - center[1]) > 0.15 + || Math.hypot(first[0] - center[0], first[2] - center[2]) > 1.3 + || Math.hypot(second[0] - center[0], second[2] - center[2]) > 1.3) continue; + const sideA = (first[0] - center[0]) * normalX + (first[2] - center[2]) * normalZ; + const sideB = (second[0] - center[0]) * normalX + (second[2] - center[2]) * normalZ; + if (sideA * sideB >= 0) continue; + // Nearby decorative faces/door-frame triangles are not room walls + // unless the actors' chest ray actually crosses this source face. + const origin = new Vector3(first[0], first[1] + 1.05, first[2]); + const destination = new Vector3(second[0], second[1] + 1.05, second[2]); + const ray = new Ray(origin, destination.clone().sub(origin).normalize()); + const intersection = ray.intersectTriangle( + new Vector3(Number(v[a]), Number(v[a + 1]), Number(v[a + 2])), + new Vector3(Number(v[b]), Number(v[b + 1]), Number(v[b + 2])), + new Vector3(Number(v[c]), Number(v[c + 1]), Number(v[c + 2])), false, new Vector3(), + ); + if (!intersection || intersection.distanceTo(origin) >= destination.distanceTo(origin)) continue; + expect(hasDungeonLineOfSight(RAPIER, world, first, second), JSON.stringify({ center, first, second })).toBe(false); + expect(hasDungeonLineOfSight(RAPIER, world, second, first), JSON.stringify({ center, first, second })).toBe(false); + checkedWalls += 1; + } + } + for (const child of prepared.children) (child as Mesh).geometry.dispose(); + expect(checkedWalls).toBeGreaterThanOrEqual(8); + for (const ramp of stockades.traversalRamps ?? []) { + expect(hasDungeonLineOfSight(RAPIER, world, ramp.start, ramp.end)).toBe(true); + expect(hasDungeonLineOfSight(RAPIER, world, ramp.end, ramp.start)).toBe(true); + } + } finally { world.free(); } + }, 20_000); + + it("moves pursuing mobs up and down all shipping ramps without a false teleport or reset", () => { + const world = collisionWorld(true, true); + try { + const directAllowed = createPartyDirectSegmentValidator(RAPIER, world); + const routeAllowed = createPartyRouteSegmentValidator(RAPIER, world); + const navigator = createMobPursuit({ graph: () => graph, directAllowed, routeAllowed, + hasLineOfSight: (a, b) => hasDungeonLineOfSight(RAPIER, world, a, b), + validateLanding: createPartyRecoveryPointValidator(RAPIER, world, graph) }); + for (const ramp of stockades.traversalRamps ?? []) { + for (const [start, target] of [[ramp.start, ramp.end], [ramp.end, ramp.start]]) { + const initialRoute = planPartyRoute(graph, "probe", start, target, 0, { + maxOriginSnapDistance: 3, maxDestinationSnapDistance: 3, + directSegmentAllowed: directAllowed, routeSegmentAllowed: routeAllowed, + destinationReached: (surface, destination) => distance(surface, destination) <= 1.5 + && hasDungeonLineOfSight(RAPIER, world, surface, destination), + }); + expect(initialRoute.blocked, JSON.stringify({ ramp: ramp.id, waypoints: initialRoute.waypoints })).toBe(false); + const position: [number, number, number] = [...start]; + for (let frame = 0; frame < 500 && distance(position, target) > 1.5; frame += 1) { + const before = [...position]; + expect(navigator.step({ id: `${ramp.id}:${start[1]}`, targetId: "target", position, + target, home: start, phase: "chasing", health: 100, engaged: true, active: true, + casting: false, controlled: false, attackRange: 1.5, leashRange: 40, + maximumStep: 0.175, nowMs: frame * 50 }), JSON.stringify({ ramp: ramp.id, start, target, before, frame })).toBe("none"); + } + expect(distance(position, target)).toBeLessThanOrEqual(1.5); + } + } + } finally { world.free(); } + }); + it("uses the reviewed warm indoor lighting profile", () => { expect(stockades.presentation).toMatchObject({ background: "#171815", @@ -310,6 +405,42 @@ describe("Stormwind Stockade party navigation", () => { world.free(); }); + it("finds grounded recovery landings on all three stair ramps", () => { + const world = collisionWorld(); + const allowed = createPartyRecoveryPointValidator(RAPIER, world, graph); + for (const ramp of stockades.traversalRamps!) { + const point = (t: number): [number, number, number] => [ + ramp.start[0] + (ramp.end[0] - ramp.start[0]) * t, + ramp.start[1] + (ramp.end[1] - ramp.start[1]) * t + 0.05, + ramp.start[2] + (ramp.end[2] - ramp.start[2]) * t, + ]; + expect(allowed(point(0.5), point(0.52)), ramp.id).not.toBeNull(); + } + world.free(); + }); + + it("recovers from boss-room approaches onto a validated visited trail near the group", () => { + const world = collisionWorld(); + const allowed = createPartyRecoveryPointValidator(RAPIER, world, graph); + const routeAllowed = createPartyRouteSegmentValidator(RAPIER, world); + const entrance = findNearestPartyNavigationSurface(graph, stockades.entrance.footPosition, 6)!.position; + for (const objective of stockades.objectiveOrder) { + const current = findNearestPartyNavigationSurface(graph, objective.position, 15)!.position; + const path = findPartyNavigationPath(graph, entrance, current, { routeSegmentAllowed: routeAllowed })!; + const trail = path.slice(1).map((point, index): [number, number, number] => [ + (path[index][0] + point[0]) / 2, (path[index][1] + point[1]) / 2, (path[index][2] + point[2]) / 2, + ]).filter((point) => distance(point, current) >= 2 && distance(point, current) <= 12 + && Math.abs(point[1] - current[1]) <= 1.25 && allowed(point, point)); + expect(trail.length, objective.name).toBeGreaterThan(0); + const anchor = trail.at(-1)!; + const landing = choosePartyStuckRecoveryPosition({ current, player: entrance, groupAnchor: anchor, + trailPoints: trail, validate: allowed }); + expect(landing?.source, objective.name).toBe("trail"); + expect(allowed(landing!.position, anchor), objective.name).not.toBeNull(); + } + world.free(); + }); + it("walks the player capsule down and back up every ramp without jumping", () => { for (const ramp of stockades.traversalRamps!) { const world = collisionWorld(); diff --git a/src/scene/MobPopulation.tsx b/src/scene/MobPopulation.tsx index 53d23d60..c433abce 100644 --- a/src/scene/MobPopulation.tsx +++ b/src/scene/MobPopulation.tsx @@ -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 = 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, 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(); @@ -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(null); const positionRef = useRef([...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(null); const initial = getMobRuntimeTransform(instanceId)?.position ?? [0, 0, 0]; const positionRef = useRef([...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, diff --git a/src/scene/PartyPopulation.tsx b/src/scene/PartyPopulation.tsx index a6121164..8bfcce46 100644 --- a/src/scene/PartyPopulation.tsx +++ b/src/scene/PartyPopulation.tsx @@ -33,12 +33,10 @@ import { createPartyBreadcrumbTrail, nextPartyBreadcrumbFollowWaypoint, partyFormationPosition, - partyMadeStuckProgress, partyMovementProfile, partyNaturalFollowPosition, partyPersonalizedCombatMovementGoal, partyPresentationY, - partyShouldRecallForStuck, partyShouldMoveToComfortTarget, personalizedPartyCombatRangeBand, resetPartyBreadcrumbTrail, @@ -47,6 +45,15 @@ import { type PartyBreadcrumbCursor, type PartyMovementProfile, } from "../game/partyMovement"; +import { + applyPartyStuckRecoveryLanding, + choosePartyStuckRecoveryPosition, + createPartyStuckRecoveryState, + markPartyStuckRecovered, + resetPartyStuckRecoveryProgress, + updatePartyStuckRecovery, + type PartyStuckRecoveryState, +} from "../game/partyStuckRecovery"; import { findNearestPartyNavigationSurface, type PartyNavigationGraph, @@ -87,6 +94,7 @@ import { ACTOR_TERRAIN_QUERY_GROUPS } from "./actorCollision"; import { createPartyDirectSegmentValidator, createPartyRouteSegmentValidator, + createPartyRecoveryPointValidator, PARTY_FLOOR_RAY_DEPTH, PARTY_FLOOR_RAY_HEIGHT, } from "./partyTraversal"; @@ -112,11 +120,6 @@ interface PartyAvatarMetrics { readonly hitCenterY: number; } -interface PartyStuckProgress { - readonly position: MutablePartyWorldPosition; - lastProgressAt: number; -} - interface PartyCombatAnchor { readonly targetId: string; readonly bearing: number; @@ -317,6 +320,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 +351,7 @@ function PartyActors({ const lastLeaderWaypointRef = useRef(null); const lastCommandRef = useRef(usePartyStore.getState().command); const lastRecallRevisionRef = useRef(usePartyStore.getState().recallRevision); - const stuckProgressRef = useRef(new Map()); + const stuckProgressRef = useRef(new Map()); const movementProfilesRef = useRef(new Map()); const directFollowMovingRef = useRef(new Map()); const combatAnchorsRef = useRef(new Map()); @@ -381,6 +388,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]; @@ -425,10 +433,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); @@ -569,7 +576,7 @@ function PartyActors({ // Time spent in a pause/menu must not count toward the stuck watchdog. useEffect(() => { - stuckProgressRef.current.clear(); + for (const state of stuckProgressRef.current.values()) resetPartyStuckRecoveryProgress(state); }, [active]); useEffect(() => clearPartyRuntimeAttackCorridor, []); @@ -597,6 +604,7 @@ function PartyActors({ const playerNavigation = game.playerNavigationPosition; appendPartyBreadcrumb(playerTrailRef.current, playerNavigation); const routeNow = clock.elapsedTime * 1_000; + const combatNow = Date.now(); const playerTrail = playerTrailRef.current.points; const latestPlayerTrailPoint = playerTrail[playerTrail.length - 1]; const previousPlayerTrailPoint = playerTrail[playerTrail.length - 2]; @@ -889,6 +897,13 @@ function PartyActors({ localFinishDistance: leaderLocalFinishDistance, directSegmentAllowed, routeSegmentAllowed: navigationRouteSegmentAllowed, + destinationReached: targetPosition ? (surface) => { + const band = personalizedPartyCombatRangeBand(partyCombatRangeBand(leaderMember), + movementProfileFor(leaderMember.id, leaderIndex)); + const distance = fullDistance(surface, targetPosition); + return distance >= band.minimum && distance <= band.maximum + && hasStaticLineOfSight(surface, targetPosition); + } : undefined, }, ); } @@ -953,7 +968,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]; @@ -1000,6 +1014,8 @@ function PartyActors({ if (signal) signal.current = false; } else if (party.command === "stop") { // Holding is represented by retaining the current world-space tuple. + } else if (member.activeCast || member.controlledUntil > combatNow) { + // Casting and crowd control are intentional immobility, not stuck navigation. } else if (index === leaderIndex) { hasDesired = leaderHasWaypoint; movementExpected = leaderMovementExpected; @@ -1069,6 +1085,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); @@ -1263,30 +1284,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 = 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", + casting: member.activeCast !== null, + controlled: member.controlledUntil > combatNow, + nowMs: routeNow, + }); + if (recovery !== "none") { + // Invalidate only this actor's route. Keep its watchdog running across + // replans so an endlessly blocked connector cannot restart the timer. + combatRoutesRef.current.delete(member.id); + followerCursorsRef.current.delete(member.id); + directFollowMovingRef.current.delete(member.id); + if (index === leaderIndex) { + routeRef.current = emptyPartyRouteState(); + leaderPlayerCursorRef.current.breadcrumbId = null; + lastLeaderWaypointRef.current = null; + } + if (recovery === "recover") { + const landing = choosePartyStuckRecoveryPosition({ + current, + player: playerNavigation, + groupAnchor: index === leaderIndex ? playerNavigation : leaderPosition ?? playerNavigation, + trailPoints: [...leaderTrailRef.current.points, ...playerTrailRef.current.points].map((point) => point.position), + validate: recoveryPointAllowed, + }); + if (landing && applyPartyStuckRecoveryLanding(positionsRef.current, member.id, landing.position, stuckProgress, routeNow)) { + updatePartyRuntimePosition(member.id, current[0], current[1], current[2]); + actor.position.set(current[0], presentationY(current), current[2]); + const movingSignal = movingSignalsRef.current.get(member.id); + if (movingSignal) movingSignal.current = false; + shouldMove = false; + combatAnchorsRef.current.delete(member.id); + updatePartyRuntimeTargetVisibility(member.id, memberTargetId, Boolean(memberTargetPosition + && hasStaticLineOfSight(current, memberTargetPosition))); + if (index === leaderIndex) { + // Do not record a teleport as a straight, walkable breadcrumb segment. + resetPartyBreadcrumbTrail(leaderTrailRef.current, current); + followerCursorsRef.current.clear(); + clearPartyRuntimeAttackCorridor(); + } + } + } } if (Math.hypot(faceX, faceZ) > 0.001) { @@ -1305,12 +1355,6 @@ function PartyActors({ body.rotation.x = 0; } } - if (autoRecallRequested) { - party.recallParty(); - lastRecallRevisionRef.current = usePartyStore.getState().recallRevision; - teleportPartyToPlayer(playerNavigation, routeNow); - return; - } party.setRouteBlocked(frameRouteBlocked); }); diff --git a/src/scene/mobNavigation.ts b/src/scene/mobNavigation.ts new file mode 100644 index 00000000..bb617e19 --- /dev/null +++ b/src/scene/mobNavigation.ts @@ -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; +} diff --git a/src/scene/partyTraversal.test.ts b/src/scene/partyTraversal.test.ts new file mode 100644 index 00000000..f92c1dd6 --- /dev/null +++ b/src/scene/partyTraversal.test.ts @@ -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(); + }); +}); diff --git a/src/scene/partyTraversal.ts b/src/scene/partyTraversal.ts index 25a76d50..dface856 100644 --- a/src/scene/partyTraversal.ts +++ b/src/scene/partyTraversal.ts @@ -2,6 +2,8 @@ import type RAPIER from "@dimforge/rapier3d-compat"; import type { World } from "@dimforge/rapier3d-compat"; import type { PartyWorldPosition } from "../game/partyRuntimeRegistry"; import { ACTOR_TERRAIN_QUERY_GROUPS } from "./actorCollision"; +import { findNearestPartyNavigationSurface, type PartyNavigationGraph } from "../game/partyNavigation"; +import { planPartyRoute } from "../game/partyPathing"; const COLLISION_RADIUS = 0.34; const COLLISION_HALF_HEIGHT = 0.48; @@ -72,3 +74,39 @@ export function createPartyRouteSegmentValidator(rapier: typeof RAPIER, world: W return !hit || hit.timeOfImpact >= distance - 0.04; }; } + +/** Recovery landings must have real walkable ground, free body space, and a route back to the group. */ +export function createPartyRecoveryPointValidator(rapier: typeof RAPIER, world: World, graph: PartyNavigationGraph | null) { + const directAllowed = createPartyDirectSegmentValidator(rapier, world); + const routeAllowed = createPartyRouteSegmentValidator(rapier, world); + const capsule = new rapier.Capsule(COLLISION_HALF_HEIGHT - WALKABLE_STEP_HEIGHT * 0.5, COLLISION_RADIUS); + const flags = rapier.QueryFilterFlags.EXCLUDE_SENSORS; + const project = (point: PartyWorldPosition): PartyWorldPosition | null => { + const surface = graph ? findNearestPartyNavigationSurface(graph, point, 0.6)?.position : point; + if (!surface || Math.abs(surface[1] - point[1]) > 0.55) return null; + if (graph && Math.hypot(surface[0] - point[0], surface[1] - point[1], surface[2] - point[2]) > 0.04 + && !directAllowed(point, surface)) return null; + const ray = new rapier.Ray({ x: surface[0], y: surface[1] + PARTY_FLOOR_RAY_HEIGHT, z: surface[2] }, { x: 0, y: -1, z: 0 }); + const floor = world.castRayAndGetNormal(ray, PARTY_FLOOR_RAY_DEPTH, true, flags, ACTOR_TERRAIN_QUERY_GROUPS); + if (!floor || floor.normal.y < 0.65 || Math.abs(ray.origin.y - floor.timeOfImpact - surface[1]) > MAX_FLOOR_DELTA) return null; + const obstruction = world.intersectionWithShape( + { x: surface[0], y: surface[1] + COLLISION_CENTER_HEIGHT + WALKABLE_STEP_HEIGHT * 0.5, z: surface[2] }, + { x: 0, y: 0, z: 0, w: 1 }, capsule, flags, ACTOR_TERRAIN_QUERY_GROUPS, + ); + return obstruction ? null : surface; + }; + return (point: PartyWorldPosition, anchor: PartyWorldPosition): PartyWorldPosition | null => { + const position = project(point); + const destination = project(anchor); + if (!position || !destination) return null; + if (!graph) return directAllowed(position, destination) ? position : null; + const route = planPartyRoute(graph, "stuck-recovery-validation", position, destination, 0, { + maxOriginSnapDistance: 0.1, + maxDestinationSnapDistance: 0.1, + localFinishDistance: 0.2, + directSegmentAllowed: directAllowed, + routeSegmentAllowed: graph.triangles?.length ? routeAllowed : undefined, + }); + return route.blocked ? null : position; + }; +}