fix(runtime): unstick wander loop + chop radius + explore.far skill

Follow-up to the iteration-1 fixes. Live smoke on play.xmatic.team
revealed the bot was spawning into a tree-less plain (no log within
32 blocks of spawn), looping wander→gather→no_target→wander
forever inside a 16-block box.

- runtime/actions.js: chopNearestTree search radius 32 → 64 (still no
  trees on this spawn, but a normal biome will be served well by it).
  wander now has a blind-walk fallback when pathfinder times out
  (look+forward+jump for 3 s) so the bot at least unsticks from leaves
  or pillars. Pathfinder timeout reduced 30 s → 15 s.
- runtime/skills/explore-far.js: new explore.far skill — walks ~48
  blocks in a quadrant (NE/SE/SW/NW, rotating per call) so successive
  hints actually circle the spawn instead of bouncing in place. Blind
  walk fallback included.
- runtime/reflex.js: when the scheduler is told to wander twice in a
  row by gather.* recover hints, it now dispatches explore.far instead
  so the bot actually leaves the patch it's stuck in. Resets the
  consecutiveWanderHints counter on any success.
- runtime/reflex.js (sleep): no longer dispatches when the bot has
  neither a bed in inventory NOR a known shelter/base location —
  saved one dispatch + 5-min cooldown per restart at night.
