diff --git a/runtime/actions.js b/runtime/actions.js index e2392bf..0050e23 100644 --- a/runtime/actions.js +++ b/runtime/actions.js @@ -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); } } diff --git a/runtime/reflex.js b/runtime/reflex.js index 0a51452..e8cb690 100644 --- a/runtime/reflex.js +++ b/runtime/reflex.js @@ -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; } }, }); diff --git a/runtime/reflex.test.js b/runtime/reflex.test.js index 02ca1c2..699636f 100644 --- a/runtime/reflex.test.js +++ b/runtime/reflex.test.js @@ -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: { diff --git a/runtime/skills/chop-logs.js b/runtime/skills/chop-logs.js index c6b3ab2..c51f556 100644 --- a/runtime/skills/chop-logs.js +++ b/runtime/skills/chop-logs.js @@ -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" diff --git a/runtime/skills/explore-far.js b/runtime/skills/explore-far.js new file mode 100644 index 0000000..de779f1 --- /dev/null +++ b/runtime/skills/explore-far.js @@ -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 }; + } + } + }, +}); diff --git a/runtime/skills/index.js b/runtime/skills/index.js index 8f13b19..c61f003 100644 --- a/runtime/skills/index.js +++ b/runtime/skills/index.js @@ -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);