fix(runtime/skills): recover from wedged pits with tunnel-out
This commit is contained in:
@@ -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,
|
||||
};
|
||||
Reference in New Issue
Block a user