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:
+23
-3
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user