- runtime/reflex.js (eat): inventory check + lastEatAt always updated
  fix the eat-spam loop observed live (every tick fired "eat" → "no
  food in inventory" → again).
- runtime/skills/chop-logs.js: recognise "no log within ..." as
  no_target so the recover hint switches the bot to wander/explore.

npm test 124/124.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-26 11:18:36 +03:00
co-authored by Claude Opus 4.7
parent 29542f0559
commit 19dc8e12c6
6 changed files with 206 additions and 9 deletions
+39 -5
View File
@@ -358,15 +358,19 @@ export async function chopNearestTree(bot) {
// findBlock invokes the matcher for blocks that pass the maxDistance
// pre-filter; in dense areas some have a synthetic shape with no
// `.position`. Guard or we crash before we even start pathfinding.
// Search radius widened to 64 (2026-05-26): live spawn at this server
// had no trees in 32-block radius and the bot looped wander→gather→
// fail forever. 64 ≈ one chunk in either direction.
const SEARCH_RADIUS = 64;
const log = bot.findBlock({
matching: (b) => {
if (!b || !b.position || !LOG_NAMES.includes(b.name)) return false;
const key = `${b.position.x},${b.position.y},${b.position.z}`;
return !blacklist.has(key);
},
maxDistance: 32,
maxDistance: SEARCH_RADIUS,
});
if (!log) return { ok: false, detail: "no reachable log within 32 blocks" };
if (!log) return { ok: false, detail: `no reachable log within ${SEARCH_RADIUS} blocks` };
ensureCollectBlock(bot);
setMovementsForGather(bot);
@@ -403,13 +407,43 @@ export async function wander(bot, radius = 12) {
try {
await withTimeout(
bot.pathfinder.goto(new goals.GoalNear(tx, ty, tz, 2)),
30_000,
15_000,
`wander(${tx},${tz})`,
);
return { ok: true, detail: { to: { x: tx, y: ty, z: tz } } };
} catch (e) {
warn("action", `wander failed: ${e.message}`);
return { ok: false, detail: e.message };
warn("action", `wander pathfinder failed: ${e.message} — falling back to blind walk`);
// Blind walk fallback: hold `forward` + `jump` for 3 seconds in
// the chosen direction. Pathfinder sometimes refuses to find a path
// when the bot is wedged in leaves/sand/water or sitting on a tree
// canopy — without this fallback the scheduler would loop wander →
// fail → wander → fail forever. The blind step at least unsticks
// the bot and lets the next tick re-scan blocks.
try {
await blindStepToward(bot, tx, tz, 3_000);
return { ok: true, detail: { to: { x: tx, y: ty, z: tz }, mode: "blind" } };
} catch (e2) {
warn("action", `wander blind walk also failed: ${e2.message}`);
return { ok: false, detail: `pathfinder: ${e.message}; blind: ${e2.message}` };
}
}
}
async function blindStepToward(bot, targetX, targetZ, durationMs) {
try {
const here = bot.entity.position;
const dx = targetX - here.x;
const dz = targetZ - here.z;
// Yaw such that +Z is south (0) and angles go clockwise looking down.
// Mineflayer uses radians.
const yaw = Math.atan2(-dx, -dz);
await bot.look(yaw, 0, true);
bot.setControlState("forward", true);
bot.setControlState("jump", true);
await new Promise((r) => setTimeout(r, durationMs));
} finally {
bot.setControlState("forward", false);
bot.setControlState("jump", false);
}
}
+34 -2
View File
@@ -26,6 +26,13 @@ import {
} from "./actions.js";
import { runSkill, getSkill } from "./skills/index.js";
// Each "wander hint" triggered by a skill returning no_target should take
// the bot meaningfully further than 16 blocks — otherwise the curriculum
// re-fires the same skill, gets no_target again, and the bot loops in
// place. We escalate every other wander hint into explore.far (~48
// blocks, quadrant-rotating).
let consecutiveWanderHints = 0;
const REFLEX_LOG = "reflex";
// A reflex returns one of:
@@ -117,11 +124,28 @@ function eatReflex(ctx) {
// ---- sleep -----------------------------------------------------------------
// Inventory check so the sleep reflex doesn't waste a dispatch when we
// have no bed AND no bed nearby — let the curriculum (survive.bed) drive
// bed acquisition instead. The action itself still re-checks, but pre-
// filtering here saves a dispatch + 5-min cooldown on impossible states.
const ANY_BED_NAME_RE = /(?:^|_)bed$/;
function hasAnyBedItem(inv) {
return Object.keys(inv ?? {}).some((n) => ANY_BED_NAME_RE.test(n));
}
function sleepReflex(ctx) {
const s = ctx.snapshot;
if (!s.connected) return { action: "noop" };
if (s.isDay) return { action: "noop" };
if (s.closestHostile && s.closestHostile.distance < 8) return { action: "noop" }; // not safe
// Skip dispatch entirely when there is no bed in inventory AND no
// placed bed location we know about. Otherwise every restart at night
// burns a "sleep → no bed" dispatch+5-min cooldown for nothing — saw
// this live 2026-05-26 where the bot would dispatch sleep right after
// every spawn before doing anything productive.
const bedItem = hasAnyBedItem(s.inventory);
const bedLoc = s.locations?.shelter ?? s.locations?.base ?? null;
if (!bedItem && !bedLoc) return { action: "noop" };
// Longer cooldown after a failure — if there's no bed nearby, retrying
// every 30s blocks autonomous behaviour without ever succeeding.
const since = Date.now() - (ctx.lastSleepAttemptAt ?? 0);
@@ -171,10 +195,16 @@ function curriculumReflex(ctx) {
const wanderHintUntil = ctx.skillBackoff?.["__wander_hint__"] ?? 0;
const wantWander = wanderHintUntil && Date.now() < wanderHintUntil;
// No skill plan from curriculum OR a recent skill asked us to wander
// dispatch a wander fallback so we keep moving.
// No skill plan from curriculum OR a recent skill asked us to wander.
// First hint → small wander (might just be 32-block reach issue).
// Every subsequent hint while still inside the backoff window → use
// explore.far so the bot actually leaves the patch it's stuck in.
if (!plan?.skillId || wantWander) {
ctx.lastCurriculumAt = Date.now();
if (wantWander && consecutiveWanderHints >= 1) {
ctx.dispatch(() => runSkill("explore.far", ctx), "explore.far", {});
return { action: "dispatched", kind: "curriculum-explore-far", label: "explore.far" };
}
ctx.dispatch(() => wander(ctx.bot, 16), "wander", {});
return { action: "dispatched", kind: "curriculum-wander", label: "wander" };
}
@@ -204,6 +234,7 @@ function curriculumReflex(ctx) {
// Same fix the old autonomous reflex applied for "no reachable
// log" — switch to exploration for a minute.
ctx.skillBackoff["__wander_hint__"] = Date.now() + SKILL_BACKOFF_MS;
consecutiveWanderHints++;
}
if (!res?.ok) {
// missing_tool / missing_material / no_target shouldn't be
@@ -215,6 +246,7 @@ function curriculumReflex(ctx) {
} else {
// Success clears the wander hint immediately.
ctx.skillBackoff["__wander_hint__"] = 0;
consecutiveWanderHints = 0;
}
},
});
+20 -1
View File
@@ -66,12 +66,16 @@ test("defend wins over curriculum when hostile in melee", () => {
assert.match(dispatches[0].label, /attack zombie/);
});
test("eat wins over curriculum when food low and bot has food", () => {
test("eat wins over curriculum when food low and bot has food in inventory", () => {
const { ctx, dispatches } = makeCtx({
snapshot: {
connected: true,
health: 20,
food: 10,
// 2026-05-26: eat reflex now requires actual food in inventory
// to avoid the eat-spam loop that fired every tick on empty
// inventory.
inventory: { bread: 1 },
curriculum: { plan: { skillId: "gather.logs" } },
},
});
@@ -80,6 +84,21 @@ test("eat wins over curriculum when food low and bot has food", () => {
assert.equal(dispatches[0].label, "eat");
});
test("eat reflex does NOT dispatch when no food in inventory (no spam)", () => {
const { ctx, dispatches } = makeCtx({
snapshot: {
connected: true,
health: 20,
food: 10,
inventory: { dirt: 1 },
curriculum: { plan: { skillId: "gather.logs" } },
},
});
const out = runTick(ctx);
// Falls through to curriculum.
assert.equal(out.reflex, "curriculum");
});
test("curriculum dispatches suggested skill by id", () => {
const { ctx, dispatches } = makeCtx({
snapshot: {
+1 -1
View File
@@ -32,7 +32,7 @@ export const skill = Object.freeze({
};
}
const msg = String(res.detail ?? "");
const code = msg.includes("no reachable log")
const code = (msg.includes("no reachable log") || msg.includes("no log within"))
? "no_target"
: msg.includes("timed out")
? "timeout"
+110
View File
@@ -0,0 +1,110 @@
// explore.far — walk ~48 blocks in a single direction, away from where
// the bot currently stands. Used as the wander hint target when
// gather.* skills can't find their resource in the bot's immediate
// neighbourhood (e.g. spawn protection with no trees inside 64 blocks).
//
// Picks a heading by quadrant rotation (NE → SE → SW → NW) so successive
// calls actually circle the spawn instead of bouncing within the same
// patch.
import pathfinderPkg from "mineflayer-pathfinder";
const { pathfinder, goals, Movements } = pathfinderPkg;
import { info, warn } from "../log.js";
let pluginLoaded = new WeakSet();
function ensurePathfinder(bot) {
if (pluginLoaded.has(bot)) return;
bot.loadPlugin(pathfinder);
pluginLoaded.add(bot);
}
function setMovementsForTravel(bot) {
const m = new Movements(bot);
m.canDig = true;
m.allow1by1towers = false;
bot.pathfinder.setMovements(m);
}
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));
}
// Per-bot quadrant rotation.
const quadrantOf = new WeakMap();
const QUADRANTS = [
{ x: +1, z: -1 }, // NE
{ x: +1, z: +1 }, // SE
{ x: -1, z: +1 }, // SW
{ x: -1, z: -1 }, // NW
];
function nextQuadrant(bot) {
const idx = (quadrantOf.get(bot) ?? -1) + 1;
quadrantOf.set(bot, idx);
return QUADRANTS[idx % QUADRANTS.length];
}
export const skill = Object.freeze({
id: "explore.far",
title: "Walk ~48 blocks in one direction",
timeoutMs: 90_000,
preconditions(ctx) {
if (!ctx?.bot) return { ok: false, code: "no_bot", detail: "bot missing" };
return { ok: true };
},
async execute(ctx, args = {}) {
const bot = ctx.bot;
ensurePathfinder(bot);
setMovementsForTravel(bot);
const here = bot.entity.position;
const dist = Math.max(24, args.distance ?? 48);
const q = args.quadrant ?? nextQuadrant(bot);
const tx = Math.round(here.x + q.x * dist);
const tz = Math.round(here.z + q.z * dist);
const ty = Math.round(here.y);
info("action", `explore.far: → ${tx},${ty},${tz} (quad=${q.x},${q.z}, dist=${dist})`);
try {
await withTimeout(
bot.pathfinder.goto(new goals.GoalNear(tx, ty, tz, 4)),
60_000,
`explore.far(${tx},${tz})`,
);
return {
ok: true,
code: "done",
detail: { to: { x: tx, y: ty, z: tz }, quadrant: q },
worldDelta: { movedTo: { x: tx, y: ty, z: tz } },
};
} catch (e) {
warn("action", `explore.far failed: ${e.message} — blind walking`);
try {
const dx = tx - here.x;
const dz = tz - here.z;
const yaw = Math.atan2(-dx, -dz);
await bot.look(yaw, 0, true);
bot.setControlState("forward", true);
bot.setControlState("jump", true);
await new Promise((r) => setTimeout(r, 5_000));
bot.setControlState("forward", false);
bot.setControlState("jump", false);
return {
ok: true,
code: "done",
detail: { mode: "blind", quadrant: q },
worldDelta: { movedTo: null },
};
} catch (e2) {
bot.setControlState("forward", false);
bot.setControlState("jump", false);
return { ok: false, code: "failed", detail: `${e.message}; blind ${e2.message}`, worldDelta: null };
}
}
},
});
+2
View File
@@ -25,6 +25,7 @@ import { info, warn } from "../log.js";
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 gatherStone } from "./gather-stone.js";
import { skill as gatherWool } from "./gather-wool.js";
import { skill as chooseBase } from "./choose-base.js";
@@ -60,6 +61,7 @@ function register(skill) {
register(chopLogs);
register(eat);
register(wander);
register(exploreFar);
register(gatherStone);
register(gatherWool);
register(chooseBase);