v0.4.0 vNext — closed-loop world model + settlement contract #29
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "pepa-pi-bot",
|
||||
"version": "0.2.0-rc.1",
|
||||
"version": "0.4.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "pepa-pi-bot",
|
||||
"version": "0.2.0-rc.1",
|
||||
"version": "0.4.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"better-sqlite3": "^11.10.0",
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "pepa-pi-bot",
|
||||
"version": "0.3.0-rc.3",
|
||||
"version": "0.4.0",
|
||||
"private": true,
|
||||
"description": "An autonomous, self-extending Minecraft player powered by Pi and Mineflayer.",
|
||||
"license": "MIT",
|
||||
@@ -17,7 +17,7 @@
|
||||
"tui:legacy": "tsx tui/tui.tsx",
|
||||
"propose:apply": "node scripts/propose-apply.js",
|
||||
"stop": "bash scripts/stop.sh",
|
||||
"test": "node --test runtime/skills/contract.test.js runtime/skills/groups.test.js runtime/skills/compat.test.js runtime/skills/recovery-tunnel-out.test.js runtime/skills/pillar-up.test.js runtime/curriculum.test.js runtime/social/social.test.js runtime/social/conversation.test.js runtime/social/chat-history.test.js runtime/social/reply-pi.test.js runtime/stuck-incident.test.js runtime/compat.test.js runtime/reflex.test.js runtime/base-site.test.js runtime/locations.test.js runtime/watch-filter.test.js runtime/world-journal.test.js runtime/scenario-memory.test.js runtime/critic.test.js runtime/skill-library.test.js runtime/skill-registry.test.js runtime/modes.test.js runtime/pathfinder-watchdog.test.js runtime/manifesto/needs.test.js runtime/manifesto/state.test.js runtime/goal/storyline.test.js runtime/awareness/events.test.js runtime/awareness/wedge-detector.test.js runtime/biome-affordances.test.js runtime/knowledge/knowledge.test.js runtime/llm/provider.test.js runtime/coach/postmortem.test.js runtime/coach/advice.test.js runtime/coach/reflect.test.js runtime/coach/fast-advisor.test.js runtime/coach/advisor-trigger.test.js runtime/coach/trigger-tuner.test.js runtime/persona/chatter.test.js scripts/edit-scope.test.js scripts/lint-patch.test.js"
|
||||
"test": "node --test runtime/skills/contract.test.js runtime/skills/groups.test.js runtime/skills/compat.test.js runtime/skills/recovery-tunnel-out.test.js runtime/skills/pillar-up.test.js runtime/skills/dig-in.test.js runtime/skills/_common.test.js runtime/skills/flee.test.js runtime/skills/worlddelta.test.js runtime/curriculum.test.js runtime/social/social.test.js runtime/social/conversation.test.js runtime/social/chat-history.test.js runtime/social/reply-pi.test.js runtime/stuck-incident.test.js runtime/compat.test.js runtime/reflex.test.js runtime/base-site.test.js runtime/locations.test.js runtime/watch-filter.test.js runtime/world-journal.test.js runtime/scenario-memory.test.js runtime/critic.test.js runtime/skill-library.test.js runtime/skill-registry.test.js runtime/modes.test.js runtime/pathfinder-watchdog.test.js runtime/anti-loop.test.js runtime/services/inventory-ledger.test.js runtime/services/motion.test.js runtime/manifesto/needs.test.js runtime/manifesto/state.test.js runtime/goal/storyline.test.js runtime/goal/goal-manager.test.js runtime/goal/village-score.test.js runtime/goal/skill-graph.test.js runtime/awareness/events.test.js runtime/awareness/wedge-detector.test.js runtime/biome-affordances.test.js runtime/knowledge/knowledge.test.js runtime/llm/provider.test.js runtime/coach/postmortem.test.js runtime/coach/advice.test.js runtime/coach/reflect.test.js runtime/coach/fast-advisor.test.js runtime/coach/advisor-trigger.test.js runtime/coach/trigger-tuner.test.js runtime/persona/chatter.test.js scripts/edit-scope.test.js scripts/lint-patch.test.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"better-sqlite3": "^11.10.0",
|
||||
|
||||
+58
-23
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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"));
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
// Skill dependency graph (Plan4MC-style, research §C). A static, declarative
|
||||
// model of "what does this skill need, what does it produce". The contract
|
||||
// already SEQUENCES the early game via the curriculum, so this graph is the
|
||||
// queryable prerequisite layer on top: the GoalManager annotates each suggested
|
||||
// skill with whether its prerequisites currently hold (surfaced for the TUI and
|
||||
// as a guard against suggesting a skill that physically cannot succeed here).
|
||||
//
|
||||
// Requirement kinds:
|
||||
// { item: <semantic-group|exact>, min } — need N of an item / group
|
||||
// { tool: "pickaxe" | "axe" | "sword" } — need any tier of that tool
|
||||
// Semantic groups: logs (*_log/_stem), planks (*_planks), sticks, cobblestone,
|
||||
// wool (*_wool), coal, bed (*_bed). Anything else is matched as an exact name.
|
||||
|
||||
import { totalMatching, has } from "./invariants.js";
|
||||
|
||||
const GROUP = {
|
||||
logs: (k) => k.endsWith("_log") || k.endsWith("_stem"),
|
||||
planks: (k) => k.endsWith("_planks"),
|
||||
wool: (k) => k.endsWith("_wool"),
|
||||
cobblestone: (k) => k === "cobblestone" || k === "cobbled_deepslate",
|
||||
coal: (k) => k === "coal" || k === "charcoal",
|
||||
};
|
||||
|
||||
const TOOL = {
|
||||
pickaxe: (k) => k.endsWith("_pickaxe"),
|
||||
axe: (k) => k.endsWith("_axe") && !k.endsWith("_pickaxe"),
|
||||
sword: (k) => k.endsWith("_sword"),
|
||||
};
|
||||
|
||||
export const SKILL_GRAPH = Object.freeze({
|
||||
"gather.logs": { requires: [], produces: ["logs"] },
|
||||
"gather.wool": { requires: [], produces: ["wool"] },
|
||||
"gather.stone": { requires: [{ tool: "pickaxe" }], produces: ["cobblestone"] },
|
||||
"craft.planks": { requires: [{ item: "logs", min: 1 }], produces: ["planks"] },
|
||||
"craft.sticks": { requires: [{ item: "planks", min: 2 }], produces: ["stick"] },
|
||||
"craft.wooden-axe": { requires: [{ item: "planks", min: 3 }, { item: "stick", min: 2 }], produces: ["wooden_axe"] },
|
||||
"craft.wooden-pickaxe": { requires: [{ item: "planks", min: 3 }, { item: "stick", min: 2 }], produces: ["wooden_pickaxe"] },
|
||||
"craft.wooden-sword": { requires: [{ item: "planks", min: 2 }, { item: "stick", min: 1 }], produces: ["wooden_sword"] },
|
||||
"craft.stone-axe": { requires: [{ item: "cobblestone", min: 3 }, { item: "stick", min: 2 }], produces: ["stone_axe"] },
|
||||
"craft.stone-pickaxe": { requires: [{ item: "cobblestone", min: 3 }, { item: "stick", min: 2 }], produces: ["stone_pickaxe"] },
|
||||
"craft.stone-sword": { requires: [{ item: "cobblestone", min: 2 }, { item: "stick", min: 1 }], produces: ["stone_sword"] },
|
||||
"craft.furnace": { requires: [{ item: "cobblestone", min: 8 }], produces: ["furnace"] },
|
||||
"craft.chest": { requires: [{ item: "planks", min: 8 }], produces: ["chest"] },
|
||||
"craft.torch": { requires: [{ item: "coal", min: 1 }, { item: "stick", min: 1 }], produces: ["torch"] },
|
||||
"craft.bed": { requires: [{ item: "wool", min: 3 }, { item: "planks", min: 3 }], produces: ["bed"] },
|
||||
"village.choose-base": { requires: [], produces: ["loc:base"] },
|
||||
"village.build-shelter": { requires: [{ item: "planks", min: 1 }], produces: ["loc:shelter"] },
|
||||
"village.place-chest": { requires: [{ item: "chest", min: 1 }], produces: ["loc:chest"] },
|
||||
"farm.wheat": { requires: [], produces: [] },
|
||||
});
|
||||
|
||||
function itemCount(inv, name) {
|
||||
const g = GROUP[name];
|
||||
return g ? totalMatching(inv, g) : (inv?.[name] ?? 0);
|
||||
}
|
||||
|
||||
function hasTool(inv, kind) {
|
||||
const t = TOOL[kind];
|
||||
if (!t) return false;
|
||||
return Object.keys(inv ?? {}).some((k) => t(k) && (inv[k] ?? 0) > 0);
|
||||
}
|
||||
|
||||
// { ok, missing: [{ item|tool, min, have }] } for a skill given the world.
|
||||
export function prerequisitesMet(skillId, world) {
|
||||
const node = SKILL_GRAPH[skillId];
|
||||
if (!node) return { ok: true, missing: [], known: false };
|
||||
const inv = world?.inventory ?? {};
|
||||
const missing = [];
|
||||
for (const req of node.requires) {
|
||||
if (req.tool) {
|
||||
if (!hasTool(inv, req.tool)) missing.push({ tool: req.tool });
|
||||
} else if (req.item) {
|
||||
const have = itemCount(inv, req.item);
|
||||
if (have < (req.min ?? 1)) missing.push({ item: req.item, min: req.min ?? 1, have });
|
||||
}
|
||||
}
|
||||
return { ok: missing.length === 0, missing, known: true };
|
||||
}
|
||||
|
||||
export function canRun(skillId, world) {
|
||||
return prerequisitesMet(skillId, world).ok;
|
||||
}
|
||||
|
||||
// All skills whose prerequisites currently hold (Plan4MC "frontier").
|
||||
export function runnableFrontier(world) {
|
||||
return Object.keys(SKILL_GRAPH).filter((id) => canRun(id, world));
|
||||
}
|
||||
|
||||
export const _internal = { GROUP, TOOL, itemCount, hasTool, has };
|
||||
@@ -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"));
|
||||
});
|
||||
@@ -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 };
|
||||
@@ -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);
|
||||
});
|
||||
@@ -138,3 +138,28 @@ registerMode({
|
||||
return { action: { skillId: "survive.sleep" }, detail: { reason: "night with bed in hand" } };
|
||||
},
|
||||
});
|
||||
|
||||
// QW8 — displaced at night with no bed: dig in for cover instead of standing
|
||||
// in the open getting shot. Fires only when night_shelter can't (no bed) and
|
||||
// no hostile is already in melee (self_preservation owns that). Requires a
|
||||
// placeable cap block; survive.dig-in's preconditions enforce dig safety.
|
||||
registerMode({
|
||||
name: "dusk_dig_in",
|
||||
description: "Night, no bed, exposed → dig a hole and cap it",
|
||||
interrupts: ["curriculum"],
|
||||
update(ctx) {
|
||||
const snap = ctx?.snapshot;
|
||||
if (!snap || snap.isDay !== false) return null; // only on explicit night
|
||||
if (ctx.modeCooldown?.dusk_dig_in && Date.now() < ctx.modeCooldown.dusk_dig_in) return null;
|
||||
const inv = snap.inventory || {};
|
||||
if (Object.keys(inv).some((n) => /_bed$/.test(n))) return null; // night_shelter handles beds
|
||||
// Only when actually exposed at night and away from a known shelter.
|
||||
const sheltered = !!snap.locations?.shelter || snap.hazards?.headBlock && snap.hazards.headBlock !== "air";
|
||||
if (sheltered) return null;
|
||||
const hasCap = Object.keys(inv).some((n) => /(_planks|_log|cobble|stone|dirt|sand|gravel|netherrack)$/i.test(n));
|
||||
if (!hasCap) return null;
|
||||
ctx.modeCooldown = ctx.modeCooldown ?? {};
|
||||
ctx.modeCooldown.dusk_dig_in = Date.now() + 60_000;
|
||||
return { action: { skillId: "survive.dig-in" }, detail: { reason: "exposed at night, no bed" } };
|
||||
},
|
||||
});
|
||||
|
||||
+29
-3
@@ -528,6 +528,18 @@ function curriculumReflex(ctx) {
|
||||
const storyStep = ctx.disableStoryline ? null : pickCurrentStep(s);
|
||||
if (storyStep) ctx.storyStep = storyStep;
|
||||
const storySkillId = (storyStep && !storyStep.emergency && storyStep.suggestion?.skillId) ? storyStep.suggestion.skillId : null;
|
||||
|
||||
// v0.4.0 — Settlement Contract is the unified progression authority,
|
||||
// replacing the storyline rail (which competed with the manifesto). It is
|
||||
// precomputed in bot.js (snapshot.contract) via the GoalManager: lowest
|
||||
// unmet milestone, with food-urgency utility preemption. Manifesto L0
|
||||
// (alive emergencies) still preempts it; storyline/curriculum remain as
|
||||
// fallbacks when the contract is disabled (tests) or has no suggestion.
|
||||
const contractGoal = ctx.disableContract ? null : (s.contract ?? null);
|
||||
if (contractGoal) ctx.contractGoal = contractGoal;
|
||||
const contractSkillId = (contractGoal && !contractGoal.done && contractGoal.suggestedSkill?.skillId)
|
||||
? contractGoal.suggestedSkill.skillId
|
||||
: null;
|
||||
const metricRecovery = metricRecoverySkill(ctx, plan?.skillId);
|
||||
if (metricRecovery) {
|
||||
ctx.lastCurriculumAt = Date.now();
|
||||
@@ -584,7 +596,7 @@ function curriculumReflex(ctx) {
|
||||
// First hint → small wander (might just be 32-block reach issue).
|
||||
// Every subsequent hint while still inside the backoff window → use
|
||||
// explore.far so the bot actually leaves the patch it's stuck in.
|
||||
if ((!plan?.skillId && !manifestoSkillId && !storySkillId) || wantWander) {
|
||||
if ((!plan?.skillId && !manifestoSkillId && !storySkillId && !contractSkillId) || wantWander) {
|
||||
ctx.lastCurriculumAt = Date.now();
|
||||
const fallbackId = wantWander && consecutiveWanderHints >= 1 ? "explore.far" : "wander";
|
||||
// v0.2.0-rc.3 — consult advice on the FALLBACK dispatch too. Without
|
||||
@@ -633,13 +645,16 @@ function curriculumReflex(ctx) {
|
||||
const manifestoEmergency = activeNeed?.need?.level === 0;
|
||||
let skillId, skillSource;
|
||||
if (manifestoEmergency) {
|
||||
skillId = manifestoSkillId ?? storySkillId ?? plan.skillId;
|
||||
skillId = manifestoSkillId ?? contractSkillId ?? storySkillId ?? plan?.skillId;
|
||||
skillSource = `manifesto:${activeNeed.need.id}`;
|
||||
} else if (contractSkillId) {
|
||||
skillId = contractSkillId;
|
||||
skillSource = `contract:${contractGoal.milestone.id}`;
|
||||
} else if (storySkillId) {
|
||||
skillId = storySkillId;
|
||||
skillSource = `storyline:${storyStep.step.id}`;
|
||||
} else {
|
||||
skillId = manifestoSkillId ?? plan.skillId;
|
||||
skillId = manifestoSkillId ?? plan?.skillId;
|
||||
skillSource = manifestoSkillId ? `manifesto:${activeNeed.need.id}` : "curriculum";
|
||||
}
|
||||
|
||||
@@ -697,6 +712,14 @@ function curriculumReflex(ctx) {
|
||||
}
|
||||
}
|
||||
|
||||
// QW5 anti-loop: this skill failed ≥3× in 5 min → it's blacklisted. Skip
|
||||
// and nudge toward exploration so we leave the situation that loops it.
|
||||
if (ctx.antiLoop?.shouldSkip(skillId)) {
|
||||
ctx.skillBackoff = ctx.skillBackoff ?? {};
|
||||
ctx.skillBackoff["__wander_hint__"] = Date.now() + SKILL_BACKOFF_MS;
|
||||
return { action: "noop", kind: "anti-loop-blacklisted", label: skillId };
|
||||
}
|
||||
|
||||
// v0.2.0 — consult learned lessons. If a high-confidence lesson says
|
||||
// "avoid <skillId> in this situation", swap to its preferred
|
||||
// alternative (or back off entirely if no safe alternative is named).
|
||||
@@ -719,6 +742,9 @@ function curriculumReflex(ctx) {
|
||||
ctx.dispatch(() => runSkill(dispatchSkillId, ctx, dispatchArgs), dispatchSkillId, {
|
||||
onComplete: (res) => {
|
||||
ctx.skillBackoff = ctx.skillBackoff ?? {};
|
||||
// QW5 anti-loop bookkeeping: feed every outcome so repeated failures
|
||||
// of the same skill get detected, blacklisted and ticketed.
|
||||
ctx.antiLoop?.record({ skillId: dispatchSkillId, ok: !!res?.ok, code: res?.code ?? null });
|
||||
if (advice.lessonId) reportAdviceOutcome({ lessonId: advice.lessonId, succeeded: !!res?.ok });
|
||||
if (appliedRecommendationId) {
|
||||
markRecommendationOutcome(appliedRecommendationId, {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
// InventoryLedger (L1 service) — diff-based "did I actually get it" verifier.
|
||||
//
|
||||
// The proximate cause of the live `no_drop` symptom (research §A.4, QW2): the
|
||||
// bot has no reliable signal that a pickup happened. mineflayer's
|
||||
// `playerCollect` event is unreliable (fires for the wrong entity when other
|
||||
// droppers tick nearby — mineflayer #1171) and item entities visually vanish
|
||||
// past ~16 blocks (Minecraft Wiki, Item entity). The only ground truth is the
|
||||
// inventory itself.
|
||||
//
|
||||
// This ledger snapshots `bot.inventory.items()` over time and answers two
|
||||
// questions deterministically:
|
||||
// - count(name) -> current count of an exact item
|
||||
// - total(matcher) -> sum of counts for items matching a predicate
|
||||
// - gainedSince(baseline, m) -> net positive gain of matching items vs a baseline
|
||||
// - acquired(matcher, sinceTs)-> same, but baselined to a wall-clock timestamp
|
||||
//
|
||||
// Skills verify success with `const base = ledger.mark(); ...; ledger.update(bot);
|
||||
// const got = ledger.gainedSince(base, isFood)` instead of trusting events.
|
||||
//
|
||||
// Pure w.r.t. the runtime: it only reads inventory. update() is driven once
|
||||
// per tick from bot.js, and a skill may call update(bot) itself to force a
|
||||
// fresh read before checking a delta (independent of tick cadence).
|
||||
|
||||
function itemsToCounts(items) {
|
||||
const m = new Map();
|
||||
for (const it of items ?? []) {
|
||||
if (!it?.name) continue;
|
||||
m.set(it.name, (m.get(it.name) ?? 0) + (it.count ?? 0));
|
||||
}
|
||||
return m;
|
||||
}
|
||||
|
||||
function countsFromBot(bot) {
|
||||
try {
|
||||
return itemsToCounts(bot?.inventory?.items?.() ?? []);
|
||||
} catch {
|
||||
return new Map();
|
||||
}
|
||||
}
|
||||
|
||||
function matchFn(matcher) {
|
||||
if (typeof matcher === "function") return matcher;
|
||||
if (matcher instanceof RegExp) return (n) => matcher.test(n);
|
||||
if (typeof matcher === "string") return (n) => n === matcher;
|
||||
if (Array.isArray(matcher)) {
|
||||
const set = new Set(matcher);
|
||||
return (n) => set.has(n);
|
||||
}
|
||||
if (matcher instanceof Set) return (n) => matcher.has(n);
|
||||
return () => true;
|
||||
}
|
||||
|
||||
export function createInventoryLedger({ historyMs = 6 * 60_000, maxSnapshots = 240 } = {}) {
|
||||
let history = []; // [{ ts, counts: Map<string,number> }], oldest→newest
|
||||
let current = new Map();
|
||||
|
||||
// Record a snapshot now. Accepts a bot or a raw items array (for tests).
|
||||
function update(botOrItems, now = Date.now()) {
|
||||
current = Array.isArray(botOrItems) ? itemsToCounts(botOrItems) : countsFromBot(botOrItems);
|
||||
history.push({ ts: now, counts: current });
|
||||
const cutoff = now - historyMs;
|
||||
while (history.length > 1 && (history[0].ts < cutoff || history.length > maxSnapshots)) {
|
||||
history.shift();
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
function count(name) {
|
||||
return current.get(name) ?? 0;
|
||||
}
|
||||
|
||||
function total(matcher) {
|
||||
const f = matchFn(matcher);
|
||||
let sum = 0;
|
||||
for (const [name, c] of current) if (f(name)) sum += c;
|
||||
return sum;
|
||||
}
|
||||
|
||||
// A baseline is just a frozen copy of the counts at a point in time.
|
||||
function mark() {
|
||||
return new Map(current);
|
||||
}
|
||||
|
||||
function gainedSince(baseline, matcher) {
|
||||
const f = matchFn(matcher);
|
||||
const base = baseline ?? new Map();
|
||||
let gained = 0;
|
||||
for (const [name, c] of current) {
|
||||
if (!f(name)) continue;
|
||||
const before = base.get(name) ?? 0;
|
||||
if (c > before) gained += c - before;
|
||||
}
|
||||
return gained;
|
||||
}
|
||||
|
||||
// The newest recorded snapshot whose ts is <= sinceTs (i.e. the world as it
|
||||
// was at that moment). Empty map if we have no history that old.
|
||||
function baselineAt(sinceTs) {
|
||||
let chosen = null;
|
||||
for (const h of history) {
|
||||
if (h.ts <= sinceTs) chosen = h;
|
||||
else break;
|
||||
}
|
||||
return chosen ? new Map(chosen.counts) : new Map();
|
||||
}
|
||||
|
||||
function acquired(matcher, sinceTs) {
|
||||
return gainedSince(baselineAt(sinceTs), matcher);
|
||||
}
|
||||
|
||||
// Full signed diff {name: change} vs the snapshot at sinceTs. Used for
|
||||
// worldDelta validation and diary narration.
|
||||
function delta(sinceTs) {
|
||||
const base = baselineAt(sinceTs);
|
||||
const out = {};
|
||||
const names = new Set([...base.keys(), ...current.keys()]);
|
||||
for (const name of names) {
|
||||
const d = (current.get(name) ?? 0) - (base.get(name) ?? 0);
|
||||
if (d !== 0) out[name] = d;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function snapshot() {
|
||||
return new Map(current);
|
||||
}
|
||||
|
||||
return {
|
||||
update,
|
||||
count,
|
||||
total,
|
||||
mark,
|
||||
gainedSince,
|
||||
acquired,
|
||||
baselineAt,
|
||||
delta,
|
||||
snapshot,
|
||||
_history: () => history,
|
||||
};
|
||||
}
|
||||
|
||||
export const _internal = { itemsToCounts, matchFn };
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 };
|
||||
@@ -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);
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
// Shared skill helpers.
|
||||
//
|
||||
// approachBlock — walk up to a target block and look at it, WITHOUT using
|
||||
// pathfinder's GoalLookAtBlock. That goal raycasts collision boxes only
|
||||
// (mineflayer-pathfinder #341), so for non-collision targets — crops, torches,
|
||||
// fences, saplings, buttons — the raycast never hits and the goal is never
|
||||
// "reached". Instead we GoalNear the block and lookAt its centre, which works
|
||||
// regardless of the target's collision shape (research QW4).
|
||||
|
||||
import pathfinderPkg from "mineflayer-pathfinder";
|
||||
const { goals } = pathfinderPkg;
|
||||
|
||||
export function blockPos(target) {
|
||||
const p = target?.position ?? target;
|
||||
if (!p || p.x == null) return null;
|
||||
return { x: Math.floor(p.x), y: Math.floor(p.y), z: Math.floor(p.z) };
|
||||
}
|
||||
|
||||
export function blockCenter(target) {
|
||||
const p = blockPos(target);
|
||||
return p ? { x: p.x + 0.5, y: p.y + 0.5, z: p.z + 0.5 } : null;
|
||||
}
|
||||
|
||||
export function withinReach(botPos, target, reach = 4) {
|
||||
const c = blockCenter(target);
|
||||
if (!botPos || !c) return false;
|
||||
return Math.hypot(botPos.x - c.x, botPos.y - c.y, botPos.z - c.z) <= reach;
|
||||
}
|
||||
|
||||
// Returns { ok, code, distance? }. Uses ctx.motion.gotoSafe when available
|
||||
// (structured, can't hang); falls back to a raw goto otherwise.
|
||||
export async function approachBlock(ctx, target, opts = {}) {
|
||||
const bot = ctx?.bot;
|
||||
const pos = blockPos(target);
|
||||
if (!bot || !pos) return { ok: false, code: "no_target" };
|
||||
|
||||
const reach = opts.reach ?? 3;
|
||||
const reachCheck = opts.reachCheck ?? 4;
|
||||
|
||||
if (!withinReach(bot.entity?.position, target, reachCheck)) {
|
||||
const goal = new goals.GoalNear(pos.x, pos.y, pos.z, reach);
|
||||
let res;
|
||||
if (ctx.motion?.gotoSafe) {
|
||||
res = await ctx.motion.gotoSafe(goal, { timeoutMs: opts.timeoutMs ?? 20_000, label: "approach_block" });
|
||||
} else {
|
||||
try {
|
||||
await bot.pathfinder.goto(goal);
|
||||
res = { ok: true, code: "reached" };
|
||||
} catch (e) {
|
||||
res = { ok: false, code: "error", detail: e?.message ?? String(e) };
|
||||
}
|
||||
}
|
||||
if (!res.ok && !withinReach(bot.entity?.position, target, reachCheck)) {
|
||||
return { ok: false, code: res.code, detail: res.detail ?? null };
|
||||
}
|
||||
}
|
||||
|
||||
// Look at the block centre — NOT GoalLookAtBlock (issue #341).
|
||||
try { await bot.lookAt(blockCenter(target), true); } catch {}
|
||||
|
||||
const here = bot.entity?.position;
|
||||
const c = blockCenter(target);
|
||||
const distance = here && c ? Math.round(Math.hypot(here.x - c.x, here.y - c.y, here.z - c.z) * 10) / 10 : null;
|
||||
return { ok: true, code: "reached", distance };
|
||||
}
|
||||
|
||||
export const _internal = { blockPos, blockCenter, withinReach };
|
||||
@@ -0,0 +1,67 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { approachBlock, _internal } from "./_common.js";
|
||||
|
||||
test("blockPos floors a position; blockCenter offsets to centre", () => {
|
||||
assert.deepEqual(_internal.blockPos({ x: 10.9, y: 64.2, z: -3.7 }), { x: 10, y: 64, z: -4 });
|
||||
assert.deepEqual(_internal.blockCenter({ position: { x: 10, y: 64, z: -4 } }), { x: 10.5, y: 64.5, z: -3.5 });
|
||||
});
|
||||
|
||||
test("withinReach respects the radius", () => {
|
||||
assert.equal(_internal.withinReach({ x: 0.5, y: 64.5, z: 0.5 }, { x: 0, y: 64, z: 0 }, 4), true);
|
||||
assert.equal(_internal.withinReach({ x: 20, y: 64, z: 0 }, { x: 0, y: 64, z: 0 }, 4), false);
|
||||
});
|
||||
|
||||
test("approachBlock returns no_target without a target", async () => {
|
||||
const r = await approachBlock({ bot: { entity: { position: { x: 0, y: 64, z: 0 } } } }, null);
|
||||
assert.equal(r.ok, false);
|
||||
assert.equal(r.code, "no_target");
|
||||
});
|
||||
|
||||
test("approachBlock looks at the block when already in reach (no path needed)", async () => {
|
||||
let looked = false;
|
||||
let gotoCalled = false;
|
||||
const ctx = {
|
||||
bot: {
|
||||
entity: { position: { x: 0.5, y: 64, z: 0.5 } },
|
||||
async lookAt() { looked = true; },
|
||||
},
|
||||
motion: { gotoSafe: async () => { gotoCalled = true; return { ok: true, code: "reached" }; } },
|
||||
};
|
||||
const r = await approachBlock(ctx, { x: 0, y: 64, z: 0 }, { reachCheck: 4 });
|
||||
assert.equal(r.ok, true);
|
||||
assert.equal(looked, true);
|
||||
assert.equal(gotoCalled, false, "should not path when already in reach");
|
||||
});
|
||||
|
||||
test("approachBlock uses motion.gotoSafe to close distance, then looks", async () => {
|
||||
let looked = false;
|
||||
const ctx = {
|
||||
bot: {
|
||||
// starts far, motion 'moves' it into reach by mutating position
|
||||
entity: { position: { x: 30, y: 64, z: 0 } },
|
||||
async lookAt() { looked = true; },
|
||||
},
|
||||
motion: {
|
||||
gotoSafe: async () => { ctx.bot.entity.position = { x: 0.6, y: 64, z: 0.6 }; return { ok: true, code: "reached" }; },
|
||||
},
|
||||
};
|
||||
const r = await approachBlock(ctx, { x: 0, y: 64, z: 0 });
|
||||
assert.equal(r.ok, true);
|
||||
assert.equal(looked, true);
|
||||
assert.ok(r.distance <= 4);
|
||||
});
|
||||
|
||||
test("approachBlock fails when motion can't get into reach", async () => {
|
||||
const ctx = {
|
||||
bot: {
|
||||
entity: { position: { x: 30, y: 64, z: 0 } }, // never moves
|
||||
async lookAt() {},
|
||||
},
|
||||
motion: { gotoSafe: async () => ({ ok: false, code: "stuck" }) },
|
||||
};
|
||||
const r = await approachBlock(ctx, { x: 0, y: 64, z: 0 });
|
||||
assert.equal(r.ok, false);
|
||||
assert.equal(r.code, "stuck");
|
||||
});
|
||||
@@ -39,6 +39,28 @@ function foodCount(bot) {
|
||||
return bot.inventory.items().reduce((sum, item) => allowed.has(item.name) ? sum + item.count : sum, 0);
|
||||
}
|
||||
|
||||
// Success here is "did edible food actually enter the inventory". Trusting the
|
||||
// `playerCollect` event or `nearestEntity` going away is what produced the live
|
||||
// `no_drop` false-failures (research §A.4). When the InventoryLedger is wired
|
||||
// (ctx.ledger) we verify by diff against a baseline; otherwise we fall back to
|
||||
// a local before/after count so the skill still works in unit tests.
|
||||
function makeFoodTracker(ctx, bot) {
|
||||
const isFood = (name) => foods(bot).has(name);
|
||||
if (ctx?.ledger) {
|
||||
ctx.ledger.update(bot);
|
||||
const base = ctx.ledger.mark();
|
||||
return {
|
||||
mode: "ledger",
|
||||
gained() {
|
||||
ctx.ledger.update(bot);
|
||||
return ctx.ledger.gainedSince(base, isFood);
|
||||
},
|
||||
};
|
||||
}
|
||||
const before = foodCount(bot);
|
||||
return { mode: "count", gained: () => foodCount(bot) - before };
|
||||
}
|
||||
|
||||
function nearestPassiveFoodMob(bot, maxDistance = 32) {
|
||||
const here = bot?.entity?.position;
|
||||
if (!here) return null;
|
||||
@@ -127,15 +149,16 @@ export const skill = Object.freeze({
|
||||
},
|
||||
async execute(ctx) {
|
||||
const bot = ctx.bot;
|
||||
const before = foodCount(bot);
|
||||
const track = makeFoodTracker(ctx, bot);
|
||||
|
||||
const picked = await pickupNearbyDrops(bot);
|
||||
if (foodCount(bot) > before) {
|
||||
const dropGain = track.gained();
|
||||
if (dropGain > 0) {
|
||||
return {
|
||||
ok: true,
|
||||
code: "done",
|
||||
detail: { source: "drop", picked },
|
||||
worldDelta: { acquiredFood: foodCount(bot) - before, source: "drop" },
|
||||
detail: { source: "drop", picked, verify: track.mode },
|
||||
worldDelta: { acquiredFood: dropGain, source: "drop" },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -184,15 +207,15 @@ export const skill = Object.freeze({
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 1_000));
|
||||
await pickupNearbyDrops(bot);
|
||||
const after = foodCount(bot);
|
||||
if (after <= before) {
|
||||
const huntGain = track.gained();
|
||||
if (huntGain <= 0) {
|
||||
return { ok: false, code: "no_drop", detail: `hunted ${target.entity.name} but found no edible drop`, worldDelta: null };
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
code: "done",
|
||||
detail: { source: "hunt", mob: target.entity.name, gained: after - before },
|
||||
worldDelta: { acquiredFood: after - before, source: "hunt", mob: target.entity.name },
|
||||
detail: { source: "hunt", mob: target.entity.name, gained: huntGain, verify: track.mode },
|
||||
worldDelta: { acquiredFood: huntGain, source: "hunt", mob: target.entity.name },
|
||||
};
|
||||
} catch (e) {
|
||||
warn("action", `survive.acquire-food failed: ${e.message}`);
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
// survive.dig-in — emergency night shelter when displaced with no bed/base
|
||||
// (research QW8, §3 "If displaced at night"). The classic survival move: dig
|
||||
// straight down a couple of blocks for cover, cap the hole with a placed
|
||||
// block, and wait out the night. Mobs can't path into a sealed 1-wide hole.
|
||||
//
|
||||
// This is intentionally conservative and safety-gated: it refuses to dig into
|
||||
// lava/water/void and never digs deeper than 3. The cap is best-effort —
|
||||
// placement timing is finicky and we never want to FAIL the skill (and bounce
|
||||
// the bot back to wandering at night) just because the roof block didn't seat;
|
||||
// being two blocks underground is already far safer than standing in the open.
|
||||
|
||||
import { info, warn } from "../log.js";
|
||||
|
||||
const CAP_PREFERENCE = [
|
||||
"dirt", "cobblestone", "stone", "andesite", "diorite", "granite",
|
||||
"cobbled_deepslate", "deepslate", "sand", "gravel", "netherrack",
|
||||
"oak_planks", "spruce_planks", "birch_planks", "dark_oak_planks",
|
||||
"jungle_planks", "acacia_planks", "mangrove_planks", "cherry_planks",
|
||||
];
|
||||
|
||||
const UNSAFE_BELOW = new Set([
|
||||
"lava", "flowing_lava", "water", "flowing_water", "bedrock",
|
||||
"air", "cave_air", "void_air",
|
||||
]);
|
||||
|
||||
export function pickCapBlock(bot) {
|
||||
const items = bot.inventory?.items?.() ?? [];
|
||||
for (const name of CAP_PREFERENCE) {
|
||||
const found = items.find((i) => i.name === name && i.count > 0);
|
||||
if (found) return found;
|
||||
}
|
||||
return items.find((i) => /(_planks|_log|_wool|cobble|stone|dirt|sand|gravel|netherrack)$/i.test(i.name)) ?? null;
|
||||
}
|
||||
|
||||
// Safe to dig the block directly below: it must be a solid, non-hazard block
|
||||
// (don't open a hole into lava/water, don't waste a dig on air/bedrock).
|
||||
export function safeToDigBelow(bot) {
|
||||
const pos = bot.entity?.position;
|
||||
if (!pos) return { ok: false, reason: "no position" };
|
||||
const below = bot.blockAt?.(pos.offset(0, -1, 0));
|
||||
if (!below) return { ok: false, reason: "no block below" };
|
||||
if (UNSAFE_BELOW.has(below.name)) return { ok: false, reason: `unsafe below: ${below.name}` };
|
||||
return { ok: true, block: below };
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function attemptCap(bot) {
|
||||
const pos = bot.entity?.position;
|
||||
if (!pos) return false;
|
||||
// Reference any solid block adjacent at the bot's head level; place onto
|
||||
// its top face to seal the column above the bot.
|
||||
const head = pos.offset(0, 1, 0);
|
||||
for (const [dx, dz] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
|
||||
const ref = bot.blockAt?.(head.offset(dx, 0, dz));
|
||||
if (ref && !UNSAFE_BELOW.has(ref.name) && ref.name !== "air") {
|
||||
try {
|
||||
await bot.placeBlock(ref, { x: 0, y: 1, z: 0 });
|
||||
return true;
|
||||
} catch {
|
||||
// try next reference
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export const skill = Object.freeze({
|
||||
id: "survive.dig-in",
|
||||
title: "Dig in for the night",
|
||||
timeoutMs: 30_000,
|
||||
preconditions(ctx) {
|
||||
if (!ctx?.bot) return { ok: false, code: "no_bot", detail: "bot missing" };
|
||||
if (!pickCapBlock(ctx.bot)) {
|
||||
return { ok: false, code: "missing_material", detail: "no placeable cap block (dirt/cobble/planks)" };
|
||||
}
|
||||
const safe = safeToDigBelow(ctx.bot);
|
||||
if (!safe.ok) return { ok: false, code: "unsafe_dig", detail: safe.reason };
|
||||
return { ok: true };
|
||||
},
|
||||
async execute(ctx, args = {}) {
|
||||
const bot = ctx.bot;
|
||||
try { bot.pathfinder?.setGoal?.(null); } catch {}
|
||||
|
||||
const depth = Math.max(1, Math.min(args?.depth ?? 2, 3));
|
||||
let dug = 0;
|
||||
let lastReason = null;
|
||||
for (let i = 0; i < depth; i++) {
|
||||
const safe = safeToDigBelow(bot);
|
||||
if (!safe.ok) { lastReason = safe.reason; break; }
|
||||
try {
|
||||
await bot.dig(safe.block);
|
||||
dug++;
|
||||
await sleep(300); // let the bot drop into the new hole
|
||||
} catch (e) {
|
||||
lastReason = e?.message ?? String(e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (dug === 0) {
|
||||
return { ok: false, code: "no_progress", detail: lastReason ?? "could not dig down", worldDelta: null };
|
||||
}
|
||||
|
||||
let capped = false;
|
||||
const cap = pickCapBlock(bot);
|
||||
if (cap) {
|
||||
try { await bot.equip(cap, "hand"); } catch {}
|
||||
try { await bot.look(bot.entity.yaw, Math.PI / 2, true); } catch {}
|
||||
capped = await attemptCap(bot);
|
||||
}
|
||||
|
||||
info("action", `dig-in: dug ${dug} down, capped=${capped}`);
|
||||
return {
|
||||
ok: true,
|
||||
code: "done",
|
||||
detail: { dug, capped, reason: lastReason },
|
||||
worldDelta: { dugDown: dug, capped, mode: "dig-in" },
|
||||
};
|
||||
},
|
||||
recover(ctx, result) {
|
||||
if (result.code === "missing_material") {
|
||||
return { hint: "wander", reason: "no cap block — gather dirt/cobble before nightfall" };
|
||||
}
|
||||
return null;
|
||||
},
|
||||
});
|
||||
|
||||
export const __testing = { pickCapBlock, safeToDigBelow, attemptCap, CAP_PREFERENCE, UNSAFE_BELOW };
|
||||
@@ -0,0 +1,75 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { skill, __testing } from "./dig-in.js";
|
||||
|
||||
function vec(x, y, z) {
|
||||
return {
|
||||
x, y, z,
|
||||
offset(dx, dy, dz) { return vec(x + dx, y + dy, z + dz); },
|
||||
};
|
||||
}
|
||||
|
||||
function fakeBot({ belowName = "dirt", sideName = "stone", items = [{ name: "dirt", count: 10, type: 3 }] } = {}) {
|
||||
const calls = { dig: 0, place: 0, equip: 0 };
|
||||
return {
|
||||
calls,
|
||||
entity: { position: vec(0, 64, 0), yaw: 0 },
|
||||
inventory: { items: () => items },
|
||||
blockAt(p) {
|
||||
// below (y-1) → belowName; same-level neighbours → sideName
|
||||
if (p.y === 63) return { name: belowName, position: p };
|
||||
return { name: sideName, position: p };
|
||||
},
|
||||
async dig() { calls.dig++; },
|
||||
async placeBlock() { calls.place++; },
|
||||
async equip() { calls.equip++; },
|
||||
async look() {},
|
||||
pathfinder: { setGoal() {} },
|
||||
};
|
||||
}
|
||||
|
||||
test("pickCapBlock prefers dirt", () => {
|
||||
const bot = fakeBot({ items: [{ name: "cobblestone", count: 3 }, { name: "dirt", count: 1 }] });
|
||||
assert.equal(__testing.pickCapBlock(bot).name, "dirt");
|
||||
});
|
||||
|
||||
test("safeToDigBelow refuses lava/water/bedrock/air", () => {
|
||||
for (const bad of ["lava", "water", "bedrock", "air"]) {
|
||||
const bot = fakeBot({ belowName: bad });
|
||||
assert.equal(__testing.safeToDigBelow(bot).ok, false, bad);
|
||||
}
|
||||
assert.equal(__testing.safeToDigBelow(fakeBot({ belowName: "dirt" })).ok, true);
|
||||
});
|
||||
|
||||
test("preconditions fail without a cap block", () => {
|
||||
const bot = fakeBot({ items: [{ name: "raw_chicken", count: 1 }] });
|
||||
const pre = skill.preconditions({ bot });
|
||||
assert.equal(pre.ok, false);
|
||||
assert.equal(pre.code, "missing_material");
|
||||
});
|
||||
|
||||
test("preconditions fail when below is unsafe", () => {
|
||||
const bot = fakeBot({ belowName: "lava" });
|
||||
const pre = skill.preconditions({ bot });
|
||||
assert.equal(pre.ok, false);
|
||||
assert.equal(pre.code, "unsafe_dig");
|
||||
});
|
||||
|
||||
test("execute digs down to depth and caps the hole", async () => {
|
||||
const bot = fakeBot();
|
||||
const res = await skill.execute({ bot }, { depth: 2 });
|
||||
assert.equal(res.ok, true);
|
||||
assert.equal(res.worldDelta.dugDown, 2);
|
||||
assert.equal(res.worldDelta.capped, true);
|
||||
assert.equal(bot.calls.dig, 2);
|
||||
assert.ok(bot.calls.place >= 1);
|
||||
});
|
||||
|
||||
test("execute stops digging when it hits something unsafe mid-dig", async () => {
|
||||
// below is water → first safeToDigBelow already false → dug 0 → no_progress
|
||||
const bot = fakeBot({ belowName: "water" });
|
||||
const res = await skill.execute({ bot }, { depth: 3 });
|
||||
assert.equal(res.ok, false);
|
||||
assert.equal(res.code, "no_progress");
|
||||
});
|
||||
@@ -39,7 +39,7 @@ export const skill = Object.freeze({
|
||||
async execute(ctx, args = {}) {
|
||||
const hit = nearestHostile(ctx.bot, args);
|
||||
if (!hit) return { ok: false, code: "no_hostile", detail: "no matching hostile after precondition", worldDelta: null };
|
||||
const res = await fleeFrom(ctx.bot, hit.entity, args.distance ?? 16);
|
||||
const res = await fleeFrom(ctx.bot, hit.entity, args.distance ?? 16, { motion: ctx.motion, blindMs: args.blindMs });
|
||||
if (res.ok) {
|
||||
return {
|
||||
ok: true,
|
||||
@@ -48,8 +48,10 @@ export const skill = Object.freeze({
|
||||
worldDelta: { fledTo: res.detail?.to ?? null },
|
||||
};
|
||||
}
|
||||
// Prefer the structured code from MotionService (stuck/timeout/nopath);
|
||||
// fall back to string-sniffing the legacy path's message.
|
||||
const msg = String(res.detail ?? "");
|
||||
const code = msg.includes("timed out") ? "timeout" : "failed";
|
||||
const code = res.code ?? (msg.includes("timed out") ? "timeout" : "failed");
|
||||
return { ok: false, code, detail: res.detail, worldDelta: null };
|
||||
},
|
||||
recover(ctx, result) {
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { skill } from "./flee.js";
|
||||
|
||||
function vec(x, y, z) {
|
||||
return {
|
||||
x, y, z,
|
||||
clone() { return vec(x, y, z); },
|
||||
distanceTo(o) { return Math.hypot(x - o.x, y - o.y, z - o.z); },
|
||||
offset(dx, dy, dz) { return vec(x + dx, y + dy, z + dz); },
|
||||
};
|
||||
}
|
||||
|
||||
function fakeBot({ moveOnForward = 0 } = {}) {
|
||||
const bot = {
|
||||
entity: { position: vec(0, 64, 0), yaw: 0 },
|
||||
entities: { z1: { name: "zombie", position: vec(2, 64, 0) } },
|
||||
loadPlugin() {},
|
||||
pathfinder: { goto: () => new Promise(() => {}), stop() {}, setMovements() {} },
|
||||
setControlState(name, on) {
|
||||
if (name === "forward" && on && moveOnForward) {
|
||||
bot.entity.position = vec(moveOnForward, 64, 0);
|
||||
}
|
||||
},
|
||||
async look() {},
|
||||
};
|
||||
return bot;
|
||||
}
|
||||
|
||||
test("flee returns done when motion reaches the retreat point", async () => {
|
||||
const bot = fakeBot();
|
||||
const ctx = { bot, motion: { gotoSafe: async () => ({ ok: true, code: "reached", movedBlocks: 16 }) } };
|
||||
const res = await skill.execute(ctx, {});
|
||||
assert.equal(res.ok, true);
|
||||
assert.equal(res.code, "done");
|
||||
assert.ok(res.worldDelta.fledTo);
|
||||
});
|
||||
|
||||
test("flee falls back to blind retreat and succeeds when it moves far enough", async () => {
|
||||
const bot = fakeBot({ moveOnForward: 8 });
|
||||
const ctx = { bot, motion: { gotoSafe: async () => ({ ok: false, code: "stuck", movedBlocks: 0 }) } };
|
||||
const res = await skill.execute(ctx, { blindMs: 20 });
|
||||
assert.equal(res.ok, true);
|
||||
assert.equal(res.detail.mode, "blind-retreat");
|
||||
});
|
||||
|
||||
test("flee surfaces the structured motion code when stuck and blind retreat fails", async () => {
|
||||
const bot = fakeBot({ moveOnForward: 0 }); // never moves
|
||||
const ctx = { bot, motion: { gotoSafe: async () => ({ ok: false, code: "stuck", movedBlocks: 0 }) } };
|
||||
const res = await skill.execute(ctx, { blindMs: 20 });
|
||||
assert.equal(res.ok, false);
|
||||
assert.equal(res.code, "stuck");
|
||||
});
|
||||
|
||||
test("flee precondition fails with no hostile", () => {
|
||||
const bot = fakeBot();
|
||||
bot.entities = {};
|
||||
const pre = skill.preconditions({ bot }, {});
|
||||
assert.equal(pre.ok, false);
|
||||
assert.equal(pre.code, "no_hostile");
|
||||
});
|
||||
@@ -30,6 +30,7 @@ import { skill as flee } from "./flee.js";
|
||||
import { skill as sleep } from "./sleep.js";
|
||||
import { skill as tunnelOut } from "./recovery-tunnel-out.js";
|
||||
import { skill as pillarUp } from "./pillar-up.js";
|
||||
import { skill as digIn } from "./dig-in.js";
|
||||
import { skill as escapePitSafe } from "./escape-pit-safe.js";
|
||||
import { skill as diagPhysics } from "./diagnose-physics.js";
|
||||
import { skill as diagScan, matchSkill as diagMatch } from "./diagnose-scan.js";
|
||||
@@ -77,6 +78,7 @@ register(flee);
|
||||
register(sleep);
|
||||
register(tunnelOut);
|
||||
register(pillarUp);
|
||||
register(digIn);
|
||||
register(escapePitSafe);
|
||||
register(diagPhysics);
|
||||
register(diagScan);
|
||||
@@ -128,6 +130,18 @@ export const RUNNER_CODES = Object.freeze({
|
||||
DONE: "done",
|
||||
});
|
||||
|
||||
// Signed inventory diff between two count Maps (from InventoryLedger.mark/
|
||||
// snapshot). Used to attach the real world change to a skill result.
|
||||
function invDiff(before, after) {
|
||||
const out = {};
|
||||
const names = new Set([...(before?.keys?.() ?? []), ...(after?.keys?.() ?? [])]);
|
||||
for (const n of names) {
|
||||
const d = (after?.get?.(n) ?? 0) - (before?.get?.(n) ?? 0);
|
||||
if (d !== 0) out[n] = d;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function normaliseResult(res, fallbackCode) {
|
||||
const ok = !!res?.ok;
|
||||
return {
|
||||
@@ -220,6 +234,12 @@ export async function runSkill(id, ctx, args = {}) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// WorldDelta diff layer (research §TL;DR): snapshot the inventory before
|
||||
// execute so we can attach the REAL inventory change to the result and,
|
||||
// for skills that opt in via `expectGain`, assert the claimed gain actually
|
||||
// happened instead of trusting the skill's own bookkeeping.
|
||||
const ledgerBefore = ctx?.ledger?.mark?.() ?? null;
|
||||
|
||||
const timeoutMs = skill.timeoutMs ?? 30_000;
|
||||
let raw;
|
||||
try {
|
||||
@@ -275,6 +295,31 @@ export async function runSkill(id, ctx, args = {}) {
|
||||
return failed;
|
||||
}
|
||||
}
|
||||
// Closed loop: compare the inventory now vs the pre-execute baseline.
|
||||
if (result.ok && ledgerBefore && ctx?.ledger) {
|
||||
try { if (ctx.bot) ctx.ledger.update(ctx.bot); } catch {}
|
||||
const observed = invDiff(ledgerBefore, ctx.ledger.snapshot());
|
||||
if (Object.keys(observed).length > 0) {
|
||||
result.worldDelta = { ...(result.worldDelta ?? {}), _invObserved: observed };
|
||||
}
|
||||
// Opt-in strict check: the world must show the claimed gain.
|
||||
if (skill.expectGain) {
|
||||
const gain = ctx.ledger.gainedSince(ledgerBefore, skill.expectGain.matcher);
|
||||
if (gain < (skill.expectGain.min ?? 1)) {
|
||||
const failed = {
|
||||
ok: false,
|
||||
code: "world_unchanged",
|
||||
detail: `${id} reported ok but ${skill.expectGain.label ?? "expected items"} did not increase (gain ${gain})`,
|
||||
worldDelta: result.worldDelta,
|
||||
};
|
||||
if (typeof skill.recover === "function") {
|
||||
try { failed.recovery = skill.recover(ctx, failed) ?? null; } catch {}
|
||||
}
|
||||
return failed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!result.ok && typeof skill.recover === "function") {
|
||||
try {
|
||||
result.recovery = skill.recover(ctx, result) ?? null;
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { runSkill, _registerForTest } from "./index.js";
|
||||
import { createInventoryLedger } from "../services/inventory-ledger.js";
|
||||
|
||||
function botWithMutableInv(initial) {
|
||||
let inv = initial;
|
||||
return {
|
||||
bot: { inventory: { items: () => inv } },
|
||||
set(next) { inv = next; },
|
||||
};
|
||||
}
|
||||
|
||||
test("expectGain passes and attaches the observed inventory delta", async () => {
|
||||
const m = botWithMutableInv([{ name: "cobblestone", count: 0 }]);
|
||||
const ledger = createInventoryLedger();
|
||||
ledger.update(m.bot);
|
||||
const teardown = _registerForTest({
|
||||
id: "test.mine-ok",
|
||||
title: "t", timeoutMs: 1000,
|
||||
preconditions: () => ({ ok: true }),
|
||||
execute: async () => { m.set([{ name: "cobblestone", count: 5 }]); return { ok: true, code: "done", worldDelta: {} }; },
|
||||
expectGain: { matcher: "cobblestone", min: 1, label: "cobblestone" },
|
||||
});
|
||||
const res = await runSkill("test.mine-ok", { bot: m.bot, ledger });
|
||||
teardown();
|
||||
assert.equal(res.ok, true);
|
||||
assert.equal(res.worldDelta._invObserved.cobblestone, 5);
|
||||
});
|
||||
|
||||
test("expectGain fails with world_unchanged when the world did not move", async () => {
|
||||
const m = botWithMutableInv([{ name: "cobblestone", count: 0 }]);
|
||||
const ledger = createInventoryLedger();
|
||||
ledger.update(m.bot);
|
||||
const teardown = _registerForTest({
|
||||
id: "test.mine-liar",
|
||||
title: "t", timeoutMs: 1000,
|
||||
preconditions: () => ({ ok: true }),
|
||||
execute: async () => ({ ok: true, code: "done", worldDelta: {} }), // claims ok, gains nothing
|
||||
expectGain: { matcher: "cobblestone", min: 1, label: "cobblestone" },
|
||||
});
|
||||
const res = await runSkill("test.mine-liar", { bot: m.bot, ledger });
|
||||
teardown();
|
||||
assert.equal(res.ok, false);
|
||||
assert.equal(res.code, "world_unchanged");
|
||||
});
|
||||
|
||||
test("no ledger in ctx → no validation, skill passes untouched", async () => {
|
||||
const teardown = _registerForTest({
|
||||
id: "test.no-ledger",
|
||||
title: "t", timeoutMs: 1000,
|
||||
preconditions: () => ({ ok: true }),
|
||||
execute: async () => ({ ok: true, code: "done", worldDelta: { foo: 1 } }),
|
||||
expectGain: { matcher: "diamond", min: 1 },
|
||||
});
|
||||
const res = await runSkill("test.no-ledger", { bot: { inventory: { items: () => [] } } });
|
||||
teardown();
|
||||
assert.equal(res.ok, true);
|
||||
assert.equal(res.worldDelta.foo, 1);
|
||||
});
|
||||
|
||||
test("observed delta is attached even without expectGain", async () => {
|
||||
const m = botWithMutableInv([{ name: "oak_log", count: 2 }]);
|
||||
const ledger = createInventoryLedger();
|
||||
ledger.update(m.bot);
|
||||
const teardown = _registerForTest({
|
||||
id: "test.observe-only",
|
||||
title: "t", timeoutMs: 1000,
|
||||
preconditions: () => ({ ok: true }),
|
||||
execute: async () => { m.set([{ name: "oak_log", count: 6 }]); return { ok: true, code: "done", worldDelta: null }; },
|
||||
});
|
||||
const res = await runSkill("test.observe-only", { bot: m.bot, ledger });
|
||||
teardown();
|
||||
assert.equal(res.ok, true);
|
||||
assert.equal(res.worldDelta._invObserved.oak_log, 4);
|
||||
});
|
||||
+28
-21
@@ -149,16 +149,16 @@ function StatusHeader({ snapshot, connectedIpc, width, startedAt }: { snapshot:
|
||||
const day = snapshot.isDay ? "☀" : "🌙";
|
||||
const session = formatDuration(Date.now() - startedAt);
|
||||
const mcOnline = snapshot.connected;
|
||||
const story = snapshot.storyStep;
|
||||
const idx = story?.index ?? 0;
|
||||
const cur = story?.step;
|
||||
const want = story?.suggestion?.skillId;
|
||||
// 11-step progress bar in 11 cells
|
||||
const bar = Array.from({ length: 11 }, (_, i) => {
|
||||
if (i < idx) return "▓";
|
||||
if (i === idx) return "▒";
|
||||
return "░";
|
||||
}).join("");
|
||||
// v0.4.0 — the Settlement Contract is the progression authority. Show its
|
||||
// milestone, completed/total, suggested skill and the Village Score.
|
||||
const contract = snapshot.contract;
|
||||
const vs = snapshot.villageScore;
|
||||
const cdone = contract?.completed ?? 0;
|
||||
const ctotal = contract?.total ?? 0;
|
||||
const cbar = Array.from({ length: ctotal || 10 }, (_, i) => (i < cdone ? "▓" : "░")).join("");
|
||||
const cmile = contract?.milestone;
|
||||
const cwant = contract?.suggestedSkill?.skillId;
|
||||
const vspct = vs ? Math.round(vs.score * 100) : null;
|
||||
return (
|
||||
<Box flexDirection="column" borderStyle="round" borderColor="cyan" paddingX={1} width={width}>
|
||||
<Box>
|
||||
@@ -188,25 +188,32 @@ function StatusHeader({ snapshot, connectedIpc, width, startedAt }: { snapshot:
|
||||
) : null}
|
||||
</Box>
|
||||
<Box>
|
||||
<Text color="magenta">story </Text>
|
||||
<Text bold>{bar}</Text>
|
||||
<Text color="magenta">build </Text>
|
||||
<Text bold>{cbar}</Text>
|
||||
<Text dimColor> </Text>
|
||||
<Text bold>{idx + 1}/11</Text>
|
||||
<Text bold>{cdone}/{ctotal}</Text>
|
||||
<Text dimColor> </Text>
|
||||
{cur ? (
|
||||
{contract?.done ? (
|
||||
<Text color="green" bold>settlement complete</Text>
|
||||
) : cmile ? (
|
||||
<>
|
||||
<Text color="white" bold>{cur.id}</Text>
|
||||
<Text color="white" bold>{cmile.id}</Text>
|
||||
<Text dimColor> · </Text>
|
||||
<Text>{cur.title}</Text>
|
||||
<Text>{cmile.title}</Text>
|
||||
</>
|
||||
) : <Text dimColor>(no story)</Text>}
|
||||
{want ? (
|
||||
) : <Text dimColor>(no contract)</Text>}
|
||||
{cwant ? (
|
||||
<>
|
||||
<Text dimColor> → </Text>
|
||||
<Text color="cyan">{want}</Text>
|
||||
<Text color="cyan">{cwant}</Text>
|
||||
</>
|
||||
) : null}
|
||||
{vspct != null ? (
|
||||
<>
|
||||
<Text dimColor> · </Text>
|
||||
<Text color="yellow">VS {vspct}%</Text>
|
||||
</>
|
||||
) : null}
|
||||
{story?.emergency ? <Text color="red" bold> [EMERGENCY]</Text> : null}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
@@ -445,7 +452,7 @@ function App() {
|
||||
|
||||
// Layout math: compact 4-section vertical stack.
|
||||
// row budget:
|
||||
// header (story + status) ≈ 4 rows
|
||||
// header (contract/build + status) ≈ 4 rows
|
||||
// middle activity/chat ≈ floor((rows - 4 - 4 - 4) / 2)
|
||||
// bottom advisor/improvements ≈ same
|
||||
// footer ≈ 4 rows
|
||||
|
||||
Reference in New Issue
Block a user