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:
Yuriy Mayatnikov
2026-05-28 11:29:39 +03:00
committed by GitHub
co-authored by Claude Opus 4.7 mayatnikov
parent 15b6c11002
commit c910457817
31 changed files with 2236 additions and 61 deletions
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "pepa-pi-bot",
"version": "0.2.0-rc.1",
"version": "0.4.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "pepa-pi-bot",
"version": "0.2.0-rc.1",
"version": "0.4.0",
"license": "MIT",
"dependencies": {
"better-sqlite3": "^11.10.0",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "pepa-pi-bot",
"version": "0.3.0-rc.3",
"version": "0.4.0",
"private": true,
"description": "An autonomous, self-extending Minecraft player powered by Pi and Mineflayer.",
"license": "MIT",
@@ -17,7 +17,7 @@
"tui:legacy": "tsx tui/tui.tsx",
"propose:apply": "node scripts/propose-apply.js",
"stop": "bash scripts/stop.sh",
"test": "node --test runtime/skills/contract.test.js runtime/skills/groups.test.js runtime/skills/compat.test.js runtime/skills/recovery-tunnel-out.test.js runtime/skills/pillar-up.test.js runtime/curriculum.test.js runtime/social/social.test.js runtime/social/conversation.test.js runtime/social/chat-history.test.js runtime/social/reply-pi.test.js runtime/stuck-incident.test.js runtime/compat.test.js runtime/reflex.test.js runtime/base-site.test.js runtime/locations.test.js runtime/watch-filter.test.js runtime/world-journal.test.js runtime/scenario-memory.test.js runtime/critic.test.js runtime/skill-library.test.js runtime/skill-registry.test.js runtime/modes.test.js runtime/pathfinder-watchdog.test.js runtime/manifesto/needs.test.js runtime/manifesto/state.test.js runtime/goal/storyline.test.js runtime/awareness/events.test.js runtime/awareness/wedge-detector.test.js runtime/biome-affordances.test.js runtime/knowledge/knowledge.test.js runtime/llm/provider.test.js runtime/coach/postmortem.test.js runtime/coach/advice.test.js runtime/coach/reflect.test.js runtime/coach/fast-advisor.test.js runtime/coach/advisor-trigger.test.js runtime/coach/trigger-tuner.test.js runtime/persona/chatter.test.js scripts/edit-scope.test.js scripts/lint-patch.test.js"
"test": "node --test runtime/skills/contract.test.js runtime/skills/groups.test.js runtime/skills/compat.test.js runtime/skills/recovery-tunnel-out.test.js runtime/skills/pillar-up.test.js runtime/skills/dig-in.test.js runtime/skills/_common.test.js runtime/skills/flee.test.js runtime/skills/worlddelta.test.js runtime/curriculum.test.js runtime/social/social.test.js runtime/social/conversation.test.js runtime/social/chat-history.test.js runtime/social/reply-pi.test.js runtime/stuck-incident.test.js runtime/compat.test.js runtime/reflex.test.js runtime/base-site.test.js runtime/locations.test.js runtime/watch-filter.test.js runtime/world-journal.test.js runtime/scenario-memory.test.js runtime/critic.test.js runtime/skill-library.test.js runtime/skill-registry.test.js runtime/modes.test.js runtime/pathfinder-watchdog.test.js runtime/anti-loop.test.js runtime/services/inventory-ledger.test.js runtime/services/motion.test.js runtime/manifesto/needs.test.js runtime/manifesto/state.test.js runtime/goal/storyline.test.js runtime/goal/goal-manager.test.js runtime/goal/village-score.test.js runtime/goal/skill-graph.test.js runtime/awareness/events.test.js runtime/awareness/wedge-detector.test.js runtime/biome-affordances.test.js runtime/knowledge/knowledge.test.js runtime/llm/provider.test.js runtime/coach/postmortem.test.js runtime/coach/advice.test.js runtime/coach/reflect.test.js runtime/coach/fast-advisor.test.js runtime/coach/advisor-trigger.test.js runtime/coach/trigger-tuner.test.js runtime/persona/chatter.test.js scripts/edit-scope.test.js scripts/lint-patch.test.js"
},
"dependencies": {
"better-sqlite3": "^11.10.0",
+58 -23
View File
@@ -143,7 +143,30 @@ export async function attackNearest(bot, hostileType) {
// ---- flee ------------------------------------------------------------------
export async function fleeFrom(bot, fromEntity, distance = 16) {
async function blindRetreat(bot, dirYaw, blindMs = 7_000) {
const before = bot.entity.position.clone?.() ?? { ...bot.entity.position };
try { bot.pathfinder?.stop?.(); } catch {}
try { await bot.look(dirYaw, 0, true); } catch {}
bot.setControlState("forward", true);
bot.setControlState("jump", true);
try {
await new Promise((r) => setTimeout(r, blindMs));
} finally {
bot.setControlState("forward", false);
bot.setControlState("jump", false);
}
const after = bot.entity.position;
const moved = Math.hypot(after.x - before.x, after.z - before.z);
return { moved, after };
}
// v0.4.0 — when a MotionService is supplied (opts.motion), retreat via
// gotoSafe: a 12s wall-clock with a 4s progress watchdog returns a STRUCTURED
// {stuck|timeout|nopath} fast instead of hanging the full 30s that we observed
// live (zombie pinning the bot, every flee timing out, watchdog burning 3
// replans). On any non-reached result we fall straight through to the blind
// retreat. Callers without a motion service keep the legacy 30s path.
export async function fleeFrom(bot, fromEntity, distance = 16, opts = {}) {
ensurePathfinder(bot);
const from = fromEntity?.position ?? bot.entity.position;
const here = bot.entity.position;
@@ -154,50 +177,62 @@ export async function fleeFrom(bot, fromEntity, distance = 16) {
const tx = Math.round(here.x + (dx / len) * distance);
const tz = Math.round(here.z + (dz / len) * distance);
const ty = Math.round(here.y);
const dirYaw = -Math.atan2(dx / len, dz / len);
const blindMs = opts.blindMs ?? 7_000;
info("action", `flee: from=${fromEntity?.name ?? "?"}${tx},${ty},${tz}`);
// canDig:true here is deliberate — without it the bot gets permanently
// stuck in dense tree canopy (observed live: bot perched at Y=85 inside
// dark-oak leaves, every flee timed out for hours). We accept the risk of
// chopping through scenery while panicking; it's how a player would react.
const movements = new Movements(bot);
movements.canDig = true;
movements.allow1by1towers = false;
bot.pathfinder.setMovements(movements);
try {
const movements = new Movements(bot);
movements.canDig = true;
movements.allow1by1towers = false;
bot.pathfinder.setMovements(movements);
} catch {
// fake/registry-less bot (tests) — skip movement tuning
}
const goal = new goals.GoalNear(tx, ty, tz, 1);
if (opts.motion?.gotoSafe) {
const res = await opts.motion.gotoSafe(goal, {
timeoutMs: opts.timeoutMs ?? 12_000,
stuckWindowMs: 4_000,
stuckDelta: 1.5,
label: `flee(${fromEntity?.name})`,
});
if (res.ok) return { ok: true, detail: { to: { x: tx, y: ty, z: tz }, moved: res.movedBlocks } };
warn("action", `flee gotoSafe → ${res.code} (moved ${res.movedBlocks}b); blind retreat`);
const b = await blindRetreat(bot, dirYaw, blindMs);
if (b.moved >= 4) {
return { ok: true, detail: { to: { x: Math.round(b.after.x), y: Math.round(b.after.y), z: Math.round(b.after.z) }, mode: "blind-retreat", moved: b.moved } };
}
return { ok: false, code: res.code, detail: `flee ${res.code} then blind retreat moved ${b.moved.toFixed(2)}b` };
}
try {
await withTimeout(
bot.pathfinder.goto(new goals.GoalNear(tx, ty, tz, 1)),
bot.pathfinder.goto(goal),
30_000,
`fleeFrom(${fromEntity?.name})`,
);
return { ok: true, detail: { to: { x: tx, y: ty, z: tz } } };
} catch (e) {
warn("action", `flee path failed: ${e.message}; trying blind retreat`);
const before = bot.entity.position.clone?.() ?? { ...bot.entity.position };
try { bot.pathfinder?.stop?.(); } catch {}
try { await bot.look(-Math.atan2(dx / len, dz / len), 0, true); } catch {}
bot.setControlState("forward", true);
bot.setControlState("jump", true);
try {
await new Promise((r) => setTimeout(r, 7_000));
} finally {
bot.setControlState("forward", false);
bot.setControlState("jump", false);
}
const after = bot.entity.position;
const moved = Math.hypot(after.x - before.x, after.z - before.z);
if (moved >= 4) {
const b = await blindRetreat(bot, dirYaw, blindMs);
if (b.moved >= 4) {
return {
ok: true,
detail: {
to: { x: Math.round(after.x), y: Math.round(after.y), z: Math.round(after.z) },
to: { x: Math.round(b.after.x), y: Math.round(b.after.y), z: Math.round(b.after.z) },
mode: "blind-retreat",
moved,
moved: b.moved,
},
};
}
warn("action", `flee blind retreat moved only ${moved.toFixed(2)} blocks`);
warn("action", `flee blind retreat moved only ${b.moved.toFixed(2)} blocks`);
return { ok: false, detail: e.message };
}
}
+70
View File
@@ -0,0 +1,70 @@
// Anti-loop detector (QW5). The scheduler already skips a (skill, situation)
// that has failed repeatedly (scenario-memory.shouldSkip), but that is keyed on
// a coarse situation hash and never escalates. This detector closes the loop
// the research describes: when the SAME skill fails N times inside a short
// window with no success in between, it (a) blacklists that skill for a cool-off
// and (b) emits a one-shot "fired" record the runtime turns into an
// improvement_request — so an operator/Codex gets a ticket instead of the bot
// silently thrashing (e.g. the live flee↔dig-in loop we observed).
//
// Pure + deterministic: inject `now` in tests. No disk, no bot.
export function createAntiLoop({
windowMs = 5 * 60_000,
threshold = 3,
blacklistMs = 30 * 60_000,
refireCooldownMs = 30 * 60_000,
} = {}) {
const state = new Map(); // key -> { fails: number[], blacklistUntil, lastFiredAt }
const firedQueue = [];
function keyOf(skillId, targetKey) {
return targetKey ? `${skillId}@${targetKey}` : skillId;
}
function get(key) {
let s = state.get(key);
// lastFiredAt = -Infinity so the FIRST loop always fires (a real epoch
// `now` minus 0 would otherwise be < refireCooldownMs early in uptime).
if (!s) { s = { fails: [], blacklistUntil: 0, lastFiredAt: Number.NEGATIVE_INFINITY }; state.set(key, s); }
return s;
}
function record({ skillId, ok, code = null, targetKey = null, detail = null, now = Date.now() }) {
if (!skillId) return { fired: false };
const key = keyOf(skillId, targetKey);
const s = get(key);
if (ok) { s.fails = []; return { fired: false }; }
s.fails.push(now);
s.fails = s.fails.filter((t) => now - t <= windowMs);
if (s.fails.length >= threshold) {
s.blacklistUntil = now + blacklistMs;
const count = s.fails.length;
s.fails = []; // reset streak so we don't blacklist-spam every further fail
if (now - s.lastFiredAt >= refireCooldownMs) {
s.lastFiredAt = now;
const fired = { key, skillId, targetKey, count, code, detail, ts: now, until: s.blacklistUntil };
firedQueue.push(fired);
return { fired: true, ...fired };
}
}
return { fired: false };
}
function shouldSkip(skillId, targetKey = null, now = Date.now()) {
const s = state.get(keyOf(skillId, targetKey));
return !!s && now < s.blacklistUntil;
}
// Returns and clears the queue of newly-fired loops (for improvement_requests).
function drainFired() {
return firedQueue.splice(0);
}
function snapshot() {
return { tracked: state.size, pendingFired: firedQueue.length };
}
return { record, shouldSkip, drainFired, snapshot, _state: () => state };
}
+58
View File
@@ -0,0 +1,58 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { createAntiLoop } from "./anti-loop.js";
test("fires after N failures in the window and blacklists the skill", () => {
const al = createAntiLoop({ windowMs: 60_000, threshold: 3, blacklistMs: 30_000 });
assert.equal(al.record({ skillId: "survive.flee", ok: false, now: 1000 }).fired, false);
assert.equal(al.record({ skillId: "survive.flee", ok: false, now: 2000 }).fired, false);
const third = al.record({ skillId: "survive.flee", ok: false, now: 3000 });
assert.equal(third.fired, true);
assert.equal(third.count, 3);
assert.equal(al.shouldSkip("survive.flee", null, 4000), true);
assert.equal(al.shouldSkip("survive.flee", null, 40_000), false); // blacklist expired
});
test("a success resets the fail streak", () => {
const al = createAntiLoop({ threshold: 3 });
al.record({ skillId: "gather.logs", ok: false, now: 1 });
al.record({ skillId: "gather.logs", ok: false, now: 2 });
al.record({ skillId: "gather.logs", ok: true, now: 3 });
const r = al.record({ skillId: "gather.logs", ok: false, now: 4 });
assert.equal(r.fired, false);
});
test("failures outside the window do not accumulate", () => {
const al = createAntiLoop({ windowMs: 1000, threshold: 3 });
al.record({ skillId: "s", ok: false, now: 0 });
al.record({ skillId: "s", ok: false, now: 500 });
const r = al.record({ skillId: "s", ok: false, now: 5000 }); // first two pruned
assert.equal(r.fired, false);
});
test("targetKey separates loops on different targets", () => {
const al = createAntiLoop({ threshold: 2 });
al.record({ skillId: "mine", ok: false, targetKey: "A", now: 1 });
const a2 = al.record({ skillId: "mine", ok: false, targetKey: "A", now: 2 });
assert.equal(a2.fired, true);
const b1 = al.record({ skillId: "mine", ok: false, targetKey: "B", now: 3 });
assert.equal(b1.fired, false); // different target, own streak
});
test("drainFired returns and clears the queue", () => {
const al = createAntiLoop({ threshold: 2 });
al.record({ skillId: "x", ok: false, now: 1 });
al.record({ skillId: "x", ok: false, now: 2 });
assert.equal(al.drainFired().length, 1);
assert.equal(al.drainFired().length, 0);
});
test("refire cooldown prevents immediate re-fire", () => {
const al = createAntiLoop({ threshold: 2, blacklistMs: 1000, refireCooldownMs: 100_000 });
al.record({ skillId: "x", ok: false, now: 1 });
assert.equal(al.record({ skillId: "x", ok: false, now: 2 }).fired, true);
// after blacklist expires, two more fails — within refire cooldown → no fire
al.record({ skillId: "x", ok: false, now: 2000 });
assert.equal(al.record({ skillId: "x", ok: false, now: 2100 }).fired, false);
});
+72
View File
@@ -57,6 +57,9 @@ import { createSkillMetrics } from "./skill-metrics.js";
import { createWorldJournal } from "./world-journal.js";
import { createScenarioMemory, situationHash } from "./scenario-memory.js";
import { createOwnedBlocksLedger } from "./owned-blocks.js";
import { createInventoryLedger } from "./services/inventory-ledger.js";
import { createMotionService } from "./services/motion.js";
import { createAntiLoop } from "./anti-loop.js";
import { initKnowledge } from "./knowledge/index.js";
import { attach as attachCoach } from "./coach/postmortem.js";
import { attach as attachReflect } from "./coach/reflect.js";
@@ -64,6 +67,8 @@ import { attach as attachTuner } from "./coach/trigger-tuner.js";
import { attach as attachChatter } from "./persona/chatter.js";
import { attachAwareness } from "./awareness/events.js";
import { pickCurrentStep } from "./goal/state.js";
import { createGoalManager } from "./goal/goal-manager.js";
import { computeVillageScore } from "./goal/village-score.js";
fs.mkdirSync(stateDir, { recursive: true });
const JOINED_FLAG = path.join(stateDir, "joined-before.flag");
@@ -104,6 +109,10 @@ const skillMetrics = createSkillMetrics();
const worldJournal = createWorldJournal();
const scenarioMemory = createScenarioMemory();
const ownedBlocks = createOwnedBlocksLedger();
const inventoryLedger = createInventoryLedger();
const antiLoop = createAntiLoop();
const goalManager = createGoalManager();
let motionService = null; // armed on spawn (needs a live bot for pathfinder)
let lastResult = null; // { label, ok, code, detail, ts }
let lastFailureAt = 0;
let lastPlanReadAt = 0;
@@ -130,6 +139,9 @@ const reflexCtx = {
memory: scenarioMemory,
metrics: skillMetrics,
owned: ownedBlocks,
ledger: inventoryLedger,
antiLoop,
motion: null, // set on spawn alongside the pathfinder watchdog
};
let chatTimestamps = [];
@@ -690,6 +702,14 @@ function connect() {
pathWatchdog = createPathfinderWatchdog(bot);
info("pathfinder", "stuck-replan watchdog armed");
} catch (e) { warn("pathfinder", `watchdog start failed: ${e?.message ?? e}`); }
// MotionService (L1): structured gotoSafe() with wall-clock timeout +
// progress watchdog + noPath/timeout listener. Skills opt in for a
// {ok|stuck|timeout|nopath} result instead of a silent hang.
try {
motionService = createMotionService(bot);
reflexCtx.motion = motionService;
info("motion", "MotionService armed");
} catch (e) { warn("motion", `MotionService start failed: ${e?.message ?? e}`); }
// v0.2.0 — self-learning coach + persona narration. Both are
// import-safe; they just attach listeners and (for coach) a periodic
// Pi-drain timer. See docs/v0.2.0-self-learning.md.
@@ -753,6 +773,8 @@ function connect() {
warn("mc", `connection ended: ${reason}`);
bot = null;
reflexCtx.bot = null;
motionService = null;
reflexCtx.motion = null;
lastSnapshot = { connected: false };
if (!shuttingDown) scheduleReconnect();
});
@@ -830,6 +852,10 @@ function tick() {
if (shuttingDown) return;
const now = Date.now();
if (bot && bot.entity) {
// L1: record an inventory snapshot every tick so skills can verify
// pickups by diff (ledger.gainedSince/acquired) rather than trusting
// the unreliable playerCollect event.
try { inventoryLedger.update(bot, now); } catch {}
lastSnapshot = buildSnapshot(bot);
lastSnapshot.pendingProposals = listProposals().length;
lastSnapshot.lastReflex = reflexCtx.lastReflex ?? null;
@@ -854,6 +880,18 @@ function tick() {
// other observers can react to step transitions without
// re-importing the picker.
try { lastSnapshot.storyStep = pickCurrentStep(lastSnapshot); } catch {}
// L3 Settlement Contract: evaluate milestone invariants against the
// world and surface the unified progression goal + suggested skill.
// Precomputed here (like curriculum/storyStep) so reflex.js consumes
// snapshot.contract and the TUI/score read it without re-walking.
try {
lastSnapshot.contract = goalManager.next(lastSnapshot, { ledger: inventoryLedger });
lastSnapshot.villageScore = computeVillageScore(lastSnapshot, {
contract: lastSnapshot.contract,
uptimeMs: botSpawnedAt ? Date.now() - botSpawnedAt : 0,
metrics: skillMetrics.snapshot(),
});
} catch (e) { warn("contract", `eval failed: ${e?.message ?? e}`); }
reflexCtx.snapshot = lastSnapshot;
if (!reflexPaused) {
const result = runTick(reflexCtx);
@@ -936,6 +974,40 @@ function tick() {
void filePostCritique(wedged, "wedged");
}
// QW5 — anti-loop: a skill that failed ≥3× in 5 min is blacklisted by
// the detector; here we turn each fired loop into an improvement_request
// so the operator/Codex gets a concrete ticket instead of silent thrash.
for (const loop of antiLoop.drainFired()) {
try {
writeProposal({
kind: `anti-loop-${loop.skillId}`,
summary: `${loop.skillId} looped ${loop.count}× in 5min (last code=${loop.code ?? "?"})`,
body: [
`# Anti-loop: ${loop.skillId}`,
``,
`The same skill failed ${loop.count} times within 5 minutes with no success`,
`in between, so it has been blacklisted until ${new Date(loop.until).toISOString()}.`,
``,
`- skill: ${loop.skillId}`,
loop.targetKey ? `- target: ${loop.targetKey}` : `- target: (none)`,
`- last failure code: ${loop.code ?? "?"}`,
`- runtime state: ${lastSnapshot.runtimeState ?? "?"}`,
`- no-progress reason: ${lastSnapshot.noProgressReason ?? "?"}`,
`- position: ${JSON.stringify(lastSnapshot.position ?? null)}`,
`- milestone: ${lastSnapshot.contract?.milestone?.id ?? lastSnapshot.currentMilestone ?? "?"}`,
``,
`## Suggested fix`,
`Either the skill's preconditions are too loose (it keeps being chosen`,
`when it cannot succeed here) or it needs a real recovery branch. Inspect`,
`runtime/skills/${loop.skillId.split(".").pop()}*.js and the scheduler path.`,
].join("\n"),
editScope: ["runtime/skills/", "runtime/reflex.js", "runtime/modes.js"],
});
} catch (e) {
warn("anti-loop", `writeProposal failed: ${e?.message ?? e}`);
}
}
ipc?.broadcast(EVENT_TYPES.STATUS, lastSnapshot);
} else {
lastSnapshot = { connected: false };
+149
View File
@@ -0,0 +1,149 @@
// Settlement Contract (L3) — the global goal as a typed, invariant-checked
// chain of milestones (research §2). This replaces the two competing
// progression rails (storyline quest + raw curriculum) with ONE ordered
// contract whose "done" is a fact about the world, not a guess.
//
// Each milestone:
// id stable string
// title human label (TUI / diary)
// invariants [{ id, describe, met(world) }] — milestone is "met" iff all hold
// suggest (world) -> { skillId, args? } | null — next concrete action
// urgency? (world) -> number — utility boost so survival-critical
// milestones (food) can preempt a lower-indexed unmet milestone
//
// The early tech-tree milestones delegate `suggest` to the proven
// deterministic curriculum (runtime/curriculum.js) so we reuse its careful
// chop→craft→tool chain instead of duplicating it. The contract owns ordering,
// invariant truth, observability and the late-game milestones curriculum lacks.
import { nextMilestone as curriculumNext } from "../curriculum.js";
import {
alive,
foodSecure,
bedSecured,
stoneTier,
locationExists,
hasItem,
has,
WOODEN_TOOLS,
totalMatching,
} from "./invariants.js";
// curriculum's plan for the current snapshot. Prefer the plan bot.js already
// precomputed onto snapshot.curriculum (single source, no double-walk); fall
// back to recomputing when it is absent (unit tests, late-game). Returns null
// when curriculum is exhausted — the late-game contract takes over.
function curriculumPlan(world) {
const pre = world.snapshot?.curriculum?.plan;
if (pre && pre.skillId) return pre;
try {
return curriculumNext(world.snapshot)?.plan ?? null;
} catch {
return null;
}
}
const FOOD_MOBS = new Set(["cow", "pig", "chicken", "sheep", "rabbit", "mooshroom"]);
function hasVisibleFoodTarget(world) {
const passives = world.nearbyEntities?.passives ?? [];
if (passives.some((e) => FOOD_MOBS.has(e.name))) return true;
return (world.nearbyEntities?.droppedItems?.length ?? 0) > 0;
}
// Wooden tools acquired, OR already advanced to stone tier (monotonic: don't
// regress to "go chop wood" after the bot burned its logs into tools).
const woodenOrStoneTier = {
id: "tool_tier",
describe: "wooden tools (or already stone tier)",
met: (w) =>
WOODEN_TOOLS.every((n) => has(w.inventory, n)) ||
totalMatching(w.inventory, (k) => /^stone_(axe|pickaxe|sword)$/.test(k)) > 0,
};
// A wheat farm is established. We mark it via a `farm` location (set by the
// farm skill) or by carrying harvested wheat as a fallback proxy.
const farmEstablished = {
id: "farm",
describe: "a wheat farm location or harvested wheat",
met: (w) => !!w.locations?.farm || has(w.inventory, "wheat", 3),
};
export const SETTLEMENT_CONTRACT = Object.freeze([
{
id: "M0_alive",
title: "Stay alive",
invariants: [alive()],
suggest: () => null, // survival layer (modes/manifesto) owns HP emergencies
},
{
id: "M1_wood_tools",
title: "Wooden tools",
invariants: [woodenOrStoneTier],
suggest: curriculumPlan,
},
{
id: "M2_bed",
title: "A bed to skip the night",
invariants: [bedSecured()],
suggest: curriculumPlan,
},
{
id: "M3_stone_tools",
title: "Stone tools + furnace",
invariants: [stoneTier()],
suggest: curriculumPlan,
},
{
id: "M4_food_security",
title: "Secure food",
invariants: [foodSecure()],
// Direct suggest (NOT curriculum): when food urgency preempts a lower
// milestone, the strictly-ordered curriculum would still return the
// wood step. We want the food action now.
suggest: (w) => ({ skillId: hasVisibleFoodTarget(w) ? "survive.acquire-food" : "survive.scout-food" }),
// Starving preempts lower-indexed progression: go eat/hunt now.
urgency: (w) => (w.food < 8 ? 100 : w.food < 12 ? 20 : 0),
},
{
id: "M5_storage",
title: "A personal chest",
invariants: [locationExists("chest")],
suggest: curriculumPlan,
},
{
id: "M6_lighting",
title: "Torches for the perimeter",
invariants: [hasItem("torch", 4, "torch")],
suggest: curriculumPlan,
},
{
id: "M7_base_site",
title: "Pick a base site",
invariants: [locationExists("base")],
suggest: curriculumPlan,
},
{
id: "M8_shelter",
title: "Build a shelter",
invariants: [locationExists("shelter")],
suggest: curriculumPlan,
},
// ---- beyond the curriculum: late-game settlement work ----
{
id: "M9_farm",
title: "Start a wheat farm",
invariants: [farmEstablished],
suggest: (w) => {
// Deposit first if we're drowning in surplus and have a chest.
const distinct = Object.keys(w.inventory ?? {}).length;
if (distinct >= 30 && w.locations?.chest) return { skillId: "village.deposit-surplus" };
return { skillId: "farm.wheat" };
},
},
]);
export function listContractMilestones() {
return SETTLEMENT_CONTRACT.map((m) => ({ id: m.id, title: m.title }));
}
export const _internal = { woodenOrStoneTier, farmEstablished, curriculumPlan };
+93
View File
@@ -0,0 +1,93 @@
// GoalManager (L3) — the single progression authority.
//
// Walks the Settlement Contract, evaluates each milestone's invariants against
// the world, and selects which milestone to pursue now. Selection is a utility
// argmax over the UNMET milestones:
//
// score(m) = -index(m) + urgency(m, world)
//
// With no urgency this is just "lowest unmet milestone wins" (strict ordered
// progression). urgency lets a survival-critical milestone (food when starving)
// preempt a lower-indexed one — the DEPS-style "consider how easy/urgent a
// sub-goal is" idea, expressed as a hand-tuned utility (research §C, DEPS).
//
// The GoalManager does NOT dispatch and never calls the LLM. It returns a
// suggestion the scheduler consumes; the survival/emergency layer (modes,
// manifesto L0) still preempts above it.
import { SETTLEMENT_CONTRACT } from "./contract.js";
import { checkInvariants, worldFromSnapshot } from "./invariants.js";
import { prerequisitesMet } from "./skill-graph.js";
export function createGoalManager({ contract = SETTLEMENT_CONTRACT } = {}) {
// Evaluate every milestone; returns the per-milestone invariant status plus
// the selected current milestone and its suggested skill.
function evaluate(world) {
const evaluated = contract.map((m, index) => {
const check = checkInvariants(m, world);
return {
index,
id: m.id,
title: m.title,
met: check.met,
unmet: check.unmet,
evidence: check.evidence,
urgency: typeof m.urgency === "function" ? (m.urgency(world) || 0) : 0,
_m: m,
};
});
const completed = evaluated.filter((e) => e.met).length;
const total = evaluated.length;
const unmet = evaluated.filter((e) => !e.met);
if (unmet.length === 0) {
return { done: true, completed, total, milestone: null, suggestedSkill: null, ranked: [], evaluated };
}
// Utility argmax. Tie-break by lower index (more foundational first).
const ranked = unmet
.map((e) => ({ ...e, score: -e.index + e.urgency }))
.sort((a, b) => b.score - a.score || a.index - b.index);
const current = ranked[0];
let suggestedSkill = null;
try {
suggestedSkill = current._m.suggest(world) ?? null;
} catch {
suggestedSkill = null;
}
// Annotate the suggestion with skill-graph prerequisite status (Plan4MC).
// Observability + a guard surface: if prereqs are unmet the curriculum
// chain should already be steering toward them, but we expose the gap.
if (suggestedSkill?.skillId) {
const pre = prerequisitesMet(suggestedSkill.skillId, world);
if (!pre.ok) suggestedSkill = { ...suggestedSkill, blockedBy: pre.missing };
}
const reason = current.urgency > 0 && current.index > unmet[0].index
? `urgent:${current.id}(${current.urgency}) preempts ${unmet[0].id}`
: `lowest unmet: ${current.id}`;
return {
done: false,
completed,
total,
milestone: { id: current.id, title: current.title, unmet: current.unmet },
suggestedSkill,
reason,
ranked: ranked.map((r) => ({ id: r.id, score: r.score, urgency: r.urgency })),
evaluated,
};
}
// Convenience: build the world from a runtime snapshot (+optional ledger)
// and evaluate. This is what the scheduler calls each tick.
function next(snapshot, extra = {}) {
const world = worldFromSnapshot(snapshot, extra);
return evaluate(world);
}
return { evaluate, next };
}
+97
View File
@@ -0,0 +1,97 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { createGoalManager } from "./goal-manager.js";
import { SETTLEMENT_CONTRACT } from "./contract.js";
import { worldFromSnapshot, checkInvariants } from "./invariants.js";
function snap(extra = {}) {
return {
connected: true,
position: { x: 0, y: 64, z: 0 },
health: 20,
food: 20,
hasFood: false,
isDay: true,
inventory: {},
locations: {},
nearbyEntities: { passives: [], droppedItems: [] },
...extra,
};
}
const ALL_TOOLS = {
wooden_axe: 1, wooden_pickaxe: 1, wooden_sword: 1,
stone_axe: 1, stone_pickaxe: 1, stone_sword: 1, furnace: 1,
white_bed: 1, torch: 8, bread: 5,
};
test("fresh spawn selects M1 wood tools, suggests gather.logs", () => {
const gm = createGoalManager();
const r = gm.next(snap());
assert.equal(r.done, false);
assert.equal(r.milestone.id, "M1_wood_tools");
assert.equal(r.suggestedSkill.skillId, "gather.logs");
});
test("starving preempts lower milestones with the food skill (not curriculum wood)", () => {
const gm = createGoalManager();
const r = gm.next(snap({ food: 5 }));
assert.equal(r.milestone.id, "M4_food_security");
// no visible target → scout, and crucially NOT gather.logs
assert.equal(r.suggestedSkill.skillId, "survive.scout-food");
assert.match(r.reason, /urgent/);
});
test("starving with a visible chicken hunts it", () => {
const gm = createGoalManager();
const r = gm.next(snap({ food: 5, nearbyEntities: { passives: [{ name: "chicken", distance: 4 }], droppedItems: [] } }));
assert.equal(r.milestone.id, "M4_food_security");
assert.equal(r.suggestedSkill.skillId, "survive.acquire-food");
});
test("not-quite-starving (food 10) does NOT preempt wood; mild urgency only", () => {
const gm = createGoalManager();
// food 10 → M4 urgency 20, M1 unmet at index 1 → score(M1)=-1, score(M4)=-4+20=16 → M4 still wins.
// To assert ordered behaviour we use food 13 (urgency 0): wood wins.
const r = gm.next(snap({ food: 13 }));
assert.equal(r.milestone.id, "M1_wood_tools");
});
test("with wood+stone+bed+food, lowest unmet is M5 storage → craft.chest", () => {
const gm = createGoalManager();
const r = gm.next(snap({ inventory: { ...ALL_TOOLS, torch: 0 }, food: 20 }));
// torch removed so M6 lighting also unmet, but storage (M5) is lower.
assert.equal(r.milestone.id, "M5_storage");
assert.equal(r.suggestedSkill.skillId, "craft.chest");
});
test("everything done → done:true, completed == total", () => {
const gm = createGoalManager();
const r = gm.next(snap({
inventory: ALL_TOOLS,
food: 20,
hasFood: true,
locations: { chest: { x: 1 }, base: { x: 2 }, shelter: { x: 3 }, farm: { x: 4 } },
}));
assert.equal(r.done, true);
assert.equal(r.completed, r.total);
assert.equal(r.milestone, null);
});
test("progress fraction increases as milestones complete", () => {
const gm = createGoalManager();
const empty = gm.next(snap());
const advanced = gm.next(snap({ inventory: ALL_TOOLS, food: 20, hasFood: true }));
assert.ok(advanced.completed > empty.completed);
assert.equal(advanced.total, SETTLEMENT_CONTRACT.length);
});
test("checkInvariants reports which invariant is unmet", () => {
const m = SETTLEMENT_CONTRACT.find((x) => x.id === "M3_stone_tools");
const world = worldFromSnapshot(snap({ inventory: { stone_axe: 1, stone_pickaxe: 1, stone_sword: 1 } }));
const c = checkInvariants(m, world);
// missing furnace → unmet
assert.equal(c.met, false);
assert.ok(c.unmet.includes("stone_tier"));
});
+177
View File
@@ -0,0 +1,177 @@
// Invariant predicate library (L3).
//
// The research's core diagnosis: the bot picks plausible tasks but never
// asserts whether the world actually moved toward a settlement, so "progress"
// is replaced by noise. The fix is a typed contract whose milestones each
// carry INVARIANTS — boolean predicates over an authoritative world view — so
// "are we done with this milestone" is a fact about the world, not a guess.
//
// A predicate is a plain object: { id, describe, met(world) -> boolean }.
// `world` is the normalised view produced by worldFromSnapshot(): it exposes
// inventory (name->count), locations, health/food, daylight and an optional
// InventoryLedger. Predicates are PURE — they never touch the bot or disk.
// ---- world view ------------------------------------------------------------
export function worldFromSnapshot(snapshot, extra = {}) {
const s = snapshot ?? {};
return {
snapshot: s,
inventory: s.inventory ?? {},
locations: s.locations ?? {},
health: s.health ?? 20,
food: s.food ?? 20,
hasFood: !!s.hasFood,
isDay: s.isDay !== false,
position: s.position ?? null,
nearbyBlocks: s.nearbyBlocks ?? {},
nearbyEntities: s.nearbyEntities ?? {},
closestHostile: s.closestHostile ?? null,
ledger: extra.ledger ?? null,
};
}
// ---- inventory helpers (shared with the contract) --------------------------
export function totalMatching(inv, matcher) {
const f =
typeof matcher === "function"
? matcher
: matcher instanceof RegExp
? (k) => matcher.test(k)
: (k) => k === matcher;
let sum = 0;
for (const [k, n] of Object.entries(inv ?? {})) if (f(k)) sum += n;
return sum;
}
export function totalLogs(inv) {
return totalMatching(inv, (k) => k.endsWith("_log") || k.endsWith("_stem"));
}
export function totalPlanks(inv) {
return totalMatching(inv, (k) => k.endsWith("_planks"));
}
export function totalCobble(inv) {
return (inv?.cobblestone ?? 0) + (inv?.cobbled_deepslate ?? 0);
}
export function totalWool(inv) {
return totalMatching(inv, (k) => k.endsWith("_wool"));
}
export function maxSingleColourWool(inv) {
let best = 0;
for (const [k, n] of Object.entries(inv ?? {})) {
if (k.endsWith("_wool") && n > best) best = n;
}
return best;
}
export function hasAnyBed(inv) {
return totalMatching(inv, (k) => k.endsWith("_bed")) > 0;
}
export function has(inv, name, n = 1) {
return (inv?.[name] ?? 0) >= n;
}
export const WOODEN_TOOLS = ["wooden_axe", "wooden_pickaxe", "wooden_sword"];
export const STONE_TOOLS = ["stone_axe", "stone_pickaxe", "stone_sword"];
export const COOKED_FOODS = [
"bread", "cooked_beef", "cooked_chicken", "cooked_porkchop",
"cooked_mutton", "cooked_rabbit", "baked_potato", "apple",
"carrot", "potato", "cooked_cod", "cooked_salmon",
];
// ---- predicate builders ----------------------------------------------------
export function alive() {
return { id: "alive", describe: "health > 0", met: (w) => w.health > 0 };
}
export function healthAtLeast(n) {
return { id: `health>=${n}`, describe: `health at least ${n}`, met: (w) => w.health >= n };
}
export function foodAtLeast(n) {
return { id: `food>=${n}`, describe: `hunger at least ${n}`, met: (w) => w.food >= n };
}
// "Food security": carrying edible food, or well-fed, or holding a cooked
// staple. We cannot introspect chest contents from the snapshot, so this is
// the observable proxy for research M1's `food_stock>=5_in_chest`.
export function foodSecure() {
return {
id: "food_secure",
describe: "carrying edible food or well-fed",
met: (w) => w.hasFood || w.food >= 18 || COOKED_FOODS.some((n) => has(w.inventory, n)),
};
}
export function hasAllItems(names) {
return {
id: `has_all:${names.join(",")}`,
describe: `carrying all of ${names.join(", ")}`,
met: (w) => names.every((n) => has(w.inventory, n)),
};
}
export function hasItem(matcher, n = 1, label = null) {
return {
id: `has:${label ?? String(matcher)}>=${n}`,
describe: `at least ${n}× ${label ?? String(matcher)}`,
met: (w) => totalMatching(w.inventory, matcher) >= n,
};
}
export function woodenTier() {
return {
id: "wooden_tier",
describe: "wooden axe + pickaxe + sword",
met: (w) => WOODEN_TOOLS.every((n) => has(w.inventory, n)),
};
}
export function stoneTier() {
return {
id: "stone_tier",
describe: "stone axe + pickaxe + sword + furnace",
met: (w) => STONE_TOOLS.every((n) => has(w.inventory, n)) && has(w.inventory, "furnace"),
};
}
export function bedSecured() {
return {
id: "bed",
describe: "a bed on hand or a placed bed location",
met: (w) => hasAnyBed(w.inventory) || !!w.locations.bed,
};
}
// A named location exists in locations.json (set by the skill that builds it:
// village.choose-base → base, build-shelter → shelter, place-chest → chest).
export function locationExists(kind) {
return {
id: `loc:${kind}`,
describe: `a known ${kind} location`,
met: (w) => !!w.locations?.[kind],
};
}
// ---- checker ---------------------------------------------------------------
// Evaluate every invariant of a milestone against the world. Returns
// { met, unmet: [ids], evidence: { [id]: bool } } so the GoalManager can pick
// the lowest unmet milestone and the TUI can show *which* invariant is open.
export function checkInvariants(milestone, world) {
const invs = milestone?.invariants ?? [];
const evidence = {};
const unmet = [];
for (const inv of invs) {
let ok = false;
try {
ok = !!inv.met(world);
} catch {
ok = false;
}
evidence[inv.id] = ok;
if (!ok) unmet.push(inv.id);
}
return { met: unmet.length === 0, unmet, evidence };
}
+89
View File
@@ -0,0 +1,89 @@
// Skill dependency graph (Plan4MC-style, research §C). A static, declarative
// model of "what does this skill need, what does it produce". The contract
// already SEQUENCES the early game via the curriculum, so this graph is the
// queryable prerequisite layer on top: the GoalManager annotates each suggested
// skill with whether its prerequisites currently hold (surfaced for the TUI and
// as a guard against suggesting a skill that physically cannot succeed here).
//
// Requirement kinds:
// { item: <semantic-group|exact>, min } — need N of an item / group
// { tool: "pickaxe" | "axe" | "sword" } — need any tier of that tool
// Semantic groups: logs (*_log/_stem), planks (*_planks), sticks, cobblestone,
// wool (*_wool), coal, bed (*_bed). Anything else is matched as an exact name.
import { totalMatching, has } from "./invariants.js";
const GROUP = {
logs: (k) => k.endsWith("_log") || k.endsWith("_stem"),
planks: (k) => k.endsWith("_planks"),
wool: (k) => k.endsWith("_wool"),
cobblestone: (k) => k === "cobblestone" || k === "cobbled_deepslate",
coal: (k) => k === "coal" || k === "charcoal",
};
const TOOL = {
pickaxe: (k) => k.endsWith("_pickaxe"),
axe: (k) => k.endsWith("_axe") && !k.endsWith("_pickaxe"),
sword: (k) => k.endsWith("_sword"),
};
export const SKILL_GRAPH = Object.freeze({
"gather.logs": { requires: [], produces: ["logs"] },
"gather.wool": { requires: [], produces: ["wool"] },
"gather.stone": { requires: [{ tool: "pickaxe" }], produces: ["cobblestone"] },
"craft.planks": { requires: [{ item: "logs", min: 1 }], produces: ["planks"] },
"craft.sticks": { requires: [{ item: "planks", min: 2 }], produces: ["stick"] },
"craft.wooden-axe": { requires: [{ item: "planks", min: 3 }, { item: "stick", min: 2 }], produces: ["wooden_axe"] },
"craft.wooden-pickaxe": { requires: [{ item: "planks", min: 3 }, { item: "stick", min: 2 }], produces: ["wooden_pickaxe"] },
"craft.wooden-sword": { requires: [{ item: "planks", min: 2 }, { item: "stick", min: 1 }], produces: ["wooden_sword"] },
"craft.stone-axe": { requires: [{ item: "cobblestone", min: 3 }, { item: "stick", min: 2 }], produces: ["stone_axe"] },
"craft.stone-pickaxe": { requires: [{ item: "cobblestone", min: 3 }, { item: "stick", min: 2 }], produces: ["stone_pickaxe"] },
"craft.stone-sword": { requires: [{ item: "cobblestone", min: 2 }, { item: "stick", min: 1 }], produces: ["stone_sword"] },
"craft.furnace": { requires: [{ item: "cobblestone", min: 8 }], produces: ["furnace"] },
"craft.chest": { requires: [{ item: "planks", min: 8 }], produces: ["chest"] },
"craft.torch": { requires: [{ item: "coal", min: 1 }, { item: "stick", min: 1 }], produces: ["torch"] },
"craft.bed": { requires: [{ item: "wool", min: 3 }, { item: "planks", min: 3 }], produces: ["bed"] },
"village.choose-base": { requires: [], produces: ["loc:base"] },
"village.build-shelter": { requires: [{ item: "planks", min: 1 }], produces: ["loc:shelter"] },
"village.place-chest": { requires: [{ item: "chest", min: 1 }], produces: ["loc:chest"] },
"farm.wheat": { requires: [], produces: [] },
});
function itemCount(inv, name) {
const g = GROUP[name];
return g ? totalMatching(inv, g) : (inv?.[name] ?? 0);
}
function hasTool(inv, kind) {
const t = TOOL[kind];
if (!t) return false;
return Object.keys(inv ?? {}).some((k) => t(k) && (inv[k] ?? 0) > 0);
}
// { ok, missing: [{ item|tool, min, have }] } for a skill given the world.
export function prerequisitesMet(skillId, world) {
const node = SKILL_GRAPH[skillId];
if (!node) return { ok: true, missing: [], known: false };
const inv = world?.inventory ?? {};
const missing = [];
for (const req of node.requires) {
if (req.tool) {
if (!hasTool(inv, req.tool)) missing.push({ tool: req.tool });
} else if (req.item) {
const have = itemCount(inv, req.item);
if (have < (req.min ?? 1)) missing.push({ item: req.item, min: req.min ?? 1, have });
}
}
return { ok: missing.length === 0, missing, known: true };
}
export function canRun(skillId, world) {
return prerequisitesMet(skillId, world).ok;
}
// All skills whose prerequisites currently hold (Plan4MC "frontier").
export function runnableFrontier(world) {
return Object.keys(SKILL_GRAPH).filter((id) => canRun(id, world));
}
export const _internal = { GROUP, TOOL, itemCount, hasTool, has };
+56
View File
@@ -0,0 +1,56 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { prerequisitesMet, canRun, runnableFrontier, _internal } from "./skill-graph.js";
function world(inv = {}) {
return { inventory: inv };
}
test("gather.logs needs nothing", () => {
assert.equal(canRun("gather.logs", world()), true);
});
test("gather.stone needs a pickaxe (any tier)", () => {
assert.equal(canRun("gather.stone", world({})), false);
assert.equal(canRun("gather.stone", world({ wooden_pickaxe: 1 })), true);
assert.equal(canRun("gather.stone", world({ stone_pickaxe: 1 })), true);
});
test("craft.planks needs a log (semantic group)", () => {
assert.equal(canRun("craft.planks", world({})), false);
assert.equal(canRun("craft.planks", world({ birch_log: 1 })), true);
assert.equal(canRun("craft.planks", world({ mangrove_stem: 2 })), true);
});
test("craft.furnace needs 8 cobblestone", () => {
assert.equal(canRun("craft.furnace", world({ cobblestone: 7 })), false);
assert.equal(canRun("craft.furnace", world({ cobblestone: 8 })), true);
assert.equal(canRun("craft.furnace", world({ cobbled_deepslate: 8 })), true);
});
test("prerequisitesMet reports the missing requirement detail", () => {
const r = prerequisitesMet("craft.wooden-pickaxe", world({ stick: 2 }));
assert.equal(r.ok, false);
assert.deepEqual(r.missing, [{ item: "planks", min: 3, have: 0 }]);
});
test("unknown skill is treated as runnable (known:false)", () => {
const r = prerequisitesMet("explore.far", world());
assert.equal(r.ok, true);
assert.equal(r.known, false);
});
test("axe matcher excludes pickaxe", () => {
assert.equal(_internal.TOOL.axe("wooden_axe"), true);
assert.equal(_internal.TOOL.axe("wooden_pickaxe"), false);
assert.equal(_internal.TOOL.pickaxe("stone_pickaxe"), true);
});
test("runnableFrontier grows as inventory fills", () => {
const empty = runnableFrontier(world());
const stocked = runnableFrontier(world({ oak_planks: 8, stick: 4, cobblestone: 8, wooden_pickaxe: 1 }));
assert.ok(stocked.length > empty.length);
assert.ok(stocked.includes("gather.stone"));
assert.ok(stocked.includes("craft.furnace"));
});
+78
View File
@@ -0,0 +1,78 @@
// Village Score (L5 eval) — one number for "is the bot actually building a
// settlement, or just walking?" (research §2). The research's formula mixes
// milestones, food stock, fence closure, lit tiles, uptime, distinct skills
// and dialog quality. We compute the subset that is *observable* today; fence
// polygon / lit-tile fraction stay at 0 until those skills exist (the score is
// honest about what it can measure rather than faking precision).
//
// Pure: snapshot + a few derived inputs in, { score, components } out. Score is
// normalised to 0..1 so a dashboard / TUI can show a single percentage.
const TWO_HOURS_MS = 2 * 60 * 60 * 1000;
const DISTINCT_SKILL_TARGET = 12;
function clamp01(n) {
if (!Number.isFinite(n)) return 0;
return n < 0 ? 0 : n > 1 ? 1 : n;
}
function milestoneFraction(contract) {
if (!contract || !contract.total) return 0;
return clamp01(contract.completed / contract.total);
}
function foodSecurity(snapshot) {
if (snapshot?.hasFood) return 1;
return clamp01((snapshot?.food ?? 0) / 18);
}
function baseEstablished(snapshot) {
const loc = snapshot?.locations ?? {};
const want = ["base", "shelter", "chest"];
const have = want.filter((k) => loc[k]).length;
return clamp01(have / want.length);
}
function distinctSkillsSucceeded(metrics) {
if (!metrics) return 0;
const n = Object.values(metrics).filter((m) => (m?.ok ?? 0) > 0).length;
return clamp01(n / DISTINCT_SKILL_TARGET);
}
function uptimeFraction(uptimeMs) {
return clamp01((uptimeMs ?? 0) / TWO_HOURS_MS);
}
function survival(snapshot) {
return clamp01((snapshot?.health ?? 0) / 20);
}
const WEIGHTS = Object.freeze({
milestones: 0.35,
food: 0.15,
base: 0.15,
distinctSkills: 0.15,
uptime: 0.10,
survival: 0.10,
});
export function computeVillageScore(snapshot, { contract, uptimeMs = 0, metrics = null } = {}) {
const components = {
milestones: milestoneFraction(contract),
food: foodSecurity(snapshot),
base: baseEstablished(snapshot),
distinctSkills: distinctSkillsSucceeded(metrics),
uptime: uptimeFraction(uptimeMs),
survival: survival(snapshot),
};
let score = 0;
for (const [k, w] of Object.entries(WEIGHTS)) score += w * components[k];
return {
score: Math.round(clamp01(score) * 1000) / 1000,
components,
milestonesCompleted: contract?.completed ?? 0,
milestonesTotal: contract?.total ?? 0,
};
}
export const _internal = { clamp01, WEIGHTS, milestoneFraction, foodSecurity, baseEstablished };
+52
View File
@@ -0,0 +1,52 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { computeVillageScore, _internal } from "./village-score.js";
function snap(extra = {}) {
return { connected: true, health: 20, food: 20, hasFood: false, locations: {}, ...extra };
}
test("empty/fresh world scores low", () => {
const r = computeVillageScore(snap({ health: 20, food: 5 }), {
contract: { completed: 0, total: 10 },
uptimeMs: 0,
metrics: {},
});
assert.ok(r.score < 0.2, `expected low score, got ${r.score}`);
});
test("a fully established settlement scores high", () => {
const metrics = {};
for (let i = 0; i < 12; i++) metrics[`skill.${i}`] = { ok: 3, fail: 0 };
const r = computeVillageScore(
snap({ health: 20, food: 20, hasFood: true, locations: { base: {}, shelter: {}, chest: {} } }),
{ contract: { completed: 10, total: 10 }, uptimeMs: 3 * 60 * 60 * 1000, metrics },
);
assert.ok(r.score > 0.9, `expected high score, got ${r.score}`);
assert.equal(r.components.milestones, 1);
assert.equal(r.components.base, 1);
});
test("score is monotonic in milestone completion", () => {
const base = { uptimeMs: 0, metrics: {} };
const low = computeVillageScore(snap(), { ...base, contract: { completed: 1, total: 10 } });
const high = computeVillageScore(snap(), { ...base, contract: { completed: 8, total: 10 } });
assert.ok(high.score > low.score);
});
test("score stays within 0..1", () => {
const r = computeVillageScore(snap({ health: 999, food: 999 }), {
contract: { completed: 100, total: 10 },
uptimeMs: 1e12,
metrics: { a: { ok: 999 } },
});
assert.ok(r.score >= 0 && r.score <= 1);
});
test("clamp01 helper", () => {
assert.equal(_internal.clamp01(-1), 0);
assert.equal(_internal.clamp01(2), 1);
assert.equal(_internal.clamp01(0.5), 0.5);
assert.equal(_internal.clamp01(NaN), 0);
});
+25
View File
@@ -138,3 +138,28 @@ registerMode({
return { action: { skillId: "survive.sleep" }, detail: { reason: "night with bed in hand" } };
},
});
// QW8 — displaced at night with no bed: dig in for cover instead of standing
// in the open getting shot. Fires only when night_shelter can't (no bed) and
// no hostile is already in melee (self_preservation owns that). Requires a
// placeable cap block; survive.dig-in's preconditions enforce dig safety.
registerMode({
name: "dusk_dig_in",
description: "Night, no bed, exposed → dig a hole and cap it",
interrupts: ["curriculum"],
update(ctx) {
const snap = ctx?.snapshot;
if (!snap || snap.isDay !== false) return null; // only on explicit night
if (ctx.modeCooldown?.dusk_dig_in && Date.now() < ctx.modeCooldown.dusk_dig_in) return null;
const inv = snap.inventory || {};
if (Object.keys(inv).some((n) => /_bed$/.test(n))) return null; // night_shelter handles beds
// Only when actually exposed at night and away from a known shelter.
const sheltered = !!snap.locations?.shelter || snap.hazards?.headBlock && snap.hazards.headBlock !== "air";
if (sheltered) return null;
const hasCap = Object.keys(inv).some((n) => /(_planks|_log|cobble|stone|dirt|sand|gravel|netherrack)$/i.test(n));
if (!hasCap) return null;
ctx.modeCooldown = ctx.modeCooldown ?? {};
ctx.modeCooldown.dusk_dig_in = Date.now() + 60_000;
return { action: { skillId: "survive.dig-in" }, detail: { reason: "exposed at night, no bed" } };
},
});
+29 -3
View File
@@ -528,6 +528,18 @@ function curriculumReflex(ctx) {
const storyStep = ctx.disableStoryline ? null : pickCurrentStep(s);
if (storyStep) ctx.storyStep = storyStep;
const storySkillId = (storyStep && !storyStep.emergency && storyStep.suggestion?.skillId) ? storyStep.suggestion.skillId : null;
// v0.4.0 — Settlement Contract is the unified progression authority,
// replacing the storyline rail (which competed with the manifesto). It is
// precomputed in bot.js (snapshot.contract) via the GoalManager: lowest
// unmet milestone, with food-urgency utility preemption. Manifesto L0
// (alive emergencies) still preempts it; storyline/curriculum remain as
// fallbacks when the contract is disabled (tests) or has no suggestion.
const contractGoal = ctx.disableContract ? null : (s.contract ?? null);
if (contractGoal) ctx.contractGoal = contractGoal;
const contractSkillId = (contractGoal && !contractGoal.done && contractGoal.suggestedSkill?.skillId)
? contractGoal.suggestedSkill.skillId
: null;
const metricRecovery = metricRecoverySkill(ctx, plan?.skillId);
if (metricRecovery) {
ctx.lastCurriculumAt = Date.now();
@@ -584,7 +596,7 @@ function curriculumReflex(ctx) {
// First hint → small wander (might just be 32-block reach issue).
// Every subsequent hint while still inside the backoff window → use
// explore.far so the bot actually leaves the patch it's stuck in.
if ((!plan?.skillId && !manifestoSkillId && !storySkillId) || wantWander) {
if ((!plan?.skillId && !manifestoSkillId && !storySkillId && !contractSkillId) || wantWander) {
ctx.lastCurriculumAt = Date.now();
const fallbackId = wantWander && consecutiveWanderHints >= 1 ? "explore.far" : "wander";
// v0.2.0-rc.3 — consult advice on the FALLBACK dispatch too. Without
@@ -633,13 +645,16 @@ function curriculumReflex(ctx) {
const manifestoEmergency = activeNeed?.need?.level === 0;
let skillId, skillSource;
if (manifestoEmergency) {
skillId = manifestoSkillId ?? storySkillId ?? plan.skillId;
skillId = manifestoSkillId ?? contractSkillId ?? storySkillId ?? plan?.skillId;
skillSource = `manifesto:${activeNeed.need.id}`;
} else if (contractSkillId) {
skillId = contractSkillId;
skillSource = `contract:${contractGoal.milestone.id}`;
} else if (storySkillId) {
skillId = storySkillId;
skillSource = `storyline:${storyStep.step.id}`;
} else {
skillId = manifestoSkillId ?? plan.skillId;
skillId = manifestoSkillId ?? plan?.skillId;
skillSource = manifestoSkillId ? `manifesto:${activeNeed.need.id}` : "curriculum";
}
@@ -697,6 +712,14 @@ function curriculumReflex(ctx) {
}
}
// QW5 anti-loop: this skill failed ≥3× in 5 min → it's blacklisted. Skip
// and nudge toward exploration so we leave the situation that loops it.
if (ctx.antiLoop?.shouldSkip(skillId)) {
ctx.skillBackoff = ctx.skillBackoff ?? {};
ctx.skillBackoff["__wander_hint__"] = Date.now() + SKILL_BACKOFF_MS;
return { action: "noop", kind: "anti-loop-blacklisted", label: skillId };
}
// v0.2.0 — consult learned lessons. If a high-confidence lesson says
// "avoid <skillId> in this situation", swap to its preferred
// alternative (or back off entirely if no safe alternative is named).
@@ -719,6 +742,9 @@ function curriculumReflex(ctx) {
ctx.dispatch(() => runSkill(dispatchSkillId, ctx, dispatchArgs), dispatchSkillId, {
onComplete: (res) => {
ctx.skillBackoff = ctx.skillBackoff ?? {};
// QW5 anti-loop bookkeeping: feed every outcome so repeated failures
// of the same skill get detected, blacklisted and ticketed.
ctx.antiLoop?.record({ skillId: dispatchSkillId, ok: !!res?.ok, code: res?.code ?? null });
if (advice.lessonId) reportAdviceOutcome({ lessonId: advice.lessonId, succeeded: !!res?.ok });
if (appliedRecommendationId) {
markRecommendationOutcome(appliedRecommendationId, {
+64
View File
@@ -53,6 +53,7 @@ function makeCtx({
// tested directly in advisor-trigger.test.js.
disableStoryline = true, // storyline tested in goal/storyline.test.js
disableWedge = true, // wedge tested in awareness/wedge-detector.test.js
disableContract = true, // contract path tested below + goal-manager.test.js
} = {}) {
const dispatches = [];
const ctx = {
@@ -69,6 +70,7 @@ function makeCtx({
disableAdvisor,
disableStoryline,
disableWedge,
disableContract,
dispatch(fn, label, opts = {}) {
dispatches.push({ fn, label, opts });
},
@@ -547,3 +549,65 @@ test("onComplete sets per-skill backoff on cooldown-class failures", () => {
cb({ ok: false, code: "missing_tool", detail: "no pickaxe" });
assert.ok((ctx.skillBackoff?.["gather.stone"] ?? 0) > Date.now());
});
// ---- v0.4.0 Settlement Contract integration --------------------------------
test("contract suggestion drives dispatch (beats curriculum plan)", () => {
const { ctx, dispatches } = makeCtx({
disableContract: false,
snapshot: {
connected: true,
health: 20,
food: 20,
isDay: true,
// curriculum (legacy) would say gather.logs; the contract says farm.
curriculum: { plan: { skillId: "gather.logs" } },
contract: {
done: false,
milestone: { id: "M9_farm", title: "Start a wheat farm" },
suggestedSkill: { skillId: "farm.wheat" },
},
},
});
const out = runTick(ctx);
assert.equal(out.reflex, "curriculum");
assert.equal(dispatches[0].label, "farm.wheat");
assert.equal(out.source, "contract:M9_farm");
});
test("contract dispatches even when curriculum plan is exhausted (no wander)", () => {
const { ctx, dispatches } = makeCtx({
disableContract: false,
snapshot: {
connected: true,
health: 20,
food: 20,
isDay: true,
curriculum: null, // curriculum exhausted
contract: {
done: false,
milestone: { id: "M9_farm", title: "Start a wheat farm" },
suggestedSkill: { skillId: "farm.wheat" },
},
},
});
const out = runTick(ctx);
assert.equal(dispatches[0].label, "farm.wheat");
});
test("contract done → falls through to wander when nothing else suggests", () => {
const { ctx, dispatches } = makeCtx({
disableContract: false,
snapshot: {
connected: true,
health: 20,
food: 20,
isDay: true,
curriculum: null,
contract: { done: true, milestone: null, suggestedSkill: null },
},
});
const out = runTick(ctx);
assert.equal(out.reflex, "curriculum");
assert.equal(dispatches[0].label, "wander");
});
+142
View File
@@ -0,0 +1,142 @@
// InventoryLedger (L1 service) — diff-based "did I actually get it" verifier.
//
// The proximate cause of the live `no_drop` symptom (research §A.4, QW2): the
// bot has no reliable signal that a pickup happened. mineflayer's
// `playerCollect` event is unreliable (fires for the wrong entity when other
// droppers tick nearby — mineflayer #1171) and item entities visually vanish
// past ~16 blocks (Minecraft Wiki, Item entity). The only ground truth is the
// inventory itself.
//
// This ledger snapshots `bot.inventory.items()` over time and answers two
// questions deterministically:
// - count(name) -> current count of an exact item
// - total(matcher) -> sum of counts for items matching a predicate
// - gainedSince(baseline, m) -> net positive gain of matching items vs a baseline
// - acquired(matcher, sinceTs)-> same, but baselined to a wall-clock timestamp
//
// Skills verify success with `const base = ledger.mark(); ...; ledger.update(bot);
// const got = ledger.gainedSince(base, isFood)` instead of trusting events.
//
// Pure w.r.t. the runtime: it only reads inventory. update() is driven once
// per tick from bot.js, and a skill may call update(bot) itself to force a
// fresh read before checking a delta (independent of tick cadence).
function itemsToCounts(items) {
const m = new Map();
for (const it of items ?? []) {
if (!it?.name) continue;
m.set(it.name, (m.get(it.name) ?? 0) + (it.count ?? 0));
}
return m;
}
function countsFromBot(bot) {
try {
return itemsToCounts(bot?.inventory?.items?.() ?? []);
} catch {
return new Map();
}
}
function matchFn(matcher) {
if (typeof matcher === "function") return matcher;
if (matcher instanceof RegExp) return (n) => matcher.test(n);
if (typeof matcher === "string") return (n) => n === matcher;
if (Array.isArray(matcher)) {
const set = new Set(matcher);
return (n) => set.has(n);
}
if (matcher instanceof Set) return (n) => matcher.has(n);
return () => true;
}
export function createInventoryLedger({ historyMs = 6 * 60_000, maxSnapshots = 240 } = {}) {
let history = []; // [{ ts, counts: Map<string,number> }], oldest→newest
let current = new Map();
// Record a snapshot now. Accepts a bot or a raw items array (for tests).
function update(botOrItems, now = Date.now()) {
current = Array.isArray(botOrItems) ? itemsToCounts(botOrItems) : countsFromBot(botOrItems);
history.push({ ts: now, counts: current });
const cutoff = now - historyMs;
while (history.length > 1 && (history[0].ts < cutoff || history.length > maxSnapshots)) {
history.shift();
}
return current;
}
function count(name) {
return current.get(name) ?? 0;
}
function total(matcher) {
const f = matchFn(matcher);
let sum = 0;
for (const [name, c] of current) if (f(name)) sum += c;
return sum;
}
// A baseline is just a frozen copy of the counts at a point in time.
function mark() {
return new Map(current);
}
function gainedSince(baseline, matcher) {
const f = matchFn(matcher);
const base = baseline ?? new Map();
let gained = 0;
for (const [name, c] of current) {
if (!f(name)) continue;
const before = base.get(name) ?? 0;
if (c > before) gained += c - before;
}
return gained;
}
// The newest recorded snapshot whose ts is <= sinceTs (i.e. the world as it
// was at that moment). Empty map if we have no history that old.
function baselineAt(sinceTs) {
let chosen = null;
for (const h of history) {
if (h.ts <= sinceTs) chosen = h;
else break;
}
return chosen ? new Map(chosen.counts) : new Map();
}
function acquired(matcher, sinceTs) {
return gainedSince(baselineAt(sinceTs), matcher);
}
// Full signed diff {name: change} vs the snapshot at sinceTs. Used for
// worldDelta validation and diary narration.
function delta(sinceTs) {
const base = baselineAt(sinceTs);
const out = {};
const names = new Set([...base.keys(), ...current.keys()]);
for (const name of names) {
const d = (current.get(name) ?? 0) - (base.get(name) ?? 0);
if (d !== 0) out[name] = d;
}
return out;
}
function snapshot() {
return new Map(current);
}
return {
update,
count,
total,
mark,
gainedSince,
acquired,
baselineAt,
delta,
snapshot,
_history: () => history,
};
}
export const _internal = { itemsToCounts, matchFn };
+84
View File
@@ -0,0 +1,84 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { createInventoryLedger, _internal } from "./inventory-ledger.js";
function items(spec) {
return Object.entries(spec).map(([name, count]) => ({ name, count }));
}
function fakeBot(spec) {
return { inventory: { items: () => items(spec) } };
}
test("count + total reflect the latest snapshot", () => {
const led = createInventoryLedger();
led.update(fakeBot({ raw_chicken: 2, oak_log: 5, cobblestone: 12 }));
assert.equal(led.count("raw_chicken"), 2);
assert.equal(led.count("missing"), 0);
assert.equal(led.total(/_log$/), 5);
assert.equal(led.total((n) => n === "cobblestone" || n === "oak_log"), 17);
});
test("gainedSince counts only positive deltas of matching items", () => {
const led = createInventoryLedger();
led.update(fakeBot({ raw_chicken: 0, feather: 1 }));
const base = led.mark();
led.update(fakeBot({ raw_chicken: 2, feather: 3 }));
// food matcher: raw_chicken only
assert.equal(led.gainedSince(base, "raw_chicken"), 2);
// regex over both
assert.equal(led.gainedSince(base, /raw_chicken|feather/), 4);
// a drop must not register as a gain
led.update(fakeBot({ raw_chicken: 1, feather: 3 }));
assert.equal(led.gainedSince(base, "raw_chicken"), 1);
});
test("acquired baselines to a wall-clock timestamp via history", () => {
const led = createInventoryLedger();
led.update(fakeBot({ raw_chicken: 0 }), 1000);
led.update(fakeBot({ raw_chicken: 1 }), 2000);
const t = 2500;
led.update(fakeBot({ raw_chicken: 3 }), 3000);
// since t=2500 the baseline is the snapshot at ts=2000 (count 1) → gained 2
assert.equal(led.acquired("raw_chicken", t), 2);
// since the very beginning → gained 3
assert.equal(led.acquired("raw_chicken", 0), 3);
});
test("delta returns the signed diff vs a timestamp", () => {
const led = createInventoryLedger();
led.update(fakeBot({ oak_log: 5, dirt: 2 }), 1000);
led.update(fakeBot({ oak_log: 8, cobblestone: 4 }), 2000);
const d = led.delta(1000);
assert.equal(d.oak_log, 3);
assert.equal(d.cobblestone, 4);
assert.equal(d.dirt, -2);
});
test("history is pruned by age and cap", () => {
const led = createInventoryLedger({ historyMs: 1000, maxSnapshots: 100 });
led.update(fakeBot({ a: 1 }), 0);
led.update(fakeBot({ a: 1 }), 500);
led.update(fakeBot({ a: 1 }), 2000); // cutoff = 2000-1000=1000 → drops ts 0 and 500
const hist = led._history();
assert.equal(hist.length, 1);
assert.equal(hist[0].ts, 2000);
});
test("matchFn supports string, regex, array, set, fn", () => {
const { matchFn } = _internal;
assert.equal(matchFn("a")("a"), true);
assert.equal(matchFn("a")("b"), false);
assert.equal(matchFn(/x/)("axb"), true);
assert.equal(matchFn(["a", "b"])("b"), true);
assert.equal(matchFn(new Set(["c"]))("c"), true);
assert.equal(matchFn((n) => n.length === 3)("abc"), true);
assert.equal(matchFn(undefined)("anything"), true);
});
test("update accepts a raw items array (test convenience)", () => {
const led = createInventoryLedger();
led.update(items({ stick: 4 }));
assert.equal(led.count("stick"), 4);
});
+160
View File
@@ -0,0 +1,160 @@
// MotionService (L1 service) — gotoSafe(): pathfinding that cannot hang silently.
//
// mineflayer-pathfinder has three documented failure modes that leak into the
// planner as "false success / silent hang" (research §B):
// - #222: obstructed by an unbreakable block → bot stops, NO error, NO
// goal_reached / path_update / path_reset event. goto() never settles.
// - #273: AStar returns a partial path → monitorMovements returns early.
// - #341: GoalLookAtBlock raycasts collision boxes only → never "reached".
//
// gotoSafe wraps bot.pathfinder.goto with three independent kill-switches and
// returns a STRUCTURED result the caller can branch on instead of awaiting a
// promise that may never resolve:
// { ok: true, code: "reached", movedBlocks }
// { ok: false, code: "stuck", movedBlocks } // progress watchdog
// { ok: false, code: "timeout", movedBlocks } // wall-clock OR pf compute
// { ok: false, code: "nopath", movedBlocks } // path_update status noPath
// { ok: false, code: "goal_changed" | "error", movedBlocks }
//
// On any non-reached outcome it calls bot.pathfinder.stop() so the caller is
// free to fall back (blind walk, dig-in, relocate) without two controllers
// fighting over the same goal.
import pathfinderPkg from "mineflayer-pathfinder";
const { pathfinder } = pathfinderPkg;
import { info, warn } from "../log.js";
function hdist(a, b) {
if (!a || !b) return Number.POSITIVE_INFINITY;
return Math.hypot((a.x ?? 0) - (b.x ?? 0), (a.z ?? 0) - (b.z ?? 0));
}
function clonePos(p) {
return p ? { x: p.x, y: p.y, z: p.z } : null;
}
function round1(n) {
return Number.isFinite(n) ? Math.round(n * 10) / 10 : 0;
}
// Classify a goto() rejection into a stable code.
export function classifyGotoError(err) {
const msg = (err?.message ?? String(err ?? "")).toLowerCase();
if (msg.includes("goalchanged") || msg.includes("goal was changed")) return "goal_changed";
if (msg.includes("took too long") || msg.includes("timed out") || msg.includes("timeout")) return "timeout";
if (msg.includes("no path") || msg.includes("nopath")) return "nopath";
return "error";
}
function ensurePathfinder(bot) {
// A real bot needs the plugin loaded once; a fake bot in tests already
// carries a stub `pathfinder`, so we only load when it is absent.
if (bot?.pathfinder) return;
try {
bot.loadPlugin(pathfinder);
} catch (e) {
warn("motion", `loadPlugin failed: ${e?.message ?? e}`);
}
}
export function createMotionService(bot, defaults = {}) {
if (!bot) throw new Error("motion: bot required");
ensurePathfinder(bot);
async function gotoSafe(goal, opts = {}) {
const timeoutMs = opts.timeoutMs ?? defaults.timeoutMs ?? 30_000;
const stuckWindowMs = opts.stuckWindowMs ?? defaults.stuckWindowMs ?? 8_000;
const stuckDelta = opts.stuckDelta ?? defaults.stuckDelta ?? 1.0;
const pollMs = opts.pollMs ?? defaults.pollMs ?? 1_000;
const graceMs = opts.graceMs ?? defaults.graceMs ?? 1_500;
const label = opts.label ?? "gotoSafe";
const startPos = clonePos(bot.entity?.position);
let lastProgressPos = startPos;
let lastProgressAt = Date.now();
const startedAt = lastProgressAt;
let settled = false;
let outcome = null;
let watchTimer = null;
let wallTimer = null;
let onPathUpdate = null;
const movedSoFar = () => hdist(startPos, bot.entity?.position);
function cleanup() {
if (watchTimer) { clearInterval(watchTimer); watchTimer = null; }
if (wallTimer) { clearTimeout(wallTimer); wallTimer = null; }
if (onPathUpdate) {
try { bot.removeListener?.("path_update", onPathUpdate); } catch {}
onPathUpdate = null;
}
}
function finalize(result) {
if (settled) return outcome;
settled = true;
outcome = { ...result, movedBlocks: round1(movedSoFar()) };
if (!result.ok) {
try { bot.pathfinder?.stop?.(); } catch {}
}
cleanup();
return outcome;
}
const gotoP = Promise.resolve()
.then(() => bot.pathfinder.goto(goal))
.then(
() => finalize({ ok: true, code: "reached", detail: null }),
(err) => finalize({ ok: false, code: classifyGotoError(err), detail: err?.message ?? String(err) }),
);
const earlyExit = new Promise((resolve) => {
onPathUpdate = (res) => {
const status = res?.status;
if (status === "noPath") {
resolve(finalize({ ok: false, code: "nopath", detail: "pathfinder: noPath" }));
} else if (status === "timeout") {
resolve(finalize({ ok: false, code: "timeout", detail: "pathfinder: compute timeout" }));
}
};
try { bot.on?.("path_update", onPathUpdate); } catch {}
watchTimer = setInterval(() => {
if (settled) return;
const now = Date.now();
if (now - startedAt < graceMs) return;
const here = bot.entity?.position;
if (hdist(lastProgressPos, here) >= stuckDelta) {
lastProgressPos = clonePos(here);
lastProgressAt = now;
return;
}
if (now - lastProgressAt >= stuckWindowMs) {
resolve(finalize({
ok: false,
code: "stuck",
detail: `no progress for ${Math.round((now - lastProgressAt) / 1000)}s`,
}));
}
}, pollMs);
});
const wallClock = new Promise((resolve) => {
wallTimer = setTimeout(() => {
resolve(finalize({ ok: false, code: "timeout", detail: `${label} wall-clock ${timeoutMs}ms` }));
}, timeoutMs);
});
const result = await Promise.race([gotoP, earlyExit, wallClock]);
if (!result.ok && result.code !== "goal_changed") {
info("motion", `${label}${result.code} (moved ${result.movedBlocks}b)`);
}
return result;
}
return { gotoSafe };
}
export const _internal = { hdist, classifyGotoError, clonePos, round1 };
+92
View File
@@ -0,0 +1,92 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { EventEmitter } from "node:events";
import { createMotionService, classifyGotoError, _internal } from "./motion.js";
function makeBot({ goto, startPos = { x: 0, y: 64, z: 0 } }) {
const em = new EventEmitter();
const bot = {
entity: { position: { ...startPos } },
pathfinder: {
goto,
_stopped: false,
stop() { this._stopped = true; },
},
on: (...a) => em.on(...a),
removeListener: (...a) => em.removeListener(...a),
emit: (...a) => em.emit(...a),
loadPlugin() {},
};
return bot;
}
const NEVER = () => new Promise(() => {});
test("reached: goto resolves → ok/reached, pathfinder not stopped", async () => {
const bot = makeBot({ goto: () => Promise.resolve() });
const m = createMotionService(bot);
const r = await m.gotoSafe({}, { timeoutMs: 1000, stuckWindowMs: 1000, pollMs: 20, graceMs: 0 });
assert.equal(r.ok, true);
assert.equal(r.code, "reached");
assert.equal(bot.pathfinder._stopped, false);
});
test("stuck: goto hangs with no movement → stuck, pathfinder stopped", async () => {
const bot = makeBot({ goto: NEVER });
const m = createMotionService(bot);
const r = await m.gotoSafe({}, { timeoutMs: 5000, stuckWindowMs: 60, pollMs: 10, graceMs: 0, stuckDelta: 1 });
assert.equal(r.ok, false);
assert.equal(r.code, "stuck");
assert.equal(bot.pathfinder._stopped, true);
});
test("nopath: path_update status noPath → nopath", async () => {
const bot = makeBot({ goto: NEVER });
const m = createMotionService(bot);
const p = m.gotoSafe({}, { timeoutMs: 5000, stuckWindowMs: 5000, pollMs: 1000, graceMs: 5000 });
setTimeout(() => bot.emit("path_update", { status: "noPath" }), 20);
const r = await p;
assert.equal(r.code, "nopath");
assert.equal(bot.pathfinder._stopped, true);
});
test("timeout: goto hangs, wall-clock fires before watchdog", async () => {
const bot = makeBot({ goto: NEVER });
const m = createMotionService(bot);
const r = await m.gotoSafe({}, { timeoutMs: 40, stuckWindowMs: 10000, pollMs: 1000, graceMs: 10000 });
assert.equal(r.code, "timeout");
});
test("goal_changed: goto rejection is classified, not treated as reached", async () => {
const bot = makeBot({
goto: () => Promise.reject(new Error("GoalChanged: The goal was changed before it could be completed")),
});
const m = createMotionService(bot);
const r = await m.gotoSafe({}, { timeoutMs: 1000, stuckWindowMs: 1000, pollMs: 50, graceMs: 1000 });
assert.equal(r.ok, false);
assert.equal(r.code, "goal_changed");
});
test("progress resets the stuck window", async () => {
const bot = makeBot({ goto: NEVER });
const m = createMotionService(bot);
// Move the bot forward steadily so the watchdog never trips before timeout.
const mover = setInterval(() => { bot.entity.position.x += 5; }, 15);
const r = await m.gotoSafe({}, { timeoutMs: 120, stuckWindowMs: 80, pollMs: 10, graceMs: 0, stuckDelta: 1 });
clearInterval(mover);
// It kept moving, so it should hit the wall-clock timeout, not "stuck".
assert.equal(r.code, "timeout");
assert.ok(r.movedBlocks > 0, `expected movedBlocks>0, got ${r.movedBlocks}`);
});
test("classifyGotoError maps messages to stable codes", () => {
assert.equal(classifyGotoError(new Error("GoalChanged")), "goal_changed");
assert.equal(classifyGotoError(new Error("Took too long to compute path")), "timeout");
assert.equal(classifyGotoError(new Error("No path to the goal!")), "nopath");
assert.equal(classifyGotoError(new Error("something else")), "error");
});
test("hdist is horizontal only", () => {
assert.equal(_internal.hdist({ x: 0, y: 0, z: 0 }, { x: 3, y: 100, z: 4 }), 5);
});
+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");
});
+4 -2
View File
@@ -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) {
+62
View File
@@ -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");
});
+45
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);
@@ -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;
+77
View File
@@ -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);
});
+28 -21
View File
@@ -149,16 +149,16 @@ function StatusHeader({ snapshot, connectedIpc, width, startedAt }: { snapshot:
const day = snapshot.isDay ? "☀" : "🌙";
const session = formatDuration(Date.now() - startedAt);
const mcOnline = snapshot.connected;
const story = snapshot.storyStep;
const idx = story?.index ?? 0;
const cur = story?.step;
const want = story?.suggestion?.skillId;
// 11-step progress bar in 11 cells
const bar = Array.from({ length: 11 }, (_, i) => {
if (i < idx) return "▓";
if (i === idx) return "▒";
return "░";
}).join("");
// v0.4.0 — the Settlement Contract is the progression authority. Show its
// milestone, completed/total, suggested skill and the Village Score.
const contract = snapshot.contract;
const vs = snapshot.villageScore;
const cdone = contract?.completed ?? 0;
const ctotal = contract?.total ?? 0;
const cbar = Array.from({ length: ctotal || 10 }, (_, i) => (i < cdone ? "▓" : "░")).join("");
const cmile = contract?.milestone;
const cwant = contract?.suggestedSkill?.skillId;
const vspct = vs ? Math.round(vs.score * 100) : null;
return (
<Box flexDirection="column" borderStyle="round" borderColor="cyan" paddingX={1} width={width}>
<Box>
@@ -188,25 +188,32 @@ function StatusHeader({ snapshot, connectedIpc, width, startedAt }: { snapshot:
) : null}
</Box>
<Box>
<Text color="magenta">story </Text>
<Text bold>{bar}</Text>
<Text color="magenta">build </Text>
<Text bold>{cbar}</Text>
<Text dimColor> </Text>
<Text bold>{idx + 1}/11</Text>
<Text bold>{cdone}/{ctotal}</Text>
<Text dimColor> </Text>
{cur ? (
{contract?.done ? (
<Text color="green" bold>settlement complete</Text>
) : cmile ? (
<>
<Text color="white" bold>{cur.id}</Text>
<Text color="white" bold>{cmile.id}</Text>
<Text dimColor> · </Text>
<Text>{cur.title}</Text>
<Text>{cmile.title}</Text>
</>
) : <Text dimColor>(no story)</Text>}
{want ? (
) : <Text dimColor>(no contract)</Text>}
{cwant ? (
<>
<Text dimColor> </Text>
<Text color="cyan">{want}</Text>
<Text color="cyan">{cwant}</Text>
</>
) : null}
{vspct != null ? (
<>
<Text dimColor> · </Text>
<Text color="yellow">VS {vspct}%</Text>
</>
) : null}
{story?.emergency ? <Text color="red" bold> [EMERGENCY]</Text> : null}
</Box>
</Box>
);
@@ -445,7 +452,7 @@ function App() {
// Layout math: compact 4-section vertical stack.
// row budget:
// header (story + status) ≈ 4 rows
// header (contract/build + status) ≈ 4 rows
// middle activity/chat ≈ floor((rows - 4 - 4 - 4) / 2)
// bottom advisor/improvements ≈ same
// footer ≈ 4 rows