Files
pepa-pi-bot/runtime/actions.js
T
mayatnikovandClaude Opus 4.7 29542f0559 fix(runtime): unstick scheduler + chop + sleep + bed/shelter/farm skills
Recovers the bot from the live-server symptoms reported 2026-05-26:
1) constant supervisor reconnects, 2) chop "clicks once and stops",
3) sleep does nothing without a bed and so blocks night-skipping for
other players, 4) curriculum reflex always fell through to wander.

Supervisor (#38):
- runtime/watch-filter.js: pure predicate excluding *.test.js + the
  supervisor itself; recursive:true so skills/ + social/ edits also
  restart. Burned a working main once when test files counted toward
  the rollback threshold.
- runtime/supervisor.js: watch-triggered restarts no longer count
  toward the crash-loop rollback path. Watcher is now recursive.

Chop / mine (#39):
- runtime/actions.js + runtime/skills/gather-stone.js: replaced raw
  pathfinder.goto + bot.dig with mineflayer-collectblock's
  bot.collectBlock.collect — handles approach, repositioning, LoS,
  dig and pickup as one primitive. Old version "swung once" because
  GoalGetToBlock often parked the bot in leaves above the log.

Sleep + bed (#40):
- runtime/actions.js: sleepInBed now ALSO places a carried bed on
  solid ground next to the bot and sleeps on it. Critical so the bot
  stops blocking player night-skipping the moment it owns a bed.

Bed pipeline (#41):
- runtime/skills/gather-wool.js: gather.wool skill — mines wool block
  if any nearby, otherwise shears or attacks the nearest sheep.
- runtime/skills/craft.js: craftBedSkill (any colour the bot has ≥3
  wool of, plus 3 planks, plus a table).
- runtime/curriculum.js: new milestone survive.bed sits between
  wood.tools and stone.32 so the bot gets a bed BEFORE everything else.
  Test fixture updated to include a red_bed in post-survive.bed stages.

Village / shelter / wheat (#42, #43):
- runtime/skills/build-shelter.js: village.build-shelter — real 3×3×3
  resumable hut blueprint around the recorded base, places one block
  per loop, idempotent so an interrupted build resumes correctly,
  marks each placed block in the owned-blocks ledger.
- runtime/skills/deposit-surplus.js: village.deposit-surplus opens
  the nearest chest and transfers surplus stacks while keeping a
  reserve of tools/food/bed.
- runtime/skills/farm-wheat.js: farm.wheat does one step per call
  (till adjacent-to-water grass, plant seeds, or harvest ripe wheat).
- runtime/curriculum.js: village.shelter milestone after base-site.

Scheduler glitch (root of "always wander"):
- runtime/bot.js: curriculum + locations are now computed BEFORE
  runTick. Previously they were stamped AFTER, so reflex.js saw
  snapshot.curriculum=undefined every tick and fell through to the
  wander fallback. Verified live: scheduler now dispatches
  gather.logs/gather.stone/craft.* by id via runSkill.

Eat-spam:
- runtime/reflex.js: eatReflex now checks inventory for actual food
  and updates lastEatAt on EVERY dispatch (not only successes), so a
  failed eat respects the 5 s cooldown instead of firing every tick.

npm test 123/123. Validated live on play.xmatic.team (curriculum
dispatched gather.logs via runSkill, recover hint switched to wander
when no log in range).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 11:12:21 +03:00

608 lines
20 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.
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,
});
if (!log) return { ok: false, detail: "no reachable log within 32 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)),
30_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 };
}
}
// ---- 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());
}