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