Release Healer Man 0.1.36

This commit is contained in:
phenom committed 2026-09-12 19:16:01 -04:00
1 parent 40d9f346d8
commit 03a4e3f51c
22 files changed
+1389 -153

No files matched your search

+91
View File
@@ -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.
+2 -2
View File
@@ -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",
+1 -1
View File
@@ -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",
+2 -2
View File
@@ -1,4 +1,4 @@
{
"versionName": "0.1.35",
"versionCode": 1035
"versionName": "0.1.36",
"versionCode": 1036
}
+8
View File
@@ -1,3 +1,5 @@
import type { PartyNavigationGraph } from "./partyNavigation";
export type CombatSpatialPosition = readonly [x: number, y: number, z: number];
export interface CombatSpatialActorSnapshot {
@@ -8,6 +10,8 @@ export interface CombatSpatialActorSnapshot {
}
export interface CombatSpatialProvider {
/** The already-loaded, reviewed dungeon surface shared by actor navigation. */
readonly navigationGraph?: () => PartyNavigationGraph | null;
readonly actor: (actorId: string) => CombatSpatialActorSnapshot | null;
readonly hasLineOfSight: (
source: CombatSpatialPosition,
@@ -45,6 +49,10 @@ export function combatHasLineOfSight(
return provider?.hasLineOfSight(source, target) ?? true;
}
export function combatNavigationGraph(): PartyNavigationGraph | null {
return provider?.navigationGraph?.() ?? null;
}
export function projectCombatGroundPoint(
requested: CombatSpatialPosition,
maximumDistance: number,
+81 -13
View File
@@ -372,6 +372,7 @@ export interface MobCombatState {
readonly lastDamageAt: number;
readonly engaged: boolean;
readonly combatPhase: "idle" | "chasing" | "attacking" | "returning";
readonly navigationEvadeUntil?: number;
readonly homePosition: CombatPosition | null;
readonly nextAttackAt: number;
readonly abilityReadyAt: Readonly<Record<string, number>>;
@@ -723,6 +724,7 @@ export interface CombatState {
cancelCast: (message?: string) => boolean;
engageNearbyMobs: (now?: number, playerPosition?: CombatPosition) => number;
advanceMobCombat: (now?: number, playerPosition?: CombatPosition) => number;
resetMobForNavigation: (id: string, atHome: boolean, now?: number) => boolean;
tick: (deltaSeconds: number, now?: number) => void;
resetEncounter: (options?: {
readonly preserveDefeated?: boolean;
@@ -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<CombatState>((set, get) => ({
tauntMob: (id, source, durationMs, now = combatNow()) => {
const target = get().mobs[id];
if (!target || target.dead || durationMs <= 0) return false;
if (!target || target.dead || durationMs <= 0 || (target.navigationEvadeUntil ?? 0) > now) return false;
const previous = target.tauntDiminishingReturns;
const applications = target.boss && previous && previous.resetAt > now ? previous.applications : 0;
const multiplier = target.boss ? [1, 0.65, 0.4225, 0][Math.min(3, applications)] : 1;
@@ -5676,6 +5681,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
));
for (const [mobId, mob] of Object.entries(mobs)) {
if (mob.dead || mobGroupId(mobId) !== groupId) continue;
if ((mob.navigationEvadeUntil ?? 0) > now) continue;
const seeded = groupWasEngaged
? mob.threatByActor
: seedActorThreat(mob.threatByActor, source.actorId);
@@ -6441,7 +6447,8 @@ export const useCombatStore = create<CombatState>((set, get) => ({
let engagedGroups = 0;
for (const id of Object.keys(mobs)) {
const mob = mobs[id];
if (mob.dead || mob.despawned || mob.engaged || mob.aggroRange <= 0) continue;
if (mob.dead || mob.despawned || mob.engaged || mob.aggroRange <= 0
|| mob.combatPhase === "returning" || (mob.navigationEvadeUntil ?? 0) > now) continue;
const position = getMobPosition(id);
if (!position) continue;
const actorId = nearestAggroActorId(runtimeState, position, mob.aggroRange);
@@ -6452,6 +6459,38 @@ export const useCombatStore = create<CombatState>((set, get) => ({
return engagedGroups;
},
resetMobForNavigation: (id, atHome, now = combatNow()) => {
const state = get();
const mob = state.mobs[id];
if (!mob || mob.dead || mob.despawned || !Number.isFinite(now)) return false;
set({
mobs: { ...state.mobs, [id]: {
...mob,
engaged: false,
combatPhase: atHome ? "idle" : "returning",
navigationEvadeUntil: Math.max(mob.navigationEvadeUntil ?? 0, now + 10_000),
health: mob.maxHealth,
statuses: [],
controlDiminishingReturns: {},
tauntDiminishingReturns: { applications: 0, resetAt: 0 },
auras: [],
abilityReadyAt: {},
abilityCastCounts: {},
activeCast: null,
schoolLockedUntil: {},
nextAttackAt: 0,
nextAttackIndex: 0,
threatByActor: {},
targetActorId: null,
forcedTarget: null,
} },
effects: state.effects.filter((effect) => effect.targetId !== id && effect.sourceActorId !== id),
auras: state.auras.filter((aura) => aura.targetId !== id && aura.sourceId !== id),
areaEffects: state.areaEffects.filter((area) => area.sourceActorId !== id),
});
return true;
},
advanceMobCombat: (now = combatNow(), playerPosition) => {
const state = get();
const runtimeState = playerPosition
@@ -6527,6 +6566,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
const dy = position[1] - targetPosition[1];
const dz = position[2] - targetPosition[2];
const distanceSquared = dx * dx + dy * dy + dz * dz;
const targetVisible = combatHasLineOfSight(position, targetPosition);
const abilityReadyAt = Object.keys(mob.abilityReadyAt).length
? { ...mob.abilityReadyAt }
: { ...initialEnemyAbilityReadyAt(mob, now) };
@@ -6537,6 +6577,20 @@ export const useCombatStore = create<CombatState>((set, get) => ({
let enemyActiveCast = cannotAttack || (silenced && mob.activeCast?.school !== "physical")
? null
: mob.activeCast;
if (enemyActiveCast) {
const pendingAttack = mob.attacks.find((attack) => attack.id === enemyActiveCast!.attackId);
const pendingPosition = enemyActiveCast.targetActorId
? actorPosition(runtimeState, enemyActiveCast.targetActorId) : null;
if (pendingAttack && enemyAbilityNeedsHostileRange(pendingAttack)
&& (!pendingPosition || !combatHasLineOfSight(position, pendingPosition)
|| Math.hypot(position[0] - pendingPosition[0], position[1] - pendingPosition[1],
position[2] - pendingPosition[2]) > pendingAttack.range)) {
emitEnemyAttackPresentation(mob, pendingAttack, "cast-cancel", now, position,
enemyActiveCast.targetActorId, pendingPosition);
abilityReadyAt[pendingAttack.id] = now + 250;
enemyActiveCast = null;
}
}
let completingEnemyCast = false;
let selectedAttackIndex = -1;
let selectedAttack: EnemyAttackDefinition | null = null;
@@ -6551,7 +6605,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
const attack = mob.attacks[index];
const attackKind = enemyAttackKind(attack);
const inAbilityRange = !enemyAbilityNeedsHostileRange(attack)
|| distanceSquared <= attack.range * attack.range;
|| (targetVisible && distanceSquared <= attack.range * attack.range);
if (
inAbilityRange
&& !(silenced && attackKind === "spell")
@@ -6567,10 +6621,24 @@ export const useCombatStore = create<CombatState>((set, get) => ({
}
}
}
const inRange = mob.attacks.some((attack) => (
!enemyAbilityNeedsHostileRange(attack)
|| distanceSquared <= attack.range * attack.range
));
// A self buff/heal must not pin an otherwise blocked attacker in place.
const hostileAttacks = mob.attacks.filter(enemyAbilityNeedsHostileRange);
const inRange = targetVisible && (hostileAttacks.length
? hostileAttacks.some((attack) => distanceSquared <= attack.range * attack.range)
: distanceSquared <= mob.attackRange * mob.attackRange);
if (selectedAttack && completingEnemyCast && enemyAbilityNeedsHostileRange(selectedAttack)) {
const castTargetPosition = enemyActiveCast?.targetActorId
? actorPosition(runtimeState, enemyActiveCast.targetActorId) : null;
if (!castTargetPosition || !combatHasLineOfSight(position, castTargetPosition)
|| Math.hypot(position[0] - castTargetPosition[0], position[1] - castTargetPosition[1],
position[2] - castTargetPosition[2]) > selectedAttack.range) {
emitEnemyAttackPresentation(mob, selectedAttack, "cast-cancel", now, position,
enemyActiveCast?.targetActorId ?? resolution.targetActorId, castTargetPosition);
abilityReadyAt[selectedAttack.id] = now + 250;
enemyActiveCast = null;
selectedAttack = null;
}
}
const phase: MobCombatState["combatPhase"] = inRange ? "attacking" : rooted ? "idle" : "chasing";
let nextAttackAt = Math.min(...Object.values(abilityReadyAt), Number.POSITIVE_INFINITY);
let attackRevision = mob.attackRevision;
+139
View File
@@ -0,0 +1,139 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { useCombatStore } from "./combatStore";
import { registerCombatSpatialProvider, resetCombatSpatialProvider } from "./combatSpatial";
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
import { clearPartyRuntimeRegistry } from "./partyRuntimeRegistry";
import { usePartyStore } from "./partyStore";
import { advanceDungeonPartyCombat, resetPartyRuntimeClock } from "./partyRuntime";
import { registerPartyRuntimePosition, updatePartyRuntimeTargetVisibility } from "./partyRuntimeRegistry";
import { DEFAULT_ENEMY_ATTACK, type EnemyAttackDefinition } from "./enemyAttacks";
import { createMobPursuit } from "./mobPursuit";
import { subscribeCombatPresentation, type CombatPresentationEvent } from "./combatPresentation";
let visible = true;
const melee = { ...DEFAULT_ENEMY_ATTACK, id: "swing", range: 4, initialCooldownMs: [0, 0] as const };
const spell: EnemyAttackDefinition = { ...melee, id: "bolt", range: 30, school: "shadow", animation: "cast", castTimeMs: 1_000 };
function register(id = "enemy", attacks: readonly EnemyAttackDefinition[] = [melee]) {
setMobPosition(id, [2, 0, 0]);
useCombatStore.getState().registerMob(id, { name: id, attacks, maxHealth: 1_000, xpReward: 0 });
useCombatStore.getState().damageMob(id, 1, 1_000);
}
describe("dungeon wall combat authorization", () => {
beforeEach(() => {
resetCombatSpatialProvider(); clearMobRuntimeRegistry(); clearPartyRuntimeRegistry(); resetPartyRuntimeClock();
useCombatStore.getState().initializeCharacter({ classId: "priest", level: 20 });
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
usePartyStore.getState().initializeParty("walls", 20, useCombatStore.getState().health);
visible = true;
registerCombatSpatialProvider({ actor: () => null, hasLineOfSight: () => visible, projectGroundPoint: (point) => point });
});
afterEach(resetCombatSpatialProvider);
it("does not swing or consume an attack cooldown through a wall despite melee distance", () => {
register(); visible = false;
const health = useCombatStore.getState().health;
const events: CombatPresentationEvent[] = [];
const unsubscribe = subscribeCombatPresentation((message) => { if (message.kind === "event") events.push(message.event); });
expect(useCombatStore.getState().advanceMobCombat(2_000)).toBe(0);
expect(useCombatStore.getState().mobs.enemy).toMatchObject({ combatPhase: "chasing", attackRevision: 0 });
expect(useCombatStore.getState().health).toBe(health);
expect(events.filter((event) => event.sourceActorId === "enemy")).toHaveLength(0);
visible = true;
expect(useCombatStore.getState().advanceMobCombat(2_100)).toBeGreaterThan(0);
expect(useCombatStore.getState().mobs.enemy.combatPhase).toBe("attacking");
unsubscribe();
});
it("does not start an obstructed cast and cancels a pending cast when sight is lost", () => {
register("enemy", [spell]); visible = false;
useCombatStore.getState().advanceMobCombat(1_000);
expect(useCombatStore.getState().mobs.enemy.activeCast).toBeNull();
visible = true;
useCombatStore.getState().advanceMobCombat(1_100);
expect(useCombatStore.getState().mobs.enemy.activeCast).not.toBeNull();
const health = useCombatStore.getState().health;
visible = false;
expect(useCombatStore.getState().advanceMobCombat(1_200)).toBe(0);
expect(useCombatStore.getState().mobs.enemy).toMatchObject({ activeCast: null, combatPhase: "chasing", attackRevision: 0 });
expect(useCombatStore.getState().health).toBe(health);
});
it("does not let a self buff keep a wall-blocked melee mob in attack mode", () => {
register("enemy", [melee, { ...melee, id: "buff", target: "self", effect: { kind: "aura", aura: "enrage", durationMs: 10_000 } }]);
visible = false;
useCombatStore.getState().advanceMobCombat(2_000);
expect(useCombatStore.getState().mobs.enemy.combatPhase).toBe("chasing");
});
it("does not proximity-pull a visible-distance mob through a wall", () => {
setMobPosition("idle", [2, 0, 0]);
useCombatStore.getState().registerMob("idle", { name: "idle", attacks: [melee] });
visible = false;
expect(useCombatStore.getState().engageNearbyMobs(1_000)).toBe(0);
visible = true;
expect(useCombatStore.getState().engageNearbyMobs(1_001)).toBe(1);
});
it("resets only the stuck living mob, clears its threat/cast, restores health, and delays repulls", () => {
register("enemy", [spell]); register("other");
useCombatStore.getState().advanceMobCombat(1_000);
const other = useCombatStore.getState().mobs.other;
expect(useCombatStore.getState().resetMobForNavigation("enemy", true, 2_000)).toBe(true);
expect(useCombatStore.getState().mobs.enemy).toMatchObject({ health: 1_000, engaged: false,
combatPhase: "idle", activeCast: null, targetActorId: null, threatByActor: {}, navigationEvadeUntil: 12_000,
dead: false, experienceGranted: false });
expect(useCombatStore.getState().mobs.other).toBe(other);
expect(useCombatStore.getState().engageNearbyMobs(11_999)).toBe(0);
expect(useCombatStore.getState().engageNearbyMobs(12_000)).toBe(1);
});
it("does not recover or resurrect a dead mob", () => {
register();
useCombatStore.getState().damageMob("enemy", 10_000, 2_000);
expect(useCombatStore.getState().resetMobForNavigation("enemy", true, 3_000)).toBe(false);
expect(useCombatStore.getState().mobs.enemy.dead).toBe(true);
});
it("rejects damage against a navigation-reset mob during its evade cooldown", () => {
register();
useCombatStore.getState().resetMobForNavigation("enemy", true, 2_000);
expect(useCombatStore.getState().damageMob("enemy", 500, 3_000).amount).toBe(0);
expect(useCombatStore.getState().tauntMob("enemy", { actorId: "__player__", role: "healer" }, 3_000, 3_000)).toBe(false);
expect(useCombatStore.getState().mobs.enemy.health).toBe(1_000);
});
it("cancels an NPC teammate's hostile cast immediately on lost sight so it can pursue", () => {
register();
const member = usePartyStore.getState().members[0];
registerPartyRuntimePosition(member.id, [0, 0, 0]);
updatePartyRuntimeTargetVisibility(member.id, "enemy", true);
usePartyStore.setState((state) => ({ members: state.members.map((candidate) => candidate.id === member.id
? { ...candidate, activeCastTargetId: "enemy", activeCast: {
abilityId: "mage-frostbolt", school: "frost", mode: "cast", startedAt: 1_000,
completesAt: 5_000, originalDurationMs: 4_000, pushbackHits: 0,
origin: [0, 0, 0] as const, targetId: "enemy",
} } : candidate) }));
visible = false;
advanceDungeonPartyCombat(1_100);
expect(usePartyStore.getState().members[0]).toMatchObject({ activeCast: null, status: "advancing" });
});
it("nearby teleport recovery leaves mob health, threat, target, and other mobs unchanged", () => {
register(); register("other");
const before = useCombatStore.getState().mobs;
const navigator = createMobPursuit({ graph: () => null, hasLineOfSight: (from) => from[0] > 2,
directAllowed: () => false, routeAllowed: () => true, validateLanding: (point) => point });
const position: [number, number, number] = [0, 0, 0];
const sample = { id: "enemy", targetId: "tank", position, target: [4, 0, 0] as const,
home: [0, 0, 0] as const, phase: "chasing" as const, health: before.enemy.health,
engaged: true, active: true, casting: false, controlled: false, attackRange: 1.5,
leashRange: 40, maximumStep: 0, nowMs: 0 };
navigator.step(sample);
navigator.step({ ...sample, nowMs: 2_000 });
expect(navigator.step({ ...sample, nowMs: 5_000 })).toBe("teleported");
expect(position[0]).toBeGreaterThan(2);
expect(useCombatStore.getState().mobs.enemy).toBe(before.enemy);
expect(useCombatStore.getState().mobs.other).toBe(before.other);
});
});
+133
View File
@@ -0,0 +1,133 @@
import { describe, expect, it } from "vitest";
import { createMobPursuit, chooseMobPursuitLanding, type MobPursuitSample, type MobPursuitSpatial } from "./mobPursuit";
import type { PartyNavigationGraph } from "./partyNavigation";
function sample(patch: Partial<MobPursuitSample> = {}): MobPursuitSample {
return { id: "mob", targetId: "tank", position: [0, 0, 0], target: [4, 0, 0], home: [0, 0, 0],
phase: "chasing", health: 100, engaged: true, active: true, casting: false, controlled: false,
attackRange: 1.5, leashRange: 40, maximumStep: 0, nowMs: 0, ...patch };
}
function spatial(patch: Partial<MobPursuitSpatial> = {}): MobPursuitSpatial {
return { graph: () => null, hasLineOfSight: () => false, directAllowed: () => false,
routeAllowed: () => true, validateLanding: (point) => point, ...patch };
}
describe("wall-aware mob pursuit", () => {
it("moves toward a blocked target even when the old combat snapshot says attacking", () => {
const navigator = createMobPursuit(spatial({ directAllowed: () => true }));
const state = sample({ phase: "attacking", attackRange: 6, maximumStep: 0.2 });
expect(navigator.step(state)).toBe("none");
expect(state.position[0]).toBeCloseTo(0.2);
});
it("holds only when range and clear sight agree", () => {
const navigator = createMobPursuit(spatial({ hasLineOfSight: () => true, directAllowed: () => true }));
const state = sample({ attackRange: 6, maximumStep: 0.2 });
navigator.step(state);
expect(state.position).toEqual([0, 0, 0]);
expect(navigator.step({ ...state, nowMs: 10_000 })).toBe("none");
});
it("routes around a wall through a doorway rather than cutting through it", () => {
const positions = [[-2, 0, 0], [-2, 0, 3], [2, 0, 3], [2, 0, 0]] as const;
const graph: PartyNavigationGraph = {
nodes: positions.map((position, id) => ({ id, position, neighbors: [id - 1, id + 1]
.filter((to) => to >= 0 && to < positions.length).map((to) => ({ to, cost: 4 })) })),
weldTolerance: 0.001, sourceVertexCount: 4, sourceTriangleCount: 0,
weldedNodeCount: 4, componentCount: 1, discardedNodeCount: 0,
};
const clear = (a: readonly number[], b: readonly number[]) => {
if (a[0] * b[0] >= 0) return true;
const t = -a[0] / (b[0] - a[0]);
return a[2] + (b[2] - a[2]) * t >= 2.5;
};
const navigator = createMobPursuit(spatial({ graph: () => graph, directAllowed: clear, hasLineOfSight: clear }));
const state = sample({ position: [-2, 0, 0], home: [-2, 0, 0], target: [2, 0, 0], maximumStep: 0.1, attackRange: 0.5 });
let reachedDoorway = false;
for (let frame = 0; frame < 120; frame += 1) {
const previous = [...state.position];
expect(navigator.step({ ...state, nowMs: frame * 50 })).toBe("none");
expect(clear(previous, state.position)).toBe(true);
if (state.position[2] >= 2.5) reachedDoorway = true;
}
expect(reachedDoorway).toBe(true);
expect(Math.hypot(state.position[0] - 2, state.position[2])).toBeLessThanOrEqual(0.6);
});
it("recovers a nearby stalled mob only after five seconds and preserves a ten-second cooldown", () => {
const navigator = createMobPursuit(spatial({ hasLineOfSight: (a) => a[0] > 2 }));
const state = sample();
navigator.step(state);
expect(navigator.step({ ...state, nowMs: 2_000 })).toBe("none");
expect(navigator.step({ ...state, nowMs: 4_999 })).toBe("none");
expect(navigator.step({ ...state, nowMs: 5_000 })).toBe("teleported");
expect(state.position[0]).toBeGreaterThan(2);
state.position[0] = 0;
navigator.step({ ...state, nowMs: 5_001 });
navigator.step({ ...state, nowMs: 7_001 });
expect(navigator.step({ ...state, nowMs: 14_999 })).toBe("none");
expect(navigator.step({ ...state, nowMs: 15_000 })).toBe("teleported");
});
it.each([
{ active: false }, { casting: true }, { controlled: true }, { health: 0 }, { engaged: false },
])("does not recover intentional immobility %j", (exclusion) => {
const navigator = createMobPursuit(spatial());
const state = sample(exclusion);
navigator.step(state);
expect(navigator.step({ ...state, nowMs: 10_000 })).toBe("none");
expect(state.position).toEqual([0, 0, 0]);
});
it.each([
{ target: [12, 0, 0] as const }, { target: [4, 5, 0] as const }, { leashRange: 2 },
])("resets instead of teleporting to a distant, wrong-floor, or out-of-leash target %j", (patch) => {
const navigator = createMobPursuit(spatial());
const state = sample(patch);
navigator.step(state);
navigator.step({ ...state, nowMs: 2_000 });
expect(navigator.step({ ...state, nowMs: 5_000 })).toBe("reset");
expect(state.position).toEqual(state.home);
});
it("resets when every nearby target landing is blocked without applying an invalid home landing", () => {
const navigator = createMobPursuit(spatial({ validateLanding: () => null }));
const state = sample({ position: [1, 0, 0] });
navigator.step(state);
navigator.step({ ...state, nowMs: 2_000 });
expect(navigator.step({ ...state, nowMs: 5_000 })).toBe("reset");
expect(state.position).toEqual([1, 0, 0]);
});
it("recovers a stalled return to validated home, never to the old target", () => {
const navigator = createMobPursuit(spatial());
const state = sample({ engaged: false, phase: "returning", position: [5, 0, 0] });
navigator.step(state);
navigator.step({ ...state, nowMs: 2_000 });
expect(navigator.step({ ...state, nowMs: 5_000 })).toBe("home");
expect(state.position).toEqual(state.home);
});
it("bounds landing checks, rejects unsafe projections, and never blindly uses the target", () => {
let checks = 0;
const result = chooseMobPursuitLanding(sample(), spatial({
hasLineOfSight: () => true,
validateLanding: () => { checks += 1; return [20, 0, 0]; },
}));
expect(result).toBeNull();
expect(checks).toBe(16);
});
it("keeps different mobs' timers independent", () => {
const navigator = createMobPursuit(spatial({ hasLineOfSight: (a) => a[0] > 2 }));
const first = sample();
const second = sample({ id: "other", position: [0, 0, 0] });
navigator.step(first);
navigator.step({ ...second, nowMs: 4_000 });
navigator.step({ ...first, nowMs: 2_000 });
expect(navigator.step({ ...first, nowMs: 5_000 })).toBe("teleported");
expect(navigator.step({ ...second, nowMs: 5_000 })).toBe("none");
expect(second.position).toEqual([0, 0, 0]);
});
});
+142
View File
@@ -0,0 +1,142 @@
import type { PartyNavigationGraph } from "./partyNavigation";
import { distanceSquared, moveMobToward, type MobAiPhase, type MobAiPosition, type MutableMobAiPosition } from "./mobAi";
import { emptyPartyRouteState, nextPartyRouteRecoveryWaypoint, nextPartyRouteWaypoint, partyRouteNeedsPlanning, planPartyRoute } from "./partyPathing";
import { createPartyStuckRecoveryState, markPartyStuckRecovered, updatePartyStuckRecovery, type PartyStuckRecoveryState } from "./partyStuckRecovery";
export const MOB_NEAR_RECOVERY_DISTANCE = 10;
const SAME_FLOOR_TOLERANCE = 1.25;
export interface MobPursuitSample {
readonly id: string;
readonly targetId: string | null;
readonly position: MutableMobAiPosition;
readonly target: MobAiPosition;
readonly home: MobAiPosition;
readonly phase: MobAiPhase;
readonly health: number;
readonly engaged: boolean;
readonly active: boolean;
readonly casting: boolean;
readonly controlled: boolean;
readonly attackRange: number;
readonly leashRange: number;
readonly maximumStep: number;
readonly nowMs: number;
}
export interface MobPursuitSpatial {
readonly graph: () => PartyNavigationGraph | null;
readonly hasLineOfSight: (from: MobAiPosition, to: MobAiPosition) => boolean;
readonly directAllowed: (from: MobAiPosition, to: MobAiPosition) => boolean;
readonly routeAllowed: (from: MobAiPosition, to: MobAiPosition) => boolean;
readonly validateLanding: (point: MobAiPosition, anchor: MobAiPosition) => MobAiPosition | null;
}
/** Never blindly land inside the target, a wall, or an overlapping dungeon floor. */
export function chooseMobPursuitLanding(sample: MobPursuitSample, spatial: MobPursuitSpatial): MobAiPosition | null {
const { position, target, home } = sample;
if (distanceSquared(position, target) > MOB_NEAR_RECOVERY_DISTANCE ** 2
|| Math.abs(position[1] - target[1]) > SAME_FLOOR_TOLERANCE
|| distanceSquared(home, target) > sample.leashRange ** 2) return null;
const bearing = Math.atan2(position[0] - target[0], position[2] - target[2]);
const radius = Math.max(0.85, Math.min(2, sample.attackRange * 0.6));
for (let offset = 0; offset < 16; offset += 1) {
const angle = bearing + offset * Math.PI / 8;
const candidate: MobAiPosition = [target[0] + Math.sin(angle) * radius, target[1], target[2] + Math.cos(angle) * radius];
const landing = spatial.validateLanding(candidate, target);
if (landing?.every(Number.isFinite) && distanceSquared(landing, candidate) <= 0.85 ** 2
&& distanceSquared(landing, target) <= sample.attackRange ** 2
&& distanceSquared(landing, home) <= sample.leashRange ** 2
&& Math.abs(landing[1] - target[1]) <= SAME_FLOOR_TOLERANCE
&& spatial.hasLineOfSight(landing, target)) return [...landing];
}
return null;
}
/** Shared imperative adapter for static spawns, roaming packs, and summoned enemies. */
export function createMobPursuit(spatial: MobPursuitSpatial) {
const states = new Map<string, {
route: ReturnType<typeof emptyPartyRouteState>;
watchdog: PartyStuckRecoveryState;
graph: PartyNavigationGraph | null;
}>();
const desired: MutableMobAiPosition = [0, 0, 0];
const next: MutableMobAiPosition = [0, 0, 0];
return {
clear: () => states.clear(),
step(sample: MobPursuitSample): "none" | "teleported" | "reset" | "home" {
const { position, target, home, nowMs } = sample;
if (![...position, ...target, ...home, nowMs, sample.attackRange, sample.leashRange, sample.maximumStep].every(Number.isFinite)) return "none";
let state = states.get(sample.id);
if (!state) {
state = { route: emptyPartyRouteState(), watchdog: createPartyStuckRecoveryState(position, nowMs), graph: spatial.graph() };
states.set(sample.id, state);
}
const returning = sample.phase === "returning";
const goal = returning ? home : target;
const visible = spatial.hasLineOfSight(position, target);
const inRange = visible && distanceSquared(position, target) <= sample.attackRange ** 2;
const movementExpected = returning ? distanceSquared(position, home) > 0.2 ** 2 : sample.engaged && !inRange;
const excluded = !sample.active || sample.health <= 0 || sample.controlled || sample.casting;
const goalKey = returning ? "return-home" : `target:${sample.targetId}`;
const graph = spatial.graph();
if (state.graph !== graph) {
state.graph = graph;
state.route = emptyPartyRouteState();
}
let waypoint: MobAiPosition | null = null;
let approvedRouteStep = false;
if (movementExpected && !excluded) {
if (spatial.directAllowed(position, goal)) waypoint = goal;
else {
if (partyRouteNeedsPlanning(state.route, goalKey, position, goal, nowMs, { replanIntervalMs: 350, blockedRetryMs: 800 })) {
state.route = planPartyRoute(graph, goalKey, position, goal, nowMs, {
maxOriginSnapDistance: 3, maxDestinationSnapDistance: 3,
directSegmentAllowed: spatial.directAllowed,
routeSegmentAllowed: graph?.triangles?.length ? spatial.routeAllowed : undefined,
destinationReached: (surface, destination) => distanceSquared(surface, destination)
<= (returning ? 0.2 : sample.attackRange) ** 2
&& (returning || spatial.hasLineOfSight(surface, destination)),
});
}
if (nextPartyRouteWaypoint(state.route, position, desired)) {
waypoint = desired;
approvedRouteStep = true;
} else if (nextPartyRouteRecoveryWaypoint(state.route, position, desired, spatial.directAllowed)) waypoint = desired;
}
if (waypoint) {
moveMobToward(position, waypoint, sample.maximumStep, next);
// Recast edges are already agent-radius eroded. A second full-radius
// sweep at boundary vertices can reject a valid stair corridor;
// use the same center/wall check as companions on approved edges.
// Off-surface connectors and direct shortcuts still need body sweeps.
if (spatial.directAllowed(position, next) || (approvedRouteStep && spatial.routeAllowed(position, next))) {
position[0] = next[0]; position[1] = next[1]; position[2] = next[2];
}
}
}
const recovery = updatePartyStuckRecovery(state.watchdog, {
position, waypoint, goalKey, movementExpected, nowMs, health: sample.health,
paused: !sample.active, stopped: false, casting: sample.casting, controlled: sample.controlled,
});
if (returning && !excluded && !movementExpected) return "home";
if (recovery !== "none") state.route = emptyPartyRouteState();
if (recovery !== "recover") return "none";
const landing = returning ? spatial.validateLanding(home, home) : chooseMobPursuitLanding(sample, spatial);
if (landing && (!returning || distanceSquared(landing, home) <= 0.85 ** 2)) {
position[0] = landing[0]; position[1] = landing[1]; position[2] = landing[2];
markPartyStuckRecovered(state.watchdog, position, nowMs);
return returning ? "home" : "teleported";
}
if (!returning) {
const homeLanding = spatial.validateLanding(home, home);
if (homeLanding?.every(Number.isFinite) && distanceSquared(homeLanding, home) <= 0.85 ** 2) {
position[0] = homeLanding[0]; position[1] = homeLanding[1]; position[2] = homeLanding[2];
}
markPartyStuckRecovered(state.watchdog, position, nowMs);
return "reset";
}
return "none";
},
};
}
-14
View File
@@ -7,14 +7,11 @@ import {
nextPartyBreadcrumbWaypoint,
partyCombatMovementGoal,
partyFormationPosition,
partyMadeStuckProgress,
partyMovementProfile,
partyNaturalFollowPosition,
partyPersonalizedCombatMovementGoal,
partyPresentationY,
partyShouldRecallForStuck,
partyShouldMoveToComfortTarget,
PARTY_STUCK_RECALL_DISTANCE,
personalizedPartyCombatRangeBand,
resetPartyBreadcrumbTrail,
stepPartyPosition,
@@ -125,17 +122,6 @@ describe("party world movement", () => {
)).toBe("hold");
});
it("recalls only distant allies that should be moving and stopped making progress", () => {
expect(partyMadeStuckProgress([0, 0, 0], [0.45, 4, 0])).toBe(true);
expect(partyMadeStuckProgress([0, 0, 0], [0.44, 0, 0])).toBe(false);
expect(PARTY_STUCK_RECALL_DISTANCE).toBe(80);
expect(partyShouldRecallForStuck([80, 0, 0], [0, 0, 0], true, 4_500, 1_000)).toBe(true);
expect(partyShouldRecallForStuck([79.9, 0, 0], [0, 0, 0], true, 4_500, 1_000)).toBe(false);
expect(partyShouldRecallForStuck([80, 0, 0], [0, 0, 0], false, 4_500, 1_000)).toBe(false);
expect(partyShouldRecallForStuck([80, 0, 0], [0, 0, 0], true, 4_499, 1_000)).toBe(false);
});
it("makes melee approach a fleeing target and hold once in range", () => {
const out: [number, number, number] = [0, 0, 0];
expect(partyCombatMovementGoal(
-39
View File
@@ -86,45 +86,6 @@ export function partyMovementProfile(memberId: string, livingRank: number): Part
};
}
// Allow companions to operate across a full boss arena before the stuck
// watchdog treats their separation from the player as a reason to recall.
export const PARTY_STUCK_RECALL_DISTANCE = 80;
export const PARTY_STUCK_RECALL_DELAY_MS = 3_500;
export const PARTY_STUCK_PROGRESS_DISTANCE = 0.45;
/** Tests whether an expected move has made enough planar progress to reset the stuck timer. */
export function partyMadeStuckProgress(
previous: PartyWorldPosition,
current: PartyWorldPosition,
minimumDistance = PARTY_STUCK_PROGRESS_DISTANCE,
): boolean {
const minimum = Math.max(0, Number.isFinite(minimumDistance) ? minimumDistance : 0);
const distance = Math.hypot(current[0] - previous[0], current[2] - previous[2]);
return Number.isFinite(distance) && distance >= minimum;
}
/**
* A distant ally is stuck only while it should be moving and its progress
* timer has expired. Intentional ranged/melee holds never enter this state.
*/
export function partyShouldRecallForStuck(
current: PartyWorldPosition,
player: PartyWorldPosition,
movementExpected: boolean,
nowMs: number,
lastProgressAtMs: number,
recallDistance = PARTY_STUCK_RECALL_DISTANCE,
stuckDelayMs = PARTY_STUCK_RECALL_DELAY_MS,
): boolean {
if (!movementExpected || !Number.isFinite(nowMs) || !Number.isFinite(lastProgressAtMs)) return false;
const distance = Math.hypot(current[0] - player[0], current[2] - player[2]);
const minimumDistance = Math.max(0, Number.isFinite(recallDistance) ? recallDistance : 0);
const delay = Math.max(0, Number.isFinite(stuckDelayMs) ? stuckDelayMs : 0);
return Number.isFinite(distance)
&& distance >= minimumDistance
&& nowMs - lastProgressAtMs >= delay;
}
export function createPartyBreadcrumbTrail(): PartyBreadcrumbTrail {
return { points: [], nextId: 0 };
}
+13
View File
@@ -92,6 +92,19 @@ describe("party path controller", () => {
expect(route.blocked).toBe(true);
});
it("accepts an in-range surface finish only when combat sight approves it", () => {
const options = { maxSnapDistance: 1, localFinishDistance: 1, directSegmentAllowed: () => false };
const visible = planPartyRoute(corridorGraph, "mob", [0, 0, 0], [2.5, 0, 2], 100, {
...options, destinationReached: (surface, target) => Math.hypot(surface[0] - target[0], surface[2] - target[2]) <= 1,
});
expect(visible.blocked).toBe(false);
expect(visible.waypoints.at(-1)).toEqual([2, 0, 2]);
const obstructed = planPartyRoute(corridorGraph, "mob", [0, 0, 0], [2.5, 0, 2], 100, {
...options, destinationReached: () => false,
});
expect(obstructed.blocked).toBe(true);
});
it("advances only one close-spaced waypoint at a corner", () => {
const route = {
...emptyPartyRouteState(),
+3 -1
View File
@@ -34,6 +34,8 @@ export interface PartyRouteOptions {
start: PartyWorldPosition,
end: PartyWorldPosition,
) => boolean;
/** Combat may finish on the retained surface only when range AND sight allow a hit. */
readonly destinationReached?: (surface: PartyWorldPosition, destination: PartyWorldPosition) => boolean;
}
const DEFAULT_MAX_SNAP_DISTANCE = 3;
@@ -117,7 +119,7 @@ export function planPartyRoute(
) return blocked(waypoints);
const finishDistance = fullDistance(waypoints[waypoints.length - 1], destination);
if (finishDistance > localFinishDistance) return blocked(waypoints);
if (finishDistance > reachedDistance * 0.5) {
if (finishDistance > reachedDistance * 0.5 && !options.destinationReached?.(waypoints[waypoints.length - 1], destination)) {
if (!options.directSegmentAllowed?.(waypoints[waypoints.length - 1], destination)) {
return blocked(waypoints);
}
+11 -1
View File
@@ -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;
+181
View File
@@ -0,0 +1,181 @@
import { describe, expect, it } from "vitest";
import {
applyPartyStuckRecoveryLanding,
choosePartyStuckRecoveryPosition,
createPartyStuckRecoveryState,
markPartyStuckRecovered,
resetPartyStuckRecoveryProgress,
updatePartyStuckRecovery,
type PartyStuckRecoverySample,
} from "./partyStuckRecovery";
import type { MutablePartyWorldPosition, PartyWorldPosition } from "./partyRuntimeRegistry";
const origin: PartyWorldPosition = [0, 0, 0];
const sample = (nowMs: number, overrides: Partial<PartyStuckRecoverySample> = {}): PartyStuckRecoverySample => ({
position: origin, waypoint: [10, 0, 0], goalKey: "follow:leader", movementExpected: true,
health: 100, paused: false, stopped: false, casting: false, controlled: false, nowMs, ...overrides,
});
describe("per-teammate stuck navigation", () => {
it("replans at two seconds and recovers at five, without a player-distance gate", () => {
const state = createPartyStuckRecoveryState(origin, 0);
expect(updatePartyStuckRecovery(state, sample(0))).toBe("none");
expect(updatePartyStuckRecovery(state, sample(1_999))).toBe("none");
expect(updatePartyStuckRecovery(state, sample(2_000))).toBe("replan");
expect(updatePartyStuckRecovery(state, sample(2_500))).toBe("none");
expect(updatePartyStuckRecovery(state, sample(4_999))).toBe("none");
expect(updatePartyStuckRecovery(state, sample(5_000))).toBe("recover");
expect(updatePartyStuckRecovery(state, sample(5_100))).toBe("none");
expect(updatePartyStuckRecovery(state, sample(6_000))).toBe("recover");
});
it("does not restart the timer when a replan changes waypoints or yields no path", () => {
const state = createPartyStuckRecoveryState(origin, 0);
updatePartyStuckRecovery(state, sample(0));
expect(updatePartyStuckRecovery(state, sample(2_000))).toBe("replan");
updatePartyStuckRecovery(state, sample(3_000, { waypoint: [0, 0, 10] }));
updatePartyStuckRecovery(state, sample(4_000, { waypoint: null }));
expect(updatePartyStuckRecovery(state, sample(5_000, { waypoint: null }))).toBe("recover");
});
it("cancels back-and-forth doorway shuffling instead of treating it as repeated progress", () => {
const state = createPartyStuckRecoveryState(origin, 0);
updatePartyStuckRecovery(state, sample(0));
updatePartyStuckRecovery(state, sample(500, { position: [0.6, 0, 0] }));
const actions: string[] = [];
for (let tick = 1; tick <= 10; tick += 1) {
actions.push(updatePartyStuckRecovery(state, sample(500 + tick * 500, {
position: tick % 2 ? origin : [0.6, 0, 0],
})));
}
expect(actions).toContain("replan");
expect(actions.at(-1)).toBe("recover");
expect(state.lastProgressAtMs).toBe(500);
});
it("counts movement toward route waypoints even when the route goes away from the player", () => {
const state = createPartyStuckRecoveryState(origin, 0);
updatePartyStuckRecovery(state, sample(0));
for (let tick = 1; tick <= 12; tick += 1) {
expect(updatePartyStuckRecovery(state, sample(tick * 500, { position: [tick * 0.5, 0, 0] }))).toBe("none");
}
expect(state.lastProgressAtMs).toBe(6_000);
});
it("does not mistake target movement or sideways motion for useful actor progress", () => {
const state = createPartyStuckRecoveryState(origin, 0);
updatePartyStuckRecovery(state, sample(0));
for (let tick = 1; tick <= 5; tick += 1) {
updatePartyStuckRecovery(state, sample(tick * 1_000, { position: [0, 0, tick % 2], waypoint: [10 + tick, 0, 0] }));
}
expect(state.lastProgressAtMs).toBe(0);
});
it("advances through dense waypoint chains and corners without false recovery", () => {
const state = createPartyStuckRecoveryState(origin, 0);
updatePartyStuckRecovery(state, sample(0, { waypoint: [0.08, 0, 0] }));
for (let frame = 1; frame <= 400; frame += 1) {
const point = (step: number): PartyWorldPosition => step <= 200
? [step * 0.08, 0, 0] : [16, 0, (step - 200) * 0.08];
expect(updatePartyStuckRecovery(state, sample(frame * 1000 / 60, {
position: point(frame), waypoint: point(frame + 1),
}))).toBe("none");
}
expect(state.lastProgressAtMs).toBeGreaterThan(6_000);
});
it.each([
{ movementExpected: false }, { health: 0 }, { paused: true }, { stopped: true }, { casting: true }, { controlled: true },
])("excludes intentional immobility %j and starts a fresh timer afterward", (excluded) => {
const state = createPartyStuckRecoveryState(origin, 0);
updatePartyStuckRecovery(state, sample(0));
expect(updatePartyStuckRecovery(state, sample(10_000, excluded))).toBe("none");
expect(updatePartyStuckRecovery(state, sample(11_000))).toBe("none");
expect(updatePartyStuckRecovery(state, sample(12_999))).toBe("none");
expect(updatePartyStuckRecovery(state, sample(13_000))).toBe("replan");
});
it("keeps a ten-second teleport cooldown across pauses, holds, and goal changes", () => {
const state = createPartyStuckRecoveryState(origin, 0);
markPartyStuckRecovered(state, origin, 5_000);
updatePartyStuckRecovery(state, sample(5_100));
expect(updatePartyStuckRecovery(state, sample(7_100))).toBe("replan");
expect(updatePartyStuckRecovery(state, sample(10_100))).toBe("none");
resetPartyStuckRecoveryProgress(state);
updatePartyStuckRecovery(state, sample(10_200, { goalKey: "mob:new-target" }));
expect(updatePartyStuckRecovery(state, sample(12_200, { goalKey: "mob:new-target" }))).toBe("replan");
expect(updatePartyStuckRecovery(state, sample(14_999, { goalKey: "mob:new-target" }))).toBe("none");
expect(updatePartyStuckRecovery(state, sample(15_200, { goalKey: "mob:new-target" }))).toBe("recover");
});
it("keeps independent teammate timers", () => {
const stuck = createPartyStuckRecoveryState(origin, 0);
const moving = createPartyStuckRecoveryState(origin, 0);
updatePartyStuckRecovery(stuck, sample(0));
updatePartyStuckRecovery(moving, sample(0));
updatePartyStuckRecovery(stuck, sample(2_000));
for (let tick = 1; tick <= 10; tick += 1) updatePartyStuckRecovery(moving, sample(tick * 500, { position: [tick * 0.5, 0, 0] }));
expect(updatePartyStuckRecovery(stuck, sample(5_000))).toBe("recover");
expect(updatePartyStuckRecovery(moving, sample(5_001, { position: [5, 0, 0] }))).toBe("none");
expect(moving.cooldownUntilMs).toBe(0);
});
it("moves only the affected teammate and starts only its teleport cooldown", () => {
const positions = new Map<string, MutablePartyWorldPosition>([
["tank", [0, 0, 0]], ["rogue", [2, 0, 1]], ["mage", [3, 0, 2]],
]);
const states = new Map([...positions].map(([id, position]) => [id, createPartyStuckRecoveryState(position, 0)]));
expect(applyPartyStuckRecoveryLanding(positions, "rogue", [10, 0, 0], states.get("rogue")!, 5_000)).toBe(true);
expect(positions.get("rogue")).toEqual([10, 0, 0]);
expect(positions.get("tank")).toEqual([0, 0, 0]);
expect(positions.get("mage")).toEqual([3, 0, 2]);
expect(states.get("rogue")!.cooldownUntilMs).toBe(15_000);
expect(states.get("tank")!.cooldownUntilMs).toBe(0);
expect(states.get("mage")!.cooldownUntilMs).toBe(0);
});
it("does not apply missing or malformed landings", () => {
const positions = new Map<string, MutablePartyWorldPosition>([["tank", [0, 0, 0]]]);
const state = createPartyStuckRecoveryState(origin, 0);
expect(applyPartyStuckRecoveryLanding(positions, "missing", [10, 0, 0], state, 5_000)).toBe(false);
expect(applyPartyStuckRecoveryLanding(positions, "tank", [NaN, 0, 0], state, 5_000)).toBe(false);
expect(positions.get("tank")).toEqual(origin);
expect(state.cooldownUntilMs).toBe(0);
});
it("handles malformed samples without poisoning later progress", () => {
const state = createPartyStuckRecoveryState(origin, 0);
expect(updatePartyStuckRecovery(state, sample(NaN))).toBe("none");
expect(updatePartyStuckRecovery(state, sample(1, { position: [NaN, 0, 0] }))).toBe("none");
expect(updatePartyStuckRecovery(state, sample(10_000))).toBe("none");
});
});
describe("safe stuck-recovery landings", () => {
const player: PartyWorldPosition = [10, 0, 0];
const choose = (trailPoints: readonly PartyWorldPosition[],
validate: (point: PartyWorldPosition) => PartyWorldPosition | null = (point) => point) => choosePartyStuckRecoveryPosition({
current: origin, player, groupAnchor: player, trailPoints, validate,
});
it("prefers a visited trail point near the group over returning to the player", () => {
expect(choose([[2, 0, 0], [9, 0, 0]])).toEqual({ position: [9, 0, 0], source: "trail" });
});
it("skips wrong floors, distant points, current position, and blocked/unsafe landings", () => {
const result = choose([[0.2, 0, 0], [10, 5, 0], [50, 0, 0], [9, 0, 0], [8, 0, 0]],
(point) => point[0] === 9 ? null : point);
expect(result).toEqual({ position: [8, 0, 0], source: "trail" });
});
it("falls back to a validated player point when no safe local trail exists", () => {
expect(choose([], (point) => point)).toEqual({ position: player, source: "player" });
expect(choose([[9, 0, 0]], (point) => point[0] === 10 ? point : null)).toEqual({ position: player, source: "player" });
});
it("never blindly teleports to an invalid player point or a far-away projected room", () => {
expect(choose([], () => null)).toBeNull();
expect(choose([[9, 0, 0]], () => [10, 0, 5])).toBeNull();
expect(choose([], () => [10, -5, 0])).toBeNull();
});
});
+175
View File
@@ -0,0 +1,175 @@
import type { MutablePartyWorldPosition, PartyWorldPosition } from "./partyRuntimeRegistry";
export const PARTY_STUCK_REPLAN_MS = 2_000;
export const PARTY_STUCK_RECOVER_MS = 5_000;
export const PARTY_STUCK_RECOVERY_COOLDOWN_MS = 10_000;
const PROGRESS_DISTANCE = 0.45;
const RECOVERY_RETRY_MS = 1_000;
const MAX_TRAIL_GROUP_DISTANCE = 12;
const MAX_TRAIL_HEIGHT_DIFFERENCE = 1.25;
const MIN_RECOVERY_DISTANCE = 1;
export interface PartyStuckRecoveryState {
goalKey: string | null;
readonly previousPosition: MutablePartyWorldPosition;
waypoint: MutablePartyWorldPosition | null;
netProgress: number;
lastProgressAtMs: number;
replanned: boolean;
cooldownUntilMs: number;
retryAtMs: number;
}
export interface PartyStuckRecoverySample {
readonly position: PartyWorldPosition;
readonly waypoint: PartyWorldPosition | null;
readonly goalKey: string;
readonly movementExpected: boolean;
readonly health: number;
readonly paused: boolean;
readonly stopped: boolean;
readonly casting: boolean;
readonly controlled: boolean;
readonly nowMs: number;
}
function distance(a: PartyWorldPosition, b: PartyWorldPosition): number {
return Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]);
}
function finitePosition(position: PartyWorldPosition): boolean {
return position.every(Number.isFinite);
}
export function createPartyStuckRecoveryState(position: PartyWorldPosition, nowMs: number): PartyStuckRecoveryState {
return {
goalKey: null,
previousPosition: [...position],
waypoint: null,
netProgress: 0,
lastProgressAtMs: nowMs,
replanned: false,
cooldownUntilMs: 0,
retryAtMs: 0,
};
}
/** Clear navigation timing without letting pauses/holds bypass a teleport cooldown. */
export function resetPartyStuckRecoveryProgress(state: PartyStuckRecoveryState): void {
state.goalKey = null;
state.waypoint = null;
state.netProgress = 0;
state.replanned = false;
}
/** Signed progress toward the previous waypoint cancels doorway back-and-forth. */
export function updatePartyStuckRecovery(
state: PartyStuckRecoveryState,
sample: PartyStuckRecoverySample,
): "none" | "replan" | "recover" {
const { position, waypoint, nowMs } = sample;
if (!Number.isFinite(nowMs) || !finitePosition(position) || (waypoint && !finitePosition(waypoint))) {
resetPartyStuckRecoveryProgress(state);
return "none";
}
const excluded = !sample.movementExpected || sample.health <= 0 || sample.paused
|| sample.stopped || sample.casting || sample.controlled;
if (excluded || state.goalKey !== sample.goalKey || nowMs < state.lastProgressAtMs) {
resetPartyStuckRecoveryProgress(state);
state.lastProgressAtMs = nowMs;
if (!excluded) state.goalKey = sample.goalKey;
} else if (state.waypoint) {
// Target/waypoint movement itself is not actor progress. Count only the
// actor's displacement relative to the last sampled route waypoint.
// Once the old waypoint was reached, movement belongs to the next leg.
// Otherwise dense waypoint chains would count every forward step as
// moving away from the just-completed point.
const progressWaypoint = waypoint && distance(state.previousPosition, state.waypoint) <= 0.06
&& distance(waypoint, state.waypoint) > 0.04 ? waypoint : state.waypoint;
state.netProgress += distance(state.previousPosition, progressWaypoint) - distance(position, progressWaypoint);
if (state.netProgress >= PROGRESS_DISTANCE) {
state.netProgress = 0;
state.lastProgressAtMs = nowMs;
state.replanned = false;
}
}
state.previousPosition[0] = position[0];
state.previousPosition[1] = position[1];
state.previousPosition[2] = position[2];
if (waypoint && !excluded) {
state.waypoint ??= [0, 0, 0];
state.waypoint[0] = waypoint[0];
state.waypoint[1] = waypoint[1];
state.waypoint[2] = waypoint[2];
} else state.waypoint = null;
if (excluded) return "none";
const elapsed = nowMs - state.lastProgressAtMs;
if (!state.replanned && elapsed >= PARTY_STUCK_REPLAN_MS) {
state.replanned = true;
return "replan";
}
if (elapsed >= PARTY_STUCK_RECOVER_MS && nowMs >= state.cooldownUntilMs && nowMs >= state.retryAtMs) {
state.retryAtMs = nowMs + RECOVERY_RETRY_MS;
return "recover";
}
return "none";
}
export function markPartyStuckRecovered(state: PartyStuckRecoveryState, position: PartyWorldPosition, nowMs: number): void {
resetPartyStuckRecoveryProgress(state);
state.previousPosition[0] = position[0];
state.previousPosition[1] = position[1];
state.previousPosition[2] = position[2];
state.lastProgressAtMs = nowMs;
state.cooldownUntilMs = nowMs + PARTY_STUCK_RECOVERY_COOLDOWN_MS;
state.retryAtMs = state.cooldownUntilMs;
}
/** Position-only recovery: no squad recall, healing, threat reset, or resurrection. */
export function applyPartyStuckRecoveryLanding(
positions: ReadonlyMap<string, MutablePartyWorldPosition>,
memberId: string,
landing: PartyWorldPosition,
state: PartyStuckRecoveryState,
nowMs: number,
): boolean {
const current = positions.get(memberId);
if (!current || !finitePosition(landing) || !Number.isFinite(nowMs)) return false;
current[0] = landing[0];
current[1] = landing[1];
current[2] = landing[2];
markPartyStuckRecovered(state, current, nowMs);
return true;
}
/** Only visited trail positions are local candidates; never jump to an arbitrary nearest room. */
export function choosePartyStuckRecoveryPosition({
current, player, groupAnchor, trailPoints, validate,
}: {
readonly current: PartyWorldPosition;
readonly player: PartyWorldPosition;
readonly groupAnchor: PartyWorldPosition;
readonly trailPoints: readonly PartyWorldPosition[];
readonly validate: (position: PartyWorldPosition, anchor: PartyWorldPosition) => PartyWorldPosition | null;
}): { readonly position: PartyWorldPosition; readonly source: "trail" | "player" } | null {
if (!finitePosition(current) || !finitePosition(player) || !finitePosition(groupAnchor)) return null;
const candidates = trailPoints.filter((point) => finitePosition(point)
&& distance(point, current) >= MIN_RECOVERY_DISTANCE
&& distance(point, groupAnchor) <= MAX_TRAIL_GROUP_DISTANCE
&& Math.abs(point[1] - groupAnchor[1]) <= MAX_TRAIL_HEIGHT_DIFFERENCE)
.sort((a, b) => distance(a, groupAnchor) - distance(b, groupAnchor));
// Bound expensive path/physics checks even when a looping trail has many samples.
for (const candidate of candidates.slice(0, 16)) {
const position = validate(candidate, groupAnchor);
if (position && finitePosition(position) && distance(position, current) >= MIN_RECOVERY_DISTANCE
&& distance(position, candidate) <= 0.85
&& Math.abs(position[1] - groupAnchor[1]) <= MAX_TRAIL_HEIGHT_DIFFERENCE) {
return { position: [...position], source: "trail" };
}
}
const position = validate(player, player);
return position && finitePosition(position) && distance(position, player) <= 0.85
? { position: [...position], source: "player" }
: null;
}
+136 -5
View File
@@ -2,7 +2,7 @@ import { NodeIO } from "@gltf-transform/core";
import RAPIER from "@dimforge/rapier3d-compat";
import { ALL_EXTENSIONS } from "@gltf-transform/extensions";
import { MeshoptDecoder } from "meshoptimizer";
import { BufferAttribute, BufferGeometry, Group, Mesh } from "three";
import { BufferAttribute, BufferGeometry, Group, Mesh, Ray, Vector3 } from "three";
import { beforeAll, describe, expect, it } from "vitest";
import { requireDungeonDefinition } from "./dungeonRegistry";
import {
@@ -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();
+43 -34
View File
@@ -27,7 +27,7 @@ import {
sampleMobAnimation,
} from "../game/mobAnimation";
import type { MobAnimationState } from "../game/mobAnimation";
import { useCombatStore } from "../game/combatStore";
import { useCombatStore, type MobCombatState } from "../game/combatStore";
import { PRIMARY_MOUSE_BUTTON } from "../game/inputManager";
import { TimedEffectStrip } from "../ui/TimedEffectStrip";
import { AuraStrip } from "../ui/AuraStrip";
@@ -76,9 +76,10 @@ import {
resolveMobAnimationInterval,
} from "./mobAnimationCadence";
import { useGameGLTF } from "./useGameGLTF";
import { onlineSynchronizedMobTransform, requestOnlineInteraction } from "../game/onlineSessionRuntime";
import { onlineSessionOwnsWorldSimulation, onlineSynchronizedMobTransform, requestOnlineInteraction } from "../game/onlineSessionRuntime";
import { isMobAggroRangeVisible } from "./mobAggroRangeVisibility";
import { DungeonOccludedHtml } from "./DungeonOccludedHtml";
import { useMobNavigation } from "./mobNavigation";
const MOVEMENT_LOCKING_MOB_STATUSES: ReadonlySet<string> = new Set([
"stun", "root", "sleep", "knockdown", "freeze", "charm", "confuse",
@@ -119,8 +120,32 @@ function mobTargetPosition(
playerPosition: MobVector3,
): MobVector3 {
if (!targetActorId || targetActorId === PLAYER_AGGRO_ID) return playerPosition;
const summon = useCombatStore.getState().summons.find((candidate) => candidate.id === targetActorId);
if (summon) return summon.position;
return getPartyRuntimePosition(targetActorId) ?? playerPosition;
}
function navigateMob(
navigator: ReturnType<typeof useMobNavigation>, mob: MobCombatState,
position: MutableMobVector3, target: MobVector3, home: MobVector3,
maximumStep: number, active: boolean, nowMs: number, controlled = false,
): void {
const game = useGameStore.getState();
const hostileRanges = mob.attacks.filter((attack) => attack.target === "primary"
|| attack.target === "random-party" || attack.target === "nearby-party").map((attack) => attack.range);
const result = navigator.step({
id: mob.id, targetId: mob.targetActorId, position, target, home,
phase: mob.combatPhase, health: mob.health, engaged: mob.engaged,
active: active && onlineSessionOwnsWorldSimulation() && !game.paused && !game.mapOpen && !game.companionOpen,
casting: mob.activeCast !== null, controlled,
attackRange: hostileRanges.length ? Math.max(...hostileRanges) : mob.attackRange,
leashRange: mob.leashRange, maximumStep, nowMs,
});
if (result === "reset" || result === "home") {
useCombatStore.getState().resetMobForNavigation(mob.id,
result === "home" || Math.hypot(position[0] - home[0], position[1] - home[1], position[2] - home[2]) <= 0.85, nowMs);
}
}
const PROXIMITY_CHECK_INTERVAL = 0.3;
const AGGRO_RANGE_VISIBILITY_CHECK_INTERVAL = 0.25;
let preparedCreatureTemplates = new WeakMap<Object3D, Object3D>();
@@ -771,23 +796,6 @@ function isPlayerOrPartyNear(points: readonly MobVector3[], maxDistance: number)
});
}
function stepToward(
current: MutableMobVector3,
target: MobVector3,
maximumStep: number,
): number {
const dx = target[0] - current[0];
const dy = target[1] - current[1];
const dz = target[2] - current[2];
const distance = Math.hypot(dx, dy, dz);
if (distance <= 0.001) return 0;
const step = Math.min(Math.max(0, maximumStep), distance);
current[0] += dx / distance * step;
current[1] += dy / distance * step;
current[2] += dz / distance * step;
return distance;
}
/**
* Activates content when the player or an autonomous party member approaches.
* The stores are read
@@ -1159,6 +1167,7 @@ function RoamingPack({
useOriginalModels,
allowedRuntimeIds,
}: RoamingPackProps) {
const navigator = useMobNavigation();
const path = useMemo(() => createLoopedPath(definition.waypoints), [definition.waypoints]);
const distanceRef = useRef(definition.startDistance ?? 0);
const { scratchPositions, scratchForwards, chasePositions } = useMemo(() => {
@@ -1277,7 +1286,7 @@ function RoamingPack({
const maximumStep = Math.min(delta, 0.05)
* mob!.moveSpeed
* Math.max(0.1, 1 - slow);
if (!movementLocked && feared) {
if (active && !movementLocked && feared) {
stepFearedMobAway(
chasePosition,
targetPosition,
@@ -1286,19 +1295,16 @@ function RoamingPack({
mob!.leashRange,
chasePosition,
);
} else if (!movementLocked && mob!.combatPhase === "chasing") {
stepToward(
chasePosition,
targetPosition,
maximumStep,
);
}
navigateMob(navigator, mob!, chasePosition, targetPosition,
mob!.homePosition ?? patrolPosition, maximumStep, active, now, movementLocked || feared);
position = chasePosition;
const facingSign = feared && !movementLocked ? -1 : 1;
forward[0] = (targetPosition[0] - position[0]) * facingSign;
forward[2] = (targetPosition[2] - position[2]) * facingSign;
} else if (mob?.combatPhase === "returning") {
stepToward(chasePosition, patrolPosition, Math.min(delta, 0.05) * mob.moveSpeed * 1.25);
navigateMob(navigator, mob, chasePosition, targetPosition,
mob.homePosition ?? patrolPosition, Math.min(delta, 0.05) * mob.moveSpeed * 1.25, active, now);
position = chasePosition;
} else {
chasePosition[0] = patrolPosition[0];
@@ -1360,6 +1366,7 @@ interface StaticPopulationProps {
}
function StaticPopulationMember({ spawn, entities, active, showLabels, useOriginalModels }: StaticPopulationProps) {
const navigator = useMobNavigation();
const entity = entities[spawn.entityId];
const groupRef = useRef<Group>(null);
const positionRef = useRef<MutableMobVector3>([...spawn.position]);
@@ -1398,7 +1405,7 @@ function StaticPopulationMember({ spawn, entities, active, showLabels, useOrigin
feared = mob.statuses.some((status) => status.endsAt > now && status.kind === "fear");
const slow = mob.statuses.find((status) => status.endsAt > now && status.kind === "slow")?.magnitude ?? 0;
const maximumStep = Math.min(delta, 0.05) * mob.moveSpeed * Math.max(0.1, 1 - slow);
if (!movementLocked && feared) {
if (active && !movementLocked && feared) {
stepFearedMobAway(
position,
targetPosition,
@@ -1407,11 +1414,12 @@ function StaticPopulationMember({ spawn, entities, active, showLabels, useOrigin
mob.leashRange,
position,
);
} else if (!movementLocked && mob.combatPhase === "chasing") {
stepToward(position, targetPosition, maximumStep);
}
navigateMob(navigator, mob, position, targetPosition, mob.homePosition ?? spawn.position,
maximumStep, active, now, movementLocked || feared);
} else if (mob?.combatPhase === "returning") {
stepToward(position, spawn.position, Math.min(delta, 0.05) * mob.moveSpeed * 1.25);
navigateMob(navigator, mob, position, targetPosition, mob.homePosition ?? spawn.position,
Math.min(delta, 0.05) * mob.moveSpeed * 1.25, active, now);
} else {
position[0] = spawn.position[0];
position[1] = spawn.position[1];
@@ -1500,6 +1508,7 @@ function SummonedPopulationMember({
readonly active: boolean;
readonly useOriginalModels: boolean;
}) {
const navigator = useMobNavigation();
const groupRef = useRef<Group>(null);
const initial = getMobRuntimeTransform(instanceId)?.position ?? [0, 0, 0];
const positionRef = useRef<MutableMobVector3>([...initial]);
@@ -1515,7 +1524,7 @@ function SummonedPopulationMember({
const feared = mob.statuses.some((status) => status.endsAt > now && status.kind === "fear");
const slow = mob.statuses.find((status) => status.endsAt > now && status.kind === "slow")?.magnitude ?? 0;
const maximumStep = Math.min(delta, 0.05) * mob.moveSpeed * Math.max(0.1, 1 - slow);
if (!movementLocked && feared) {
if (active && !movementLocked && feared) {
stepFearedMobAway(
position,
targetPosition,
@@ -1524,9 +1533,9 @@ function SummonedPopulationMember({
mob.leashRange,
position,
);
} else if (!movementLocked && mob.combatPhase === "chasing") {
stepToward(position, targetPosition, maximumStep);
}
navigateMob(navigator, mob, position, targetPosition, mob.homePosition ?? initial,
maximumStep, active, now, movementLocked || feared);
const facingSign = feared && !movementLocked ? -1 : 1;
const yaw = Math.atan2(
(targetPosition[0] - position[0]) * facingSign,
+85 -41
View File
@@ -33,12 +33,10 @@ import {
createPartyBreadcrumbTrail,
nextPartyBreadcrumbFollowWaypoint,
partyFormationPosition,
partyMadeStuckProgress,
partyMovementProfile,
partyNaturalFollowPosition,
partyPersonalizedCombatMovementGoal,
partyPresentationY,
partyShouldRecallForStuck,
partyShouldMoveToComfortTarget,
personalizedPartyCombatRangeBand,
resetPartyBreadcrumbTrail,
@@ -47,6 +45,15 @@ import {
type PartyBreadcrumbCursor,
type PartyMovementProfile,
} from "../game/partyMovement";
import {
applyPartyStuckRecoveryLanding,
choosePartyStuckRecoveryPosition,
createPartyStuckRecoveryState,
markPartyStuckRecovered,
resetPartyStuckRecoveryProgress,
updatePartyStuckRecovery,
type PartyStuckRecoveryState,
} from "../game/partyStuckRecovery";
import {
findNearestPartyNavigationSurface,
type PartyNavigationGraph,
@@ -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<MutablePartyWorldPosition | null>(null);
const lastCommandRef = useRef(usePartyStore.getState().command);
const lastRecallRevisionRef = useRef(usePartyStore.getState().recallRevision);
const stuckProgressRef = useRef(new Map<string, PartyStuckProgress>());
const stuckProgressRef = useRef(new Map<string, PartyStuckRecoveryState>());
const movementProfilesRef = useRef(new Map<string, PartyMovementProfile>());
const directFollowMovingRef = useRef(new Map<string, boolean>());
const combatAnchorsRef = useRef(new Map<string, PartyCombatAnchor>());
@@ -381,6 +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);
});
+32
View File
@@ -0,0 +1,32 @@
import { useRapier } from "@react-three/rapier";
import { useEffect, useMemo } from "react";
import { combatNavigationGraph } from "../game/combatSpatial";
import { createMobPursuit } from "../game/mobPursuit";
import { registerDungeonRuntimeReset } from "../game/dungeonRuntimeReset";
import { hasDungeonLineOfSight } from "./dungeonLineOfSight";
import { createPartyDirectSegmentValidator, createPartyRecoveryPointValidator, createPartyRouteSegmentValidator } from "./partyTraversal";
/** Uses terrain only; actors remain non-blocking and the graph is loaded only once. */
export function useMobNavigation() {
const { rapier, world } = useRapier();
const navigator = useMemo(() => {
let graph = combatNavigationGraph();
let landing = createPartyRecoveryPointValidator(rapier, world, graph);
return createMobPursuit({
graph: combatNavigationGraph,
hasLineOfSight: (from, to) => hasDungeonLineOfSight(rapier, world, from, to),
directAllowed: createPartyDirectSegmentValidator(rapier, world),
routeAllowed: createPartyRouteSegmentValidator(rapier, world),
validateLanding: (point, anchor) => {
const currentGraph = combatNavigationGraph();
if (graph !== currentGraph) {
graph = currentGraph;
landing = createPartyRecoveryPointValidator(rapier, world, graph);
}
return landing(point, anchor);
},
});
}, [rapier, world]);
useEffect(() => registerDungeonRuntimeReset(navigator.clear), [navigator]);
return navigator;
}
+73
View File
@@ -0,0 +1,73 @@
import RAPIER from "@dimforge/rapier3d-compat";
import { BufferAttribute, BufferGeometry } from "three";
import { beforeAll, describe, expect, it } from "vitest";
import { buildPartyNavigationGraph } from "../game/partyNavigation";
import { ACTOR_COLLISION_GROUPS } from "./actorCollision";
import { DUNGEON_PHYSICAL_COLLISION_GROUPS, DUNGEON_WALL_COLLISION_GROUPS } from "./dungeonLineOfSight";
import { createPartyRecoveryPointValidator } from "./partyTraversal";
beforeAll(async () => { await RAPIER.init(); });
function floorWorld() {
const world = new RAPIER.World({ x: 0, y: 0, z: 0 });
world.createCollider(RAPIER.ColliderDesc.cuboid(5, 0.1, 5).setTranslation(0, -0.1, 0)
.setCollisionGroups(DUNGEON_PHYSICAL_COLLISION_GROUPS));
return world;
}
function floorGraph() {
const geometry = new BufferGeometry();
geometry.setAttribute("position", new BufferAttribute(new Float32Array([
-4, 0.05, -4, 4, 0.05, -4, -4, 0.05, 4, 4, 0.05, 4,
]), 3));
geometry.setIndex([0, 2, 1, 1, 2, 3]);
const graph = buildPartyNavigationGraph(geometry);
geometry.dispose();
return graph;
}
describe("safe recovery terrain", () => {
it("accepts walkable, unobstructed landings connected to the group", () => {
const world = floorWorld();
world.step();
const allowed = createPartyRecoveryPointValidator(RAPIER, world, floorGraph());
expect(allowed([-2, 0.05, 0], [2, 0.05, 0])).not.toBeNull();
world.free();
});
it("rejects a landing inside a wall and a route cut off by a wall", () => {
const world = floorWorld();
world.createCollider(RAPIER.ColliderDesc.cuboid(0.1, 2, 5).setTranslation(0, 2, 0)
.setCollisionGroups(DUNGEON_WALL_COLLISION_GROUPS));
world.step();
const allowed = createPartyRecoveryPointValidator(RAPIER, world, floorGraph());
expect(allowed([0, 0.05, 0], [-2, 0.05, 0])).toBeNull();
expect(allowed([-2, 0.05, 0], [2, 0.05, 0])).toBeNull();
expect(allowed([-2, 0.05, 0], [-3, 0.05, 0])).not.toBeNull();
world.free();
});
it("rejects low ceilings, missing floors, and points on the wrong vertical level", () => {
const world = floorWorld();
world.createCollider(RAPIER.ColliderDesc.cuboid(0.5, 0.1, 0.5).setTranslation(-2, 1.4, 0)
.setCollisionGroups(DUNGEON_PHYSICAL_COLLISION_GROUPS));
world.step();
const allowed = createPartyRecoveryPointValidator(RAPIER, world, floorGraph());
expect(allowed([-2, 0.05, 0], [2, 0.05, 0])).toBeNull();
expect(allowed([0, 5, 0], [2, 0.05, 0])).toBeNull();
const noFloor = new RAPIER.World({ x: 0, y: 0, z: 0 });
noFloor.step();
expect(createPartyRecoveryPointValidator(RAPIER, noFloor, floorGraph())([0, 0.05, 0], [0, 0.05, 0])).toBeNull();
noFloor.free();
world.free();
});
it("allows a grounded player fallback without a navigation asset and ignores actor occupancy", () => {
const world = floorWorld();
const body = world.createRigidBody(RAPIER.RigidBodyDesc.kinematicPositionBased().setTranslation(0, 0.84, 0));
world.createCollider(RAPIER.ColliderDesc.capsule(0.48, 0.36).setCollisionGroups(ACTOR_COLLISION_GROUPS), body);
world.step();
expect(createPartyRecoveryPointValidator(RAPIER, world, null)([0, 0, 0], [0, 0, 0])).toEqual([0, 0, 0]);
world.free();
});
});
+38
View File
@@ -2,6 +2,8 @@ import type RAPIER from "@dimforge/rapier3d-compat";
import type { World } from "@dimforge/rapier3d-compat";
import type { PartyWorldPosition } from "../game/partyRuntimeRegistry";
import { ACTOR_TERRAIN_QUERY_GROUPS } from "./actorCollision";
import { findNearestPartyNavigationSurface, type PartyNavigationGraph } from "../game/partyNavigation";
import { planPartyRoute } from "../game/partyPathing";
const COLLISION_RADIUS = 0.34;
const COLLISION_HALF_HEIGHT = 0.48;
@@ -72,3 +74,39 @@ export function createPartyRouteSegmentValidator(rapier: typeof RAPIER, world: W
return !hit || hit.timeOfImpact >= distance - 0.04;
};
}
/** Recovery landings must have real walkable ground, free body space, and a route back to the group. */
export function createPartyRecoveryPointValidator(rapier: typeof RAPIER, world: World, graph: PartyNavigationGraph | null) {
const directAllowed = createPartyDirectSegmentValidator(rapier, world);
const routeAllowed = createPartyRouteSegmentValidator(rapier, world);
const capsule = new rapier.Capsule(COLLISION_HALF_HEIGHT - WALKABLE_STEP_HEIGHT * 0.5, COLLISION_RADIUS);
const flags = rapier.QueryFilterFlags.EXCLUDE_SENSORS;
const project = (point: PartyWorldPosition): PartyWorldPosition | null => {
const surface = graph ? findNearestPartyNavigationSurface(graph, point, 0.6)?.position : point;
if (!surface || Math.abs(surface[1] - point[1]) > 0.55) return null;
if (graph && Math.hypot(surface[0] - point[0], surface[1] - point[1], surface[2] - point[2]) > 0.04
&& !directAllowed(point, surface)) return null;
const ray = new rapier.Ray({ x: surface[0], y: surface[1] + PARTY_FLOOR_RAY_HEIGHT, z: surface[2] }, { x: 0, y: -1, z: 0 });
const floor = world.castRayAndGetNormal(ray, PARTY_FLOOR_RAY_DEPTH, true, flags, ACTOR_TERRAIN_QUERY_GROUPS);
if (!floor || floor.normal.y < 0.65 || Math.abs(ray.origin.y - floor.timeOfImpact - surface[1]) > MAX_FLOOR_DELTA) return null;
const obstruction = world.intersectionWithShape(
{ x: surface[0], y: surface[1] + COLLISION_CENTER_HEIGHT + WALKABLE_STEP_HEIGHT * 0.5, z: surface[2] },
{ x: 0, y: 0, z: 0, w: 1 }, capsule, flags, ACTOR_TERRAIN_QUERY_GROUPS,
);
return obstruction ? null : surface;
};
return (point: PartyWorldPosition, anchor: PartyWorldPosition): PartyWorldPosition | null => {
const position = project(point);
const destination = project(anchor);
if (!position || !destination) return null;
if (!graph) return directAllowed(position, destination) ? position : null;
const route = planPartyRoute(graph, "stuck-recovery-validation", position, destination, 0, {
maxOriginSnapDistance: 0.1,
maxDestinationSnapDistance: 0.1,
localFinishDistance: 0.2,
directSegmentAllowed: directAllowed,
routeSegmentAllowed: graph.triangles?.length ? routeAllowed : undefined,
});
return route.blocked ? null : position;
};
}