Files
pepa-pi-bot/runtime/skills/recovery-tunnel-out.js
mayatnikovandClaude Opus 4.7 86e5294bb8 chore: snapshot pre-v0.2.0 WIP (pathfinder/reflex/metrics/skills improvements)
Baseline for the v0.2.0 self-learning iteration. All 205 tests pass on this
state. Subsequent commits in this branch layer the knowledge base,
post-mortem coach, and persona narration on top.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 13:05:12 +03:00

402 lines
11 KiB
JavaScript

// 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 horizontalDistance(a, b) {
return Math.hypot((b?.x ?? 0) - (a?.x ?? 0), (b?.z ?? 0) - (a?.z ?? 0));
}
function verticalDelta(a, b) {
return (b?.y ?? 0) - (a?.y ?? 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;
const tool = await equipLikelyTool(bot, block.name);
const timeoutMs = digTimeoutMs(block.name, tool);
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), timeoutMs, `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;
}
function digTimeoutMs(blockName, equippedTool) {
const kind = toolKindFor(blockName);
if (!kind) return 12_000;
if (equippedTool?.includes(kind)) return 12_000;
if (kind === "pickaxe") return 25_000;
if (kind === "axe") return 18_000;
if (kind === "shovel") return 15_000;
return 12_000;
}
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 {
let dug = 0;
let lastStep = 0;
const byStep = [...dir.digTargets]
.sort((a, b) => (a.step - b.step) || (a.kind === "feet" ? -1 : 1));
for (const target of byStep) {
if (target.step !== lastStep && lastStep > 0) {
await pushForward(bot, dir.yaw, Math.min(pushMs, 900));
}
lastStep = target.step;
if (await digOne(bot, target.block)) dug++;
}
await pushForward(bot, dir.yaw, pushMs);
const moved = horizontalDistance(before, bot.entity.position);
const movedY = verticalDelta(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, movedY, dug },
worldDelta: { mode: "tunnel-out", movedTo },
};
}
lastError = `dug ${dir.name} but moved only ${moved.toFixed(2)} horizontally (dy=${movedY.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: 120_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,
};