import type { MutablePartyWorldPosition, PartyWorldPosition } from "./partyRuntimeRegistry"; export const PARTY_STUCK_REPLAN_MS = 2_000; export const PARTY_STUCK_RECOVER_MS = 5_000; export const PARTY_STUCK_RECOVERY_COOLDOWN_MS = 10_000; const PROGRESS_DISTANCE = 0.45; const RECOVERY_RETRY_MS = 1_000; const MAX_TRAIL_GROUP_DISTANCE = 12; const MAX_TRAIL_HEIGHT_DIFFERENCE = 1.25; const MIN_RECOVERY_DISTANCE = 1; export interface PartyStuckRecoveryState { goalKey: string | null; readonly previousPosition: MutablePartyWorldPosition; waypoint: MutablePartyWorldPosition | null; netProgress: number; lastProgressAtMs: number; replanned: boolean; cooldownUntilMs: number; retryAtMs: number; } export interface PartyStuckRecoverySample { readonly position: PartyWorldPosition; readonly waypoint: PartyWorldPosition | null; readonly goalKey: string; readonly movementExpected: boolean; readonly health: number; readonly paused: boolean; readonly stopped: boolean; readonly casting: boolean; readonly controlled: boolean; readonly nowMs: number; } function distance(a: PartyWorldPosition, b: PartyWorldPosition): number { return Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]); } function finitePosition(position: PartyWorldPosition): boolean { return position.every(Number.isFinite); } export function createPartyStuckRecoveryState(position: PartyWorldPosition, nowMs: number): PartyStuckRecoveryState { return { goalKey: null, previousPosition: [...position], waypoint: null, netProgress: 0, lastProgressAtMs: nowMs, replanned: false, cooldownUntilMs: 0, retryAtMs: 0, }; } /** Clear navigation timing without letting pauses/holds bypass a teleport cooldown. */ export function resetPartyStuckRecoveryProgress(state: PartyStuckRecoveryState): void { state.goalKey = null; state.waypoint = null; state.netProgress = 0; state.replanned = false; } /** Signed progress toward the previous waypoint cancels doorway back-and-forth. */ export function updatePartyStuckRecovery( state: PartyStuckRecoveryState, sample: PartyStuckRecoverySample, ): "none" | "replan" | "recover" { const { position, waypoint, nowMs } = sample; if (!Number.isFinite(nowMs) || !finitePosition(position) || (waypoint && !finitePosition(waypoint))) { resetPartyStuckRecoveryProgress(state); return "none"; } const excluded = !sample.movementExpected || sample.health <= 0 || sample.paused || sample.stopped || sample.casting || sample.controlled; if (excluded || state.goalKey !== sample.goalKey || nowMs < state.lastProgressAtMs) { resetPartyStuckRecoveryProgress(state); state.lastProgressAtMs = nowMs; if (!excluded) state.goalKey = sample.goalKey; } else if (state.waypoint) { // Target/waypoint movement itself is not actor progress. Count only the // actor's displacement relative to the last sampled route waypoint. // Once the old waypoint was reached, movement belongs to the next leg. // Otherwise dense waypoint chains would count every forward step as // moving away from the just-completed point. const progressWaypoint = waypoint && distance(state.previousPosition, state.waypoint) <= 0.06 && distance(waypoint, state.waypoint) > 0.04 ? waypoint : state.waypoint; state.netProgress += distance(state.previousPosition, progressWaypoint) - distance(position, progressWaypoint); if (state.netProgress >= PROGRESS_DISTANCE) { state.netProgress = 0; state.lastProgressAtMs = nowMs; state.replanned = false; } } state.previousPosition[0] = position[0]; state.previousPosition[1] = position[1]; state.previousPosition[2] = position[2]; if (waypoint && !excluded) { state.waypoint ??= [0, 0, 0]; state.waypoint[0] = waypoint[0]; state.waypoint[1] = waypoint[1]; state.waypoint[2] = waypoint[2]; } else state.waypoint = null; if (excluded) return "none"; const elapsed = nowMs - state.lastProgressAtMs; if (!state.replanned && elapsed >= PARTY_STUCK_REPLAN_MS) { state.replanned = true; return "replan"; } if (elapsed >= PARTY_STUCK_RECOVER_MS && nowMs >= state.cooldownUntilMs && nowMs >= state.retryAtMs) { state.retryAtMs = nowMs + RECOVERY_RETRY_MS; return "recover"; } return "none"; } export function markPartyStuckRecovered(state: PartyStuckRecoveryState, position: PartyWorldPosition, nowMs: number): void { resetPartyStuckRecoveryProgress(state); state.previousPosition[0] = position[0]; state.previousPosition[1] = position[1]; state.previousPosition[2] = position[2]; state.lastProgressAtMs = nowMs; state.cooldownUntilMs = nowMs + PARTY_STUCK_RECOVERY_COOLDOWN_MS; state.retryAtMs = state.cooldownUntilMs; } /** Position-only recovery: no squad recall, healing, threat reset, or resurrection. */ export function applyPartyStuckRecoveryLanding( positions: ReadonlyMap, memberId: string, landing: PartyWorldPosition, state: PartyStuckRecoveryState, nowMs: number, ): boolean { const current = positions.get(memberId); if (!current || !finitePosition(landing) || !Number.isFinite(nowMs)) return false; current[0] = landing[0]; current[1] = landing[1]; current[2] = landing[2]; markPartyStuckRecovered(state, current, nowMs); return true; } /** Only visited trail positions are local candidates; never jump to an arbitrary nearest room. */ export function choosePartyStuckRecoveryPosition({ current, player, groupAnchor, trailPoints, validate, }: { readonly current: PartyWorldPosition; readonly player: PartyWorldPosition; readonly groupAnchor: PartyWorldPosition; readonly trailPoints: readonly PartyWorldPosition[]; readonly validate: (position: PartyWorldPosition, anchor: PartyWorldPosition) => PartyWorldPosition | null; }): { readonly position: PartyWorldPosition; readonly source: "trail" | "player" } | null { if (!finitePosition(current) || !finitePosition(player) || !finitePosition(groupAnchor)) return null; const candidates = trailPoints.filter((point) => finitePosition(point) && distance(point, current) >= MIN_RECOVERY_DISTANCE && distance(point, groupAnchor) <= MAX_TRAIL_GROUP_DISTANCE && Math.abs(point[1] - groupAnchor[1]) <= MAX_TRAIL_HEIGHT_DIFFERENCE) .sort((a, b) => distance(a, groupAnchor) - distance(b, groupAnchor)); // Bound expensive path/physics checks even when a looping trail has many samples. for (const candidate of candidates.slice(0, 16)) { const position = validate(candidate, groupAnchor); if (position && finitePosition(position) && distance(position, current) >= MIN_RECOVERY_DISTANCE && distance(position, candidate) <= 0.85 && Math.abs(position[1] - groupAnchor[1]) <= MAX_TRAIL_HEIGHT_DIFFERENCE) { return { position: [...position], source: "trail" }; } } const position = validate(player, player); return position && finitePosition(position) && distance(position, player) <= 0.85 ? { position: [...position], source: "player" } : null; }