refactor(minecraft): add ETA-based pathfinding timeout with stuck detection and retry limits

Patches mineflayer-pathfinder to implement estimated-time-of-arrival based navigation timeouts (2× estimated travel time + grace period). Adds stuck detection counter that triggers failure after 3 consecutive resets without progress. Introduces patchedGoto wrapper returning {ok, reason, elapsedMs, estimatedTimeMs, message} for all navigation calls. Updates goToPlayer/goToPosition actions to surface timeout
This commit is contained in:
Rin
2026-02-19 00:25:06 +08:00
parent 91e5158cf3
commit a5287c08d2
10 changed files with 513 additions and 66 deletions
@@ -82,21 +82,24 @@ export const actionsList: Action[] = [
const targetStart = getPlayerPos()
const distanceToTargetBefore = targetStart ? selfStart.distanceTo(targetStart) : null
// TODO estimate time cost based on distance, trigger failure if time runs out
const ok = await skills.goToPlayer(mineflayer, player_name, closeness)
const result = await skills.goToPlayer(mineflayer, player_name, closeness)
const selfEnd = cloneVec3(mineflayer.bot.entity.position)
const targetEnd = getPlayerPos()
const distanceToTargetAfter = targetEnd ? selfEnd.distanceTo(targetEnd) : null
return {
ok,
ok: result.ok,
reason: result.reason,
target: { player_name, closeness },
startPos: toCoord(selfStart),
endPos: toCoord(selfEnd),
movedDistance: selfStart.distanceTo(selfEnd),
distanceToTargetBefore,
distanceToTargetAfter,
elapsedMs: result.elapsedMs,
estimatedTimeMs: result.estimatedTimeMs,
message: result.message,
}
},
},
@@ -149,13 +152,14 @@ export const actionsList: Action[] = [
const targetVec = new Vec3(x, y, z)
const distanceToTargetBefore = selfStart.distanceTo(targetVec)
const ok = await skills.goToPosition(mineflayer, x, y, z, closeness)
const result = await skills.goToPosition(mineflayer, x, y, z, closeness)
const selfEnd = cloneVec3(mineflayer.bot.entity.position)
const distanceToTargetAfter = selfEnd.distanceTo(targetVec)
return {
ok,
ok: result.ok,
reason: result.reason,
target: { x, y, z, closeness },
startPos: toCoord(selfStart),
endPos: toCoord(selfEnd),
@@ -163,6 +167,9 @@ export const actionsList: Action[] = [
distanceToTargetBefore,
distanceToTargetAfter,
withinCloseness: distanceToTargetAfter <= closeness,
elapsedMs: result.elapsedMs,
estimatedTimeMs: result.estimatedTimeMs,
message: result.message,
}
},
},
@@ -201,6 +201,10 @@ Common patterns:
- To cross terrain, go through walls, or reach any reachable coordinate: one `goToCoordinate` call is sufficient.
- **Never** write manual mine-then-move loops. That is what the pathfinder already does internally.
- `collectBlocks` also uses pathfinding internally to reach and mine target blocks.
- Navigation results include `reason`, `elapsedMs`, `estimatedTimeMs`, `movedDistance`, `distanceToTargetAfter`, and `message`.
- Pathfinding has an **ETA-based timeout** (2× estimated travel time + grace). The ETA accounts for digging, block placement, parkour, and walking speed.
- If navigation fails with `reason: 'timeout'` or `reason: 'stagnation'`, try a closer intermediate waypoint, a different route, or `giveUp`.
- If navigation fails with `reason: 'noPath'`, the destination is unreachable from the current position.
# Context Management (Mandatory)
You MUST use context boundaries to manage your conversation history. Without them, old messages accumulate and degrade your reasoning quality.
@@ -7,6 +7,7 @@ import pathfinder from 'mineflayer-pathfinder'
import { ActionError } from '../../utils/errors'
import { useLogger } from '../../utils/logger'
import { breakBlockAt } from '../blocks'
import { patchedGoto } from '../patched-goto'
import { getNearestBlocks } from '../world'
import { expandBlockAliases } from './block-type-normalizer'
import { ensurePickaxe } from './ensure'
@@ -105,7 +106,11 @@ export async function collectBlock(
veinBlock.position.y,
veinBlock.position.z,
)
await mineflayer.bot.pathfinder.goto(goal)
const navResult = await patchedGoto(mineflayer.bot, goal)
if (!navResult.ok) {
logger.log(`Failed to reach ${blockType} block: ${navResult.reason}${navResult.message}`)
continue
}
// Break the block and collect drops
await mineAndCollect(mineflayer, veinBlock)
@@ -12,6 +12,7 @@ import { ActionError } from '../../utils/errors'
import { useLogger } from '../../utils/logger'
import { McData } from '../../utils/mcdata'
import { goToPosition } from '../movement'
import { patchedGoto } from '../patched-goto'
const logger = useLogger()
@@ -161,7 +162,7 @@ export async function placeBlock(
),
)
// bot.pathfinder.setMovements(new pf.Movements(bot));
await mineflayer.bot.pathfinder.goto(goal)
await patchedGoto(mineflayer.bot, goal)
}
// Move closer if too far
@@ -282,7 +283,7 @@ export async function activateNearestBlock(mineflayer: Mineflayer, type: string)
if (mineflayer.bot.entity.position.distanceTo(block.position) > 4.5) {
const pos = block.position
// bot.pathfinder.setMovements(new pf.Movements(bot));
await mineflayer.bot.pathfinder.goto(new pathfinder.goals.GoalNear(pos.x, pos.y, pos.z, 4))
await patchedGoto(mineflayer.bot, new pathfinder.goals.GoalNear(pos.x, pos.y, pos.z, 4))
}
await mineflayer.bot.activateBlock(block)
logger.log(
@@ -388,10 +389,7 @@ export async function pickupNearbyItems(
let pickedUp = 0
while (nearestItem) {
// bot.pathfinder.setMovements(new pf.Movements(bot));
await mineflayer.bot.pathfinder.goto(
new pathfinder.goals.GoalFollow(nearestItem, 0.8),
() => { },
)
await patchedGoto(mineflayer.bot, new pathfinder.goals.GoalFollow(nearestItem, 0.8))
await sleep(500)
const prev = nearestItem
nearestItem = getNearestItem(mineflayer.bot)
+4 -5
View File
@@ -10,6 +10,7 @@ import { Vec3 } from 'vec3'
import { McData } from '../utils/mcdata'
import { log } from './base'
import { goToPosition } from './movement'
import { patchedGoto } from './patched-goto'
import { getNearestBlock, getNearestBlocks, getPosition, shouldPlaceTorch } from './world'
const { goals, Movements } = pathfinderModel
@@ -81,7 +82,7 @@ async function moveIntoRange(mineflayer: Mineflayer, block: any) {
movements.allowParkour = false
movements.allowSprinting = false
mineflayer.bot.pathfinder.setMovements(movements)
await mineflayer.bot.pathfinder.goto(new goals.GoalNear(pos.x, pos.y, pos.z, 4))
await patchedGoto(mineflayer.bot, new goals.GoalNear(pos.x, pos.y, pos.z, 4))
}
}
@@ -356,16 +357,14 @@ async function moveAwayFromBlock(mineflayer: Mineflayer, targetBlock: any) {
)
const invertedGoal = new goals.GoalInvert(goal)
mineflayer.bot.pathfinder.setMovements(new Movements(mineflayer.bot))
await mineflayer.bot.pathfinder.goto(invertedGoal)
await patchedGoto(mineflayer.bot, invertedGoal)
}
async function moveToBlock(mineflayer: Mineflayer, targetBlock: any) {
const pos = targetBlock.position
const movements = new Movements(mineflayer.bot)
mineflayer.bot.pathfinder.setMovements(movements)
await mineflayer.bot.pathfinder.goto(
new goals.GoalNear(pos.x, pos.y, pos.z, 4),
)
await patchedGoto(mineflayer.bot, new goals.GoalNear(pos.x, pos.y, pos.z, 4))
}
async function tryPlaceBlock(
+4 -3
View File
@@ -9,6 +9,7 @@ import { sleep } from '@moeru/std'
import { isHostile } from '../utils/mcdata'
import { log } from './base'
import { patchedGoto } from './patched-goto'
import { getNearbyEntities, getNearestEntityWhere } from './world'
const { goals } = pathfinderModel
@@ -71,7 +72,7 @@ export async function attackEntity(
if (!kill) {
if (mineflayer.bot.entity.position.distanceTo(pos) > 5) {
const goal = new goals.GoalNear(pos.x, pos.y, pos.z, 4)
await mineflayer.bot.pathfinder.goto(goal)
await patchedGoto(mineflayer.bot, goal)
}
await mineflayer.bot.attack(entity)
return true
@@ -101,7 +102,7 @@ export async function defendSelf(mineflayer: Mineflayer, range = 9): Promise<boo
&& enemy.name !== 'creeper' && enemy.name !== 'phantom') {
try {
const goal = new goals.GoalFollow(enemy, 3.5)
await mineflayer.bot.pathfinder.goto(goal)
await patchedGoto(mineflayer.bot, goal)
}
catch { /* might error if entity dies, ignore */ }
}
@@ -110,7 +111,7 @@ export async function defendSelf(mineflayer: Mineflayer, range = 9): Promise<boo
try {
const followGoal = new goals.GoalFollow(enemy, 2)
const invertedGoal = new goals.GoalInvert(followGoal)
await mineflayer.bot.pathfinder.goto(invertedGoal)
await patchedGoto(mineflayer.bot, invertedGoal)
}
catch { /* might error if entity dies, ignore */ }
}
+90 -19
View File
@@ -2,6 +2,7 @@ import type { Block } from 'prismarine-block'
import type { Entity } from 'prismarine-entity'
import type { Mineflayer } from '../libs/mineflayer'
import type { PathfindProgressInfo, PathfindResult } from './patched-goto'
import pathfinder from 'mineflayer-pathfinder'
@@ -11,8 +12,11 @@ import { Vec3 } from 'vec3'
import { useLogger } from '../utils/logger'
import { log } from './base'
import { patchedGoto } from './patched-goto'
import { getNearestBlock, getNearestEntityWhere } from './world'
export type { PathfindProgressInfo, PathfindResult } from './patched-goto'
const logger = useLogger()
const { goals, Movements } = pathfinder
@@ -22,16 +26,39 @@ export async function goToPosition(
y: number,
z: number,
minDistance = 2,
): Promise<boolean> {
options: { onProgress?: (info: PathfindProgressInfo) => void } = {},
): Promise<PathfindResult> {
if (x == null || y == null || z == null) {
log(mineflayer, `Missing coordinates, given x:${x} y:${y} z:${z}`)
return false
return {
ok: false,
reason: 'error',
message: `Missing coordinates, given x:${x} y:${y} z:${z}`,
startPos: { x: 0, y: 0, z: 0 },
endPos: { x: 0, y: 0, z: 0 },
distanceTraveled: 0,
distanceToTarget: 0,
elapsedMs: 0,
estimatedTimeMs: 0,
pathCost: 0,
}
}
if (mineflayer.allowCheats) {
mineflayer.bot.chat(`/tp @s ${x} ${y} ${z}`)
log(mineflayer, `Teleported to ${x}, ${y}, ${z}.`)
return true
return {
ok: true,
reason: 'success',
message: `Teleported to ${x}, ${y}, ${z}.`,
startPos: { x: 0, y: 0, z: 0 },
endPos: { x, y, z },
distanceTraveled: 0,
distanceToTarget: 0,
elapsedMs: 0,
estimatedTimeMs: 0,
pathCost: 0,
}
}
const targetBlock = mineflayer.bot.blockAt(new Vec3(Math.floor(x), Math.floor(y), Math.floor(z)))
const blockAbove1 = mineflayer.bot.blockAt(new Vec3(Math.floor(x), Math.floor(y) + 1, Math.floor(z)))
@@ -42,9 +69,18 @@ export async function goToPosition(
y += 1
}
await mineflayer.bot.pathfinder.goto(new goals.GoalNear(x, y, z, minDistance))
log(mineflayer, `You have reached ${x}, ${y}, ${z}.`)
return true
const result = await patchedGoto(mineflayer.bot, new goals.GoalNear(x, y, z, minDistance), {
onProgress: options.onProgress,
})
if (result.ok) {
log(mineflayer, `You have reached ${x}, ${y}, ${z}.`)
}
else {
log(mineflayer, `Navigation to ${x}, ${y}, ${z} ended: ${result.reason}${result.message}`)
}
return result
}
export async function goToNearestBlock(
@@ -65,7 +101,10 @@ export async function goToNearestBlock(
}
log(mineflayer, `Found ${blockType} at ${block.position}.`)
await goToPosition(mineflayer, block.position.x, block.position.y, block.position.z, minDistance)
const result = await goToPosition(mineflayer, block.position.x, block.position.y, block.position.z, minDistance)
if (!result.ok) {
throw new Error(`Failed to reach ${blockType}: ${result.reason}${result.message}`)
}
return block
}
@@ -88,36 +127,68 @@ export async function goToNearestEntity(
const distance = mineflayer.bot.entity.position.distanceTo(entity.position)
log(mineflayer, `Found ${entityType} ${distance} blocks away.`)
await goToPosition(
const result = await goToPosition(
mineflayer,
entity.position.x,
entity.position.y,
entity.position.z,
minDistance,
)
return true
return result.ok
}
export async function goToPlayer(
mineflayer: Mineflayer,
username: string,
distance = 3,
): Promise<boolean> {
options: { onProgress?: (info: PathfindProgressInfo) => void } = {},
): Promise<PathfindResult> {
if (mineflayer.allowCheats) {
mineflayer.bot.chat(`/tp @s ${username}`)
log(mineflayer, `Teleported to ${username}.`)
return true
return {
ok: true,
reason: 'success',
message: `Teleported to ${username}.`,
startPos: { x: 0, y: 0, z: 0 },
endPos: { x: 0, y: 0, z: 0 },
distanceTraveled: 0,
distanceToTarget: 0,
elapsedMs: 0,
estimatedTimeMs: 0,
pathCost: 0,
}
}
const player = mineflayer.bot.players[username]?.entity
if (!player) {
log(mineflayer, `Could not find ${username}.`)
return false
return {
ok: false,
reason: 'error',
message: `Could not find ${username}.`,
startPos: { x: 0, y: 0, z: 0 },
endPos: { x: 0, y: 0, z: 0 },
distanceTraveled: 0,
distanceToTarget: 0,
elapsedMs: 0,
estimatedTimeMs: 0,
pathCost: 0,
}
}
await mineflayer.bot.pathfinder.goto(new goals.GoalFollow(player, distance))
log(mineflayer, `You have reached ${username}.`)
return true
const result = await patchedGoto(mineflayer.bot, new goals.GoalFollow(player, distance), {
onProgress: options.onProgress,
})
if (result.ok) {
log(mineflayer, `You have reached ${username}.`)
}
else {
log(mineflayer, `Navigation to ${username} ended: ${result.reason}${result.message}`)
}
return result
}
export async function followPlayer(
@@ -172,11 +243,11 @@ export async function moveAway(mineflayer: Mineflayer, distance: number): Promis
const farGoal = new pathfinder.goals.GoalXZ(newX, newZ)
await mineflayer.bot.pathfinder.goto(farGoal)
const result = await patchedGoto(mineflayer.bot, farGoal)
const newPos = mineflayer.bot.entity.position
logger.log(`I moved away from nearest entity to ${newPos}.`)
await sleep(500)
return true
return result.ok
}
catch (err) {
logger.log(`I failed to move away: ${(err as Error).message}`)
@@ -191,8 +262,8 @@ export async function moveAwayFromEntity(
): Promise<boolean> {
const goal = new goals.GoalFollow(entity, distance)
const invertedGoal = new goals.GoalInvert(goal)
await mineflayer.bot.pathfinder.goto(invertedGoal)
return true
const result = await patchedGoto(mineflayer.bot, invertedGoal)
return result.ok
}
export async function stay(mineflayer: Mineflayer, seconds = 30): Promise<boolean> {
@@ -0,0 +1,316 @@
import type { Bot } from 'mineflayer'
import type { Vec3 } from 'vec3'
import { useLogger } from '../utils/logger'
const logger = useLogger()
// --- ETA Calibration Constants ---
const SPRINT_SPEED = 5.6 // blocks/s
// NOTICE: WALK_SPEED kept as reference for non-sprinting ETA calculations
const _WALK_SPEED = 4.3 // blocks/s
const JUMP_TIME = 0.6 // seconds per jump move
const PARKOUR_TIME = 1.0 // seconds per parkour move
const PLACE_TIME = 0.5 // seconds per block placement
const GRACE_FACTOR = 2.0 // multiply ETA by this for timeout
const BASE_GRACE_S = 10 // minimum grace seconds
const MIN_TIMEOUT_MS = 15_000 // absolute floor 15s
const MAX_TIMEOUT_MS = 300_000 // absolute ceiling 5min
// --- Progress / Stagnation ---
const PROGRESS_INTERVAL_MS = 5_000 // check progress every 5s
const STAGNATION_THRESHOLD = 1.5 // blocks — less than this = stagnant
const MAX_STAGNANT_TICKS = 3 // 3 stagnant ticks (~15s) → cancel
export interface PathfindResult {
ok: boolean
reason: 'success' | 'timeout' | 'stagnation' | 'noPath' | 'error' | 'interrupted'
message: string
startPos: { x: number, y: number, z: number }
endPos: { x: number, y: number, z: number }
distanceTraveled: number
distanceToTarget: number
elapsedMs: number
estimatedTimeMs: number
pathCost: number
}
export interface PathfindProgressInfo {
elapsedMs: number
estimatedTimeMs: number
distanceTraveled: number
distanceToTarget: number
currentPos: { x: number, y: number, z: number }
stagnantTicks: number
pathCost: number
}
interface MoveNode {
x: number
y: number
z: number
cost: number
toBreak: Array<{ x: number, y: number, z: number }>
toPlace: Array<{ x: number, y: number, z: number }>
parkour?: boolean
}
interface PathUpdateResult {
status: string
cost: number
path: MoveNode[]
}
function vecToCoord(v: Vec3): { x: number, y: number, z: number } {
return { x: Math.round(v.x * 10) / 10, y: Math.round(v.y * 10) / 10, z: Math.round(v.z * 10) / 10 }
}
/**
* Estimate real-world seconds to traverse a computed A* path.
*
* Walks each Move node and sums up time for walking, digging, placing, and parkour.
* The dig time is estimated from the cost model: `laborCost = (1 + 3 * digTime_ms / 1000) * digCost`.
* Since digCost defaults to 1, we can reverse: `digTime_ms ≈ (laborCost - 1) * 1000 / 3`.
* But we don't have per-block labor cost separated out. Instead, we use heuristics:
* - Each toBreak block: ~1.5s average (conservative; stone with iron pick is ~0.75s, obsidian is 9.4s)
* - Each toPlace block: ~0.5s
* - Parkour moves: ~1.0s
* - Jump moves (cost >= 2 without parkour): ~0.6s
* - Normal moves: distance / walk speed
*/
export function estimatePathTimeMs(path: MoveNode[]): number {
if (path.length === 0)
return 0
let totalTimeS = 0
for (let i = 0; i < path.length; i++) {
const node = path[i]
// Dig time: each block to break
totalTimeS += node.toBreak.length * 1.5
// Place time: each block to place
totalTimeS += node.toPlace.length * PLACE_TIME
if (node.parkour) {
totalTimeS += PARKOUR_TIME
}
else if (node.cost >= 2 && node.toBreak.length === 0 && node.toPlace.length === 0) {
// Jump move (cost=2 base for jump-up)
totalTimeS += JUMP_TIME
}
else {
// Normal walking move — estimate from node distance
// Diagonal moves have cost √2, forward moves cost 1
const walkDistance = node.cost >= 1.4 ? Math.SQRT2 : 1
totalTimeS += walkDistance / SPRINT_SPEED
}
}
return totalTimeS * 1000
}
/**
* Compute a timeout from the estimated path time.
* timeout = ETA * graceFactor + baseGrace, clamped to [MIN, MAX].
*/
export function computeTimeoutFromEta(estimatedMs: number): number {
const timeoutMs = estimatedMs * GRACE_FACTOR + BASE_GRACE_S * 1000
return Math.max(MIN_TIMEOUT_MS, Math.min(MAX_TIMEOUT_MS, timeoutMs))
}
/**
* A robust pathfinding wrapper that provides:
* - ETA-based dynamic timeout (recalculated on path replanning)
* - Periodic progress tracking with stagnation detection
* - Structured result with telemetry
* - Optional progress callback for LLM feedback
*
* Uses `bot.pathfinder.setGoal` directly (not `goto`) for full event control.
*/
export function patchedGoto(
bot: Bot,
goal: any,
options: {
onProgress?: (info: PathfindProgressInfo) => void
} = {},
): Promise<PathfindResult> {
return new Promise((resolve) => {
const startPos = bot.entity.position.clone()
const startTime = Date.now()
let lastProgressPos = startPos.clone()
let stagnantTicks = 0
let currentEstimatedMs = 0
let currentTimeoutMs = MIN_TIMEOUT_MS
let currentPathCost = 0
let timeoutTimer: ReturnType<typeof setTimeout> | null = null
let progressTimer: ReturnType<typeof setInterval> | null = null
let settled = false
function getDistanceToTarget(): number {
try {
// Use the goal's heuristic if available, otherwise euclidean to start target
if (goal && typeof goal.heuristic === 'function') {
return goal.heuristic(bot.entity.position.floored())
}
}
catch {}
return 0
}
function buildResult(ok: boolean, reason: PathfindResult['reason'], message: string): PathfindResult {
const endPos = bot.entity.position.clone()
return {
ok,
reason,
message,
startPos: vecToCoord(startPos),
endPos: vecToCoord(endPos),
distanceTraveled: startPos.distanceTo(endPos),
distanceToTarget: getDistanceToTarget(),
elapsedMs: Date.now() - startTime,
estimatedTimeMs: currentEstimatedMs,
pathCost: currentPathCost,
}
}
function cleanup() {
if (timeoutTimer) {
clearTimeout(timeoutTimer)
timeoutTimer = null
}
if (progressTimer) {
clearInterval(progressTimer)
progressTimer = null
}
bot.removeListener('goal_reached', onGoalReached)
bot.removeListener('path_update', onPathUpdate)
bot.removeListener('goal_updated', onGoalUpdated)
bot.removeListener('path_stop', onPathStop)
}
function settle(result: PathfindResult) {
if (settled)
return
settled = true
cleanup()
// Resolve on next tick to let pathfinder clean up
setTimeout(() => resolve(result), 0)
}
function resetTimeout() {
if (timeoutTimer) {
clearTimeout(timeoutTimer)
}
timeoutTimer = setTimeout(() => {
logger.withFields({ elapsedMs: Date.now() - startTime, estimatedMs: currentEstimatedMs, timeoutMs: currentTimeoutMs }).log('Pathfinding timeout reached')
try {
bot.pathfinder.stop()
}
catch {}
settle(buildResult(false, 'timeout', `Navigation timed out after ${Math.round((Date.now() - startTime) / 1000)}s (ETA was ${Math.round(currentEstimatedMs / 1000)}s)`))
}, currentTimeoutMs)
}
// --- Event handlers ---
function onGoalReached() {
settle(buildResult(true, 'success', 'Reached the goal'))
}
function onPathUpdate(results: PathUpdateResult) {
// Recalculate ETA from the new path
if (results.path && results.path.length > 0) {
currentEstimatedMs = estimatePathTimeMs(results.path)
currentTimeoutMs = computeTimeoutFromEta(currentEstimatedMs)
currentPathCost = results.cost
resetTimeout()
}
// Check for noPath / timeout from A* computation
// Only fail when the path is empty AND status indicates failure.
// If there's a partial path, the bot should walk it while A* continues.
if (results.path.length === 0) {
if (results.status === 'noPath') {
settle(buildResult(false, 'noPath', 'No path to the goal'))
}
else if (results.status === 'timeout') {
settle(buildResult(false, 'noPath', 'Pathfinding computation timed out (A* could not find a path in time)'))
}
// else: empty path but status is 'partial' — still computing, don't fail yet
}
}
function onGoalUpdated(newGoal: any) {
if (newGoal !== goal) {
settle(buildResult(false, 'interrupted', 'Goal was changed externally'))
}
}
function onPathStop() {
settle(buildResult(false, 'interrupted', 'Path was stopped'))
}
// --- Progress ticker ---
function checkProgress() {
if (settled)
return
const currentPos = bot.entity.position.clone()
const movedSinceLastTick = currentPos.distanceTo(lastProgressPos)
if (movedSinceLastTick < STAGNATION_THRESHOLD) {
stagnantTicks++
}
else {
stagnantTicks = 0
}
lastProgressPos = currentPos
const progressInfo: PathfindProgressInfo = {
elapsedMs: Date.now() - startTime,
estimatedTimeMs: currentEstimatedMs,
distanceTraveled: startPos.distanceTo(currentPos),
distanceToTarget: getDistanceToTarget(),
currentPos: vecToCoord(currentPos),
stagnantTicks,
pathCost: currentPathCost,
}
// Notify callback
options.onProgress?.(progressInfo)
// Check stagnation limit
if (stagnantTicks >= MAX_STAGNANT_TICKS) {
logger.withFields({ stagnantTicks, pos: vecToCoord(currentPos) }).log('Pathfinding stagnation detected')
try {
bot.pathfinder.stop()
}
catch {}
settle(buildResult(false, 'stagnation', `Bot stagnated for ${stagnantTicks * PROGRESS_INTERVAL_MS / 1000}s without meaningful movement`))
}
}
// --- Start ---
bot.on('goal_reached', onGoalReached)
bot.on('path_update', onPathUpdate)
bot.on('goal_updated', onGoalUpdated)
bot.on('path_stop', onPathStop)
// Set initial timeout (will be recalculated on first path_update)
resetTimeout()
// Start progress ticker
progressTimer = setInterval(checkProgress, PROGRESS_INTERVAL_MS)
// Kick off pathfinding
try {
bot.pathfinder.setGoal(goal)
}
catch (err) {
settle(buildResult(false, 'error', `Failed to set pathfinding goal: ${(err as Error).message}`))
}
})
}