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
# SOFTWARE.
diff --git a/index.js b/index.js
index b38bd30..2ca8205 100644
index b38bd30..patched 100644
--- a/index.js
+++ b/index.js
@@ -14,6 +14,7 @@ const interactableBlocks = require('./lib/interactable.json')
@@ -14,6 +14,7 @@
function inject (bot) {
const waterType = bot.registry.blocksByName.water.id
@@ -31,7 +31,16 @@ index b38bd30..2ca8205 100644
const ladderId = bot.registry.blocksByName.ladder.id
const vineId = bot.registry.blocksByName.vine.id
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) => {
@@ -42,7 +51,19 @@ index b38bd30..2ca8205 100644
const generator = bot.pathfinder.getPathFromTo(movements, bot.entity.position, goal, { timeout })
const { value: { result, astarContext: context } } = generator.next()
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]
if (curPoint.toBreak.length > 0 || curPoint.toPlace.length > 0) break
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))) {
curPoint.x = Math.floor(curPoint.x) + 0.5
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(() => {
lockUseBlock.release()
placingBlock = nextPoint.toPlace.shift()
@@ -69,7 +90,7 @@ index b38bd30..2ca8205 100644
}, err => {
console.error(err)
lockUseBlock.release()
@@ -550,6 +568,7 @@ function inject (bot) {
@@ -550,6 +575,7 @@
lockEquipItem.release()
const refBlock = bot.blockAt(new Vec3(placingBlock.x, placingBlock.y, placingBlock.z), false)
if (!lockPlaceBlock.tryAcquire()) return
@@ -77,7 +98,7 @@ index b38bd30..2ca8205 100644
if (interactableBlocks.includes(refBlock.name)) {
bot.setControlState('sneak', true)
}
@@ -557,6 +576,7 @@ function inject (bot) {
@@ -557,6 +583,7 @@
.then(function () {
// Dont release Sneak if the block placement was not successful
bot.setControlState('sneak', false)
@@ -85,7 +106,7 @@ index b38bd30..2ca8205 100644
if (bot.pathfinder.LOSWhenPlacingBlocks && placingBlock.returnPos) returningPos = placingBlock.returnPos.clone()
})
.catch(_ignoreError => {
@@ -576,7 +596,7 @@ function inject (bot) {
@@ -576,7 +603,7 @@
let dx = nextPoint.x - p.x
const dy = nextPoint.y - p.y
let dz = nextPoint.z - p.z
@@ -94,7 +115,7 @@ index b38bd30..2ca8205 100644
// arrived at next point
lastNodeTime = performance.now()
if (stopPathing) {
@@ -611,6 +631,9 @@ function inject (bot) {
@@ -611,6 +638,9 @@
if (bot.entity.isInWater) {
bot.setControlState('jump', true)
bot.setControlState('sprint', false)
@@ -104,11 +125,36 @@ index b38bd30..2ca8205 100644
} else if (stateMovements.allowSprinting && physics.canStraightLine(path, true)) {
bot.setControlState('jump', false)
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
index a7e3505..84a0841 100644
index a7e3505..patched 100644
--- a/lib/movements.js
+++ b/lib/movements.js
@@ -50,7 +50,12 @@ class Movements {
@@ -50,7 +50,12 @@
this.blocksToAvoid.add(registry.blocksByName.fire.id)
if (registry.blocksByName.cobweb) this.blocksToAvoid.add(registry.blocksByName.cobweb.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.add(registry.blocksByName.water.id)
@@ -62,7 +67,13 @@ class Movements {
@@ -62,7 +67,13 @@
this.climbables = new Set()
this.climbables.add(registry.blocksByName.ladder.id)
@@ -137,7 +183,7 @@ index a7e3505..84a0841 100644
this.emptyBlocks = new Set()
this.replaceables = new Set()
@@ -92,13 +103,15 @@ class Movements {
@@ -92,13 +103,15 @@
}
})
registry.blocksArray.forEach(block => {
@@ -155,7 +201,7 @@ index a7e3505..84a0841 100644
this.exclusionAreasStep = []
this.exclusionAreasBreak = []
@@ -230,8 +243,13 @@ class Movements {
@@ -230,8 +243,13 @@
}
}
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.liquid = this.liquids.has(b.type)
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.getNumEntitiesAt(block.position, 0, 0, 0) * this.entityCost
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
toBreak.push(block.position)
@@ -387,8 +417,8 @@ class Movements {
@@ -387,8 +417,8 @@
cost += this.safeOrBreak(blockB, toBreak)
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
} else {
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))
}
@@ -256,7 +302,7 @@ index a7e3505..84a0841 100644
// Jump up, down or forward over a 1 block gap
getMoveParkourForward (node, dir, neighbors) {
const block0 = this.getBlock(node, 0, -1, 0)
@@ -656,8 +734,27 @@ class Movements {
@@ -656,8 +734,27 @@
this.getMoveDown(node, neighbors)
this.getMoveUp(node, neighbors)
+9 -9
View File
@@ -250,7 +250,7 @@ overrides:
patchedDependencies:
mineflayer-pathfinder:
hash: 4e932c106617efe7663216fc5ed42556761146633ebae88a5c831dc253abafa1
hash: 4bbfdca823ab48b74086e6e7d4b2f9baf5ee7c0ba9aee0c279b3c91c50bfd797
path: patches/mineflayer-pathfinder.patch
mineflayer@4.33.0:
hash: 03a5a80439dece74f880cf754f7317b3c9e4c5dbc6488eba67c36ab4fbfbea3a
@@ -3069,7 +3069,7 @@ importers:
version: 14.1.0(vue@3.5.26(typescript@5.9.3))
'@wxt-dev/module-vue':
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:
specifier: ^5.1.6
version: 5.1.6
@@ -3189,7 +3189,7 @@ importers:
version: 1.6.0(encoding@0.1.13)
mineflayer-pathfinder:
specifier: ^2.4.5
version: 2.4.5(patch_hash=4e932c106617efe7663216fc5ed42556761146633ebae88a5c831dc253abafa1)
version: 2.4.5(patch_hash=4bbfdca823ab48b74086e6e7d4b2f9baf5ee7c0ba9aee0c279b3c91c50bfd797)
mineflayer-pvp:
specifier: ^1.3.2
version: 1.3.2(encoding@0.1.13)(prismarine-registry@1.11.0)
@@ -23309,9 +23309,9 @@ snapshots:
'@types/filesystem': 0.0.36
'@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:
'@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)
transitivePeerDependencies:
- vite
@@ -27880,13 +27880,13 @@ snapshots:
mineflayer-collectblock@1.6.0(encoding@0.1.13):
dependencies:
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)
transitivePeerDependencies:
- encoding
- supports-color
mineflayer-pathfinder@2.4.5(patch_hash=4e932c106617efe7663216fc5ed42556761146633ebae88a5c831dc253abafa1):
mineflayer-pathfinder@2.4.5(patch_hash=4bbfdca823ab48b74086e6e7d4b2f9baf5ee7c0ba9aee0c279b3c91c50bfd797):
dependencies:
minecraft-data: 3.102.3
prismarine-block: 1.22.0
@@ -27899,7 +27899,7 @@ snapshots:
mineflayer-pvp@1.3.2(encoding@0.1.13)(prismarine-registry@1.11.0):
dependencies:
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)
transitivePeerDependencies:
- encoding
@@ -27909,7 +27909,7 @@ snapshots:
mineflayer-tool@1.2.0(encoding@0.1.13):
dependencies:
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
transitivePeerDependencies:
- encoding
@@ -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}`))
}
})
}