fix(runtime/skills): recover from wedged pits with tunnel-out

This commit is contained in:
2026-05-26 12:39:23 +03:00
parent a57625b541
commit 564557450d
5 changed files with 512 additions and 13 deletions
+27 -7
View File
@@ -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) {
+35 -6
View File
@@ -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) {
+2
View File
@@ -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);
+377
View File
@@ -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,
};
@@ -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);
});