diff --git a/patches/mineflayer-pathfinder.patch b/patches/mineflayer-pathfinder.patch index e181e6f2b..761ae2110 100644 --- a/patches/mineflayer-pathfinder.patch +++ b/patches/mineflayer-pathfinder.patch @@ -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) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a0368cd98..b161dc625 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/services/minecraft/src/cognitive/action/llm-actions.ts b/services/minecraft/src/cognitive/action/llm-actions.ts index d2a9a8a33..36bad71fd 100644 --- a/services/minecraft/src/cognitive/action/llm-actions.ts +++ b/services/minecraft/src/cognitive/action/llm-actions.ts @@ -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, } }, }, diff --git a/services/minecraft/src/cognitive/conscious/prompts/brain-prompt.md b/services/minecraft/src/cognitive/conscious/prompts/brain-prompt.md index 0ee0c8255..2f1f951c9 100644 --- a/services/minecraft/src/cognitive/conscious/prompts/brain-prompt.md +++ b/services/minecraft/src/cognitive/conscious/prompts/brain-prompt.md @@ -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. diff --git a/services/minecraft/src/skills/actions/collect-block.ts b/services/minecraft/src/skills/actions/collect-block.ts index 7a2826b1f..f5d56c25a 100644 --- a/services/minecraft/src/skills/actions/collect-block.ts +++ b/services/minecraft/src/skills/actions/collect-block.ts @@ -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) diff --git a/services/minecraft/src/skills/actions/world-interactions.ts b/services/minecraft/src/skills/actions/world-interactions.ts index e06165941..77360256e 100644 --- a/services/minecraft/src/skills/actions/world-interactions.ts +++ b/services/minecraft/src/skills/actions/world-interactions.ts @@ -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) diff --git a/services/minecraft/src/skills/blocks.ts b/services/minecraft/src/skills/blocks.ts index 147013d79..65660125c 100644 --- a/services/minecraft/src/skills/blocks.ts +++ b/services/minecraft/src/skills/blocks.ts @@ -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( diff --git a/services/minecraft/src/skills/combat.ts b/services/minecraft/src/skills/combat.ts index 79cafea79..4a184e36b 100644 --- a/services/minecraft/src/skills/combat.ts +++ b/services/minecraft/src/skills/combat.ts @@ -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 { + options: { onProgress?: (info: PathfindProgressInfo) => void } = {}, +): Promise { 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 { + options: { onProgress?: (info: PathfindProgressInfo) => void } = {}, +): Promise { 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 { 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 { diff --git a/services/minecraft/src/skills/patched-goto.ts b/services/minecraft/src/skills/patched-goto.ts new file mode 100644 index 000000000..0cc2f6c40 --- /dev/null +++ b/services/minecraft/src/skills/patched-goto.ts @@ -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 { + 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 | null = null + let progressTimer: ReturnType | 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}`)) + } + }) +}