feat(v0.4.0): vNext — closed-loop world model + settlement contract

Implements the vNext architecture from the research doc: demote the noisy
multi-rail planner in favour of a closed loop (world truth → invariant check)
plus a single utility-driven goal authority.

L1 services (fix no_drop / silent pathfinder hang first):
- InventoryLedger: diff-based "did I actually get it" verifier; acquire-food
  now confirms via ledger.gainedSince instead of the unreliable count/event.
- MotionService.gotoSafe: wall-clock timeout + progress watchdog +
  path_update(noPath/timeout) → structured {reached|stuck|timeout|nopath}.

L3 plan — unify the three competing rails (curriculum/manifesto/storyline):
- Settlement Contract: ordered M0–M9 milestones, each invariant-checked
  against an authoritative world view (early steps delegate to the proven
  curriculum; late game adds farming).
- InvariantChecker + predicate library; GoalManager selects the lowest unmet
  milestone via utility argmax (food-urgency preempts, DEPS-style).
- Wired into the scheduler: bot.js precomputes snapshot.contract; reflex.js
  consumes it in place of the storyline rail. Manifesto L0 still preempts.

Eval + robustness:
- Village Score (single 0..1 metric) on the snapshot + TUI "build" line.
- survive.dig-in skill + dusk_dig_in mode (exposed at night, no bed → cover).
- approach_block helper (GoalNear + lookAt, avoids GoalLookAtBlock #341).

+28 new tests (450 total green). LLM remains entirely off the tick path.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-28 11:05:43 +03:00
co-authored by Claude Opus 4.7
parent 15b6c11002
commit 2a6cc0cdb1
23 changed files with 1662 additions and 36 deletions
+67
View File
@@ -0,0 +1,67 @@
// Shared skill helpers.
//
// approachBlock — walk up to a target block and look at it, WITHOUT using
// pathfinder's GoalLookAtBlock. That goal raycasts collision boxes only
// (mineflayer-pathfinder #341), so for non-collision targets — crops, torches,
// fences, saplings, buttons — the raycast never hits and the goal is never
// "reached". Instead we GoalNear the block and lookAt its centre, which works
// regardless of the target's collision shape (research QW4).
import pathfinderPkg from "mineflayer-pathfinder";
const { goals } = pathfinderPkg;
export function blockPos(target) {
const p = target?.position ?? target;
if (!p || p.x == null) return null;
return { x: Math.floor(p.x), y: Math.floor(p.y), z: Math.floor(p.z) };
}
export function blockCenter(target) {
const p = blockPos(target);
return p ? { x: p.x + 0.5, y: p.y + 0.5, z: p.z + 0.5 } : null;
}
export function withinReach(botPos, target, reach = 4) {
const c = blockCenter(target);
if (!botPos || !c) return false;
return Math.hypot(botPos.x - c.x, botPos.y - c.y, botPos.z - c.z) <= reach;
}
// Returns { ok, code, distance? }. Uses ctx.motion.gotoSafe when available
// (structured, can't hang); falls back to a raw goto otherwise.
export async function approachBlock(ctx, target, opts = {}) {
const bot = ctx?.bot;
const pos = blockPos(target);
if (!bot || !pos) return { ok: false, code: "no_target" };
const reach = opts.reach ?? 3;
const reachCheck = opts.reachCheck ?? 4;
if (!withinReach(bot.entity?.position, target, reachCheck)) {
const goal = new goals.GoalNear(pos.x, pos.y, pos.z, reach);
let res;
if (ctx.motion?.gotoSafe) {
res = await ctx.motion.gotoSafe(goal, { timeoutMs: opts.timeoutMs ?? 20_000, label: "approach_block" });
} else {
try {
await bot.pathfinder.goto(goal);
res = { ok: true, code: "reached" };
} catch (e) {
res = { ok: false, code: "error", detail: e?.message ?? String(e) };
}
}
if (!res.ok && !withinReach(bot.entity?.position, target, reachCheck)) {
return { ok: false, code: res.code, detail: res.detail ?? null };
}
}
// Look at the block centre — NOT GoalLookAtBlock (issue #341).
try { await bot.lookAt(blockCenter(target), true); } catch {}
const here = bot.entity?.position;
const c = blockCenter(target);
const distance = here && c ? Math.round(Math.hypot(here.x - c.x, here.y - c.y, here.z - c.z) * 10) / 10 : null;
return { ok: true, code: "reached", distance };
}
export const _internal = { blockPos, blockCenter, withinReach };
+67
View File
@@ -0,0 +1,67 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { approachBlock, _internal } from "./_common.js";
test("blockPos floors a position; blockCenter offsets to centre", () => {
assert.deepEqual(_internal.blockPos({ x: 10.9, y: 64.2, z: -3.7 }), { x: 10, y: 64, z: -4 });
assert.deepEqual(_internal.blockCenter({ position: { x: 10, y: 64, z: -4 } }), { x: 10.5, y: 64.5, z: -3.5 });
});
test("withinReach respects the radius", () => {
assert.equal(_internal.withinReach({ x: 0.5, y: 64.5, z: 0.5 }, { x: 0, y: 64, z: 0 }, 4), true);
assert.equal(_internal.withinReach({ x: 20, y: 64, z: 0 }, { x: 0, y: 64, z: 0 }, 4), false);
});
test("approachBlock returns no_target without a target", async () => {
const r = await approachBlock({ bot: { entity: { position: { x: 0, y: 64, z: 0 } } } }, null);
assert.equal(r.ok, false);
assert.equal(r.code, "no_target");
});
test("approachBlock looks at the block when already in reach (no path needed)", async () => {
let looked = false;
let gotoCalled = false;
const ctx = {
bot: {
entity: { position: { x: 0.5, y: 64, z: 0.5 } },
async lookAt() { looked = true; },
},
motion: { gotoSafe: async () => { gotoCalled = true; return { ok: true, code: "reached" }; } },
};
const r = await approachBlock(ctx, { x: 0, y: 64, z: 0 }, { reachCheck: 4 });
assert.equal(r.ok, true);
assert.equal(looked, true);
assert.equal(gotoCalled, false, "should not path when already in reach");
});
test("approachBlock uses motion.gotoSafe to close distance, then looks", async () => {
let looked = false;
const ctx = {
bot: {
// starts far, motion 'moves' it into reach by mutating position
entity: { position: { x: 30, y: 64, z: 0 } },
async lookAt() { looked = true; },
},
motion: {
gotoSafe: async () => { ctx.bot.entity.position = { x: 0.6, y: 64, z: 0.6 }; return { ok: true, code: "reached" }; },
},
};
const r = await approachBlock(ctx, { x: 0, y: 64, z: 0 });
assert.equal(r.ok, true);
assert.equal(looked, true);
assert.ok(r.distance <= 4);
});
test("approachBlock fails when motion can't get into reach", async () => {
const ctx = {
bot: {
entity: { position: { x: 30, y: 64, z: 0 } }, // never moves
async lookAt() {},
},
motion: { gotoSafe: async () => ({ ok: false, code: "stuck" }) },
};
const r = await approachBlock(ctx, { x: 0, y: 64, z: 0 });
assert.equal(r.ok, false);
assert.equal(r.code, "stuck");
});
+31 -8
View File
@@ -39,6 +39,28 @@ function foodCount(bot) {
return bot.inventory.items().reduce((sum, item) => allowed.has(item.name) ? sum + item.count : sum, 0);
}
// Success here is "did edible food actually enter the inventory". Trusting the
// `playerCollect` event or `nearestEntity` going away is what produced the live
// `no_drop` false-failures (research §A.4). When the InventoryLedger is wired
// (ctx.ledger) we verify by diff against a baseline; otherwise we fall back to
// a local before/after count so the skill still works in unit tests.
function makeFoodTracker(ctx, bot) {
const isFood = (name) => foods(bot).has(name);
if (ctx?.ledger) {
ctx.ledger.update(bot);
const base = ctx.ledger.mark();
return {
mode: "ledger",
gained() {
ctx.ledger.update(bot);
return ctx.ledger.gainedSince(base, isFood);
},
};
}
const before = foodCount(bot);
return { mode: "count", gained: () => foodCount(bot) - before };
}
function nearestPassiveFoodMob(bot, maxDistance = 32) {
const here = bot?.entity?.position;
if (!here) return null;
@@ -127,15 +149,16 @@ export const skill = Object.freeze({
},
async execute(ctx) {
const bot = ctx.bot;
const before = foodCount(bot);
const track = makeFoodTracker(ctx, bot);
const picked = await pickupNearbyDrops(bot);
if (foodCount(bot) > before) {
const dropGain = track.gained();
if (dropGain > 0) {
return {
ok: true,
code: "done",
detail: { source: "drop", picked },
worldDelta: { acquiredFood: foodCount(bot) - before, source: "drop" },
detail: { source: "drop", picked, verify: track.mode },
worldDelta: { acquiredFood: dropGain, source: "drop" },
};
}
@@ -184,15 +207,15 @@ export const skill = Object.freeze({
}
await new Promise((r) => setTimeout(r, 1_000));
await pickupNearbyDrops(bot);
const after = foodCount(bot);
if (after <= before) {
const huntGain = track.gained();
if (huntGain <= 0) {
return { ok: false, code: "no_drop", detail: `hunted ${target.entity.name} but found no edible drop`, worldDelta: null };
}
return {
ok: true,
code: "done",
detail: { source: "hunt", mob: target.entity.name, gained: after - before },
worldDelta: { acquiredFood: after - before, source: "hunt", mob: target.entity.name },
detail: { source: "hunt", mob: target.entity.name, gained: huntGain, verify: track.mode },
worldDelta: { acquiredFood: huntGain, source: "hunt", mob: target.entity.name },
};
} catch (e) {
warn("action", `survive.acquire-food failed: ${e.message}`);
+131
View File
@@ -0,0 +1,131 @@
// survive.dig-in — emergency night shelter when displaced with no bed/base
// (research QW8, §3 "If displaced at night"). The classic survival move: dig
// straight down a couple of blocks for cover, cap the hole with a placed
// block, and wait out the night. Mobs can't path into a sealed 1-wide hole.
//
// This is intentionally conservative and safety-gated: it refuses to dig into
// lava/water/void and never digs deeper than 3. The cap is best-effort —
// placement timing is finicky and we never want to FAIL the skill (and bounce
// the bot back to wandering at night) just because the roof block didn't seat;
// being two blocks underground is already far safer than standing in the open.
import { info, warn } from "../log.js";
const CAP_PREFERENCE = [
"dirt", "cobblestone", "stone", "andesite", "diorite", "granite",
"cobbled_deepslate", "deepslate", "sand", "gravel", "netherrack",
"oak_planks", "spruce_planks", "birch_planks", "dark_oak_planks",
"jungle_planks", "acacia_planks", "mangrove_planks", "cherry_planks",
];
const UNSAFE_BELOW = new Set([
"lava", "flowing_lava", "water", "flowing_water", "bedrock",
"air", "cave_air", "void_air",
]);
export function pickCapBlock(bot) {
const items = bot.inventory?.items?.() ?? [];
for (const name of CAP_PREFERENCE) {
const found = items.find((i) => i.name === name && i.count > 0);
if (found) return found;
}
return items.find((i) => /(_planks|_log|_wool|cobble|stone|dirt|sand|gravel|netherrack)$/i.test(i.name)) ?? null;
}
// Safe to dig the block directly below: it must be a solid, non-hazard block
// (don't open a hole into lava/water, don't waste a dig on air/bedrock).
export function safeToDigBelow(bot) {
const pos = bot.entity?.position;
if (!pos) return { ok: false, reason: "no position" };
const below = bot.blockAt?.(pos.offset(0, -1, 0));
if (!below) return { ok: false, reason: "no block below" };
if (UNSAFE_BELOW.has(below.name)) return { ok: false, reason: `unsafe below: ${below.name}` };
return { ok: true, block: below };
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function attemptCap(bot) {
const pos = bot.entity?.position;
if (!pos) return false;
// Reference any solid block adjacent at the bot's head level; place onto
// its top face to seal the column above the bot.
const head = pos.offset(0, 1, 0);
for (const [dx, dz] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
const ref = bot.blockAt?.(head.offset(dx, 0, dz));
if (ref && !UNSAFE_BELOW.has(ref.name) && ref.name !== "air") {
try {
await bot.placeBlock(ref, { x: 0, y: 1, z: 0 });
return true;
} catch {
// try next reference
}
}
}
return false;
}
export const skill = Object.freeze({
id: "survive.dig-in",
title: "Dig in for the night",
timeoutMs: 30_000,
preconditions(ctx) {
if (!ctx?.bot) return { ok: false, code: "no_bot", detail: "bot missing" };
if (!pickCapBlock(ctx.bot)) {
return { ok: false, code: "missing_material", detail: "no placeable cap block (dirt/cobble/planks)" };
}
const safe = safeToDigBelow(ctx.bot);
if (!safe.ok) return { ok: false, code: "unsafe_dig", detail: safe.reason };
return { ok: true };
},
async execute(ctx, args = {}) {
const bot = ctx.bot;
try { bot.pathfinder?.setGoal?.(null); } catch {}
const depth = Math.max(1, Math.min(args?.depth ?? 2, 3));
let dug = 0;
let lastReason = null;
for (let i = 0; i < depth; i++) {
const safe = safeToDigBelow(bot);
if (!safe.ok) { lastReason = safe.reason; break; }
try {
await bot.dig(safe.block);
dug++;
await sleep(300); // let the bot drop into the new hole
} catch (e) {
lastReason = e?.message ?? String(e);
break;
}
}
if (dug === 0) {
return { ok: false, code: "no_progress", detail: lastReason ?? "could not dig down", worldDelta: null };
}
let capped = false;
const cap = pickCapBlock(bot);
if (cap) {
try { await bot.equip(cap, "hand"); } catch {}
try { await bot.look(bot.entity.yaw, Math.PI / 2, true); } catch {}
capped = await attemptCap(bot);
}
info("action", `dig-in: dug ${dug} down, capped=${capped}`);
return {
ok: true,
code: "done",
detail: { dug, capped, reason: lastReason },
worldDelta: { dugDown: dug, capped, mode: "dig-in" },
};
},
recover(ctx, result) {
if (result.code === "missing_material") {
return { hint: "wander", reason: "no cap block — gather dirt/cobble before nightfall" };
}
return null;
},
});
export const __testing = { pickCapBlock, safeToDigBelow, attemptCap, CAP_PREFERENCE, UNSAFE_BELOW };
+75
View File
@@ -0,0 +1,75 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { skill, __testing } from "./dig-in.js";
function vec(x, y, z) {
return {
x, y, z,
offset(dx, dy, dz) { return vec(x + dx, y + dy, z + dz); },
};
}
function fakeBot({ belowName = "dirt", sideName = "stone", items = [{ name: "dirt", count: 10, type: 3 }] } = {}) {
const calls = { dig: 0, place: 0, equip: 0 };
return {
calls,
entity: { position: vec(0, 64, 0), yaw: 0 },
inventory: { items: () => items },
blockAt(p) {
// below (y-1) → belowName; same-level neighbours → sideName
if (p.y === 63) return { name: belowName, position: p };
return { name: sideName, position: p };
},
async dig() { calls.dig++; },
async placeBlock() { calls.place++; },
async equip() { calls.equip++; },
async look() {},
pathfinder: { setGoal() {} },
};
}
test("pickCapBlock prefers dirt", () => {
const bot = fakeBot({ items: [{ name: "cobblestone", count: 3 }, { name: "dirt", count: 1 }] });
assert.equal(__testing.pickCapBlock(bot).name, "dirt");
});
test("safeToDigBelow refuses lava/water/bedrock/air", () => {
for (const bad of ["lava", "water", "bedrock", "air"]) {
const bot = fakeBot({ belowName: bad });
assert.equal(__testing.safeToDigBelow(bot).ok, false, bad);
}
assert.equal(__testing.safeToDigBelow(fakeBot({ belowName: "dirt" })).ok, true);
});
test("preconditions fail without a cap block", () => {
const bot = fakeBot({ items: [{ name: "raw_chicken", count: 1 }] });
const pre = skill.preconditions({ bot });
assert.equal(pre.ok, false);
assert.equal(pre.code, "missing_material");
});
test("preconditions fail when below is unsafe", () => {
const bot = fakeBot({ belowName: "lava" });
const pre = skill.preconditions({ bot });
assert.equal(pre.ok, false);
assert.equal(pre.code, "unsafe_dig");
});
test("execute digs down to depth and caps the hole", async () => {
const bot = fakeBot();
const res = await skill.execute({ bot }, { depth: 2 });
assert.equal(res.ok, true);
assert.equal(res.worldDelta.dugDown, 2);
assert.equal(res.worldDelta.capped, true);
assert.equal(bot.calls.dig, 2);
assert.ok(bot.calls.place >= 1);
});
test("execute stops digging when it hits something unsafe mid-dig", async () => {
// below is water → first safeToDigBelow already false → dug 0 → no_progress
const bot = fakeBot({ belowName: "water" });
const res = await skill.execute({ bot }, { depth: 3 });
assert.equal(res.ok, false);
assert.equal(res.code, "no_progress");
});
+2
View File
@@ -30,6 +30,7 @@ import { skill as flee } from "./flee.js";
import { skill as sleep } from "./sleep.js";
import { skill as tunnelOut } from "./recovery-tunnel-out.js";
import { skill as pillarUp } from "./pillar-up.js";
import { skill as digIn } from "./dig-in.js";
import { skill as escapePitSafe } from "./escape-pit-safe.js";
import { skill as diagPhysics } from "./diagnose-physics.js";
import { skill as diagScan, matchSkill as diagMatch } from "./diagnose-scan.js";
@@ -77,6 +78,7 @@ register(flee);
register(sleep);
register(tunnelOut);
register(pillarUp);
register(digIn);
register(escapePitSafe);
register(diagPhysics);
register(diagScan);