diff --git a/runtime/actions.js b/runtime/actions.js index 0050e23..5cbe9b4 100644 --- a/runtime/actions.js +++ b/runtime/actions.js @@ -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 }; } } diff --git a/runtime/bot.js b/runtime/bot.js index ab0cd51..8f67912 100644 --- a/runtime/bot.js +++ b/runtime/bot.js @@ -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}`); } diff --git a/runtime/compat.test.js b/runtime/compat.test.js index 7b41699..fc5e32d 100644 --- a/runtime/compat.test.js +++ b/runtime/compat.test.js @@ -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); }); diff --git a/runtime/ipc-protocol.js b/runtime/ipc-protocol.js index 04c40a5..6f5c48d 100644 --- a/runtime/ipc-protocol.js +++ b/runtime/ipc-protocol.js @@ -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) { diff --git a/runtime/movement-profiles.js b/runtime/movement-profiles.js index f405554..13e7795 100644 --- a/runtime/movement-profiles.js +++ b/runtime/movement-profiles.js @@ -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 }, }); diff --git a/runtime/skills/chop-logs.js b/runtime/skills/chop-logs.js index c51f556..c29dcad 100644 --- a/runtime/skills/chop-logs.js +++ b/runtime/skills/chop-logs.js @@ -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) { diff --git a/runtime/skills/diagnose-physics.js b/runtime/skills/diagnose-physics.js new file mode 100644 index 0000000..0dffb83 --- /dev/null +++ b/runtime/skills/diagnose-physics.js @@ -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, + }, + }, + }; + }, +}); diff --git a/runtime/skills/gather-stone.js b/runtime/skills/gather-stone.js index f64c5bc..040d294 100644 --- a/runtime/skills/gather-stone.js +++ b/runtime/skills/gather-stone.js @@ -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}`); diff --git a/runtime/skills/index.js b/runtime/skills/index.js index c61f003..b2b0dca 100644 --- a/runtime/skills/index.js +++ b/runtime/skills/index.js @@ -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);