fix(runtime): pin MC_VERSION=1.21.4 + ground-truth probe + close-loop dig

Two-pronged response to user-confirmed "bot stands still, doesn't actually
chop" on play.xmatic.team:

1. Pin protocol — .env now sets MC_VERSION=1.21.4. minecraft-data has
   wrong packet ID mappings for protocol 775 (server 26.1.2 via
   ViaBackwards 5.9.1) — see mineflayer#3888 and #3717. 1.21.5 also has
   an enchants decoder bug that breaks bot.dig. 1.21.4 is the last
   protocol mineflayer 4.37.1 can speak cleanly through VIA.

2. Don't trust dig success — runtime/actions.js chopNearestTree and
   runtime/skills/gather-stone.js now lookAt(face center)+forceLook,
   await collectBlock, then re-read the target block. If the log/stone
   is STILL there, return ok:false code:"silent_dig_failure" and
   blacklist the position. Prevents the curriculum from reporting
   "wood.16 in progress" while the world hasn't actually changed.

3. Defensive default — runtime/movement-profiles.js: canDig=false on
   every profile until dig is confirmed working live. Otherwise
   pathfinder schedules paths through must-dig blocks and the bot loops.

4. Ground-truth probe — runtime/skills/diagnose-physics.js dispatches
   forward/jump/dig probes and writes the result to the diary. New
   IPC command cmd:run-skill lets the operator (or a future curriculum
   trigger) fire any skill on demand; it waits for the current action
   to finish before dispatching. /tmp/pepa-runskill.mjs is a one-shot
   client.

Live probe on play.xmatic.team confirmed: forward Δ=0.003 over 2s
(BROKEN — server rejects movement packets), jump ΔY=0.42 (likely
physics jitter, not a real jump). Strongly suggests an anti-cheat
plugin gating bot-style movements server-side — beyond protocol pin.

