Files
c910457817 v0.4.0 vNext — closed-loop world model + settlement contract (#29)
* feat(v0.4.0): vNext — closed-loop world model + settlement contract

Implements the vNext architecture from the research doc: demote the noisy
multi-rail planner in favour of a closed loop (world truth → invariant check)
plus a single utility-driven goal authority.

L1 services (fix no_drop / silent pathfinder hang first):
- InventoryLedger: diff-based "did I actually get it" verifier; acquire-food
  now confirms via ledger.gainedSince instead of the unreliable count/event.
- MotionService.gotoSafe: wall-clock timeout + progress watchdog +
  path_update(noPath/timeout) → structured {reached|stuck|timeout|nopath}.

L3 plan — unify the three competing rails (curriculum/manifesto/storyline):
- Settlement Contract: ordered M0–M9 milestones, each invariant-checked
  against an authoritative world view (early steps delegate to the proven
  curriculum; late game adds farming).
- InvariantChecker + predicate library; GoalManager selects the lowest unmet
  milestone via utility argmax (food-urgency preempts, DEPS-style).
- Wired into the scheduler: bot.js precomputes snapshot.contract; reflex.js
  consumes it in place of the storyline rail. Manifesto L0 still preempts.

Eval + robustness:
- Village Score (single 0..1 metric) on the snapshot + TUI "build" line.
- survive.dig-in skill + dusk_dig_in mode (exposed at night, no bed → cover).
- approach_block helper (GoalNear + lookAt, avoids GoalLookAtBlock #341).

+28 new tests (450 total green). LLM remains entirely off the tick path.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(v0.4.0): finish vNext plan — anti-loop, skill-graph, worldDelta diff, flee→motion

Completes the remaining v0.4.0 plan items and one fix motivated by a live
in-game observation (flee hanging 30s against a persistent zombie).

- flee → MotionService.gotoSafe: structured {stuck|timeout|nopath} in ~4s with
  a blind-retreat fallback, instead of the observed 30s pathfinder hang + 3
  watchdog replans. Movements setup guarded so it is unit-testable.
- QW5 anti-loop (runtime/anti-loop.js): same skill failing >=3x in 5min →
  30min blacklist (reflex shouldSkip) + one-shot improvement_request
  (bot.js drainFired -> writeProposal).
- 4.1 closed-loop worldDelta: runSkill snapshots inventory before execute and
  attaches the real delta (_invObserved) to every successful result; opt-in
  skill.expectGain asserts the claimed gain or returns world_unchanged.
- 3.6 skill-graph (Plan4MC): declarative requires/produces for ~20 skills;
  prerequisitesMet/canRun/runnableFrontier; GoalManager annotates suggestions
  with blockedBy when prereqs are unmet.

+22 tests (472 total green). Live smoke confirmed dig-in works and no new
errors; flee loop is what this commit's flee migration addresses.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Yuriy Mayatnikov <mayatnikov@me.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 11:29:39 +03:00

878 lines
31 KiB
JavaScript
Raw Permalink 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 toolPkg from "mineflayer-tool";
const toolPlugin = toolPkg.plugin ?? toolPkg.default?.plugin ?? toolPkg.default ?? toolPkg;
import { info, warn } from "./log.js";
import { digEscapeTunnel } from "./skills/recovery-tunnel-out.js";
import { findNearestBlockByName } from "./perception.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 toolLoaded = new WeakSet();
function ensureTool(bot) {
if (toolLoaded.has(bot)) return;
bot.loadPlugin(toolPlugin);
toolLoaded.add(bot);
}
let collectBlockLoaded = new WeakSet();
function ensureCollectBlock(bot) {
ensurePathfinder(bot);
ensureTool(bot); // collectblock requires bot.tool — see live error 2026-05-26
if (collectBlockLoaded.has(bot)) return;
bot.loadPlugin(collectBlockPlugin);
collectBlockLoaded.add(bot);
}
function forceStopCollectBlock(bot) {
// collectBlock.collect() is not abortable by Promise.race. If its own
// cancelTask also stalls, leave the plugin empty so the next gather does
// not spend another 60s trying to cancel the previous target queue.
try { bot.collectBlock?.targets?.clear?.(); } catch {}
try { bot.pathfinder?.stop?.(); } catch {}
}
function clearPathfinderGoal(bot) {
try { bot.pathfinder?.setGoal?.(null); } catch {}
}
// 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 ------------------------------------------------------------------
async function blindRetreat(bot, dirYaw, blindMs = 7_000) {
const before = bot.entity.position.clone?.() ?? { ...bot.entity.position };
try { bot.pathfinder?.stop?.(); } catch {}
try { await bot.look(dirYaw, 0, true); } catch {}
bot.setControlState("forward", true);
bot.setControlState("jump", true);
try {
await new Promise((r) => setTimeout(r, blindMs));
} finally {
bot.setControlState("forward", false);
bot.setControlState("jump", false);
}
const after = bot.entity.position;
const moved = Math.hypot(after.x - before.x, after.z - before.z);
return { moved, after };
}
// v0.4.0 — when a MotionService is supplied (opts.motion), retreat via
// gotoSafe: a 12s wall-clock with a 4s progress watchdog returns a STRUCTURED
// {stuck|timeout|nopath} fast instead of hanging the full 30s that we observed
// live (zombie pinning the bot, every flee timing out, watchdog burning 3
// replans). On any non-reached result we fall straight through to the blind
// retreat. Callers without a motion service keep the legacy 30s path.
export async function fleeFrom(bot, fromEntity, distance = 16, opts = {}) {
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);
const dirYaw = -Math.atan2(dx / len, dz / len);
const blindMs = opts.blindMs ?? 7_000;
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.
try {
const movements = new Movements(bot);
movements.canDig = true;
movements.allow1by1towers = false;
bot.pathfinder.setMovements(movements);
} catch {
// fake/registry-less bot (tests) — skip movement tuning
}
const goal = new goals.GoalNear(tx, ty, tz, 1);
if (opts.motion?.gotoSafe) {
const res = await opts.motion.gotoSafe(goal, {
timeoutMs: opts.timeoutMs ?? 12_000,
stuckWindowMs: 4_000,
stuckDelta: 1.5,
label: `flee(${fromEntity?.name})`,
});
if (res.ok) return { ok: true, detail: { to: { x: tx, y: ty, z: tz }, moved: res.movedBlocks } };
warn("action", `flee gotoSafe → ${res.code} (moved ${res.movedBlocks}b); blind retreat`);
const b = await blindRetreat(bot, dirYaw, blindMs);
if (b.moved >= 4) {
return { ok: true, detail: { to: { x: Math.round(b.after.x), y: Math.round(b.after.y), z: Math.round(b.after.z) }, mode: "blind-retreat", moved: b.moved } };
}
return { ok: false, code: res.code, detail: `flee ${res.code} then blind retreat moved ${b.moved.toFixed(2)}b` };
}
try {
await withTimeout(
bot.pathfinder.goto(goal),
30_000,
`fleeFrom(${fromEntity?.name})`,
);
return { ok: true, detail: { to: { x: tx, y: ty, z: tz } } };
} catch (e) {
warn("action", `flee path failed: ${e.message}; trying blind retreat`);
const b = await blindRetreat(bot, dirYaw, blindMs);
if (b.moved >= 4) {
return {
ok: true,
detail: {
to: { x: Math.round(b.after.x), y: Math.round(b.after.y), z: Math.round(b.after.z) },
mode: "blind-retreat",
moved: b.moved,
},
};
}
warn("action", `flee blind retreat moved only ${b.moved.toFixed(2)} blocks`);
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. Numeric-id search — see chopNearestTree.
const bedBlock = findNearestBlockByName(bot, BED_NAMES, { 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 = findNearestBlockByName(bot, BED_NAMES, { 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);
// 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.
//
// 2026-05-26 — bigger root-cause fix: stopped using bot.findBlock with
// a callback matcher. Under ViaBackwards the Block objects fed into
// the callback have wrong .name fields (mineflayer issue #2347), so
// LOG_NAMES.includes(b.name) was always false and gather.logs reported
// "no log within 64 blocks" while standing on dark_oak_leaves. We now
// search by numeric registry id and post-filter the blacklist.
const SEARCH_RADIUS = 64;
const log = findNearestBlockByName(bot, LOG_NAMES, {
maxDistance: SEARCH_RADIUS,
predicate: (b) => !blacklist.has(`${b.position.x},${b.position.y},${b.position.z}`),
});
if (!log) return { ok: false, detail: `no reachable log within ${SEARCH_RADIUS} blocks` };
// 2026-05-26: ground-truth verification + lookAt+force. On
// play.xmatic.team (1.21 via ViaBackwards) bot.dig/collect can return
// success even when the block survives — the server silently drops our
// serverbound packet (project_mineflayer_via_protocol_pin). We refuse
// to call it ok unless the block is actually gone from the world.
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"})`,
);
const targetPos = log.position.clone();
const key = `${targetPos.x},${targetPos.y},${targetPos.z}`;
try {
try {
await withTimeout(bot.lookAt(targetPos.offset(0.5, 0.5, 0.5), true), 2_000, "lookAt(log)");
} catch {}
await withTimeout(bot.collectBlock.collect(log), 60_000, "collectLog");
const after = bot.blockAt(targetPos);
if (after && LOG_NAMES.includes(after.name)) {
warn("action", `chop reported ok but log still at ${key} — silent dig failure`);
blacklist.set(key, Date.now() + BLACKLIST_TTL_MS);
return {
ok: false,
code: "silent_dig_failure",
detail: "block still exists after collect — server dropped dig packet?",
blacklisted: targetPos,
};
}
return { ok: true, detail: { logType: log.name, at: targetPos } };
} catch (e) {
const msg = e?.message ?? String(e);
warn("action", `chop failed: ${msg}`);
// Promise.race timeouts do not cancel mineflayer-collectblock; an old
// collect task can keep owning pathfinder and make every retry time out.
try {
await withTimeout(bot.collectBlock?.cancelTask?.() ?? Promise.resolve(), 2_000, "cancelCollectLog");
} catch (cancelErr) {
warn("action", `chop cancel failed: ${cancelErr.message}`);
} finally {
forceStopCollectBlock(bot);
}
blacklist.set(key, Date.now() + BLACKLIST_TTL_MS);
return { ok: false, code: msg.includes("timed out") ? "timeout" : "failed", detail: msg, blacklisted: targetPos };
}
}
// ---- exploration -----------------------------------------------------------
// wander — probe-then-go. Old version picked a random angle and trusted
// pathfinder; on this server pathfinder routinely times out (the bot is
// in a 3-block corridor, on a tree, in spawn area without good graph)
// and the blind-walk fallback then went in the SAME direction the
// pathfinder couldn't solve. 2026-05-26 fix: try every cardinal
// direction for 800 ms each, measure the actual Δ in-world, then commit
// to the best one for the rest of the budget.
export async function wander(bot, radius = 12) {
ensurePathfinder(bot);
setMovementsForTravel(bot);
const trials = await probeCardinalSteps(bot, 800);
const best = trials.reduce((b, t) => (t.dist > b.dist ? t : b), { dist: 0 });
// If nothing moved at all, the bot is wedged — pit, corridor corner,
// surrounded by leaves. Try to escape: dig the block straight above
// + jump, repeat up to 3 times, then try forward+jump.
if (best.dist < 0.5) {
info("action", `wander: all cardinals blocked → escape-pit, then tunnel-out if still stuck`);
const escape = await escapePit(bot, 3);
const detail = { ...(escape.detail ?? {}), trials };
if (!escape.ok) return { ok: false, code: escape.code ?? "wedged", detail };
return { ok: true, detail };
}
// Commit to best direction.
const wantDist = Math.max(6, Math.min(radius, best.dist * 4 + 2));
const here = bot.entity.position;
const tx = Math.round(here.x + Math.sin(-best.yaw) * wantDist);
const tz = Math.round(here.z + Math.cos(-best.yaw) * wantDist);
const ty = Math.round(here.y);
info("action", `wander: best=${best.name}(Δ=${best.dist.toFixed(1)}) → ${tx},${ty},${tz}`);
try {
await withTimeout(
bot.pathfinder.goto(new goals.GoalNear(tx, ty, tz, 2)),
12_000,
`wander(${tx},${tz})`,
);
return { ok: true, detail: { to: { x: tx, y: ty, z: tz }, via: best.name } };
} catch (e) {
warn("action", `wander pathfinder failed: ${e.message} — continuing blind in ${best.name}`);
clearPathfinderGoal(bot);
const beforeBlind = clonePos(bot.entity.position);
try { await bot.look(best.yaw, 0, true); } catch {}
bot.setControlState("forward", true);
bot.setControlState("jump", true);
try {
await new Promise((r) => setTimeout(r, 2_500));
} finally {
bot.setControlState("forward", false);
bot.setControlState("jump", false);
}
const moved = horizontalDistance(beforeBlind, bot.entity.position);
if (moved < 0.75) {
info("action", `wander: blind ${best.name} moved only ${moved.toFixed(2)} horizontally → tunnel-out`);
const tunnel = await digEscapeTunnel(bot, { maxSteps: 3, reason: `wander blind ${best.name}` });
const detail = { to: { x: tx, y: ty, z: tz }, mode: "blind", via: best.name, moved, recovery: tunnel.detail ?? null };
if (!tunnel.ok) return { ok: false, code: tunnel.code ?? "wedged", detail, worldDelta: tunnel.worldDelta ?? null };
return { ok: true, detail: { ...(tunnel.detail ?? {}), previousMode: "blind", recovered: true }, worldDelta: tunnel.worldDelta ?? null };
}
return {
ok: true,
detail: { to: { x: tx, y: ty, z: tz }, mode: "blind-moved", previousMode: "blind", via: best.name, moved },
worldDelta: { movedTo: clonePos(bot.entity.position) },
};
}
}
const CARDINAL_YAWS = [
{ name: "N", yaw: Math.PI },
{ name: "E", yaw: -Math.PI / 2 },
{ name: "S", yaw: 0 },
{ name: "W", yaw: Math.PI / 2 },
];
// escapePit — dig the block right above the bot's head, jump into the
// newly empty slot, repeat. If that does not actually move the bot, fall
// back to recovery.tunnel-out's two-high horizontal tunnel. Returning OK
// without movement was what produced repeated wedged "done" results.
function clonePos(pos) {
if (typeof pos?.clone === "function") return pos.clone();
return { x: pos?.x ?? 0, y: pos?.y ?? 0, z: pos?.z ?? 0 };
}
function horizontalDistance(a, b) {
return Math.hypot((b?.x ?? 0) - (a?.x ?? 0), (b?.z ?? 0) - (a?.z ?? 0));
}
function verticalGain(a, b) {
return (b?.y ?? 0) - (a?.y ?? 0);
}
async function escapePit(bot, maxSteps = 3) {
const before = clonePos(bot.entity.position);
for (let i = 0; i < maxSteps; i++) {
const head = bot.entity.position.offset(0, 1.7, 0);
const above = bot.blockAt(head.offset(0, 0.5, 0));
if (!above || above.name === "air" || above.name === "cave_air" || above.name === "void_air") {
// Already clear above. Just jump+forward in case bot is in a
// horizontal pit (gap in floor).
bot.setControlState("jump", true);
bot.setControlState("forward", true);
await new Promise((r) => setTimeout(r, 700));
bot.setControlState("jump", false);
bot.setControlState("forward", false);
continue;
}
// Try to break the block above us. Force lookAt + 1-tick wait so the
// server accepts the dig packet on protocol 775.
try {
await withTimeout(bot.lookAt(above.position.offset(0.5, 0.5, 0.5), true), 1_500, "lookAt-up");
} catch {}
try {
await withTimeout(bot.dig(above), 8_000, "dig-up");
} catch (e) {
warn("action", `escape-pit dig-up failed: ${e.message}`);
break;
}
// jump into the now-empty slot
bot.setControlState("jump", true);
await new Promise((r) => setTimeout(r, 600));
bot.setControlState("jump", false);
await new Promise((r) => setTimeout(r, 400));
}
// Let jump physics settle before deciding whether escape-pit worked.
// Vertical-only motion is not freedom from a wedged shaft; require real
// horizontal movement, otherwise fall through to the tunnel-out skill.
await new Promise((r) => setTimeout(r, 500));
const moved = horizontalDistance(before, bot.entity.position);
const climbed = verticalGain(before, bot.entity.position);
if (moved >= 0.75) {
return { ok: true, code: "done", detail: { mode: "escape-pit-up", moved, climbed } };
}
info("action", `escape-pit moved only ${moved.toFixed(2)} horizontally (dy=${climbed.toFixed(2)}) → tunnel-out`);
return digEscapeTunnel(bot, { maxSteps: 3, reason: "wander escape-pit" });
}
async function probeCardinalSteps(bot, durationMs = 800) {
const trials = [];
for (const { name, yaw } of CARDINAL_YAWS) {
try { await bot.look(yaw, 0, true); } catch {}
const before = bot.entity.position.clone();
bot.setControlState("forward", true);
try {
await new Promise((r) => setTimeout(r, durationMs));
} finally {
bot.setControlState("forward", false);
}
const after = bot.entity.position;
const dist = Math.hypot(after.x - before.x, after.z - before.z);
trials.push({ name, yaw, dist });
// settle physics
await new Promise((r) => setTimeout(r, 150));
}
return trials;
}
// ---- 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(",");
}
function isSolidPlacementSupport(block) {
return !!block && block.boundingBox && block.boundingBox !== "empty" && !block.liquid;
}
function isClearPlacementTarget(block) {
return !!block && block.boundingBox === "empty" && !block.liquid;
}
function findAdjacentTablePlacement(bot) {
const here = bot.entity.position;
const offsets = [
[1, 0], [-1, 0], [0, 1], [0, -1],
[1, 1], [1, -1], [-1, 1], [-1, -1],
];
for (const [dx, dz] of offsets) {
const referenceBlock = bot.blockAt(here.offset(dx, -1, dz));
const targetBlock = bot.blockAt(here.offset(dx, 0, dz));
if (isSolidPlacementSupport(referenceBlock) && isClearPlacementTarget(targetBlock)) {
return { referenceBlock, faceVector: { x: 0, y: 1, z: 0 }, target: targetBlock.position };
}
}
return null;
}
// 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? Numeric-id search — see chopNearestTree.
const existing = findNearestBlockByName(bot, ["crafting_table"], { maxDistance: 4 });
if (existing) return { ok: true, detail: { at: existing.position, reused: true }, block: existing };
// Pick an adjacent placement target before crafting one. The old fallback
// clicked the block directly beneath the bot, which attempts to place the
// table in the bot's own feet block and can hang on Paper/ViaBackwards.
const placement = findAdjacentTablePlacement(bot);
if (!placement) return { ok: false, detail: "no clear adjacent block to place crafting table" };
// 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;
}
const tableItem = bot.inventory.items().find((i) => i.name === "crafting_table");
if (!tableItem) return { ok: false, detail: "crafting_table missing after craft" };
try {
await withTimeout(bot.equip(tableItem, "hand"), 3000, "equip table");
try {
await withTimeout(bot.lookAt(placement.referenceBlock.position.offset(0.5, 0.5, 0.5), true), 2000, "lookAt(table spot)");
} catch {}
await withTimeout(
bot.placeBlock(placement.referenceBlock, placement.faceVector),
5000,
"placeBlock(table)",
);
} catch (e) {
return { ok: false, detail: `place table: ${e.message}` };
}
const placedAtTarget = bot.blockAt(placement.target);
const placed = placedAtTarget?.name === "crafting_table"
? placedAtTarget
: findNearestBlockByName(bot, ["crafting_table"], { maxDistance: 4 });
if (!placed) return { ok: false, detail: "placed crafting table not found" };
info("action", `craft: placed crafting_table at ${placed.position.x},${placed.position.y},${placed.position.z}`);
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());
}