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:
+39
-5
@@ -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
@@ -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
@@ -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: {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user