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

+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();