// Pathfinder stuck-watchdog. // // Problem (observed live 2026-05-26): mineflayer-pathfinder computes a // path once when goto() is called and does NOT recompute when the // world changes mid-traversal. If a player places a block in front of // the bot — or a creeper craters the path — pathfinder keeps trying to // step into the old node and the bot just stands there pressing forward // against the new obstacle until the goto() timeout (typically 30–60 s). // // Fix: a poll loop that runs while `bot.pathfinder.goal` is non-null. // Every WATCH_INTERVAL_MS we read the horizontal position. If we haven't // moved STUCK_DELTA blocks in STUCK_WINDOW_MS, we declare the path // stale and force a replan by clearing the goal and re-setting it. The // pathfinder then recomputes against the current world, taking the new // obstacle into account — including digging through it if canDig=true. // // Side benefit: even when no obstacle was placed, this catches the // pathological "pathfinder stuck on a goal it can never reach" case // (mineflayer-pathfinder issue #222) much earlier than the 45 s goto // timeout we wrapped goto() in. import { info, warn } from "./log.js"; const WATCH_INTERVAL_MS = 2_000; const STUCK_WINDOW_MS = 6_000; const STUCK_DELTA = 0.5; const MIN_TRAVEL_TIME_MS = 1_500; // grace at the start so we don't replan during initial step const MAX_REPLAN_PER_GOAL = 3; function hpos(p) { return p ? { x: p.x, z: p.z } : null; } function hdist(a, b) { if (!a || !b) return Number.POSITIVE_INFINITY; return Math.hypot(a.x - b.x, a.z - b.z); } function collectBlockOwnsPathfinder(bot) { try { const targets = bot.collectBlock?.targets; if (!targets) return false; if (typeof targets.empty === "boolean") return targets.empty === false; if (Array.isArray(targets.targets)) return targets.targets.length > 0; if (typeof targets.size === "number") return targets.size > 0; return false; } catch { return false; } } export function createPathfinderWatchdog(bot, { intervalMs = WATCH_INTERVAL_MS, windowMs = STUCK_WINDOW_MS, delta = STUCK_DELTA, maxReplans = MAX_REPLAN_PER_GOAL, } = {}) { if (!bot) throw new Error("pathfinder-watchdog: bot required"); let lastSeenAt = 0; let lastSeenPos = null; let lastGoal = null; let goalStartedAt = 0; let replansThisGoal = 0; let stopped = false; let timer = null; function tick() { if (stopped) return; const pf = bot.pathfinder; const goal = pf?.goal; if (!goal) { // No active goal — reset our state. lastGoal = null; lastSeenPos = null; replansThisGoal = 0; return; } if (goal !== lastGoal) { // New goal started — reset counters. lastGoal = goal; lastSeenPos = hpos(bot.entity?.position); lastSeenAt = Date.now(); goalStartedAt = Date.now(); replansThisGoal = 0; return; } if (Date.now() - goalStartedAt < MIN_TRAVEL_TIME_MS) return; if (collectBlockOwnsPathfinder(bot)) { // mineflayer-collectblock treats pathfinder goal changes as a // hard cancellation ("The goal was changed before it could be // completed"). Let the collect skill's own timeout/blacklist // handle these paths instead of invalidating the current dig. lastSeenPos = hpos(bot.entity?.position); lastSeenAt = Date.now(); return; } const now = Date.now(); const here = hpos(bot.entity?.position); if (here && hdist(here, lastSeenPos) >= delta) { lastSeenPos = here; lastSeenAt = now; return; } if (now - lastSeenAt < windowMs) return; // Stuck. Force a replan by setting the exact same goal object again. // mineflayer-pathfinder emits goal_updated on every setGoal() call // and rebuilds the graph, but goto() only rejects as GoalChanged when // the new goal object is different. Clearing to null first breaks the // caller, as observed live with gather.logs/explore.far. if (replansThisGoal >= maxReplans) { warn("pathfinder", `stuck > ${windowMs / 1000}s and hit ${maxReplans} replans; giving up — caller's timeout will fire`); lastSeenAt = now; // throttle further warnings within this window return; } replansThisGoal++; info("pathfinder", `stuck for ${Math.round((now - lastSeenAt) / 1000)}s at (${Math.round(here?.x ?? 0)},${Math.round(here?.z ?? 0)}) — forcing replan #${replansThisGoal}`); try { const goalCopy = goal; pf.setGoal(goalCopy); lastSeenAt = now; // reset window } catch (e) { warn("pathfinder", `replan failed: ${e.message}`); } } timer = setInterval(tick, intervalMs); return { stop() { stopped = true; if (timer) clearInterval(timer); timer = null; }, }; } // Pure helpers for tests. export const _internal = { hpos, hdist, collectBlockOwnsPathfinder };