Files
pepa-pi-bot/runtime/pathfinder-watchdog.js
T
mayatnikovandClaude Opus 4.7 01531c54c8 feat(runtime/pathfinder): stuck-replan watchdog — react to mid-path obstacles
Problem (live 2026-05-26 screenshot): player drops a block in front of
the bot mid-path. mineflayer-pathfinder computes the path once when
goto() is called and never recomputes for world changes. Bot pushes
forward against the new block until the 30–60 s goto timeout fires,
visible as the bot just standing there pressing W.

Fix — runtime/pathfinder-watchdog.js: per-bot poll loop (2 s tick) that
runs while bot.pathfinder.goal is non-null. Tracks horizontal position.
If movement < 0.5 blocks for > 6 s after an initial 1.5 s grace,
forces a replan: setGoal(null) + setGoal(<same goal>) on a 250 ms
delay. That makes the planner rebuild the path against the current
world, so it routes around the new block — or, with canDig=true in our
profiles, digs through it. Capped at 3 replans per goal so a genuinely
unreachable target still bubbles up to the caller's timeout.

Side benefit: catches mineflayer-pathfinder issue #222 ("path hangs
on unreachable goal") much earlier than our 45 s goto wrappers.

Wired into bot.js on the "spawn" event and stopped on gracefulExit.
7 new unit tests cover the polling math + replan cap. 197/197 green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 20:00:01 +03:00

122 lines
4.0 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 3060 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);
}
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;
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 — clear the goal, then re-set the same
// goal so pathfinder rebuilds the graph against the current world.
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;
// setGoal(null) cancels the current pathing without bubbling
// an error to the awaiting goto() promise.
pf.setGoal(null);
// Re-set immediately. mineflayer-pathfinder will compute a
// fresh path off the latest world snapshot.
setTimeout(() => {
if (stopped) return;
try { pf.setGoal(goalCopy); } catch (e) { warn("pathfinder", `replan setGoal failed: ${e.message}`); }
}, 250);
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 };