From 6909715f753c4a4c65f8ec17c553ba8b9fc3ad1a Mon Sep 17 00:00:00 2001 From: Yuriy Mayatnikov Date: Tue, 26 May 2026 13:21:39 +0300 Subject: [PATCH] fix(runtime/skills): guard stationary blind fallback --- runtime/actions.js | 15 ++++- runtime/skills/explore-far.js | 66 +++++++++++++++++----- runtime/skills/recovery-tunnel-out.test.js | 23 ++++++++ 3 files changed, 89 insertions(+), 15 deletions(-) diff --git a/runtime/actions.js b/runtime/actions.js index 9e52a4d..fcdf7f6 100644 --- a/runtime/actions.js +++ b/runtime/actions.js @@ -454,6 +454,7 @@ export async function wander(bot, radius = 12) { return { ok: true, detail: { to: { x: tx, y: ty, z: tz }, via: best.name } }; } catch (e) { warn("action", `wander pathfinder failed: ${e.message} — continuing blind in ${best.name}`); + const beforeBlind = clonePos(bot.entity.position); try { await bot.look(best.yaw, 0, true); } catch {} bot.setControlState("forward", true); bot.setControlState("jump", true); @@ -463,7 +464,19 @@ export async function wander(bot, radius = 12) { bot.setControlState("forward", false); bot.setControlState("jump", false); } - return { ok: true, detail: { to: { x: tx, y: ty, z: tz }, mode: "blind", via: best.name } }; + const moved = horizontalDistance(beforeBlind, bot.entity.position); + if (moved < 0.75) { + info("action", `wander: blind ${best.name} moved only ${moved.toFixed(2)} horizontally → tunnel-out`); + const tunnel = await digEscapeTunnel(bot, { maxSteps: 3, reason: `wander blind ${best.name}` }); + const detail = { to: { x: tx, y: ty, z: tz }, mode: "blind", via: best.name, moved, recovery: tunnel.detail ?? null }; + if (!tunnel.ok) return { ok: false, code: tunnel.code ?? "wedged", detail, worldDelta: tunnel.worldDelta ?? null }; + return { ok: true, detail: { ...(tunnel.detail ?? {}), previousMode: "blind", recovered: true }, worldDelta: tunnel.worldDelta ?? null }; + } + return { + ok: true, + detail: { to: { x: tx, y: ty, z: tz }, mode: "blind-moved", previousMode: "blind", via: best.name, moved }, + worldDelta: { movedTo: clonePos(bot.entity.position) }, + }; } } diff --git a/runtime/skills/explore-far.js b/runtime/skills/explore-far.js index 60d7a34..96dd371 100644 --- a/runtime/skills/explore-far.js +++ b/runtime/skills/explore-far.js @@ -122,24 +122,62 @@ export const skill = Object.freeze({ }; } catch (e) { warn("action", `explore.far pathfinder failed: ${e.message} — continuing blind`); - try { await bot.look(best.yaw, 0, true); } catch {} - bot.setControlState("forward", true); - bot.setControlState("jump", true); - try { - await new Promise((r) => setTimeout(r, 7_000)); - } finally { - bot.setControlState("forward", false); - bot.setControlState("jump", false); - } - return { - ok: true, code: "done", - detail: { mode: "blind", dir: best.name }, - worldDelta: { movedTo: null }, - }; + return blindWalkOrTunnelOut(bot, { + yaw: best.yaw, + dirName: best.name, + blindMs: args.blindMs ?? 7_000, + tunnelPushMs: args.tunnelPushMs, + reason: `explore.far blind ${best.name}`, + }); } }, }); +async function blindWalkOrTunnelOut(bot, { yaw, dirName, blindMs = 7_000, minMove = 0.75, tunnelPushMs, reason = "blind fallback" } = {}) { + const before = clonePos(bot.entity.position); + try { await bot.look(yaw, 0, true); } catch {} + bot.setControlState("forward", true); + bot.setControlState("jump", true); + try { + await new Promise((r) => setTimeout(r, Math.max(0, blindMs))); + } finally { + bot.setControlState("forward", false); + bot.setControlState("jump", false); + } + + const moved = horizontalDistance(before, bot.entity.position); + if (moved >= minMove) { + return { + ok: true, + code: "done", + detail: { mode: "blind-moved", previousMode: "blind", dir: dirName, moved }, + worldDelta: { movedTo: clonePos(bot.entity.position) }, + }; + } + + info("action", `explore.far: blind ${dirName} moved only ${moved.toFixed(2)} horizontally → tunnel-out`); + const tunnelArgs = { maxSteps: 3, reason }; + if (tunnelPushMs !== undefined) tunnelArgs.pushMs = tunnelPushMs; + const tunnel = await digEscapeTunnel(bot, tunnelArgs); + const detail = { mode: "blind", dir: dirName, moved, recovery: tunnel.detail ?? null }; + if (!tunnel.ok) { + return { + ok: false, + code: tunnel.code ?? "wedged", + detail, + worldDelta: tunnel.worldDelta ?? null, + }; + } + return { + ok: true, + code: "done", + detail: { ...(tunnel.detail ?? {}), previousMode: "blind", recovered: true }, + worldDelta: tunnel.worldDelta ?? { movedTo: clonePos(bot.entity.position) }, + }; +} + +export const _internal = { blindWalkOrTunnelOut }; + const CARDINAL_YAWS = [ { name: "N", yaw: Math.PI }, { name: "E", yaw: -Math.PI / 2 }, diff --git a/runtime/skills/recovery-tunnel-out.test.js b/runtime/skills/recovery-tunnel-out.test.js index 8fc505f..1905276 100644 --- a/runtime/skills/recovery-tunnel-out.test.js +++ b/runtime/skills/recovery-tunnel-out.test.js @@ -2,6 +2,7 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { getSkill } from "./index.js"; +import { _internal as exploreFarInternal } from "./explore-far.js"; import { digEscapeTunnel, _internal } from "./recovery-tunnel-out.js"; function makePos(x, y, z) { @@ -98,3 +99,25 @@ test("tunnel-out does not count jumping in place as escape", async () => { assert.equal(res.code, "wedged"); assert.match(res.detail.error, /moved only 0\.00 horizontally/); }); + +test("explore.far blind fallback does not report done when position is unchanged", async () => { + const blocks = {}; + const bot = makeBot(blocks); + bot.look = async () => {}; + bot.lookAt = async () => {}; + bot.dig = async (block) => { + blocks[`${block.position.x},${block.position.y},${block.position.z}`] = "air"; + }; + bot.setControlState = () => {}; // no physics movement in this wedged simulation + + const res = await exploreFarInternal.blindWalkOrTunnelOut(bot, { + yaw: 0, + dirName: "E", + blindMs: 0, + tunnelPushMs: 0, + }); + assert.equal(res.ok, false); + assert.equal(res.code, "wedged"); + assert.equal(res.detail.mode, "blind"); + assert.equal(res.detail.dir, "E"); +});