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
+64 -18
View File
@@ -20,10 +20,10 @@
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE. # SOFTWARE.
diff --git a/index.js b/index.js diff --git a/index.js b/index.js
index b38bd30..2ca8205 100644 index b38bd30..patched 100644
--- a/index.js --- a/index.js
+++ b/index.js +++ b/index.js
@@ -14,6 +14,7 @@ const interactableBlocks = require('./lib/interactable.json') @@ -14,6 +14,7 @@
function inject (bot) { function inject (bot) {
const waterType = bot.registry.blocksByName.water.id const waterType = bot.registry.blocksByName.water.id
@@ -31,7 +31,16 @@ index b38bd30..2ca8205 100644
const ladderId = bot.registry.blocksByName.ladder.id const ladderId = bot.registry.blocksByName.ladder.id
const vineId = bot.registry.blocksByName.vine.id const vineId = bot.registry.blocksByName.vine.id
let stateMovements = new Movements(bot) let stateMovements = new Movements(bot)
@@ -61,6 +62,10 @@ function inject (bot) { @@ -29,6 +30,8 @@
let lastNodeTime = performance.now()
let returningPos = null
let stopPathing = false
+ let stuckCount = 0
+ let lastStuckPos = null
const physics = new Physics(bot)
const lockPlaceBlock = new Lock()
const lockEquipItem = new Lock()
@@ -61,6 +64,10 @@
} }
bot.pathfinder.getPathTo = (movements, goal, timeout) => { bot.pathfinder.getPathTo = (movements, goal, timeout) => {
@@ -42,7 +51,19 @@ index b38bd30..2ca8205 100644
const generator = bot.pathfinder.getPathFromTo(movements, bot.entity.position, goal, { timeout }) const generator = bot.pathfinder.getPathFromTo(movements, bot.entity.position, goal, { timeout })
const { value: { result, astarContext: context } } = generator.next() const { value: { result, astarContext: context } } = generator.next()
astarContext = context astarContext = context
@@ -170,6 +175,16 @@ function inject (bot) { @@ -136,6 +143,11 @@
lockUseBlock.release()
stateMovements.clearCollisionIndex()
if (clearStates) bot.clearControlStates()
+ // Reset stuck counter when path is reset for non-stuck reasons
+ if (reason !== 'stuck') {
+ stuckCount = 0
+ lastStuckPos = null
+ }
if (stopPathing) return stop()
}
@@ -170,6 +182,16 @@
const curPoint = path[i] const curPoint = path[i]
if (curPoint.toBreak.length > 0 || curPoint.toPlace.length > 0) break if (curPoint.toBreak.length > 0 || curPoint.toPlace.length > 0) break
const b = bot.blockAt(new Vec3(curPoint.x, curPoint.y, curPoint.z)) const b = bot.blockAt(new Vec3(curPoint.x, curPoint.y, curPoint.z))
@@ -59,7 +80,7 @@ index b38bd30..2ca8205 100644
if (b && (b.type === waterType || ((b.type === ladderId || b.type === vineId) && i + 1 < path.length && path[i + 1].y < curPoint.y))) { if (b && (b.type === waterType || ((b.type === ladderId || b.type === vineId) && i + 1 < path.length && path[i + 1].y < curPoint.y))) {
curPoint.x = Math.floor(curPoint.x) + 0.5 curPoint.x = Math.floor(curPoint.x) + 0.5
curPoint.y = Math.floor(curPoint.y) curPoint.y = Math.floor(curPoint.y)
@@ -524,6 +539,9 @@ function inject (bot) { @@ -524,6 +546,9 @@
bot.activateBlock(bot.blockAt(new Vec3(placingBlock.x, placingBlock.y, placingBlock.z))).then(() => { bot.activateBlock(bot.blockAt(new Vec3(placingBlock.x, placingBlock.y, placingBlock.z))).then(() => {
lockUseBlock.release() lockUseBlock.release()
placingBlock = nextPoint.toPlace.shift() placingBlock = nextPoint.toPlace.shift()
@@ -69,7 +90,7 @@ index b38bd30..2ca8205 100644
}, err => { }, err => {
console.error(err) console.error(err)
lockUseBlock.release() lockUseBlock.release()
@@ -550,6 +568,7 @@ function inject (bot) { @@ -550,6 +575,7 @@
lockEquipItem.release() lockEquipItem.release()
const refBlock = bot.blockAt(new Vec3(placingBlock.x, placingBlock.y, placingBlock.z), false) const refBlock = bot.blockAt(new Vec3(placingBlock.x, placingBlock.y, placingBlock.z), false)
if (!lockPlaceBlock.tryAcquire()) return if (!lockPlaceBlock.tryAcquire()) return
@@ -77,7 +98,7 @@ index b38bd30..2ca8205 100644
if (interactableBlocks.includes(refBlock.name)) { if (interactableBlocks.includes(refBlock.name)) {
bot.setControlState('sneak', true) bot.setControlState('sneak', true)
} }
@@ -557,6 +576,7 @@ function inject (bot) { @@ -557,6 +583,7 @@
.then(function () { .then(function () {
// Dont release Sneak if the block placement was not successful // Dont release Sneak if the block placement was not successful
bot.setControlState('sneak', false) bot.setControlState('sneak', false)
@@ -85,7 +106,7 @@ index b38bd30..2ca8205 100644
if (bot.pathfinder.LOSWhenPlacingBlocks && placingBlock.returnPos) returningPos = placingBlock.returnPos.clone() if (bot.pathfinder.LOSWhenPlacingBlocks && placingBlock.returnPos) returningPos = placingBlock.returnPos.clone()
}) })
.catch(_ignoreError => { .catch(_ignoreError => {
@@ -576,7 +596,7 @@ function inject (bot) { @@ -576,7 +603,7 @@
let dx = nextPoint.x - p.x let dx = nextPoint.x - p.x
const dy = nextPoint.y - p.y const dy = nextPoint.y - p.y
let dz = nextPoint.z - p.z let dz = nextPoint.z - p.z
@@ -94,7 +115,7 @@ index b38bd30..2ca8205 100644
// arrived at next point // arrived at next point
lastNodeTime = performance.now() lastNodeTime = performance.now()
if (stopPathing) { if (stopPathing) {
@@ -611,6 +631,9 @@ function inject (bot) { @@ -611,6 +638,9 @@
if (bot.entity.isInWater) { if (bot.entity.isInWater) {
bot.setControlState('jump', true) bot.setControlState('jump', true)
bot.setControlState('sprint', false) bot.setControlState('sprint', false)
@@ -104,11 +125,36 @@ index b38bd30..2ca8205 100644
} else if (stateMovements.allowSprinting && physics.canStraightLine(path, true)) { } else if (stateMovements.allowSprinting && physics.canStraightLine(path, true)) {
bot.setControlState('jump', false) bot.setControlState('jump', false)
bot.setControlState('sprint', true) bot.setControlState('sprint', true)
@@ -628,9 +658,22 @@
bot.setControlState('sprint', false)
}
- // check for futility
+ // check for futility — with stuck counter to prevent infinite reset loops
if (performance.now() - lastNodeTime > 3500) {
- // should never take this long to go to the next node
+ const currentPos = bot.entity.position.clone()
+ if (lastStuckPos && currentPos.distanceTo(lastStuckPos) < 1.5) {
+ stuckCount++
+ } else {
+ stuckCount = 1
+ lastStuckPos = currentPos
+ }
+ if (stuckCount >= 3) {
+ // Truly stuck after 3 consecutive resets without progress — give up
+ stuckCount = 0
+ lastStuckPos = null
+ stop()
+ return
+ }
resetPath('stuck')
}
}
diff --git a/lib/movements.js b/lib/movements.js diff --git a/lib/movements.js b/lib/movements.js
index a7e3505..84a0841 100644 index a7e3505..patched 100644
--- a/lib/movements.js --- a/lib/movements.js
+++ b/lib/movements.js +++ b/lib/movements.js
@@ -50,7 +50,12 @@ class Movements { @@ -50,7 +50,12 @@
this.blocksToAvoid.add(registry.blocksByName.fire.id) this.blocksToAvoid.add(registry.blocksByName.fire.id)
if (registry.blocksByName.cobweb) this.blocksToAvoid.add(registry.blocksByName.cobweb.id) if (registry.blocksByName.cobweb) this.blocksToAvoid.add(registry.blocksByName.cobweb.id)
if (registry.blocksByName.web) this.blocksToAvoid.add(registry.blocksByName.web.id) if (registry.blocksByName.web) this.blocksToAvoid.add(registry.blocksByName.web.id)
@@ -122,7 +168,7 @@ index a7e3505..84a0841 100644
this.liquids = new Set() this.liquids = new Set()
this.liquids.add(registry.blocksByName.water.id) this.liquids.add(registry.blocksByName.water.id)
@@ -62,7 +67,13 @@ class Movements { @@ -62,7 +67,13 @@
this.climbables = new Set() this.climbables = new Set()
this.climbables.add(registry.blocksByName.ladder.id) this.climbables.add(registry.blocksByName.ladder.id)
@@ -137,7 +183,7 @@ index a7e3505..84a0841 100644
this.emptyBlocks = new Set() this.emptyBlocks = new Set()
this.replaceables = new Set() this.replaceables = new Set()
@@ -92,13 +103,15 @@ class Movements { @@ -92,13 +103,15 @@
} }
}) })
registry.blocksArray.forEach(block => { registry.blocksArray.forEach(block => {
@@ -155,7 +201,7 @@ index a7e3505..84a0841 100644
this.exclusionAreasStep = [] this.exclusionAreasStep = []
this.exclusionAreasBreak = [] this.exclusionAreasBreak = []
@@ -230,8 +243,13 @@ class Movements { @@ -230,8 +243,13 @@
} }
} }
b.climbable = this.climbables.has(b.type) b.climbable = this.climbables.has(b.type)
@@ -171,7 +217,7 @@ index a7e3505..84a0841 100644
b.replaceable = this.replaceables.has(b.type) && !b.physical b.replaceable = this.replaceables.has(b.type) && !b.physical
b.liquid = this.liquids.has(b.type) b.liquid = this.liquids.has(b.type)
b.height = pos.y + dy b.height = pos.y + dy
@@ -284,6 +302,18 @@ class Movements { @@ -284,6 +302,18 @@
cost += this.exclusionStep(block) // Is excluded so can't move or break cost += this.exclusionStep(block) // Is excluded so can't move or break
cost += this.getNumEntitiesAt(block.position, 0, 0, 0) * this.entityCost cost += this.getNumEntitiesAt(block.position, 0, 0, 0) * this.entityCost
if (block.safe) return cost if (block.safe) return cost
@@ -190,7 +236,7 @@ index a7e3505..84a0841 100644
if (!this.safeToBreak(block)) return 100 // Can't break, so can't move if (!this.safeToBreak(block)) return 100 // Can't break, so can't move
toBreak.push(block.position) toBreak.push(block.position)
@@ -387,8 +417,8 @@ class Movements { @@ -387,8 +417,8 @@
cost += this.safeOrBreak(blockB, toBreak) cost += this.safeOrBreak(blockB, toBreak)
if (cost > 100) return if (cost > 100) return
@@ -201,7 +247,7 @@ index a7e3505..84a0841 100644
toPlace.push({ x: node.x + dir.x, y: node.y, z: node.z + dir.z, dx: 0, dy: 0, dz: 0, useOne: true }) // Indicate that a block should be used on this block not placed toPlace.push({ x: node.x + dir.x, y: node.y, z: node.z + dir.z, dx: 0, dy: 0, dz: 0, useOne: true }) // Indicate that a block should be used on this block not placed
} else { } else {
cost += this.safeOrBreak(blockC, toBreak) cost += this.safeOrBreak(blockC, toBreak)
@@ -554,6 +584,54 @@ class Movements { @@ -554,6 +584,54 @@
neighbors.push(new Move(node.x, node.y + 1, node.z, node.remainingBlocks - toPlace.length, cost, toBreak, toPlace)) neighbors.push(new Move(node.x, node.y + 1, node.z, node.remainingBlocks - toPlace.length, cost, toBreak, toPlace))
} }
@@ -256,7 +302,7 @@ index a7e3505..84a0841 100644
// Jump up, down or forward over a 1 block gap // Jump up, down or forward over a 1 block gap
getMoveParkourForward (node, dir, neighbors) { getMoveParkourForward (node, dir, neighbors) {
const block0 = this.getBlock(node, 0, -1, 0) const block0 = this.getBlock(node, 0, -1, 0)
@@ -656,8 +734,27 @@ class Movements { @@ -656,8 +734,27 @@
this.getMoveDown(node, neighbors) this.getMoveDown(node, neighbors)
this.getMoveUp(node, neighbors) this.getMoveUp(node, neighbors)
+9 -9
View File
@@ -250,7 +250,7 @@ overrides:
patchedDependencies: patchedDependencies:
mineflayer-pathfinder: mineflayer-pathfinder:
hash: 4e932c106617efe7663216fc5ed42556761146633ebae88a5c831dc253abafa1 hash: 4bbfdca823ab48b74086e6e7d4b2f9baf5ee7c0ba9aee0c279b3c91c50bfd797
path: patches/mineflayer-pathfinder.patch path: patches/mineflayer-pathfinder.patch
mineflayer@4.33.0: mineflayer@4.33.0:
hash: 03a5a80439dece74f880cf754f7317b3c9e4c5dbc6488eba67c36ab4fbfbea3a hash: 03a5a80439dece74f880cf754f7317b3c9e4c5dbc6488eba67c36ab4fbfbea3a
@@ -3069,7 +3069,7 @@ importers:
version: 14.1.0(vue@3.5.26(typescript@5.9.3)) version: 14.1.0(vue@3.5.26(typescript@5.9.3))
'@wxt-dev/module-vue': '@wxt-dev/module-vue':
specifier: ^1.0.3 specifier: ^1.0.3
version: 1.0.3(vite@7.3.1(@types/node@24.10.9)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.26(typescript@5.9.3))(wxt@0.20.13(@types/node@24.10.9)(canvas@3.2.1)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(rollup@4.55.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) version: 1.0.3(vite@8.0.0-beta.9(@types/node@24.10.9)(esbuild@0.27.2)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.26(typescript@5.9.3))(wxt@0.20.13(@types/node@24.10.9)(canvas@3.2.1)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(rollup@4.55.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
nanoid: nanoid:
specifier: ^5.1.6 specifier: ^5.1.6
version: 5.1.6 version: 5.1.6
@@ -3189,7 +3189,7 @@ importers:
version: 1.6.0(encoding@0.1.13) version: 1.6.0(encoding@0.1.13)
mineflayer-pathfinder: mineflayer-pathfinder:
specifier: ^2.4.5 specifier: ^2.4.5
version: 2.4.5(patch_hash=4e932c106617efe7663216fc5ed42556761146633ebae88a5c831dc253abafa1) version: 2.4.5(patch_hash=4bbfdca823ab48b74086e6e7d4b2f9baf5ee7c0ba9aee0c279b3c91c50bfd797)
mineflayer-pvp: mineflayer-pvp:
specifier: ^1.3.2 specifier: ^1.3.2
version: 1.3.2(encoding@0.1.13)(prismarine-registry@1.11.0) version: 1.3.2(encoding@0.1.13)(prismarine-registry@1.11.0)
@@ -23309,9 +23309,9 @@ snapshots:
'@types/filesystem': 0.0.36 '@types/filesystem': 0.0.36
'@types/har-format': 1.2.16 '@types/har-format': 1.2.16
'@wxt-dev/module-vue@1.0.3(vite@7.3.1(@types/node@24.10.9)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.26(typescript@5.9.3))(wxt@0.20.13(@types/node@24.10.9)(canvas@3.2.1)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(rollup@4.55.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': '@wxt-dev/module-vue@1.0.3(vite@8.0.0-beta.9(@types/node@24.10.9)(esbuild@0.27.2)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.26(typescript@5.9.3))(wxt@0.20.13(@types/node@24.10.9)(canvas@3.2.1)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(rollup@4.55.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))':
dependencies: dependencies:
'@vitejs/plugin-vue': 6.0.3(vite@7.3.1(@types/node@24.10.9)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.26(typescript@5.9.3)) '@vitejs/plugin-vue': 6.0.3(vite@8.0.0-beta.9(@types/node@24.10.9)(esbuild@0.27.2)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.26(typescript@5.9.3))
wxt: 0.20.13(@types/node@24.10.9)(canvas@3.2.1)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(rollup@4.55.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) wxt: 0.20.13(@types/node@24.10.9)(canvas@3.2.1)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(rollup@4.55.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
transitivePeerDependencies: transitivePeerDependencies:
- vite - vite
@@ -27880,13 +27880,13 @@ snapshots:
mineflayer-collectblock@1.6.0(encoding@0.1.13): mineflayer-collectblock@1.6.0(encoding@0.1.13):
dependencies: dependencies:
mineflayer: 4.33.0(patch_hash=03a5a80439dece74f880cf754f7317b3c9e4c5dbc6488eba67c36ab4fbfbea3a)(encoding@0.1.13) mineflayer: 4.33.0(patch_hash=03a5a80439dece74f880cf754f7317b3c9e4c5dbc6488eba67c36ab4fbfbea3a)(encoding@0.1.13)
mineflayer-pathfinder: 2.4.5(patch_hash=4e932c106617efe7663216fc5ed42556761146633ebae88a5c831dc253abafa1) mineflayer-pathfinder: 2.4.5(patch_hash=4bbfdca823ab48b74086e6e7d4b2f9baf5ee7c0ba9aee0c279b3c91c50bfd797)
mineflayer-tool: 1.2.0(encoding@0.1.13) mineflayer-tool: 1.2.0(encoding@0.1.13)
transitivePeerDependencies: transitivePeerDependencies:
- encoding - encoding
- supports-color - supports-color
mineflayer-pathfinder@2.4.5(patch_hash=4e932c106617efe7663216fc5ed42556761146633ebae88a5c831dc253abafa1): mineflayer-pathfinder@2.4.5(patch_hash=4bbfdca823ab48b74086e6e7d4b2f9baf5ee7c0ba9aee0c279b3c91c50bfd797):
dependencies: dependencies:
minecraft-data: 3.102.3 minecraft-data: 3.102.3
prismarine-block: 1.22.0 prismarine-block: 1.22.0
@@ -27899,7 +27899,7 @@ snapshots:
mineflayer-pvp@1.3.2(encoding@0.1.13)(prismarine-registry@1.11.0): mineflayer-pvp@1.3.2(encoding@0.1.13)(prismarine-registry@1.11.0):
dependencies: dependencies:
mineflayer: 4.33.0(patch_hash=03a5a80439dece74f880cf754f7317b3c9e4c5dbc6488eba67c36ab4fbfbea3a)(encoding@0.1.13) mineflayer: 4.33.0(patch_hash=03a5a80439dece74f880cf754f7317b3c9e4c5dbc6488eba67c36ab4fbfbea3a)(encoding@0.1.13)
mineflayer-pathfinder: 2.4.5(patch_hash=4e932c106617efe7663216fc5ed42556761146633ebae88a5c831dc253abafa1) mineflayer-pathfinder: 2.4.5(patch_hash=4bbfdca823ab48b74086e6e7d4b2f9baf5ee7c0ba9aee0c279b3c91c50bfd797)
mineflayer-utils: 0.1.4(encoding@0.1.13)(prismarine-registry@1.11.0) mineflayer-utils: 0.1.4(encoding@0.1.13)(prismarine-registry@1.11.0)
transitivePeerDependencies: transitivePeerDependencies:
- encoding - encoding
@@ -27909,7 +27909,7 @@ snapshots:
mineflayer-tool@1.2.0(encoding@0.1.13): mineflayer-tool@1.2.0(encoding@0.1.13):
dependencies: dependencies:
mineflayer: 4.33.0(patch_hash=03a5a80439dece74f880cf754f7317b3c9e4c5dbc6488eba67c36ab4fbfbea3a)(encoding@0.1.13) mineflayer: 4.33.0(patch_hash=03a5a80439dece74f880cf754f7317b3c9e4c5dbc6488eba67c36ab4fbfbea3a)(encoding@0.1.13)
mineflayer-pathfinder: 2.4.5(patch_hash=4e932c106617efe7663216fc5ed42556761146633ebae88a5c831dc253abafa1) mineflayer-pathfinder: 2.4.5(patch_hash=4bbfdca823ab48b74086e6e7d4b2f9baf5ee7c0ba9aee0c279b3c91c50bfd797)
prismarine-nbt: 2.8.0 prismarine-nbt: 2.8.0
transitivePeerDependencies: transitivePeerDependencies:
- encoding - encoding
@@ -82,21 +82,24 @@ export const actionsList: Action[] = [
const targetStart = getPlayerPos() const targetStart = getPlayerPos()
const distanceToTargetBefore = targetStart ? selfStart.distanceTo(targetStart) : null const distanceToTargetBefore = targetStart ? selfStart.distanceTo(targetStart) : null
// TODO estimate time cost based on distance, trigger failure if time runs out const result = await skills.goToPlayer(mineflayer, player_name, closeness)
const ok = await skills.goToPlayer(mineflayer, player_name, closeness)
const selfEnd = cloneVec3(mineflayer.bot.entity.position) const selfEnd = cloneVec3(mineflayer.bot.entity.position)
const targetEnd = getPlayerPos() const targetEnd = getPlayerPos()
const distanceToTargetAfter = targetEnd ? selfEnd.distanceTo(targetEnd) : null const distanceToTargetAfter = targetEnd ? selfEnd.distanceTo(targetEnd) : null
return { return {
ok, ok: result.ok,
reason: result.reason,
target: { player_name, closeness }, target: { player_name, closeness },
startPos: toCoord(selfStart), startPos: toCoord(selfStart),
endPos: toCoord(selfEnd), endPos: toCoord(selfEnd),
movedDistance: selfStart.distanceTo(selfEnd), movedDistance: selfStart.distanceTo(selfEnd),
distanceToTargetBefore, distanceToTargetBefore,
distanceToTargetAfter, 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 targetVec = new Vec3(x, y, z)
const distanceToTargetBefore = selfStart.distanceTo(targetVec) 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 selfEnd = cloneVec3(mineflayer.bot.entity.position)
const distanceToTargetAfter = selfEnd.distanceTo(targetVec) const distanceToTargetAfter = selfEnd.distanceTo(targetVec)
return { return {
ok, ok: result.ok,
reason: result.reason,
target: { x, y, z, closeness }, target: { x, y, z, closeness },
startPos: toCoord(selfStart), startPos: toCoord(selfStart),
endPos: toCoord(selfEnd), endPos: toCoord(selfEnd),
@@ -163,6 +167,9 @@ export const actionsList: Action[] = [
distanceToTargetBefore, distanceToTargetBefore,
distanceToTargetAfter, distanceToTargetAfter,
withinCloseness: distanceToTargetAfter <= closeness, 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. - 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. - **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. - `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) # Context Management (Mandatory)
You MUST use context boundaries to manage your conversation history. Without them, old messages accumulate and degrade your reasoning quality. 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 { ActionError } from '../../utils/errors'
import { useLogger } from '../../utils/logger' import { useLogger } from '../../utils/logger'
import { breakBlockAt } from '../blocks' import { breakBlockAt } from '../blocks'
import { patchedGoto } from '../patched-goto'
import { getNearestBlocks } from '../world' import { getNearestBlocks } from '../world'
import { expandBlockAliases } from './block-type-normalizer' import { expandBlockAliases } from './block-type-normalizer'
import { ensurePickaxe } from './ensure' import { ensurePickaxe } from './ensure'
@@ -105,7 +106,11 @@ export async function collectBlock(
veinBlock.position.y, veinBlock.position.y,
veinBlock.position.z, 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 // Break the block and collect drops
await mineAndCollect(mineflayer, veinBlock) await mineAndCollect(mineflayer, veinBlock)
@@ -12,6 +12,7 @@ import { ActionError } from '../../utils/errors'
import { useLogger } from '../../utils/logger' import { useLogger } from '../../utils/logger'
import { McData } from '../../utils/mcdata' import { McData } from '../../utils/mcdata'
import { goToPosition } from '../movement' import { goToPosition } from '../movement'
import { patchedGoto } from '../patched-goto'
const logger = useLogger() const logger = useLogger()
@@ -161,7 +162,7 @@ export async function placeBlock(
), ),
) )
// bot.pathfinder.setMovements(new pf.Movements(bot)); // bot.pathfinder.setMovements(new pf.Movements(bot));
await mineflayer.bot.pathfinder.goto(goal) await patchedGoto(mineflayer.bot, goal)
} }
// Move closer if too far // 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) { if (mineflayer.bot.entity.position.distanceTo(block.position) > 4.5) {
const pos = block.position const pos = block.position
// bot.pathfinder.setMovements(new pf.Movements(bot)); // 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) await mineflayer.bot.activateBlock(block)
logger.log( logger.log(
@@ -388,10 +389,7 @@ export async function pickupNearbyItems(
let pickedUp = 0 let pickedUp = 0
while (nearestItem) { while (nearestItem) {
// bot.pathfinder.setMovements(new pf.Movements(bot)); // bot.pathfinder.setMovements(new pf.Movements(bot));
await mineflayer.bot.pathfinder.goto( await patchedGoto(mineflayer.bot, new pathfinder.goals.GoalFollow(nearestItem, 0.8))
new pathfinder.goals.GoalFollow(nearestItem, 0.8),
() => { },
)
await sleep(500) await sleep(500)
const prev = nearestItem const prev = nearestItem
nearestItem = getNearestItem(mineflayer.bot) nearestItem = getNearestItem(mineflayer.bot)
+4 -5
View File
@@ -10,6 +10,7 @@ import { Vec3 } from 'vec3'
import { McData } from '../utils/mcdata' import { McData } from '../utils/mcdata'
import { log } from './base' import { log } from './base'
import { goToPosition } from './movement' import { goToPosition } from './movement'
import { patchedGoto } from './patched-goto'
import { getNearestBlock, getNearestBlocks, getPosition, shouldPlaceTorch } from './world' import { getNearestBlock, getNearestBlocks, getPosition, shouldPlaceTorch } from './world'
const { goals, Movements } = pathfinderModel const { goals, Movements } = pathfinderModel
@@ -81,7 +82,7 @@ async function moveIntoRange(mineflayer: Mineflayer, block: any) {
movements.allowParkour = false movements.allowParkour = false
movements.allowSprinting = false movements.allowSprinting = false
mineflayer.bot.pathfinder.setMovements(movements) 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) const invertedGoal = new goals.GoalInvert(goal)
mineflayer.bot.pathfinder.setMovements(new Movements(mineflayer.bot)) 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) { async function moveToBlock(mineflayer: Mineflayer, targetBlock: any) {
const pos = targetBlock.position const pos = targetBlock.position
const movements = new Movements(mineflayer.bot) const movements = new Movements(mineflayer.bot)
mineflayer.bot.pathfinder.setMovements(movements) mineflayer.bot.pathfinder.setMovements(movements)
await mineflayer.bot.pathfinder.goto( await patchedGoto(mineflayer.bot, new goals.GoalNear(pos.x, pos.y, pos.z, 4))
new goals.GoalNear(pos.x, pos.y, pos.z, 4),
)
} }
async function tryPlaceBlock( async function tryPlaceBlock(
+4 -3
View File
@@ -9,6 +9,7 @@ import { sleep } from '@moeru/std'
import { isHostile } from '../utils/mcdata' import { isHostile } from '../utils/mcdata'
import { log } from './base' import { log } from './base'
import { patchedGoto } from './patched-goto'
import { getNearbyEntities, getNearestEntityWhere } from './world' import { getNearbyEntities, getNearestEntityWhere } from './world'
const { goals } = pathfinderModel const { goals } = pathfinderModel
@@ -71,7 +72,7 @@ export async function attackEntity(
if (!kill) { if (!kill) {
if (mineflayer.bot.entity.position.distanceTo(pos) > 5) { if (mineflayer.bot.entity.position.distanceTo(pos) > 5) {
const goal = new goals.GoalNear(pos.x, pos.y, pos.z, 4) 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) await mineflayer.bot.attack(entity)
return true return true
@@ -101,7 +102,7 @@ export async function defendSelf(mineflayer: Mineflayer, range = 9): Promise<boo
&& enemy.name !== 'creeper' && enemy.name !== 'phantom') { && enemy.name !== 'creeper' && enemy.name !== 'phantom') {
try { try {
const goal = new goals.GoalFollow(enemy, 3.5) 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 */ } catch { /* might error if entity dies, ignore */ }
} }
@@ -110,7 +111,7 @@ export async function defendSelf(mineflayer: Mineflayer, range = 9): Promise<boo
try { try {
const followGoal = new goals.GoalFollow(enemy, 2) const followGoal = new goals.GoalFollow(enemy, 2)
const invertedGoal = new goals.GoalInvert(followGoal) const invertedGoal = new goals.GoalInvert(followGoal)
await mineflayer.bot.pathfinder.goto(invertedGoal) await patchedGoto(mineflayer.bot, invertedGoal)
} }
catch { /* might error if entity dies, ignore */ } 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 { Entity } from 'prismarine-entity'
import type { Mineflayer } from '../libs/mineflayer' import type { Mineflayer } from '../libs/mineflayer'
import type { PathfindProgressInfo, PathfindResult } from './patched-goto'
import pathfinder from 'mineflayer-pathfinder' import pathfinder from 'mineflayer-pathfinder'
@@ -11,8 +12,11 @@ import { Vec3 } from 'vec3'
import { useLogger } from '../utils/logger' import { useLogger } from '../utils/logger'
import { log } from './base' import { log } from './base'
import { patchedGoto } from './patched-goto'
import { getNearestBlock, getNearestEntityWhere } from './world' import { getNearestBlock, getNearestEntityWhere } from './world'
export type { PathfindProgressInfo, PathfindResult } from './patched-goto'
const logger = useLogger() const logger = useLogger()
const { goals, Movements } = pathfinder const { goals, Movements } = pathfinder
@@ -22,16 +26,39 @@ export async function goToPosition(
y: number, y: number,
z: number, z: number,
minDistance = 2, minDistance = 2,
): Promise<boolean> { options: { onProgress?: (info: PathfindProgressInfo) => void } = {},
): Promise<PathfindResult> {
if (x == null || y == null || z == null) { if (x == null || y == null || z == null) {
log(mineflayer, `Missing coordinates, given x:${x} y:${y} z:${z}`) 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) { if (mineflayer.allowCheats) {
mineflayer.bot.chat(`/tp @s ${x} ${y} ${z}`) mineflayer.bot.chat(`/tp @s ${x} ${y} ${z}`)
log(mineflayer, `Teleported to ${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 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))) 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 y += 1
} }
await mineflayer.bot.pathfinder.goto(new goals.GoalNear(x, y, z, minDistance)) const result = await patchedGoto(mineflayer.bot, new goals.GoalNear(x, y, z, minDistance), {
log(mineflayer, `You have reached ${x}, ${y}, ${z}.`) onProgress: options.onProgress,
return true })
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( export async function goToNearestBlock(
@@ -65,7 +101,10 @@ export async function goToNearestBlock(
} }
log(mineflayer, `Found ${blockType} at ${block.position}.`) 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 return block
} }
@@ -88,36 +127,68 @@ export async function goToNearestEntity(
const distance = mineflayer.bot.entity.position.distanceTo(entity.position) const distance = mineflayer.bot.entity.position.distanceTo(entity.position)
log(mineflayer, `Found ${entityType} ${distance} blocks away.`) log(mineflayer, `Found ${entityType} ${distance} blocks away.`)
await goToPosition( const result = await goToPosition(
mineflayer, mineflayer,
entity.position.x, entity.position.x,
entity.position.y, entity.position.y,
entity.position.z, entity.position.z,
minDistance, minDistance,
) )
return true return result.ok
} }
export async function goToPlayer( export async function goToPlayer(
mineflayer: Mineflayer, mineflayer: Mineflayer,
username: string, username: string,
distance = 3, distance = 3,
): Promise<boolean> { options: { onProgress?: (info: PathfindProgressInfo) => void } = {},
): Promise<PathfindResult> {
if (mineflayer.allowCheats) { if (mineflayer.allowCheats) {
mineflayer.bot.chat(`/tp @s ${username}`) mineflayer.bot.chat(`/tp @s ${username}`)
log(mineflayer, `Teleported to ${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 const player = mineflayer.bot.players[username]?.entity
if (!player) { if (!player) {
log(mineflayer, `Could not find ${username}.`) 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)) const result = await patchedGoto(mineflayer.bot, new goals.GoalFollow(player, distance), {
log(mineflayer, `You have reached ${username}.`) onProgress: options.onProgress,
return true })
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( 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) 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 const newPos = mineflayer.bot.entity.position
logger.log(`I moved away from nearest entity to ${newPos}.`) logger.log(`I moved away from nearest entity to ${newPos}.`)
await sleep(500) await sleep(500)
return true return result.ok
} }
catch (err) { catch (err) {
logger.log(`I failed to move away: ${(err as Error).message}`) logger.log(`I failed to move away: ${(err as Error).message}`)
@@ -191,8 +262,8 @@ export async function moveAwayFromEntity(
): Promise<boolean> { ): Promise<boolean> {
const goal = new goals.GoalFollow(entity, distance) const goal = new goals.GoalFollow(entity, distance)
const invertedGoal = new goals.GoalInvert(goal) const invertedGoal = new goals.GoalInvert(goal)
await mineflayer.bot.pathfinder.goto(invertedGoal) const result = await patchedGoto(mineflayer.bot, invertedGoal)
return true return result.ok
} }
export async function stay(mineflayer: Mineflayer, seconds = 30): Promise<boolean> { 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}`))
}
})
}