import type { PartyNavigationGraph } from "./partyNavigation"; import { distanceSquared, moveMobToward, type MobAiPhase, type MobAiPosition, type MutableMobAiPosition } from "./mobAi"; import { emptyPartyRouteState, nextPartyRouteRecoveryWaypoint, nextPartyRouteWaypoint, partyRouteNeedsPlanning, planPartyRoute } from "./partyPathing"; import { createPartyStuckRecoveryState, markPartyStuckRecovered, updatePartyStuckRecovery, type PartyStuckRecoveryState } from "./partyStuckRecovery"; export const MOB_NEAR_RECOVERY_DISTANCE = 10; const SAME_FLOOR_TOLERANCE = 1.25; export interface MobPursuitSample { readonly id: string; readonly targetId: string | null; readonly position: MutableMobAiPosition; readonly target: MobAiPosition; readonly home: MobAiPosition; readonly phase: MobAiPhase; readonly health: number; readonly engaged: boolean; readonly active: boolean; readonly casting: boolean; readonly controlled: boolean; readonly attackRange: number; readonly leashRange: number; readonly maximumStep: number; readonly nowMs: number; } export interface MobPursuitSpatial { readonly graph: () => PartyNavigationGraph | null; readonly hasLineOfSight: (from: MobAiPosition, to: MobAiPosition) => boolean; readonly directAllowed: (from: MobAiPosition, to: MobAiPosition) => boolean; readonly routeAllowed: (from: MobAiPosition, to: MobAiPosition) => boolean; readonly validateLanding: (point: MobAiPosition, anchor: MobAiPosition) => MobAiPosition | null; } /** Never blindly land inside the target, a wall, or an overlapping dungeon floor. */ export function chooseMobPursuitLanding(sample: MobPursuitSample, spatial: MobPursuitSpatial): MobAiPosition | null { const { position, target, home } = sample; if (distanceSquared(position, target) > MOB_NEAR_RECOVERY_DISTANCE ** 2 || Math.abs(position[1] - target[1]) > SAME_FLOOR_TOLERANCE || distanceSquared(home, target) > sample.leashRange ** 2) return null; const bearing = Math.atan2(position[0] - target[0], position[2] - target[2]); const radius = Math.max(0.85, Math.min(2, sample.attackRange * 0.6)); for (let offset = 0; offset < 16; offset += 1) { const angle = bearing + offset * Math.PI / 8; const candidate: MobAiPosition = [target[0] + Math.sin(angle) * radius, target[1], target[2] + Math.cos(angle) * radius]; const landing = spatial.validateLanding(candidate, target); if (landing?.every(Number.isFinite) && distanceSquared(landing, candidate) <= 0.85 ** 2 && distanceSquared(landing, target) <= sample.attackRange ** 2 && distanceSquared(landing, home) <= sample.leashRange ** 2 && Math.abs(landing[1] - target[1]) <= SAME_FLOOR_TOLERANCE && spatial.hasLineOfSight(landing, target)) return [...landing]; } return null; } /** Shared imperative adapter for static spawns, roaming packs, and summoned enemies. */ export function createMobPursuit(spatial: MobPursuitSpatial) { const states = new Map; watchdog: PartyStuckRecoveryState; graph: PartyNavigationGraph | null; }>(); const desired: MutableMobAiPosition = [0, 0, 0]; const next: MutableMobAiPosition = [0, 0, 0]; return { clear: () => states.clear(), step(sample: MobPursuitSample): "none" | "teleported" | "reset" | "home" { const { position, target, home, nowMs } = sample; if (![...position, ...target, ...home, nowMs, sample.attackRange, sample.leashRange, sample.maximumStep].every(Number.isFinite)) return "none"; let state = states.get(sample.id); if (!state) { state = { route: emptyPartyRouteState(), watchdog: createPartyStuckRecoveryState(position, nowMs), graph: spatial.graph() }; states.set(sample.id, state); } const returning = sample.phase === "returning"; const goal = returning ? home : target; const visible = spatial.hasLineOfSight(position, target); const inRange = visible && distanceSquared(position, target) <= sample.attackRange ** 2; const movementExpected = returning ? distanceSquared(position, home) > 0.2 ** 2 : sample.engaged && !inRange; const excluded = !sample.active || sample.health <= 0 || sample.controlled || sample.casting; const goalKey = returning ? "return-home" : `target:${sample.targetId}`; const graph = spatial.graph(); if (state.graph !== graph) { state.graph = graph; state.route = emptyPartyRouteState(); } let waypoint: MobAiPosition | null = null; let approvedRouteStep = false; if (movementExpected && !excluded) { if (spatial.directAllowed(position, goal)) waypoint = goal; else { if (partyRouteNeedsPlanning(state.route, goalKey, position, goal, nowMs, { replanIntervalMs: 350, blockedRetryMs: 800 })) { state.route = planPartyRoute(graph, goalKey, position, goal, nowMs, { maxOriginSnapDistance: 3, maxDestinationSnapDistance: 3, directSegmentAllowed: spatial.directAllowed, routeSegmentAllowed: graph?.triangles?.length || graph?.detourSurface ? 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"; }, }; }