v0.4.0 vNext — closed-loop world model + settlement contract (#29)
* 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>
* feat(v0.4.0): finish vNext plan — anti-loop, skill-graph, worldDelta diff, flee→motion
Completes the remaining v0.4.0 plan items and one fix motivated by a live
in-game observation (flee hanging 30s against a persistent zombie).
- flee → MotionService.gotoSafe: structured {stuck|timeout|nopath} in ~4s with
a blind-retreat fallback, instead of the observed 30s pathfinder hang + 3
watchdog replans. Movements setup guarded so it is unit-testable.
- QW5 anti-loop (runtime/anti-loop.js): same skill failing >=3x in 5min →
30min blacklist (reflex shouldSkip) + one-shot improvement_request
(bot.js drainFired -> writeProposal).
- 4.1 closed-loop worldDelta: runSkill snapshots inventory before execute and
attaches the real delta (_invObserved) to every successful result; opt-in
skill.expectGain asserts the claimed gain or returns world_unchanged.
- 3.6 skill-graph (Plan4MC): declarative requires/produces for ~20 skills;
prerequisitesMet/canRun/runnableFrontier; GoalManager annotates suggestions
with blockedBy when prereqs are unmet.
+22 tests (472 total green). Live smoke confirmed dig-in works and no new
errors; flee loop is what this commit's flee migration addresses.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Yuriy Mayatnikov <mayatnikov@me.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit was merged in pull request #29.
This commit is contained in:
@@ -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 };
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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 };
|
||||
@@ -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");
|
||||
});
|
||||
@@ -39,7 +39,7 @@ export const skill = Object.freeze({
|
||||
async execute(ctx, args = {}) {
|
||||
const hit = nearestHostile(ctx.bot, args);
|
||||
if (!hit) return { ok: false, code: "no_hostile", detail: "no matching hostile after precondition", worldDelta: null };
|
||||
const res = await fleeFrom(ctx.bot, hit.entity, args.distance ?? 16);
|
||||
const res = await fleeFrom(ctx.bot, hit.entity, args.distance ?? 16, { motion: ctx.motion, blindMs: args.blindMs });
|
||||
if (res.ok) {
|
||||
return {
|
||||
ok: true,
|
||||
@@ -48,8 +48,10 @@ export const skill = Object.freeze({
|
||||
worldDelta: { fledTo: res.detail?.to ?? null },
|
||||
};
|
||||
}
|
||||
// Prefer the structured code from MotionService (stuck/timeout/nopath);
|
||||
// fall back to string-sniffing the legacy path's message.
|
||||
const msg = String(res.detail ?? "");
|
||||
const code = msg.includes("timed out") ? "timeout" : "failed";
|
||||
const code = res.code ?? (msg.includes("timed out") ? "timeout" : "failed");
|
||||
return { ok: false, code, detail: res.detail, worldDelta: null };
|
||||
},
|
||||
recover(ctx, result) {
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { skill } from "./flee.js";
|
||||
|
||||
function vec(x, y, z) {
|
||||
return {
|
||||
x, y, z,
|
||||
clone() { return vec(x, y, z); },
|
||||
distanceTo(o) { return Math.hypot(x - o.x, y - o.y, z - o.z); },
|
||||
offset(dx, dy, dz) { return vec(x + dx, y + dy, z + dz); },
|
||||
};
|
||||
}
|
||||
|
||||
function fakeBot({ moveOnForward = 0 } = {}) {
|
||||
const bot = {
|
||||
entity: { position: vec(0, 64, 0), yaw: 0 },
|
||||
entities: { z1: { name: "zombie", position: vec(2, 64, 0) } },
|
||||
loadPlugin() {},
|
||||
pathfinder: { goto: () => new Promise(() => {}), stop() {}, setMovements() {} },
|
||||
setControlState(name, on) {
|
||||
if (name === "forward" && on && moveOnForward) {
|
||||
bot.entity.position = vec(moveOnForward, 64, 0);
|
||||
}
|
||||
},
|
||||
async look() {},
|
||||
};
|
||||
return bot;
|
||||
}
|
||||
|
||||
test("flee returns done when motion reaches the retreat point", async () => {
|
||||
const bot = fakeBot();
|
||||
const ctx = { bot, motion: { gotoSafe: async () => ({ ok: true, code: "reached", movedBlocks: 16 }) } };
|
||||
const res = await skill.execute(ctx, {});
|
||||
assert.equal(res.ok, true);
|
||||
assert.equal(res.code, "done");
|
||||
assert.ok(res.worldDelta.fledTo);
|
||||
});
|
||||
|
||||
test("flee falls back to blind retreat and succeeds when it moves far enough", async () => {
|
||||
const bot = fakeBot({ moveOnForward: 8 });
|
||||
const ctx = { bot, motion: { gotoSafe: async () => ({ ok: false, code: "stuck", movedBlocks: 0 }) } };
|
||||
const res = await skill.execute(ctx, { blindMs: 20 });
|
||||
assert.equal(res.ok, true);
|
||||
assert.equal(res.detail.mode, "blind-retreat");
|
||||
});
|
||||
|
||||
test("flee surfaces the structured motion code when stuck and blind retreat fails", async () => {
|
||||
const bot = fakeBot({ moveOnForward: 0 }); // never moves
|
||||
const ctx = { bot, motion: { gotoSafe: async () => ({ ok: false, code: "stuck", movedBlocks: 0 }) } };
|
||||
const res = await skill.execute(ctx, { blindMs: 20 });
|
||||
assert.equal(res.ok, false);
|
||||
assert.equal(res.code, "stuck");
|
||||
});
|
||||
|
||||
test("flee precondition fails with no hostile", () => {
|
||||
const bot = fakeBot();
|
||||
bot.entities = {};
|
||||
const pre = skill.preconditions({ bot }, {});
|
||||
assert.equal(pre.ok, false);
|
||||
assert.equal(pre.code, "no_hostile");
|
||||
});
|
||||
@@ -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);
|
||||
@@ -128,6 +130,18 @@ export const RUNNER_CODES = Object.freeze({
|
||||
DONE: "done",
|
||||
});
|
||||
|
||||
// Signed inventory diff between two count Maps (from InventoryLedger.mark/
|
||||
// snapshot). Used to attach the real world change to a skill result.
|
||||
function invDiff(before, after) {
|
||||
const out = {};
|
||||
const names = new Set([...(before?.keys?.() ?? []), ...(after?.keys?.() ?? [])]);
|
||||
for (const n of names) {
|
||||
const d = (after?.get?.(n) ?? 0) - (before?.get?.(n) ?? 0);
|
||||
if (d !== 0) out[n] = d;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function normaliseResult(res, fallbackCode) {
|
||||
const ok = !!res?.ok;
|
||||
return {
|
||||
@@ -220,6 +234,12 @@ export async function runSkill(id, ctx, args = {}) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// WorldDelta diff layer (research §TL;DR): snapshot the inventory before
|
||||
// execute so we can attach the REAL inventory change to the result and,
|
||||
// for skills that opt in via `expectGain`, assert the claimed gain actually
|
||||
// happened instead of trusting the skill's own bookkeeping.
|
||||
const ledgerBefore = ctx?.ledger?.mark?.() ?? null;
|
||||
|
||||
const timeoutMs = skill.timeoutMs ?? 30_000;
|
||||
let raw;
|
||||
try {
|
||||
@@ -275,6 +295,31 @@ export async function runSkill(id, ctx, args = {}) {
|
||||
return failed;
|
||||
}
|
||||
}
|
||||
// Closed loop: compare the inventory now vs the pre-execute baseline.
|
||||
if (result.ok && ledgerBefore && ctx?.ledger) {
|
||||
try { if (ctx.bot) ctx.ledger.update(ctx.bot); } catch {}
|
||||
const observed = invDiff(ledgerBefore, ctx.ledger.snapshot());
|
||||
if (Object.keys(observed).length > 0) {
|
||||
result.worldDelta = { ...(result.worldDelta ?? {}), _invObserved: observed };
|
||||
}
|
||||
// Opt-in strict check: the world must show the claimed gain.
|
||||
if (skill.expectGain) {
|
||||
const gain = ctx.ledger.gainedSince(ledgerBefore, skill.expectGain.matcher);
|
||||
if (gain < (skill.expectGain.min ?? 1)) {
|
||||
const failed = {
|
||||
ok: false,
|
||||
code: "world_unchanged",
|
||||
detail: `${id} reported ok but ${skill.expectGain.label ?? "expected items"} did not increase (gain ${gain})`,
|
||||
worldDelta: result.worldDelta,
|
||||
};
|
||||
if (typeof skill.recover === "function") {
|
||||
try { failed.recovery = skill.recover(ctx, failed) ?? null; } catch {}
|
||||
}
|
||||
return failed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!result.ok && typeof skill.recover === "function") {
|
||||
try {
|
||||
result.recovery = skill.recover(ctx, result) ?? null;
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { runSkill, _registerForTest } from "./index.js";
|
||||
import { createInventoryLedger } from "../services/inventory-ledger.js";
|
||||
|
||||
function botWithMutableInv(initial) {
|
||||
let inv = initial;
|
||||
return {
|
||||
bot: { inventory: { items: () => inv } },
|
||||
set(next) { inv = next; },
|
||||
};
|
||||
}
|
||||
|
||||
test("expectGain passes and attaches the observed inventory delta", async () => {
|
||||
const m = botWithMutableInv([{ name: "cobblestone", count: 0 }]);
|
||||
const ledger = createInventoryLedger();
|
||||
ledger.update(m.bot);
|
||||
const teardown = _registerForTest({
|
||||
id: "test.mine-ok",
|
||||
title: "t", timeoutMs: 1000,
|
||||
preconditions: () => ({ ok: true }),
|
||||
execute: async () => { m.set([{ name: "cobblestone", count: 5 }]); return { ok: true, code: "done", worldDelta: {} }; },
|
||||
expectGain: { matcher: "cobblestone", min: 1, label: "cobblestone" },
|
||||
});
|
||||
const res = await runSkill("test.mine-ok", { bot: m.bot, ledger });
|
||||
teardown();
|
||||
assert.equal(res.ok, true);
|
||||
assert.equal(res.worldDelta._invObserved.cobblestone, 5);
|
||||
});
|
||||
|
||||
test("expectGain fails with world_unchanged when the world did not move", async () => {
|
||||
const m = botWithMutableInv([{ name: "cobblestone", count: 0 }]);
|
||||
const ledger = createInventoryLedger();
|
||||
ledger.update(m.bot);
|
||||
const teardown = _registerForTest({
|
||||
id: "test.mine-liar",
|
||||
title: "t", timeoutMs: 1000,
|
||||
preconditions: () => ({ ok: true }),
|
||||
execute: async () => ({ ok: true, code: "done", worldDelta: {} }), // claims ok, gains nothing
|
||||
expectGain: { matcher: "cobblestone", min: 1, label: "cobblestone" },
|
||||
});
|
||||
const res = await runSkill("test.mine-liar", { bot: m.bot, ledger });
|
||||
teardown();
|
||||
assert.equal(res.ok, false);
|
||||
assert.equal(res.code, "world_unchanged");
|
||||
});
|
||||
|
||||
test("no ledger in ctx → no validation, skill passes untouched", async () => {
|
||||
const teardown = _registerForTest({
|
||||
id: "test.no-ledger",
|
||||
title: "t", timeoutMs: 1000,
|
||||
preconditions: () => ({ ok: true }),
|
||||
execute: async () => ({ ok: true, code: "done", worldDelta: { foo: 1 } }),
|
||||
expectGain: { matcher: "diamond", min: 1 },
|
||||
});
|
||||
const res = await runSkill("test.no-ledger", { bot: { inventory: { items: () => [] } } });
|
||||
teardown();
|
||||
assert.equal(res.ok, true);
|
||||
assert.equal(res.worldDelta.foo, 1);
|
||||
});
|
||||
|
||||
test("observed delta is attached even without expectGain", async () => {
|
||||
const m = botWithMutableInv([{ name: "oak_log", count: 2 }]);
|
||||
const ledger = createInventoryLedger();
|
||||
ledger.update(m.bot);
|
||||
const teardown = _registerForTest({
|
||||
id: "test.observe-only",
|
||||
title: "t", timeoutMs: 1000,
|
||||
preconditions: () => ({ ok: true }),
|
||||
execute: async () => { m.set([{ name: "oak_log", count: 6 }]); return { ok: true, code: "done", worldDelta: null }; },
|
||||
});
|
||||
const res = await runSkill("test.observe-only", { bot: m.bot, ledger });
|
||||
teardown();
|
||||
assert.equal(res.ok, true);
|
||||
assert.equal(res.worldDelta._invObserved.oak_log, 4);
|
||||
});
|
||||
Reference in New Issue
Block a user