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
+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);