From 28f5d9e483821b9cd5a7e06c868334394bbbc747 Mon Sep 17 00:00:00 2001 From: Yuriy Mayatnikov Date: Tue, 26 May 2026 13:45:29 +0300 Subject: [PATCH] =?UTF-8?q?fix(perception):=20use=20numeric=20block=20ids?= =?UTF-8?q?=20=E2=80=94=20callback=20matchers=20silently=20fail=20under=20?= =?UTF-8?q?ViaBackwards?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause of "bot just stands still": every gather.* skill was using bot.findBlock({ matching: (b) => names.includes(b.name) }), and under mineflayer 1.21.4 + ViaBackwards the Block objects fed into the callback have a wrong .name field (Block.type / numeric id is still correct — this is mineflayer issue #2347). Every search returned null, every skill reported "no_target", reflex looped wander → tunnel-out forever. The bot's logs said "dispatch ok" while the operator watched it pace in circles. Proven live with a new diag.match skill on play.xmatic.team: findBlocks({matching: numericIds}) → 50 hits findBlocks({matching: (b) => b.name === ...}) → 0 hits ← the bug findBlock({matching: (b) => b.name === ...}) → null ← the bug findBlock({matching: numericIds}) → dark_oak_log @ (606,62,110) After this fix the same bot from the same spawn dispatches gather.logs and reaches the chop loop ("chop: dark_oak_log at 606,62,110 (tool=fists)") instead of returning "no reachable log within 64 blocks". Changes: - runtime/perception.js (new): findBlocksByName / findNearestBlockByName centralise the numeric-id workaround for any future skill. - runtime/actions.js: chopNearestTree, sleepInBed, placeCraftingTable now use perception. Also load mineflayer-tool plugin alongside collectblock (collectblock 1.6 hard-requires bot.tool to dispatch a dig). - gather-stone, gather-wool, deposit-surplus rewritten to numeric-id search. gather-wool also loads mineflayer-tool. - diagnose-scan.js (new): two diagnostic skills — diag.scan reports findBlocks counts per radius for common blocks; diag.match cross-tests the four matcher styles so this regression can be re-proven on demand. - runtime/skills/index.js: registers diag.scan + diag.match. Memory: project_findblock_callback_broken_under_viabackwards.md. Co-Authored-By: Claude Opus 4.7 (1M context) --- runtime/actions.js | 52 +++++++------ runtime/perception.js | 51 +++++++++++++ runtime/skills/deposit-surplus.js | 13 ++-- runtime/skills/diagnose-scan.js | 118 ++++++++++++++++++++++++++++++ runtime/skills/gather-stone.js | 19 +++-- runtime/skills/gather-wool.js | 16 ++-- runtime/skills/index.js | 3 + 7 files changed, 226 insertions(+), 46 deletions(-) create mode 100644 runtime/perception.js create mode 100644 runtime/skills/diagnose-scan.js diff --git a/runtime/actions.js b/runtime/actions.js index fcdf7f6..5dd0c3a 100644 --- a/runtime/actions.js +++ b/runtime/actions.js @@ -11,9 +11,12 @@ const collectBlockPlugin = 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. @@ -41,9 +44,16 @@ function ensurePathfinder(bot) { // 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); @@ -234,11 +244,8 @@ 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, - }); + // 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}`); @@ -284,10 +291,7 @@ export async function sleepInBed(bot) { // 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, - }); + 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 } }; @@ -356,20 +360,20 @@ function getBlacklist(bot) { 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. + // + // 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 = 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); - }, + 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` }; @@ -645,11 +649,8 @@ export async function craftSticks(bot, 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, - }); + // 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 }; // Need to craft one first if we don't have it. @@ -677,10 +678,7 @@ export async function placeCraftingTable(bot) { } catch (e) { return { ok: false, detail: `place table: ${e.message}` }; } - const placed = bot.findBlock({ - matching: (b) => b && b.name === "crafting_table", - maxDistance: 4, - }); + const placed = findNearestBlockByName(bot, ["crafting_table"], { maxDistance: 4 }); info("action", `craft: placed crafting_table at ${placed?.position}`); return { ok: true, detail: { at: placed?.position, reused: false }, block: placed }; } diff --git a/runtime/perception.js b/runtime/perception.js new file mode 100644 index 0000000..d5f5b43 --- /dev/null +++ b/runtime/perception.js @@ -0,0 +1,51 @@ +// World perception primitives. +// +// PROBLEM: under mineflayer 1.21.4 + ViaBackwards (this server runs +// Paper 26.1.x with VV/VB translating 1.21.5+ protocol down to 1.21.4), +// bot.findBlock and bot.findBlocks with a CALLBACK matcher silently +// return null/empty — the Block objects passed into the callback have a +// wrong `.name` field because the protocol-side block-id mapping has +// drifted from minecraft-data 1.21.4. Confirmed live 2026-05-26 via +// diag.scan: oak_log count in radius 16 = 46 with `matching: numericId` +// but 0 with `matching: (b) => b.name === "oak_log"`. +// +// Mineflayer issue #2347 (findBlocks fails under ViaBackwards) is the +// upstream bug; the workaround is to feed numeric block IDs from the +// bot's registry directly. This module centralises that workaround so +// every skill stops growing its own subtly-broken matcher closure. + +export function nameToId(bot, name) { + return bot.registry?.blocksByName?.[name]?.id ?? null; +} + +export function namesToIds(bot, names) { + const out = []; + for (const n of names) { + const id = nameToId(bot, n); + if (typeof id === "number") out.push(id); + } + return out; +} + +// Find up to `count` blocks matching any of the given names. Returns an +// array of Vec3 positions, sorted by mineflayer (typically by Manhattan +// distance from the bot). Empty when no name resolves to a registry id. +export function findBlocksByName(bot, names, { maxDistance = 64, count = 32 } = {}) { + const ids = namesToIds(bot, names); + if (ids.length === 0) return []; + return bot.findBlocks({ matching: ids, maxDistance, count }) || []; +} + +// Same as findBlocksByName but returns the nearest matching Block object +// (not a Vec3). Skips positions for which a custom predicate returns +// false — used by callers that need post-filter logic like blacklists. +export function findNearestBlockByName(bot, names, { maxDistance = 64, predicate } = {}) { + const positions = findBlocksByName(bot, names, { maxDistance, count: 32 }); + for (const pos of positions) { + const blk = bot.blockAt(pos); + if (!blk) continue; + if (predicate && !predicate(blk)) continue; + return blk; + } + return null; +} diff --git a/runtime/skills/deposit-surplus.js b/runtime/skills/deposit-surplus.js index e0796e1..979f330 100644 --- a/runtime/skills/deposit-surplus.js +++ b/runtime/skills/deposit-surplus.js @@ -13,6 +13,9 @@ const { pathfinder, goals, Movements } = pathfinderPkg; import { applyProfile, PROFILES } from "../movement-profiles.js"; import { info, warn } from "../log.js"; +import { findNearestBlockByName } from "../perception.js"; + +const CHEST_NAMES = ["chest", "trapped_chest"]; const KEEP_ALWAYS_NAME_RE = /(_axe|_pickaxe|_sword|_shovel|_hoe|_bed|bread|cooked_|apple|carrot|potato|wheat_seeds)$/; const STORABLE_NAME_RE = /(_log$|_stem$|cobblestone|cobbled_deepslate|deepslate|stone$|dirt|sand|gravel|wheat$|_planks$|stick$)/; @@ -61,19 +64,13 @@ export const skill = Object.freeze({ if (!ctx?.bot) return { ok: false, code: "no_bot", detail: "bot missing" }; const surplus = pickSurplus(ctx.bot); if (surplus.length === 0) return { ok: false, code: "nothing_to_deposit", detail: "no surplus stacks" }; - const chest = ctx.bot.findBlock({ - matching: (b) => b?.name === "chest" || b?.name === "trapped_chest", - maxDistance: 24, - }); + const chest = findNearestBlockByName(ctx.bot, CHEST_NAMES, { maxDistance: 24 }); if (!chest) return { ok: false, code: "no_chest", detail: "no chest within 24 blocks" }; return { ok: true }; }, async execute(ctx) { const bot = ctx.bot; - const chest = bot.findBlock({ - matching: (b) => b?.name === "chest" || b?.name === "trapped_chest", - maxDistance: 24, - }); + const chest = findNearestBlockByName(bot, CHEST_NAMES, { maxDistance: 24 }); if (!chest) return { ok: false, code: "no_chest", detail: "no chest after move", worldDelta: null }; ensurePathfinder(bot); diff --git a/runtime/skills/diagnose-scan.js b/runtime/skills/diagnose-scan.js new file mode 100644 index 0000000..2e2d8d8 --- /dev/null +++ b/runtime/skills/diagnose-scan.js @@ -0,0 +1,118 @@ +// diag.scan / diag.match — ground-truth probes of bot.findBlock(s). +// Counts how many instances of common blocks are visible at increasing +// radii. Lets the operator (or a stuck detector) tell two failure modes +// apart: +// +// case A: grass_block.32 > 0 but oak_log.96 = 0 → world has no trees +// case B: grass_block.32 = 0 → findBlocks itself is broken (mineflayer +// issue #2347 under ViaBackwards), and +// gather.* skills will silently no-op +// forever no matter how far we walk. +// +// Writes nothing to journal. Always returns ok so it never wedges. + +const TYPES = ["grass_block", "dirt", "stone", "oak_log", "birch_log", "spruce_log", "jungle_log", "coal_ore", "iron_ore"]; +const RADII = [16, 32, 64, 96]; +const LOG_NAMES = ["oak_log", "dark_oak_log", "spruce_log", "birch_log", "jungle_log", "acacia_log", "mangrove_log", "cherry_log", "pale_oak_log"]; + +export const matchSkill = Object.freeze({ + id: "diag.match", + title: "Compare findBlock matcher styles for logs", + timeoutMs: 15_000, + preconditions(ctx) { + if (!ctx?.bot) return { ok: false, code: "no_bot", detail: "bot missing" }; + return { ok: true }; + }, + async execute(ctx) { + const bot = ctx.bot; + const mcData = bot.registry; + const ids = LOG_NAMES.map((n) => mcData?.blocksByName?.[n]?.id).filter((x) => typeof x === "number"); + const r = 32; + // Style A: numeric matching (what diag.scan uses, and what works) + const a = (bot.findBlocks({ matching: ids, maxDistance: r, count: 50 }) || []).length; + // Style B: callback matching by .name (what chopNearestTree uses, and what fails) + const b = (bot.findBlocks({ + matching: (blk) => !!blk && !!blk.position && LOG_NAMES.includes(blk.name), + maxDistance: r, + count: 50, + }) || []).length; + // Style C: callback matching by .type (numeric id) — control + const c = (bot.findBlocks({ + matching: (blk) => !!blk && ids.includes(blk.type), + maxDistance: r, + count: 50, + }) || []).length; + // Style D: singular findBlock with callback matcher (what chopNearestTree literally calls) + const d = bot.findBlock({ + matching: (blk) => !!blk && !!blk.position && LOG_NAMES.includes(blk.name), + maxDistance: r, + }); + // Style E: singular findBlock with numeric id array + const e = bot.findBlock({ matching: ids, maxDistance: r }); + return { + ok: true, + code: "match_done", + detail: { + ids, + radius: r, + A_findBlocks_numeric: a, + B_findBlocks_callback_name: b, + C_findBlocks_callback_type: c, + D_findBlock_callback_name: d ? { name: d.name, type: d.type, pos: { x: d.position.x, y: d.position.y, z: d.position.z } } : null, + E_findBlock_numeric: e ? { name: e.name, type: e.type, pos: { x: e.position.x, y: e.position.y, z: e.position.z } } : null, + }, + worldDelta: null, + }; + }, +}); + +export const skill = Object.freeze({ + id: "diag.scan", + title: "Scan for findBlocks visibility", + timeoutMs: 20_000, + preconditions(ctx) { + if (!ctx?.bot) return { ok: false, code: "no_bot", detail: "bot missing" }; + return { ok: true }; + }, + async execute(ctx) { + const bot = ctx.bot; + const mcData = bot.registry; + const here = bot.entity.position.clone(); + const report = {}; + for (const name of TYPES) { + const blk = mcData?.blocksByName?.[name]; + if (!blk) { + report[name] = { absent_in_registry: true }; + continue; + } + const perRadius = {}; + for (const r of RADII) { + const hits = bot.findBlocks({ matching: blk.id, maxDistance: r, count: 50 }) || []; + perRadius[r] = hits.length; + } + report[name] = perRadius; + } + // Also blockAt directly underfoot and 5-block ring scan: this bypasses + // findBlocks entirely and proves whether the protocol decode is sane. + const under = bot.blockAt(here.offset(0, -1, 0)); + const ring = {}; + for (let dx = -5; dx <= 5; dx++) { + for (let dz = -5; dz <= 5; dz++) { + const b = bot.blockAt(here.offset(dx, -1, dz)); + if (!b) continue; + ring[b.name] = (ring[b.name] || 0) + 1; + } + } + return { + ok: true, + code: "scan_done", + detail: { + pos: { x: +here.x.toFixed(1), y: +here.y.toFixed(1), z: +here.z.toFixed(1) }, + under: under?.name ?? null, + ring_11x11_underfoot: ring, + findBlocks: report, + }, + worldDelta: null, + }; + }, +}); diff --git a/runtime/skills/gather-stone.js b/runtime/skills/gather-stone.js index 040d294..3145656 100644 --- a/runtime/skills/gather-stone.js +++ b/runtime/skills/gather-stone.js @@ -10,9 +10,12 @@ const collectBlockPlugin = collectBlockPkg.default?.plugin ?? collectBlockPkg.default ?? collectBlockPkg; +import toolPkg from "mineflayer-tool"; +const toolPlugin = toolPkg.plugin ?? toolPkg.default?.plugin ?? toolPkg.default ?? toolPkg; import { pickaxes } from "./groups.js"; import { info, warn } from "../log.js"; +import { findNearestBlockByName } from "../perception.js"; const STONE_NAMES = ["stone", "cobblestone", "deepslate", "cobbled_deepslate", "andesite", "diorite", "granite"]; @@ -23,9 +26,16 @@ function ensurePathfinder(bot) { pluginLoaded.add(bot); } +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); if (collectBlockLoaded.has(bot)) return; bot.loadPlugin(collectBlockPlugin); collectBlockLoaded.add(bot); @@ -93,13 +103,10 @@ export const skill = Object.freeze({ async execute(ctx) { const bot = ctx.bot; const blacklist = getBlacklist(bot); - const target = bot.findBlock({ - matching: (b) => { - if (!b || !b.position || !STONE_NAMES.includes(b.name)) return false; - const key = `${b.position.x},${b.position.y},${b.position.z}`; - return !blacklist.has(key); - }, + // Numeric-id search — callback matcher returns 0 under ViaBackwards. See runtime/perception.js. + const target = findNearestBlockByName(bot, STONE_NAMES, { maxDistance: 32, + predicate: (b) => !blacklist.has(`${b.position.x},${b.position.y},${b.position.z}`), }); if (!target) { return { ok: false, code: "no_target", detail: "no reachable stone within 32 blocks", worldDelta: null }; diff --git a/runtime/skills/gather-wool.js b/runtime/skills/gather-wool.js index 35ea334..e3158e9 100644 --- a/runtime/skills/gather-wool.js +++ b/runtime/skills/gather-wool.js @@ -9,10 +9,18 @@ import collectBlockPkg from "mineflayer-collectblock"; import pathfinderPkg from "mineflayer-pathfinder"; +import toolPkg from "mineflayer-tool"; +const toolPlugin = toolPkg.plugin ?? toolPkg.default?.plugin ?? toolPkg.default ?? toolPkg; import { info, warn } from "../log.js"; +import { findNearestBlockByName } from "../perception.js"; const { pathfinder, goals, Movements } = pathfinderPkg; +const WOOL_NAMES = [ + "white_wool", "orange_wool", "magenta_wool", "light_blue_wool", "yellow_wool", + "lime_wool", "pink_wool", "gray_wool", "light_gray_wool", "cyan_wool", + "purple_wool", "blue_wool", "brown_wool", "green_wool", "red_wool", "black_wool", +]; const collectBlockPlugin = collectBlockPkg.plugin ?? collectBlockPkg.default?.plugin ?? @@ -25,6 +33,7 @@ let pluginLoaded = new WeakSet(); function ensurePlugins(bot) { if (pluginLoaded.has(bot)) return; bot.loadPlugin(pathfinder); + bot.loadPlugin(toolPlugin); // collectblock needs bot.tool bot.loadPlugin(collectBlockPlugin); pluginLoaded.add(bot); } @@ -77,11 +86,8 @@ export const skill = Object.freeze({ ensurePlugins(bot); setMovementsForGather(bot); - // 1. Placed wool block? - const woolBlock = bot.findBlock({ - matching: (b) => b?.name && (b.name.endsWith("_wool") || b.name === "wool"), - maxDistance: 32, - }); + // 1. Placed wool block? Numeric-id search — see runtime/perception.js. + const woolBlock = findNearestBlockByName(bot, WOOL_NAMES, { maxDistance: 32 }); if (woolBlock) { info("action", `gather.wool: mining ${woolBlock.name} at ${woolBlock.position}`); try { diff --git a/runtime/skills/index.js b/runtime/skills/index.js index e878a2e..38a8b0f 100644 --- a/runtime/skills/index.js +++ b/runtime/skills/index.js @@ -28,6 +28,7 @@ import { skill as wander } from "./wander.js"; import { skill as exploreFar } from "./explore-far.js"; import { skill as tunnelOut } from "./recovery-tunnel-out.js"; import { skill as diagPhysics } from "./diagnose-physics.js"; +import { skill as diagScan, matchSkill as diagMatch } from "./diagnose-scan.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"; @@ -66,6 +67,8 @@ register(wander); register(exploreFar); register(tunnelOut); register(diagPhysics); +register(diagScan); +register(diagMatch); register(gatherStone); register(gatherWool); register(chooseBase);