From c910457817476c786f9d9487350a3d9ba5d5f89d Mon Sep 17 00:00:00 2001 From: Yuriy Mayatnikov Date: Thu, 28 May 2026 11:29:39 +0300 Subject: [PATCH] =?UTF-8?q?v0.4.0=20vNext=20=E2=80=94=20closed-loop=20worl?= =?UTF-8?q?d=20model=20+=20settlement=20contract=20(#29)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * 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 --------- Co-authored-by: Yuriy Mayatnikov Co-authored-by: Claude Opus 4.7 --- package-lock.json | 4 +- package.json | 4 +- runtime/actions.js | 81 +++++++--- runtime/anti-loop.js | 70 +++++++++ runtime/anti-loop.test.js | 58 +++++++ runtime/bot.js | 72 +++++++++ runtime/goal/contract.js | 149 ++++++++++++++++++ runtime/goal/goal-manager.js | 93 ++++++++++++ runtime/goal/goal-manager.test.js | 97 ++++++++++++ runtime/goal/invariants.js | 177 ++++++++++++++++++++++ runtime/goal/skill-graph.js | 89 +++++++++++ runtime/goal/skill-graph.test.js | 56 +++++++ runtime/goal/village-score.js | 78 ++++++++++ runtime/goal/village-score.test.js | 52 +++++++ runtime/modes.js | 25 +++ runtime/reflex.js | 32 +++- runtime/reflex.test.js | 64 ++++++++ runtime/services/inventory-ledger.js | 142 +++++++++++++++++ runtime/services/inventory-ledger.test.js | 84 ++++++++++ runtime/services/motion.js | 160 +++++++++++++++++++ runtime/services/motion.test.js | 92 +++++++++++ runtime/skills/_common.js | 67 ++++++++ runtime/skills/_common.test.js | 67 ++++++++ runtime/skills/acquire-food.js | 39 ++++- runtime/skills/dig-in.js | 131 ++++++++++++++++ runtime/skills/dig-in.test.js | 75 +++++++++ runtime/skills/flee.js | 6 +- runtime/skills/flee.test.js | 62 ++++++++ runtime/skills/index.js | 45 ++++++ runtime/skills/worlddelta.test.js | 77 ++++++++++ tui/monitor.tsx | 49 +++--- 31 files changed, 2236 insertions(+), 61 deletions(-) create mode 100644 runtime/anti-loop.js create mode 100644 runtime/anti-loop.test.js create mode 100644 runtime/goal/contract.js create mode 100644 runtime/goal/goal-manager.js create mode 100644 runtime/goal/goal-manager.test.js create mode 100644 runtime/goal/invariants.js create mode 100644 runtime/goal/skill-graph.js create mode 100644 runtime/goal/skill-graph.test.js create mode 100644 runtime/goal/village-score.js create mode 100644 runtime/goal/village-score.test.js create mode 100644 runtime/services/inventory-ledger.js create mode 100644 runtime/services/inventory-ledger.test.js create mode 100644 runtime/services/motion.js create mode 100644 runtime/services/motion.test.js create mode 100644 runtime/skills/_common.js create mode 100644 runtime/skills/_common.test.js create mode 100644 runtime/skills/dig-in.js create mode 100644 runtime/skills/dig-in.test.js create mode 100644 runtime/skills/flee.test.js create mode 100644 runtime/skills/worlddelta.test.js diff --git a/package-lock.json b/package-lock.json index 8e38e3c..49d04de 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 16626d5..bad80fd 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/runtime/actions.js b/runtime/actions.js index 1b8aade..7ee8d1c 100644 --- a/runtime/actions.js +++ b/runtime/actions.js @@ -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 }; } } diff --git a/runtime/anti-loop.js b/runtime/anti-loop.js new file mode 100644 index 0000000..f0cfe07 --- /dev/null +++ b/runtime/anti-loop.js @@ -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 }; +} diff --git a/runtime/anti-loop.test.js b/runtime/anti-loop.test.js new file mode 100644 index 0000000..f4b93b0 --- /dev/null +++ b/runtime/anti-loop.test.js @@ -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); +}); diff --git a/runtime/bot.js b/runtime/bot.js index 351c358..d4b80e9 100644 --- a/runtime/bot.js +++ b/runtime/bot.js @@ -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 }; diff --git a/runtime/goal/contract.js b/runtime/goal/contract.js new file mode 100644 index 0000000..e8d78ba --- /dev/null +++ b/runtime/goal/contract.js @@ -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 }; diff --git a/runtime/goal/goal-manager.js b/runtime/goal/goal-manager.js new file mode 100644 index 0000000..3a4440a --- /dev/null +++ b/runtime/goal/goal-manager.js @@ -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 }; +} diff --git a/runtime/goal/goal-manager.test.js b/runtime/goal/goal-manager.test.js new file mode 100644 index 0000000..102193d --- /dev/null +++ b/runtime/goal/goal-manager.test.js @@ -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")); +}); diff --git a/runtime/goal/invariants.js b/runtime/goal/invariants.js new file mode 100644 index 0000000..d2a83f2 --- /dev/null +++ b/runtime/goal/invariants.js @@ -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 }; +} diff --git a/runtime/goal/skill-graph.js b/runtime/goal/skill-graph.js new file mode 100644 index 0000000..b98df0f --- /dev/null +++ b/runtime/goal/skill-graph.js @@ -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: , 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 }; diff --git a/runtime/goal/skill-graph.test.js b/runtime/goal/skill-graph.test.js new file mode 100644 index 0000000..0359f49 --- /dev/null +++ b/runtime/goal/skill-graph.test.js @@ -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")); +}); diff --git a/runtime/goal/village-score.js b/runtime/goal/village-score.js new file mode 100644 index 0000000..05d1224 --- /dev/null +++ b/runtime/goal/village-score.js @@ -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 }; diff --git a/runtime/goal/village-score.test.js b/runtime/goal/village-score.test.js new file mode 100644 index 0000000..7045ac7 --- /dev/null +++ b/runtime/goal/village-score.test.js @@ -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); +}); diff --git a/runtime/modes.js b/runtime/modes.js index f3e30e5..26c55e0 100644 --- a/runtime/modes.js +++ b/runtime/modes.js @@ -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" } }; + }, +}); diff --git a/runtime/reflex.js b/runtime/reflex.js index 571b29a..38fc3d2 100644 --- a/runtime/reflex.js +++ b/runtime/reflex.js @@ -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 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, { diff --git a/runtime/reflex.test.js b/runtime/reflex.test.js index ed23e67..4513838 100644 --- a/runtime/reflex.test.js +++ b/runtime/reflex.test.js @@ -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"); +}); diff --git a/runtime/services/inventory-ledger.js b/runtime/services/inventory-ledger.js new file mode 100644 index 0000000..2e443ba --- /dev/null +++ b/runtime/services/inventory-ledger.js @@ -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 }], 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 }; diff --git a/runtime/services/inventory-ledger.test.js b/runtime/services/inventory-ledger.test.js new file mode 100644 index 0000000..b8a3d0f --- /dev/null +++ b/runtime/services/inventory-ledger.test.js @@ -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); +}); diff --git a/runtime/services/motion.js b/runtime/services/motion.js new file mode 100644 index 0000000..15e9b8e --- /dev/null +++ b/runtime/services/motion.js @@ -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 }; diff --git a/runtime/services/motion.test.js b/runtime/services/motion.test.js new file mode 100644 index 0000000..0456f8b --- /dev/null +++ b/runtime/services/motion.test.js @@ -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); +}); diff --git a/runtime/skills/_common.js b/runtime/skills/_common.js new file mode 100644 index 0000000..ac49948 --- /dev/null +++ b/runtime/skills/_common.js @@ -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 }; diff --git a/runtime/skills/_common.test.js b/runtime/skills/_common.test.js new file mode 100644 index 0000000..db5b259 --- /dev/null +++ b/runtime/skills/_common.test.js @@ -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"); +}); diff --git a/runtime/skills/acquire-food.js b/runtime/skills/acquire-food.js index a1dc9f0..1449833 100644 --- a/runtime/skills/acquire-food.js +++ b/runtime/skills/acquire-food.js @@ -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}`); diff --git a/runtime/skills/dig-in.js b/runtime/skills/dig-in.js new file mode 100644 index 0000000..3b710d1 --- /dev/null +++ b/runtime/skills/dig-in.js @@ -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 }; diff --git a/runtime/skills/dig-in.test.js b/runtime/skills/dig-in.test.js new file mode 100644 index 0000000..cbce978 --- /dev/null +++ b/runtime/skills/dig-in.test.js @@ -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"); +}); diff --git a/runtime/skills/flee.js b/runtime/skills/flee.js index 90a33f3..6655af5 100644 --- a/runtime/skills/flee.js +++ b/runtime/skills/flee.js @@ -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) { diff --git a/runtime/skills/flee.test.js b/runtime/skills/flee.test.js new file mode 100644 index 0000000..f5cfa80 --- /dev/null +++ b/runtime/skills/flee.test.js @@ -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"); +}); diff --git a/runtime/skills/index.js b/runtime/skills/index.js index 4a19060..ce28944 100644 --- a/runtime/skills/index.js +++ b/runtime/skills/index.js @@ -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; diff --git a/runtime/skills/worlddelta.test.js b/runtime/skills/worlddelta.test.js new file mode 100644 index 0000000..09ee675 --- /dev/null +++ b/runtime/skills/worlddelta.test.js @@ -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); +}); diff --git a/tui/monitor.tsx b/tui/monitor.tsx index 546c57a..820dd7d 100644 --- a/tui/monitor.tsx +++ b/tui/monitor.tsx @@ -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 ( @@ -188,25 +188,32 @@ function StatusHeader({ snapshot, connectedIpc, width, startedAt }: { snapshot: ) : null} - story - {bar} + build + {cbar} - {idx + 1}/11 + {cdone}/{ctotal} - {cur ? ( + {contract?.done ? ( + settlement complete + ) : cmile ? ( <> - {cur.id} + {cmile.id} · - {cur.title} + {cmile.title} - ) : (no story)} - {want ? ( + ) : (no contract)} + {cwant ? ( <> - {want} + {cwant} + + ) : null} + {vspct != null ? ( + <> + · + VS {vspct}% ) : null} - {story?.emergency ? [EMERGENCY] : null} ); @@ -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