Files
pepa-pi-bot/runtime/actions.js
T
mayatnikovandClaude Opus 4.7 19dc8e12c6 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>
2026-05-26 11:18:36 +03:00

642 lines
21 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Mineflayer action primitives. Each function is async, has a hard timeout,
// catches its own errors, and returns { ok: boolean, detail?: any }.
// Actions are dispatched from the reflex layer via ctx.dispatch — never
// awaited inline in a tick, because they may take seconds.
import pathfinderPkg from "mineflayer-pathfinder";
const { pathfinder, goals, Movements } = pathfinderPkg;
import collectBlockPkg from "mineflayer-collectblock";
const collectBlockPlugin =
collectBlockPkg.plugin ??
collectBlockPkg.default?.plugin ??
collectBlockPkg.default ??
collectBlockPkg;
import { info, warn } from "./log.js";
// Hard timeout wrapper. Mineflayer goals (pathfinder, pvp targeting) can hang
// when the goal is unreachable; without a ceiling the whole reflex chain stops.
function withTimeout(promise, ms, label) {
return Promise.race([
Promise.resolve(promise),
new Promise((_, reject) =>
setTimeout(() => reject(new Error(`${label} timed out after ${ms / 1000}s`)), ms),
),
]);
}
// Mineflayer ships with the pathfinder plugin externally — but we need to
// ensure it's loaded exactly once per bot. The reflex bot doesn't auto-load
// it the way mineflayer-bridge.ts did, so we lazy-load here.
let pluginLoaded = new WeakSet();
function ensurePathfinder(bot) {
if (pluginLoaded.has(bot)) return;
bot.loadPlugin(pathfinder);
pluginLoaded.add(bot);
}
// collectblock handles the full "find → approach → reposition → dig →
// pickup" cycle which raw bot.dig + pathfinder.goto does not. The old
// chop primitive "clicked once and stopped" because bot.dig requires a
// stable LoS that GoalGetToBlock doesn't always satisfy — bot ended up
// in leaves above the log and swung once with no progress.
let collectBlockLoaded = new WeakSet();
function ensureCollectBlock(bot) {
ensurePathfinder(bot);
if (collectBlockLoaded.has(bot)) return;
bot.loadPlugin(collectBlockPlugin);
collectBlockLoaded.add(bot);
}
// Each action that uses pathfinder should set its own Movements profile
// before calling goto — otherwise it inherits whatever the previous caller
// left set, which has caused live regressions (e.g. flee setting canDig=false,
// then a later chop inheriting the same restrictive profile).
function setMovementsForGather(bot) {
const m = new Movements(bot);
m.canDig = true; // chopping is the entire point
m.allow1by1towers = false;
bot.pathfinder.setMovements(m);
}
function setMovementsForTravel(bot) {
const m = new Movements(bot);
m.canDig = true; // dig through leaves rather than get stuck
m.allow1by1towers = false;
bot.pathfinder.setMovements(m);
}
// ---- combat ----------------------------------------------------------------
const MELEE_WEAPONS = [
"netherite_sword",
"diamond_sword",
"iron_sword",
"stone_sword",
"golden_sword",
"wooden_sword",
"netherite_axe",
"diamond_axe",
"iron_axe",
"stone_axe",
"golden_axe",
"wooden_axe",
];
async function equipBestMelee(bot) {
for (const name of MELEE_WEAPONS) {
const item = bot.inventory.items().find((i) => i.name === name);
if (item) {
try {
await bot.equip(item, "hand");
return name;
} catch {
// keep trying
}
}
}
return null;
}
export async function attackNearest(bot, hostileType) {
const target = Object.values(bot.entities).find(
(e) => (hostileType ? e.name === hostileType : isHostile(e)) && e.position.distanceTo(bot.entity.position) <= 4,
);
if (!target) return { ok: false, detail: "no target in reach" };
const weapon = await equipBestMelee(bot);
info("action", `attack: target=${target.name} dist=${target.position.distanceTo(bot.entity.position).toFixed(1)} weapon=${weapon ?? "fists"}`);
try {
// Look at target then swing. Single hit per call — reflex tick re-fires
// until the target is dead or out of range.
await withTimeout(bot.lookAt(target.position.offset(0, target.height ?? 1, 0)), 2000, "lookAt");
bot.attack(target);
return { ok: true, detail: { target: target.name, weapon } };
} catch (e) {
warn("action", `attack failed: ${e.message}`);
return { ok: false, detail: e.message };
}
}
// ---- flee ------------------------------------------------------------------
export async function fleeFrom(bot, fromEntity, distance = 16) {
ensurePathfinder(bot);
const from = fromEntity?.position ?? bot.entity.position;
const here = bot.entity.position;
// vector away
const dx = here.x - from.x;
const dz = here.z - from.z;
const len = Math.hypot(dx, dz) || 1;
const tx = Math.round(here.x + (dx / len) * distance);
const tz = Math.round(here.z + (dz / len) * distance);
const ty = Math.round(here.y);
info("action", `flee: from=${fromEntity?.name ?? "?"}${tx},${ty},${tz}`);
// canDig:true here is deliberate — without it the bot gets permanently
// stuck in dense tree canopy (observed live: bot perched at Y=85 inside
// dark-oak leaves, every flee timed out for hours). We accept the risk of
// chopping through scenery while panicking; it's how a player would react.
const movements = new Movements(bot);
movements.canDig = true;
movements.allow1by1towers = false;
bot.pathfinder.setMovements(movements);
try {
await withTimeout(
bot.pathfinder.goto(new goals.GoalNear(tx, ty, tz, 1)),
30_000,
`fleeFrom(${fromEntity?.name})`,
);
return { ok: true, detail: { to: { x: tx, y: ty, z: tz } } };
} catch (e) {
warn("action", `flee failed: ${e.message}`);
return { ok: false, detail: e.message };
}
}
// ---- food ------------------------------------------------------------------
const FOOD_PRIORITY = [
"cooked_beef",
"cooked_porkchop",
"cooked_mutton",
"cooked_chicken",
"cooked_rabbit",
"cooked_salmon",
"cooked_cod",
"baked_potato",
"bread",
"carrot",
"apple",
"sweet_berries",
"melon_slice",
"beef",
"porkchop",
"chicken",
"mutton",
];
function findFood(bot) {
for (const name of FOOD_PRIORITY) {
const item = bot.inventory.items().find((i) => i.name === name);
if (item) return item;
}
// fallback: anything with food value via mc-data is too brittle; we accept
// only the priority list to avoid accidentally eating poisonous spider eyes.
return null;
}
export async function eatBestFood(bot) {
const item = findFood(bot);
if (!item) return { ok: false, detail: "no food in inventory" };
info("action", `eat: ${item.name}`);
try {
await withTimeout(bot.equip(item, "hand"), 3000, "equip food");
await withTimeout(bot.consume(), 15_000, "consume");
return { ok: true, detail: { ate: item.name } };
} catch (e) {
warn("action", `eat failed: ${e.message}`);
return { ok: false, detail: e.message };
}
}
// ---- sleep -----------------------------------------------------------------
const BED_NAMES = [
"red_bed",
"white_bed",
"orange_bed",
"yellow_bed",
"lime_bed",
"green_bed",
"cyan_bed",
"light_blue_bed",
"blue_bed",
"purple_bed",
"magenta_bed",
"pink_bed",
"brown_bed",
"gray_bed",
"light_gray_bed",
"black_bed",
];
function carriedBedItem(bot) {
for (const item of bot.inventory.items()) {
if (BED_NAMES.includes(item.name)) return item;
}
return null;
}
export async function sleepInBed(bot) {
// Already in a bed?
if (bot.isSleeping) return { ok: true, detail: "already sleeping" };
// 1. Find a nearby placed bed first.
const bedBlock = bot.findBlock({
matching: (b) => BED_NAMES.includes(b?.name),
maxDistance: 16,
});
if (bedBlock) {
info("action", `sleep: nearest bed at ${bedBlock.position.x},${bedBlock.position.y},${bedBlock.position.z}`);
ensurePathfinder(bot);
setMovementsForTravel(bot);
try {
await withTimeout(
bot.pathfinder.goto(new goals.GoalNear(bedBlock.position.x, bedBlock.position.y, bedBlock.position.z, 2)),
15_000,
"goto bed",
);
await withTimeout(bot.sleep(bedBlock), 10_000, "bot.sleep");
return { ok: true, detail: { bedAt: bedBlock.position } };
} catch (e) {
warn("action", `sleep failed: ${e.message}`);
return { ok: false, detail: e.message };
}
}
// 2. No placed bed — if we're carrying one, place it right next to us
// and sleep on it. This is critical so the bot stops blocking player
// night-skipping the moment it owns a bed. We pick a footing block at
// the bot's feet level + 1 in the +X direction.
const carried = carriedBedItem(bot);
if (carried) {
const here = bot.entity.position;
const referenceBlock = bot.blockAt(here.offset(1, -1, 0));
const targetSlot = bot.blockAt(here.offset(1, 0, 0));
if (!referenceBlock || !referenceBlock.boundingBox || referenceBlock.boundingBox === "empty") {
return { ok: false, detail: "no solid ground to place bed on" };
}
if (targetSlot && targetSlot.boundingBox && targetSlot.boundingBox !== "empty") {
return { ok: false, detail: "no space to place bed" };
}
try {
await withTimeout(bot.equip(carried, "hand"), 3000, "equip bed");
await withTimeout(
bot.placeBlock(referenceBlock, { x: 0, y: 1, z: 0 }),
5000,
"placeBlock(bed)",
);
info("action", `sleep: placed ${carried.name} at ${referenceBlock.position.x + 0},${referenceBlock.position.y + 1},${referenceBlock.position.z + 0}`);
// Re-scan for the placed bed (its block name may differ from the
// item name slightly, e.g. on some servers, and the placement may
// have shifted to an adjacent slot for the bed's second half).
const placed = bot.findBlock({
matching: (b) => BED_NAMES.includes(b?.name),
maxDistance: 4,
});
if (!placed) return { ok: false, detail: "placed bed not found after placement" };
await withTimeout(bot.sleep(placed), 10_000, "bot.sleep(placed)");
return { ok: true, detail: { bedAt: placed.position, placed: true, name: carried.name } };
} catch (e) {
warn("action", `sleep place+sleep failed: ${e.message}`);
return { ok: false, detail: e.message };
}
}
return { ok: false, detail: "no bed in inventory or nearby" };
}
// ---- gathering -------------------------------------------------------------
const LOG_NAMES = [
"oak_log",
"dark_oak_log",
"spruce_log",
"birch_log",
"jungle_log",
"acacia_log",
"mangrove_log",
"cherry_log",
"pale_oak_log",
];
const AXE_NAMES = [
"netherite_axe",
"diamond_axe",
"iron_axe",
"stone_axe",
"golden_axe",
"wooden_axe",
];
async function equipBestAxe(bot) {
for (const name of AXE_NAMES) {
const item = bot.inventory.items().find((i) => i.name === name);
if (item) {
try {
await bot.equip(item, "hand");
return name;
} catch {}
}
}
return null;
}
// Per-bot blacklist of (x,y,z) positions that pathfinder failed to reach
// recently. Cleared after BLACKLIST_TTL_MS so the bot can retry if the world
// has changed (a player chopped a path, the tree fell to natural decay, etc).
const chopBlacklist = new WeakMap(); // bot → Map<"x,y,z", expireMs>
const BLACKLIST_TTL_MS = 5 * 60_000;
function getBlacklist(bot) {
let m = chopBlacklist.get(bot);
if (!m) {
m = new Map();
chopBlacklist.set(bot, m);
}
// Sweep expired entries on each lookup — small, cheap.
const now = Date.now();
for (const [k, exp] of m) if (exp < now) m.delete(k);
return m;
}
export async function chopNearestTree(bot) {
const blacklist = getBlacklist(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: SEARCH_RADIUS,
});
if (!log) return { ok: false, detail: `no reachable log within ${SEARCH_RADIUS} blocks` };
ensureCollectBlock(bot);
setMovementsForGather(bot);
const axe = await equipBestAxe(bot);
info(
"action",
`chop: ${log.name} at ${log.position.x},${log.position.y},${log.position.z} (tool=${axe ?? "fists"})`,
);
try {
await withTimeout(bot.collectBlock.collect(log), 60_000, "collectLog");
return { ok: true, detail: { logType: log.name, at: log.position } };
} catch (e) {
warn("action", `chop failed: ${e.message}`);
const key = `${log.position.x},${log.position.y},${log.position.z}`;
blacklist.set(key, Date.now() + BLACKLIST_TTL_MS);
return { ok: false, detail: e.message, blacklisted: log.position };
}
}
// ---- exploration -----------------------------------------------------------
export async function wander(bot, radius = 12) {
ensurePathfinder(bot);
setMovementsForTravel(bot);
// Pick a random offset that's at least 6 blocks away — small enough to be
// safe, large enough to not be a no-op when we're stuck on the same block.
const here = bot.entity.position;
const angle = Math.random() * Math.PI * 2;
const dist = 6 + Math.random() * (radius - 6);
const tx = Math.round(here.x + Math.cos(angle) * dist);
const tz = Math.round(here.z + Math.sin(angle) * dist);
const ty = Math.round(here.y);
info("action", `wander: → ${tx},${ty},${tz} (dist=${dist.toFixed(1)})`);
try {
await withTimeout(
bot.pathfinder.goto(new goals.GoalNear(tx, ty, tz, 2)),
15_000,
`wander(${tx},${tz})`,
);
return { ok: true, detail: { to: { x: tx, y: ty, z: tz } } };
} catch (e) {
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);
}
}
// ---- crafting --------------------------------------------------------------
//
// Mineflayer's crafting API is two-step: find a recipe with bot.recipesFor()
// (which considers what's in inventory + nearby crafting tables), then call
// bot.craft(recipe, count, tableBlock?). For "needs a table" items the
// caller must place one within ~3 blocks first or pass the table block.
//
// We keep the actions small and explicit so the tech-tree reflex can compose
// them: chop → planks → sticks → table → axe → keep chopping (now faster).
function getItemCount(bot, name) {
return bot.inventory.items().reduce((sum, i) => (i.name === name ? sum + i.count : sum), 0);
}
function getAnyPlanksCount(bot) {
return bot.inventory
.items()
.reduce((sum, i) => (i.name.endsWith("_planks") ? sum + i.count : sum), 0);
}
function getAnyLogCount(bot) {
return bot.inventory.items().reduce((sum, i) => (i.name.endsWith("_log") ? sum + i.count : sum), 0);
}
// Pick a recipe by item-name regardless of table presence.
function findRecipe(bot, itemName, tableBlock = null) {
const mcdata = bot.registry ?? null;
const itemId = mcdata?.itemsByName?.[itemName]?.id;
if (itemId == null) return null;
const recipes = bot.recipesFor(itemId, null, 1, tableBlock);
return recipes[0] ?? null;
}
async function craftRecipe(bot, itemName, count = 1, tableBlock = null) {
const recipe = findRecipe(bot, itemName, tableBlock);
if (!recipe) {
return { ok: false, detail: `no recipe for ${itemName} (have: ${summarizeInv(bot)})` };
}
try {
await withTimeout(bot.craft(recipe, count, tableBlock), 15_000, `craft(${itemName})`);
return { ok: true, detail: { item: itemName, count } };
} catch (e) {
return { ok: false, detail: `craft(${itemName}): ${e.message}` };
}
}
function summarizeInv(bot) {
const items = bot.inventory.items();
if (!items.length) return "empty";
return items
.slice(0, 5)
.map((i) => `${i.name}×${i.count}`)
.join(",");
}
// Convert any wood logs into planks (4 planks per log). Picks the first log
// type we have. Most recipes don't need a table.
export async function craftPlanks(bot, count = 4) {
const log = bot.inventory.items().find((i) => i.name.endsWith("_log"));
if (!log) return { ok: false, detail: "no log in inventory" };
const planks = log.name.replace("_log", "_planks");
info("action", `craft: ${count} ${planks} (from ${log.name})`);
return craftRecipe(bot, planks, Math.ceil(count / 4));
}
// 2 planks → 4 sticks. No table needed.
export async function craftSticks(bot, count = 4) {
if (getAnyPlanksCount(bot) < 2) return { ok: false, detail: "need 2 planks" };
info("action", `craft: ${count} sticks`);
return craftRecipe(bot, "stick", Math.ceil(count / 4));
}
// Place a crafting table at the bot's feet+1 (or near). Returns the placed
// block so subsequent craft calls can pass it as tableBlock.
export async function placeCraftingTable(bot) {
// Already placed nearby?
const existing = bot.findBlock({
matching: (b) => b && b.name === "crafting_table",
maxDistance: 4,
});
if (existing) return { ok: true, detail: { at: existing.position, reused: true }, block: existing };
// Need to craft one first if we don't have it.
if (getItemCount(bot, "crafting_table") === 0) {
if (getAnyPlanksCount(bot) < 4) {
return { ok: false, detail: "need 4 planks to craft a table" };
}
const craftRes = await craftRecipe(bot, "crafting_table", 1);
if (!craftRes.ok) return craftRes;
}
// Pick a placement target — block beside the bot at foot level + 1 face.
const here = bot.entity.position;
const referenceBlock = bot.blockAt(here.offset(0, -1, 0));
if (!referenceBlock) return { ok: false, detail: "no reference block beneath bot" };
const tableItem = bot.inventory.items().find((i) => i.name === "crafting_table");
try {
await withTimeout(bot.equip(tableItem, "hand"), 3000, "equip table");
await withTimeout(
bot.placeBlock(referenceBlock, { x: 0, y: 1, z: 0 }),
5000,
"placeBlock(table)",
);
} catch (e) {
return { ok: false, detail: `place table: ${e.message}` };
}
const placed = bot.findBlock({
matching: (b) => b && b.name === "crafting_table",
maxDistance: 4,
});
info("action", `craft: placed crafting_table at ${placed?.position}`);
return { ok: true, detail: { at: placed?.position, reused: false }, block: placed };
}
export async function craftWoodenAxe(bot) {
const tableRes = await placeCraftingTable(bot);
if (!tableRes.ok) return tableRes;
if (getAnyPlanksCount(bot) < 3) return { ok: false, detail: "need 3 planks" };
if (getItemCount(bot, "stick") < 2) return { ok: false, detail: "need 2 sticks" };
info("action", `craft: wooden_axe`);
return craftRecipe(bot, "wooden_axe", 1, tableRes.block);
}
export async function craftWoodenPickaxe(bot) {
const tableRes = await placeCraftingTable(bot);
if (!tableRes.ok) return tableRes;
if (getAnyPlanksCount(bot) < 3) return { ok: false, detail: "need 3 planks" };
if (getItemCount(bot, "stick") < 2) return { ok: false, detail: "need 2 sticks" };
info("action", `craft: wooden_pickaxe`);
return craftRecipe(bot, "wooden_pickaxe", 1, tableRes.block);
}
export async function craftWoodenSword(bot) {
const tableRes = await placeCraftingTable(bot);
if (!tableRes.ok) return tableRes;
if (getAnyPlanksCount(bot) < 2) return { ok: false, detail: "need 2 planks" };
if (getItemCount(bot, "stick") < 1) return { ok: false, detail: "need 1 stick" };
info("action", `craft: wooden_sword`);
return craftRecipe(bot, "wooden_sword", 1, tableRes.block);
}
// Re-exported helpers for the reflex layer.
export const inv = { getItemCount, getAnyPlanksCount, getAnyLogCount };
// ---- navigation (for operator come/follow) --------------------------------
export async function goTo(bot, x, y, z, minDistance = 2) {
ensurePathfinder(bot);
setMovementsForTravel(bot);
info("action", `goTo: ${x},${y},${z} (min ${minDistance})`);
try {
await withTimeout(
bot.pathfinder.goto(new goals.GoalNear(x, y, z, minDistance)),
60_000,
`goTo(${x},${y},${z})`,
);
return { ok: true, detail: { x, y, z } };
} catch (e) {
warn("action", `goTo failed: ${e.message}`);
return { ok: false, detail: e.message };
}
}
// ---- helpers ---------------------------------------------------------------
const HOSTILE_NAMES = new Set([
"zombie",
"skeleton",
"creeper",
"spider",
"witch",
"pillager",
"vindicator",
"husk",
"stray",
"drowned",
"phantom",
"enderman",
"slime",
"magma_cube",
"hoglin",
"piglin_brute",
"ravager",
"warden",
"breeze",
"bogged",
]);
export function isHostile(entity) {
return HOSTILE_NAMES.has((entity?.name || "").toLowerCase());
}