Two bugs the live self-improvement run exposed: 1) Auto-patch was spawned with detached:false, so when supervisor restarted bot.js (file change after Pi's commit landed on the auto branch), the auto-patch child was killed mid-way — Pi's commit lived in the auto branch but never got cherry-picked. Recovered manually this round via reflog + cherry-pick. Now detached:true + child.unref() + a per-run log at state/_auto-patch-last.log so the operator can read Pi's full output later. 2) Pi's recovery-tunnel-out.test.js was created but not in npm test script; tests would have stayed unrun forever. Added. Also commits the Pi-authored skill (eb29591 cherry-picked): - runtime/skills/recovery-tunnel-out.js (+ test) - improvements to runtime/actions.js + runtime/skills/explore-far.js - wired into runtime/skills/index.js npm test 137/137. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
383 lines
10 KiB
JavaScript
383 lines
10 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;
|
|
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 = 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: dir.digTargets.length },
|
|
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: 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,
|
|
};
|