From 564557450d03c1d99f9ca1f7e55b3e93c5ce1852 Mon Sep 17 00:00:00 2001 From: Yuriy Mayatnikov Date: Tue, 26 May 2026 12:37:16 +0300 Subject: [PATCH] fix(runtime/skills): recover from wedged pits with tunnel-out --- runtime/actions.js | 34 +- runtime/skills/explore-far.js | 41 ++- runtime/skills/index.js | 2 + runtime/skills/recovery-tunnel-out.js | 377 +++++++++++++++++++++ runtime/skills/recovery-tunnel-out.test.js | 71 ++++ 5 files changed, 512 insertions(+), 13 deletions(-) create mode 100644 runtime/skills/recovery-tunnel-out.js create mode 100644 runtime/skills/recovery-tunnel-out.test.js diff --git a/runtime/actions.js b/runtime/actions.js index 5dfa6e4..30774ef 100644 --- a/runtime/actions.js +++ b/runtime/actions.js @@ -13,6 +13,7 @@ const collectBlockPlugin = collectBlockPkg; import { info, warn } from "./log.js"; +import { digEscapeTunnel } from "./skills/recovery-tunnel-out.js"; // Hard timeout wrapper. Mineflayer goals (pathfinder, pvp targeting) can hang // when the goal is unreachable; without a ceiling the whole reflex chain stops. @@ -430,9 +431,11 @@ export async function wander(bot, radius = 12) { // surrounded by leaves. Try to escape: dig the block straight above // + jump, repeat up to 3 times, then try forward+jump. if (best.dist < 0.5) { - info("action", `wander: all cardinals blocked → escape-pit (dig up + jump)`); - await escapePit(bot, 3); - return { ok: true, detail: { mode: "escape-pit", trials } }; + info("action", `wander: all cardinals blocked → escape-pit, then tunnel-out if still stuck`); + const escape = await escapePit(bot, 3); + const detail = { ...(escape.detail ?? {}), trials }; + if (!escape.ok) return { ok: false, code: escape.code ?? "wedged", detail }; + return { ok: true, detail }; } // Commit to best direction. @@ -472,14 +475,24 @@ const CARDINAL_YAWS = [ ]; // escapePit — dig the block right above the bot's head, jump into the -// newly empty slot, repeat. Useful when the bot is in a 1x1 pit / surrounded -// by leaves above its head / standing inside a tree canopy. We use bot.dig -// directly (not collectBlock) because we don't care about pickup here. +// newly empty slot, repeat. If that does not actually move the bot, fall +// back to recovery.tunnel-out's two-high horizontal tunnel. Returning OK +// without movement was what produced repeated wedged "done" results. +function clonePos(pos) { + if (typeof pos?.clone === "function") return pos.clone(); + return { x: pos?.x ?? 0, y: pos?.y ?? 0, z: pos?.z ?? 0 }; +} + +function movedDistance(a, b) { + return Math.hypot((b?.x ?? 0) - (a?.x ?? 0), (b?.y ?? 0) - (a?.y ?? 0), (b?.z ?? 0) - (a?.z ?? 0)); +} + async function escapePit(bot, maxSteps = 3) { + const before = clonePos(bot.entity.position); for (let i = 0; i < maxSteps; i++) { const head = bot.entity.position.offset(0, 1.7, 0); const above = bot.blockAt(head.offset(0, 0.5, 0)); - if (!above || above.name === "air" || above.name === "cave_air") { + if (!above || above.name === "air" || above.name === "cave_air" || above.name === "void_air") { // Already clear above. Just jump+forward in case bot is in a // horizontal pit (gap in floor). bot.setControlState("jump", true); @@ -506,6 +519,13 @@ async function escapePit(bot, maxSteps = 3) { bot.setControlState("jump", false); await new Promise((r) => setTimeout(r, 400)); } + + const moved = movedDistance(before, bot.entity.position); + if (moved >= 0.75) { + return { ok: true, code: "done", detail: { mode: "escape-pit-up", moved } }; + } + info("action", `escape-pit moved only ${moved.toFixed(2)} blocks → tunnel-out`); + return digEscapeTunnel(bot, { maxSteps: 3, reason: "wander escape-pit" }); } async function probeCardinalSteps(bot, durationMs = 800) { diff --git a/runtime/skills/explore-far.js b/runtime/skills/explore-far.js index f71ac56..adc21ec 100644 --- a/runtime/skills/explore-far.js +++ b/runtime/skills/explore-far.js @@ -11,6 +11,7 @@ import pathfinderPkg from "mineflayer-pathfinder"; const { pathfinder, goals, Movements } = pathfinderPkg; import { info, warn } from "../log.js"; +import { digEscapeTunnel } from "./recovery-tunnel-out.js"; let pluginLoaded = new WeakSet(); function ensurePathfinder(bot) { @@ -89,11 +90,17 @@ export const skill = Object.freeze({ info("action", `explore.far: cardinal probe trials=${trials.map((t) => `${t.name}:${t.dist.toFixed(1)}`).join(" ")} best=${best.name}`); if (best.dist < 0.5) { - // All cardinals blocked → escape pit: dig the block above the - // bot's head (if any), jump into the gap, repeat. Then break. - info("action", "explore.far: wedged — escape-pit (dig up + jump)"); - await escapePit(bot, 3); - return { ok: true, code: "done", detail: { mode: "escape-pit", trials }, worldDelta: null }; + // All cardinals blocked. Try the cheap vertical escape first; if it + // does not actually move us, carve a short horizontal tunnel. The + // previous code returned OK after dig-up+jump even when position was + // unchanged, causing repeated false "done" completions. + info("action", "explore.far: wedged — escape-pit, then tunnel-out if still stuck"); + const escape = await escapePit(bot, 3); + const detail = { ...(escape.detail ?? {}), trials }; + if (!escape.ok) { + return { ok: false, code: escape.code ?? "wedged", detail, worldDelta: escape.worldDelta ?? null }; + } + return { ok: true, code: "done", detail, worldDelta: escape.worldDelta ?? null }; } const here = bot.entity.position.clone(); @@ -140,11 +147,21 @@ const CARDINAL_YAWS = [ { name: "W", yaw: Math.PI / 2 }, ]; +function clonePos(pos) { + if (typeof pos?.clone === "function") return pos.clone(); + return { x: pos?.x ?? 0, y: pos?.y ?? 0, z: pos?.z ?? 0 }; +} + +function movedDistance(a, b) { + return Math.hypot((b?.x ?? 0) - (a?.x ?? 0), (b?.y ?? 0) - (a?.y ?? 0), (b?.z ?? 0) - (a?.z ?? 0)); +} + async function escapePit(bot, maxSteps = 3) { + const before = clonePos(bot.entity.position); for (let i = 0; i < maxSteps; i++) { const head = bot.entity.position.offset(0, 1.7, 0); const above = bot.blockAt(head.offset(0, 0.5, 0)); - if (!above || above.name === "air" || above.name === "cave_air") { + if (!above || above.name === "air" || above.name === "cave_air" || above.name === "void_air") { bot.setControlState("jump", true); bot.setControlState("forward", true); await new Promise((r) => setTimeout(r, 700)); @@ -164,6 +181,18 @@ async function escapePit(bot, maxSteps = 3) { bot.setControlState("jump", false); await new Promise((r) => setTimeout(r, 400)); } + + const moved = movedDistance(before, bot.entity.position); + if (moved >= 0.75) { + return { + ok: true, + code: "done", + detail: { mode: "escape-pit-up", moved }, + worldDelta: { mode: "escape-pit-up", movedTo: clonePos(bot.entity.position) }, + }; + } + info("action", `escape-pit moved only ${moved.toFixed(2)} blocks → tunnel-out`); + return digEscapeTunnel(bot, { maxSteps: 3, reason: "explore.far escape-pit" }); } async function probeCardinalStep(bot, durationMs = 800) { diff --git a/runtime/skills/index.js b/runtime/skills/index.js index b2b0dca..e878a2e 100644 --- a/runtime/skills/index.js +++ b/runtime/skills/index.js @@ -26,6 +26,7 @@ import { skill as chopLogs } from "./chop-logs.js"; import { skill as eat } from "./eat.js"; import { skill as wander } from "./wander.js"; import { skill as exploreFar } from "./explore-far.js"; +import { skill as tunnelOut } from "./recovery-tunnel-out.js"; import { skill as diagPhysics } from "./diagnose-physics.js"; import { skill as gatherStone } from "./gather-stone.js"; import { skill as gatherWool } from "./gather-wool.js"; @@ -63,6 +64,7 @@ register(chopLogs); register(eat); register(wander); register(exploreFar); +register(tunnelOut); register(diagPhysics); register(gatherStone); register(gatherWool); diff --git a/runtime/skills/recovery-tunnel-out.js b/runtime/skills/recovery-tunnel-out.js new file mode 100644 index 0000000..ab7c93c --- /dev/null +++ b/runtime/skills/recovery-tunnel-out.js @@ -0,0 +1,377 @@ +// recovery.tunnel-out — last-resort wedged recovery. When cardinal probes +// report no movement and the old "dig up + jump" escape does not change +// position, carve a short two-high tunnel in the safest/most-open cardinal +// direction and push forward through it. + +import { info, warn } from "../log.js"; + +const CARDINALS = Object.freeze([ + { name: "N", yaw: Math.PI, dx: 0, dz: -1 }, + { name: "E", yaw: -Math.PI / 2, dx: 1, dz: 0 }, + { name: "S", yaw: 0, dx: 0, dz: 1 }, + { name: "W", yaw: Math.PI / 2, dx: -1, dz: 0 }, +]); + +const PASSABLE_NAMES = new Set(["air", "cave_air", "void_air"]); +const LIQUID_NAMES = new Set(["water", "lava"]); + +const NEVER_DIG_EXACT = new Set([ + "bedrock", + "barrier", + "command_block", + "chain_command_block", + "repeating_command_block", + "structure_block", + "jigsaw", + "end_portal_frame", + "end_portal", + "nether_portal", + "obsidian", + "crying_obsidian", + "respawn_anchor", + "chest", + "trapped_chest", + "barrel", + "shulker_box", + "furnace", + "blast_furnace", + "smoker", + "crafting_table", + "stonecutter", + "grindstone", + "enchanting_table", + "loom", + "cartography_table", + "fletching_table", + "composter", + "lectern", + "bell", + "cauldron", +]); + +const MAN_MADE_PARTS = [ + "_planks", + "_slab", + "_stairs", + "_fence", + "_door", + "_trapdoor", + "glass", + "pane", + "brick", + "concrete", + "terracotta", + "wool", + "carpet", + "banner", + "sign", + "torch", + "lantern", + "ladder", + "rail", + "anvil", + "bookshelf", + "lectern", + "bell", + "polished_", + "chiseled_", + "smooth_stone", + "stripped_", + "_bed", +]; + +const NATURAL_EXACT = new Set([ + "grass_block", + "dirt", + "coarse_dirt", + "rooted_dirt", + "podzol", + "mycelium", + "mud", + "clay", + "sand", + "red_sand", + "gravel", + "snow", + "snow_block", + "powder_snow", + "stone", + "deepslate", + "granite", + "diorite", + "andesite", + "tuff", + "calcite", + "dripstone_block", + "netherrack", + "end_stone", + "blackstone", + "glowstone", + "basalt", + "moss_block", + "mushroom_stem", + "brown_mushroom_block", + "red_mushroom_block", +]); + +const TOOL_PRIORITY = Object.freeze({ + pickaxe: ["netherite_pickaxe", "diamond_pickaxe", "iron_pickaxe", "stone_pickaxe", "wooden_pickaxe"], + axe: ["netherite_axe", "diamond_axe", "iron_axe", "stone_axe", "wooden_axe"], + shovel: ["netherite_shovel", "diamond_shovel", "iron_shovel", "stone_shovel", "wooden_shovel"], +}); + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function withTimeout(promise, ms, label) { + let timer; + const timeout = new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms / 1000}s`)), ms); + }); + return Promise.race([promise, timeout]).finally(() => clearTimeout(timer)); +} + +function posOffset(pos, dx, dy, dz) { + if (typeof pos?.offset === "function") return pos.offset(dx, dy, dz); + return { x: (pos?.x ?? 0) + dx, y: (pos?.y ?? 0) + dy, z: (pos?.z ?? 0) + dz }; +} + +function posClone(pos) { + if (typeof pos?.clone === "function") return pos.clone(); + return { x: pos?.x ?? 0, y: pos?.y ?? 0, z: pos?.z ?? 0 }; +} + +function centerOf(pos) { + if (typeof pos?.offset === "function") return pos.offset(0.5, 0.5, 0.5); + return { x: (pos?.x ?? 0) + 0.5, y: (pos?.y ?? 0) + 0.5, z: (pos?.z ?? 0) + 0.5 }; +} + +function distance(a, b) { + return Math.hypot((b?.x ?? 0) - (a?.x ?? 0), (b?.y ?? 0) - (a?.y ?? 0), (b?.z ?? 0) - (a?.z ?? 0)); +} + +function isLiquidBlock(block) { + return LIQUID_NAMES.has(block?.name); +} + +export function isPassableBlock(block) { + if (!block) return true; + if (PASSABLE_NAMES.has(block.name)) return true; + return block.boundingBox === "empty"; +} + +function isProbablyNaturalName(name) { + if (!name) return false; + if (NATURAL_EXACT.has(name)) return true; + return ( + name.endsWith("_leaves") || + name.endsWith("_log") || + name.endsWith("_stem") || + name.endsWith("_ore") || + name.endsWith("_dirt") + ); +} + +function isUnsafeName(name) { + if (!name) return true; + if (PASSABLE_NAMES.has(name)) return false; + if (LIQUID_NAMES.has(name)) return true; + if (NEVER_DIG_EXACT.has(name)) return true; + return MAN_MADE_PARTS.some((part) => name.includes(part)); +} + +export function isSafeTunnelDigTarget(bot, block) { + if (isPassableBlock(block)) return true; + if (!block?.position || isLiquidBlock(block)) return false; + if (isUnsafeName(block.name)) return false; + if (!isProbablyNaturalName(block.name)) return false; + if (typeof bot?.canDigBlock === "function") { + try { + if (!bot.canDigBlock(block)) return false; + } catch { + return false; + } + } + return true; +} + +function toolKindFor(name) { + if (!name) return null; + if (name.endsWith("_log") || name.endsWith("_stem") || name.endsWith("_leaves") || name.includes("mushroom")) return "axe"; + if (name.includes("dirt") || name.includes("sand") || name.includes("gravel") || name === "clay" || name.includes("snow") || name === "mud") return "shovel"; + if (name.includes("stone") || name.endsWith("_ore") || name === "granite" || name === "diorite" || name === "andesite" || name === "tuff" || name === "calcite") return "pickaxe"; + return null; +} + +async function equipLikelyTool(bot, blockName) { + const kind = toolKindFor(blockName); + if (!kind) return null; + const items = bot?.inventory?.items?.() ?? []; + for (const toolName of TOOL_PRIORITY[kind]) { + const item = items.find((i) => i.name === toolName); + if (!item) continue; + try { + await withTimeout(bot.equip(item, "hand"), 3_000, `equip(${toolName})`); + return toolName; + } catch { + // Try the next-best tool. + } + } + return null; +} + +function summarizeDirection(d) { + return { + name: d.name, + usable: d.usable, + score: Number.isFinite(d.score) ? d.score : null, + digCount: d.digTargets.length, + passable: d.passable, + floor: d.floor, + blockedBy: d.blockers.map((b) => `${b.kind}:${b.name}`), + hazards: d.hazards.map((h) => `${h.kind}:${h.name}`), + }; +} + +export function inspectTunnelDirection(bot, dir, maxSteps = 3) { + const here = bot?.entity?.position; + const digTargets = []; + const blockers = []; + const hazards = []; + let passable = 0; + let floor = 0; + + for (let step = 1; step <= maxSteps; step++) { + const x = dir.dx * step; + const z = dir.dz * step; + for (const [kind, y] of [["head", 1], ["feet", 0]]) { + const block = bot.blockAt(posOffset(here, x, y, z)); + if (isLiquidBlock(block)) { + hazards.push({ step, kind, name: block.name }); + } else if (isPassableBlock(block)) { + passable++; + } else if (isSafeTunnelDigTarget(bot, block)) { + digTargets.push({ step, kind, block }); + } else { + blockers.push({ step, kind, name: block?.name ?? "unknown" }); + } + } + + const below = bot.blockAt(posOffset(here, x, -1, z)); + if (isLiquidBlock(below)) hazards.push({ step, kind: "floor", name: below.name }); + else if (!isPassableBlock(below)) floor++; + } + + const usable = blockers.length === 0 && hazards.length === 0; + const score = usable ? passable * 3 + floor - digTargets.length * 2 : -Infinity; + return { ...dir, usable, score, passable, floor, digTargets, blockers, hazards }; +} + +export function rankTunnelDirections(bot, maxSteps = 3) { + return CARDINALS + .map((dir) => inspectTunnelDirection(bot, dir, maxSteps)) + .sort((a, b) => (b.score - a.score) || (a.digTargets.length - b.digTargets.length)); +} + +async function digOne(bot, block) { + if (isPassableBlock(block)) return false; + await equipLikelyTool(bot, block.name); + try { + if (typeof bot.lookAt === "function") { + await withTimeout(bot.lookAt(centerOf(block.position), true), 1_500, `lookAt(${block.name})`); + } + } catch { + // Dig may still work; do not abort on look jitter. + } + await withTimeout(bot.dig(block), 10_000, `dig(${block.name})`); + const after = bot.blockAt(block.position); + if (after && !isPassableBlock(after) && after.name === block.name) { + throw new Error(`block still present after dig: ${block.name}`); + } + return true; +} + +async function pushForward(bot, yaw, ms) { + try { await bot.look(yaw, 0, true); } catch {} + bot.setControlState("forward", true); + bot.setControlState("jump", true); + try { + await sleep(ms); + } finally { + bot.setControlState("forward", false); + bot.setControlState("jump", false); + } +} + +export async function digEscapeTunnel(bot, { maxSteps = 3, minMove = 0.75, pushMs = 2_500, reason = "wedged" } = {}) { + if (!bot?.entity?.position || typeof bot.blockAt !== "function" || typeof bot.dig !== "function") { + return { ok: false, code: "no_bot", detail: "bot missing tunnel APIs", worldDelta: null }; + } + + const ranked = rankTunnelDirections(bot, maxSteps); + const candidates = ranked.filter((d) => d.usable); + if (!candidates.length) { + return { + ok: false, + code: "no_safe_tunnel", + detail: { mode: "tunnel-out", reason, directions: ranked.map(summarizeDirection) }, + worldDelta: { mode: "tunnel-out" }, + }; + } + + let lastError = null; + for (const dir of candidates) { + const before = posClone(bot.entity.position); + info("action", `tunnel-out: ${reason} → ${dir.name} (${dir.digTargets.length} blocks to clear)`); + try { + for (const target of dir.digTargets) { + await digOne(bot, target.block); + } + await pushForward(bot, dir.yaw, pushMs); + const moved = distance(before, bot.entity.position); + if (moved >= minMove) { + const movedTo = posClone(bot.entity.position); + return { + ok: true, + code: "done", + detail: { mode: "tunnel-out", dir: dir.name, moved, dug: dir.digTargets.length }, + worldDelta: { mode: "tunnel-out", movedTo }, + }; + } + lastError = `dug ${dir.name} but moved only ${moved.toFixed(2)}`; + warn("action", `tunnel-out: ${lastError}`); + } catch (e) { + lastError = e?.message ?? String(e); + warn("action", `tunnel-out ${dir.name} failed: ${lastError}`); + } + } + + return { + ok: false, + code: "wedged", + detail: { mode: "tunnel-out", reason, error: lastError, directions: ranked.map(summarizeDirection) }, + worldDelta: { mode: "tunnel-out" }, + }; +} + +export const skill = Object.freeze({ + id: "recovery.tunnel-out", + title: "Tunnel out of a wedged 1x1 hole", + timeoutMs: 45_000, + preconditions(ctx) { + if (!ctx?.bot) return { ok: false, code: "no_bot", detail: "bot missing" }; + return { ok: true }; + }, + async execute(ctx, args = {}) { + return digEscapeTunnel(ctx.bot, args); + }, +}); + +export const _internal = { + CARDINALS, + inspectTunnelDirection, + rankTunnelDirections, + isPassableBlock, + isSafeTunnelDigTarget, +}; diff --git a/runtime/skills/recovery-tunnel-out.test.js b/runtime/skills/recovery-tunnel-out.test.js new file mode 100644 index 0000000..19cc4e9 --- /dev/null +++ b/runtime/skills/recovery-tunnel-out.test.js @@ -0,0 +1,71 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { getSkill } from "./index.js"; +import { _internal } from "./recovery-tunnel-out.js"; + +function makePos(x, y, z) { + return { + x, y, z, + offset(dx, dy, dz) { return makePos(x + dx, y + dy, z + dz); }, + clone() { return makePos(x, y, z); }, + }; +} + +function makeBlock(name, x, y, z) { + return { + name, + boundingBox: name === "air" ? "empty" : "block", + position: makePos(x, y, z), + }; +} + +function makeBot(blocks = {}) { + return { + entity: { position: makePos(0, 64, 0) }, + blockAt(pos) { + const x = Math.floor(pos.x); + const y = Math.floor(pos.y); + const z = Math.floor(pos.z); + const name = blocks[`${x},${y},${z}`] ?? "stone"; + return makeBlock(name, x, y, z); + }, + canDigBlock(block) { return block.name !== "bedrock"; }, + }; +} + +test("recovery.tunnel-out is registered", () => { + const skill = getSkill("recovery.tunnel-out"); + assert.ok(skill); + assert.equal(skill.preconditions({}).ok, false); + assert.equal(skill.preconditions({ bot: makeBot() }).ok, true); +}); + +test("tunnel ranking prefers the most-free safe cardinal", () => { + const blocks = {}; + // North is already a two-high open tunnel with solid floor. + for (let step = 1; step <= 3; step++) { + blocks[`0,64,${-step}`] = "air"; + blocks[`0,65,${-step}`] = "air"; + blocks[`0,63,${-step}`] = "dirt"; + } + // West is blocked by a player/build-looking block and must be unusable. + blocks["-1,64,0"] = "oak_planks"; + + const ranked = _internal.rankTunnelDirections(makeBot(blocks), 3); + assert.equal(ranked[0].name, "N"); + assert.equal(ranked[0].usable, true); + const west = ranked.find((d) => d.name === "W"); + assert.equal(west.usable, false); + assert.deepEqual(west.blockers.map((b) => b.name), ["oak_planks"]); +}); + +test("safe dig guard allows natural blocks and rejects build/storage blocks", () => { + const bot = makeBot(); + assert.equal(_internal.isSafeTunnelDigTarget(bot, makeBlock("dirt", 1, 64, 0)), true); + assert.equal(_internal.isSafeTunnelDigTarget(bot, makeBlock("oak_leaves", 1, 64, 0)), true); + assert.equal(_internal.isSafeTunnelDigTarget(bot, makeBlock("oak_log", 1, 64, 0)), true); + assert.equal(_internal.isSafeTunnelDigTarget(bot, makeBlock("oak_planks", 1, 64, 0)), false); + assert.equal(_internal.isSafeTunnelDigTarget(bot, makeBlock("chest", 1, 64, 0)), false); + assert.equal(_internal.isSafeTunnelDigTarget(bot, makeBlock("bedrock", 1, 64, 0)), false); +});