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
|
// findBlock invokes the matcher for blocks that pass the maxDistance
|
||||||
// pre-filter; in dense areas some have a synthetic shape with no
|
// pre-filter; in dense areas some have a synthetic shape with no
|
||||||
// `.position`. Guard or we crash before we even start pathfinding.
|
// `.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({
|
const log = bot.findBlock({
|
||||||
matching: (b) => {
|
matching: (b) => {
|
||||||
if (!b || !b.position || !LOG_NAMES.includes(b.name)) return false;
|
if (!b || !b.position || !LOG_NAMES.includes(b.name)) return false;
|
||||||
const key = `${b.position.x},${b.position.y},${b.position.z}`;
|
const key = `${b.position.x},${b.position.y},${b.position.z}`;
|
||||||
return !blacklist.has(key);
|
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);
|
ensureCollectBlock(bot);
|
||||||
setMovementsForGather(bot);
|
setMovementsForGather(bot);
|
||||||
@@ -403,13 +407,43 @@ export async function wander(bot, radius = 12) {
|
|||||||
try {
|
try {
|
||||||
await withTimeout(
|
await withTimeout(
|
||||||
bot.pathfinder.goto(new goals.GoalNear(tx, ty, tz, 2)),
|
bot.pathfinder.goto(new goals.GoalNear(tx, ty, tz, 2)),
|
||||||
30_000,
|
15_000,
|
||||||
`wander(${tx},${tz})`,
|
`wander(${tx},${tz})`,
|
||||||
);
|
);
|
||||||
return { ok: true, detail: { to: { x: tx, y: ty, z: tz } } };
|
return { ok: true, detail: { to: { x: tx, y: ty, z: tz } } };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
warn("action", `wander failed: ${e.message}`);
|
warn("action", `wander pathfinder failed: ${e.message} — falling back to blind walk`);
|
||||||
return { ok: false, detail: e.message };
|
// 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";
|
} from "./actions.js";
|
||||||
import { runSkill, getSkill } from "./skills/index.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";
|
const REFLEX_LOG = "reflex";
|
||||||
|
|
||||||
// A reflex returns one of:
|
// A reflex returns one of:
|
||||||
@@ -117,11 +124,28 @@ function eatReflex(ctx) {
|
|||||||
|
|
||||||
// ---- sleep -----------------------------------------------------------------
|
// ---- 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) {
|
function sleepReflex(ctx) {
|
||||||
const s = ctx.snapshot;
|
const s = ctx.snapshot;
|
||||||
if (!s.connected) return { action: "noop" };
|
if (!s.connected) return { action: "noop" };
|
||||||
if (s.isDay) return { action: "noop" };
|
if (s.isDay) return { action: "noop" };
|
||||||
if (s.closestHostile && s.closestHostile.distance < 8) return { action: "noop" }; // not safe
|
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
|
// Longer cooldown after a failure — if there's no bed nearby, retrying
|
||||||
// every 30s blocks autonomous behaviour without ever succeeding.
|
// every 30s blocks autonomous behaviour without ever succeeding.
|
||||||
const since = Date.now() - (ctx.lastSleepAttemptAt ?? 0);
|
const since = Date.now() - (ctx.lastSleepAttemptAt ?? 0);
|
||||||
@@ -171,10 +195,16 @@ function curriculumReflex(ctx) {
|
|||||||
const wanderHintUntil = ctx.skillBackoff?.["__wander_hint__"] ?? 0;
|
const wanderHintUntil = ctx.skillBackoff?.["__wander_hint__"] ?? 0;
|
||||||
const wantWander = wanderHintUntil && Date.now() < wanderHintUntil;
|
const wantWander = wanderHintUntil && Date.now() < wanderHintUntil;
|
||||||
|
|
||||||
// No skill plan from curriculum OR a recent skill asked us to wander —
|
// No skill plan from curriculum OR a recent skill asked us to wander.
|
||||||
// dispatch a wander fallback so we keep moving.
|
// 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) {
|
if (!plan?.skillId || wantWander) {
|
||||||
ctx.lastCurriculumAt = Date.now();
|
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", {});
|
ctx.dispatch(() => wander(ctx.bot, 16), "wander", {});
|
||||||
return { action: "dispatched", kind: "curriculum-wander", label: "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
|
// Same fix the old autonomous reflex applied for "no reachable
|
||||||
// log" — switch to exploration for a minute.
|
// log" — switch to exploration for a minute.
|
||||||
ctx.skillBackoff["__wander_hint__"] = Date.now() + SKILL_BACKOFF_MS;
|
ctx.skillBackoff["__wander_hint__"] = Date.now() + SKILL_BACKOFF_MS;
|
||||||
|
consecutiveWanderHints++;
|
||||||
}
|
}
|
||||||
if (!res?.ok) {
|
if (!res?.ok) {
|
||||||
// missing_tool / missing_material / no_target shouldn't be
|
// missing_tool / missing_material / no_target shouldn't be
|
||||||
@@ -215,6 +246,7 @@ function curriculumReflex(ctx) {
|
|||||||
} else {
|
} else {
|
||||||
// Success clears the wander hint immediately.
|
// Success clears the wander hint immediately.
|
||||||
ctx.skillBackoff["__wander_hint__"] = 0;
|
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/);
|
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({
|
const { ctx, dispatches } = makeCtx({
|
||||||
snapshot: {
|
snapshot: {
|
||||||
connected: true,
|
connected: true,
|
||||||
health: 20,
|
health: 20,
|
||||||
food: 10,
|
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" } },
|
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");
|
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", () => {
|
test("curriculum dispatches suggested skill by id", () => {
|
||||||
const { ctx, dispatches } = makeCtx({
|
const { ctx, dispatches } = makeCtx({
|
||||||
snapshot: {
|
snapshot: {
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ export const skill = Object.freeze({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
const msg = String(res.detail ?? "");
|
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"
|
? "no_target"
|
||||||
: msg.includes("timed out")
|
: msg.includes("timed out")
|
||||||
? "timeout"
|
? "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 chopLogs } from "./chop-logs.js";
|
||||||
import { skill as eat } from "./eat.js";
|
import { skill as eat } from "./eat.js";
|
||||||
import { skill as wander } from "./wander.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 gatherStone } from "./gather-stone.js";
|
||||||
import { skill as gatherWool } from "./gather-wool.js";
|
import { skill as gatherWool } from "./gather-wool.js";
|
||||||
import { skill as chooseBase } from "./choose-base.js";
|
import { skill as chooseBase } from "./choose-base.js";
|
||||||
@@ -60,6 +61,7 @@ function register(skill) {
|
|||||||
register(chopLogs);
|
register(chopLogs);
|
||||||
register(eat);
|
register(eat);
|
||||||
register(wander);
|
register(wander);
|
||||||
|
register(exploreFar);
|
||||||
register(gatherStone);
|
register(gatherStone);
|
||||||
register(gatherWool);
|
register(gatherWool);
|
||||||
register(chooseBase);
|
register(chooseBase);
|
||||||
|
|||||||
Reference in New Issue
Block a user