npm test 124/124.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-26 11:51:43 +03:00
co-authored by Claude Opus 4.7
parent 19dc8e12c6
commit 86f0c1799e
9 changed files with 232 additions and 22 deletions
+23 -3
View File
@@ -372,6 +372,11 @@ export async function chopNearestTree(bot) {
});
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);
@@ -379,14 +384,29 @@ export async function chopNearestTree(bot) {
"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");
return { ok: true, detail: { logType: log.name, at: log.position } };
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) {
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 };
return { ok: false, detail: e.message, blacklisted: targetPos };
}
}
+41
View File
@@ -42,6 +42,7 @@ import { createNoProgressDetector } from "./no-progress.js";
import { maybeStartViewer } from "./viewer.js";
import { nextMilestone as nextCurriculumMilestone } from "./curriculum.js";
import { listLocations } from "./locations.js";
import { runSkill } from "./skills/index.js";
import { classifyIntent, INTENTS } from "./social/intent.js";
import { generateReply } from "./social/reply.js";
import { createChatMemory } from "./social/memory.js";
@@ -803,6 +804,46 @@ function handleCommand(msg, send) {
}
break;
}
case COMMAND_TYPES.RUN_SKILL: {
const skillId = msg.payload?.skillId;
const args = msg.payload?.args ?? {};
if (!skillId) {
send(EVENT_TYPES.ERROR, { source: "run-skill", text: "missing skillId" });
return;
}
info("ipc", `run-skill: queued ${skillId} args=${JSON.stringify(args)} (will wait for current action)`);
// Pause the reflex loop while we wait so it doesn't immediately
// schedule another action and starve our request.
const wasPaused = reflexPaused;
reflexPaused = true;
const deadline = Date.now() + 120_000;
const tryDispatch = () => {
if (!reflexCtx.busy) {
info("ipc", `run-skill: dispatching ${skillId}`);
dispatchAction(() => runSkill(skillId, reflexCtx, args), `ipc:${skillId}`, {
onComplete: (res) => {
reflexPaused = wasPaused;
send(EVENT_TYPES.LOG, {
ts: new Date().toISOString(),
level: "info",
source: "run-skill",
text: `${skillId}${res.code ?? (res.ok ? "ok" : "fail")}`,
details: res,
});
},
});
return;
}
if (Date.now() > deadline) {
reflexPaused = wasPaused;
send(EVENT_TYPES.ERROR, { source: "run-skill", text: `still busy after 2 min with ${reflexCtx.currentActionLabel}` });
return;
}
setTimeout(tryDispatch, 500);
};
tryDispatch();
break;
}
default:
warn("ipc", `unknown command type: ${msg.type}`);
}
+8 -4
View File
@@ -13,16 +13,20 @@ import { isManMadeBlockName, classifyArea, shouldAvoid } from "./claim-avoidance
// We can't import owned-blocks.js until config-driven stateDir exists,
// so it's tested via an isolated import in a temp dir below.
test("movement profile descriptor: gather has canDig=true, canPlace=false", () => {
// 2026-05-26: canDig is FALSE on every profile because bot.dig silently
// fails on the live server (mineflayer #3888 / protocol 775). Once the
// 1.21.4 pin restores real digging, gather/travel/flee profiles can flip
// canDig back to true.
test("movement profile descriptor: gather has canDig=false (silent-dig safeguard)", () => {
const d = describeProfile(PROFILES.GATHER);
assert.equal(d.canDig, true);
assert.equal(d.canDig, false);
assert.equal(d.canPlace, false);
assert.equal(d.allow1by1towers, false);
});
test("movement profile descriptor: flee allows higher drop, still canDig=true", () => {
test("movement profile descriptor: flee allows higher drop, canDig=false", () => {
const d = describeProfile(PROFILES.FLEE);
assert.equal(d.canDig, true);
assert.equal(d.canDig, false);
assert.equal(d.maxDropDown, 8);
});
+1
View File
@@ -27,6 +27,7 @@ export const COMMAND_TYPES = Object.freeze({
SNAPSHOT: "cmd:snapshot", // request immediate STATUS event
PROPOSAL_LATEST: "cmd:proposal-latest", // request latest pending proposal
PROPOSAL_APPROVE: "cmd:proposal-approve", // { filename } move to approved/
RUN_SKILL: "cmd:run-skill", // { skillId, args? } dispatch a skill once (operator ground-truth probes)
});
export function encodeFrame(obj) {
+11 -8
View File
@@ -19,16 +19,19 @@ export const PROFILES = Object.freeze({
});
// Pure descriptors — safe to import without a live bot.
//
// canDig is FALSE everywhere by default (2026-05-26). On the live server
// (play.xmatic.team 26.1.2+ViaBackwards 5.9.1) bot.dig silently fails —
// the packet ID table for protocol 775 is wrong in minecraft-data
// (mineflayer#3888) — so pathfinder would schedule paths through
// must-dig blocks the bot can't actually break, and we'd loop. Once
// 1.21.4 pin + lookAt+wait fix is verified live, we can re-enable
// canDig for gather/travel profiles.
export const PROFILE_DEFAULTS = Object.freeze({
[PROFILES.GATHER]: { canDig: true, canPlace: false, allow1by1towers: false },
[PROFILES.TRAVEL]: { canDig: true, canPlace: false, allow1by1towers: false },
// canDig:true on flee is deliberate — observed live: flee with canDig=false
// in dense canopy leaves the bot perched in leaves indefinitely.
[PROFILES.FLEE]: { canDig: true, canPlace: false, allow1by1towers: false, maxDropDown: 8 },
// Build: don't accidentally mine the structure we're placing; allow
// 1x1 step-ups so shelter blueprints can layer.
[PROFILES.GATHER]: { canDig: false, canPlace: false, allow1by1towers: false },
[PROFILES.TRAVEL]: { canDig: false, canPlace: false, allow1by1towers: false },
[PROFILES.FLEE]: { canDig: false, canPlace: false, allow1by1towers: false, maxDropDown: 8 },
[PROFILES.BUILD]: { canDig: false, canPlace: true, allow1by1towers: true },
// Return: don't carve tunnels home or place stepping blocks; just walk.
[PROFILES.RETURN_TO_BASE]: { canDig: false, canPlace: false, allow1by1towers: false },
});
+9 -5
View File
@@ -32,11 +32,15 @@ export const skill = Object.freeze({
};
}
const msg = String(res.detail ?? "");
const code = (msg.includes("no reachable log") || msg.includes("no log within"))
? "no_target"
: msg.includes("timed out")
? "timeout"
: "failed";
// res.code can come straight from actions.js (silent_dig_failure),
// otherwise we classify from the detail string.
const code = res.code
? res.code
: (msg.includes("no reachable log") || msg.includes("no log within"))
? "no_target"
: msg.includes("timed out")
? "timeout"
: "failed";
return { ok: false, code, detail: res.detail, worldDelta: null };
},
validate(ctx, result) {
+119
View File
@@ -0,0 +1,119 @@
// diag.physics — one-shot ground truth probe. Tests whether
// setControlState("forward"), setControlState("jump"), and bot.dig
// actually do anything on this server. Writes results to the diary so
// the operator can inspect later. NOT for production loops — it has
// side effects (1-2 blocks moved if forward works) and we don't want
// to dispatch it every tick.
//
// Triggered by: explicit IPC command (cmd:probe-physics) or by the
// curriculum once if the bot has been "working" but its position
// hasn't changed by more than 4 blocks in 5 min. We add the curriculum
// trigger in a follow-up.
import { info, warn } from "../log.js";
import { appendDiary } from "../state-store.js";
function withTimeout(promise, ms, label) {
let timer;
const timeout = new Promise((_, reject) => {
timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms / 1000}s`)), ms);
});
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
}
async function probeForward(bot, durationMs = 2_000) {
const before = bot.entity.position.clone();
try {
bot.setControlState("forward", true);
await new Promise((r) => setTimeout(r, durationMs));
} finally {
bot.setControlState("forward", false);
}
const after = bot.entity.position;
const dx = after.x - before.x;
const dz = after.z - before.z;
const dist = Math.hypot(dx, dz);
return { before, after: after.clone(), dist, works: dist > 0.5 };
}
async function probeJump(bot) {
const startY = bot.entity.position.y;
let maxY = startY;
const start = Date.now();
try {
bot.setControlState("jump", true);
while (Date.now() - start < 800) {
await new Promise((r) => setTimeout(r, 50));
if (bot.entity.position.y > maxY) maxY = bot.entity.position.y;
}
} finally {
bot.setControlState("jump", false);
}
return { startY, maxY, deltaY: maxY - startY, works: maxY - startY > 0.4 };
}
async function probeDig(bot) {
const ds = ["sand", "dirt", "grass_block", "stone", "cobblestone", "gravel", "oak_log", "oak_leaves", "leaves"];
const target = bot.findBlock({
matching: (b) => b && b.position && ds.includes(b.name) && b.position.y >= bot.entity.position.y - 3,
maxDistance: 6,
});
if (!target) return { works: false, reason: "no soft block within 6 to test on" };
const tPos = target.position.clone();
const tName = target.name;
try {
await withTimeout(bot.lookAt(tPos.offset(0.5, 0.5, 0.5), true), 2_000, "lookAt");
} catch {}
try {
await withTimeout(bot.dig(target), 12_000, "dig");
} catch (e) {
return { works: false, reason: `dig threw: ${e.message}`, name: tName, at: tPos };
}
const after = bot.blockAt(tPos);
const gone = !after || after.name === "air" || after.name === "cave_air" || after.name === "void_air";
return { works: gone, before: tName, after: after?.name ?? "(none)", at: tPos };
}
export const skill = Object.freeze({
id: "diag.physics",
title: "Probe forward/jump/dig and report",
timeoutMs: 30_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;
info("action", "diag.physics: starting probe");
const fwd = await probeForward(bot);
info("action", `diag.physics: forward Δ=${fwd.dist.toFixed(2)} (${fwd.works ? "OK" : "BROKEN"})`);
const jmp = await probeJump(bot);
info("action", `diag.physics: jump ΔY=${jmp.deltaY.toFixed(2)} (${jmp.works ? "OK" : "BROKEN"})`);
const dig = await probeDig(bot);
info("action", `diag.physics: dig ${dig.before ?? "?"}${dig.after ?? "?"} (${dig.works ? "OK" : "BROKEN"}: ${dig.reason ?? ""})`);
const summary = `physics probe: forward=${fwd.works ? "ok" : "BROKEN"}${fwd.dist.toFixed(1)}) jump=${jmp.works ? "ok" : "BROKEN"}(Δy${jmp.deltaY.toFixed(1)}) dig=${dig.works ? "ok" : "BROKEN"}(${dig.before ?? "no-target"}${dig.after ?? "?"})`;
appendDiary(summary);
return {
ok: true,
code: "done",
detail: {
forward: fwd,
jump: jmp,
dig: dig,
},
worldDelta: {
probe: {
forwardWorks: fwd.works,
jumpWorks: jmp.works,
digWorks: dig.works,
},
},
};
},
});
+18 -2
View File
@@ -109,13 +109,29 @@ export const skill = Object.freeze({
setMovementsForGather(bot);
const pickaxe = await equipBestPickaxe(bot);
info("action", `gather.stone: ${target.name} at ${target.position.x},${target.position.y},${target.position.z} (tool=${pickaxe ?? "fists"})`);
const targetPos = target.position.clone();
try {
try {
await withTimeout(bot.lookAt(targetPos.offset(0.5, 0.5, 0.5), true), 2_000, "lookAt(stone)");
} catch {}
await withTimeout(bot.collectBlock.collect(target), 60_000, "collectStone");
const after = bot.blockAt(targetPos);
if (after && STONE_NAMES.includes(after.name)) {
warn("action", `gather.stone reported ok but block still at ${targetPos.x},${targetPos.y},${targetPos.z} — silent dig failure`);
const key = `${targetPos.x},${targetPos.y},${targetPos.z}`;
blacklist.set(key, Date.now() + BLACKLIST_TTL_MS);
return {
ok: false,
code: "silent_dig_failure",
detail: "block still exists after collect — protocol/anti-cheat issue",
worldDelta: null,
};
}
return {
ok: true,
code: "done",
detail: { blockType: target.name, at: target.position },
worldDelta: { minedAt: target.position, blockType: target.name },
detail: { blockType: target.name, at: targetPos },
worldDelta: { minedAt: targetPos, blockType: target.name },
};
} catch (e) {
warn("action", `gather.stone failed: ${e.message}`);
+2
View File
@@ -26,6 +26,7 @@ import { skill as chopLogs } from "./chop-logs.js";
import { skill as eat } from "./eat.js";
import { skill as wander } from "./wander.js";
import { skill as exploreFar } from "./explore-far.js";
import { skill as diagPhysics } from "./diagnose-physics.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";
@@ -62,6 +63,7 @@ register(chopLogs);
register(eat);
register(wander);
register(exploreFar);
register(diagPhysics);
register(gatherStone);
register(gatherWool);
register(chooseBase);