diff --git a/dev/v0.3.0/STATUS.md b/dev/v0.3.0/STATUS.md index 2e41401..2c60554 100644 --- a/dev/v0.3.0/STATUS.md +++ b/dev/v0.3.0/STATUS.md @@ -55,7 +55,59 @@ Tests: 279 green (was 257 on rc.3). Added: - `runtime/llm/provider.test.js` — 9 tests - `runtime/coach/fast-advisor.test.js` — 10 tests -### rc.2 — (pending) Manifesto / Needs ladder +### rc.2 — Manifesto / Needs ladder L0-L10 +**Root problem solved**: pre-v0.3.0 the bot had no notion of intermediate +goals. The curriculum produced a single "next milestone" but no +hierarchy. So when the bot was wedged with no pickaxe, it kept trying +`explore.far` instead of recognising "I need wood → planks → pickaxe +first". Lessons from Pi couldn't help because there was no +internal-state language to express "L2 not satisfied". + +The needs ladder gives the bot an explicit, ordered list of survival +concerns. Each reflex tick picks the LOWEST unsatisfied need and +dispatches a concrete skill toward it. + +``` +L0 alive HP>5, food>0, no lava, no creeper@close +L1 food ≥6 food items in inventory (or hungry+have any) +L2 tools_wood wooden_pickaxe + wooden_axe + wooden_sword +L3 shelter_basic bed placed nearby or in inventory +L4 tools_stone stone tier (pickaxe + axe + sword) +L5 armor_basic any chestplate equipped (pursue=null for now) +L6 food_security ≥16 food items +L7 tools_iron iron tier (pursue=gather.stone until craft.iron-* lands) +L8 armor_iron iron chestplate (pursue=null for now) +L9 village_seed bed + chest nearby +L10 village_full global goal (never detected, falls through to curriculum) +``` + +- [`runtime/manifesto/needs.js`](../../runtime/manifesto/needs.js) — + catalogue of 11 needs. Each has `detect(snapshot)` and + `pursue(snapshot)`. Pursue can return `null` (e.g. armor levels) and + the ladder gracefully skips, recording the level as "blocked". +- [`runtime/manifesto/state.js`](../../runtime/manifesto/state.js) — + `pickActiveNeed(snapshot)` walks the ladder, picks the first + unsatisfied + pursuable need. Returns `{need, skillId, args, blockedNeeds}`. + 3-second cache to avoid re-walking the ladder on every micro-tick. + Validates `skillId` against the live registry (rc.1 piece) before + returning — manifesto can't ship a hallucinated id. +- [`runtime/reflex.js`](../../runtime/reflex.js): + - `curriculumReflex` now consults manifesto FIRST. If a need dictates + a skill, that's what gets dispatched. The curriculum plan is the + fallback when manifesto has no concrete pursue. + - Tests can pass `ctx.disableManifesto = true` to exercise the + curriculum branch in isolation. +- [`runtime/coach/reflect.js`](../../runtime/coach/reflect.js) — Pi + self-reflection prompt now includes the active need + (`L2 tools_wood → gather.logs (Деревянные орудия)`) so Pi can give + level-appropriate advice instead of generic suggestions. + +Tests: 315 green (was 279 on rc.1, +36 new): +- `runtime/manifesto/needs.test.js` — 24 tests (one per need detect/pursue) +- `runtime/manifesto/state.test.js` — 10 tests (ladder walk, caching, skipping) +- `runtime/reflex.test.js` — 2 new integration tests (manifesto-on + overrides curriculum; well-fed bot pursues tools_stone) + ### rc.3 — (pending) Event-driven awareness + skill pre-emption ## Next session quick start diff --git a/package.json b/package.json index a1653f5..8934550 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pepa-pi-bot", - "version": "0.3.0-rc.1", + "version": "0.3.0-rc.2", "private": true, "description": "An autonomous, self-extending Minecraft player powered by Pi and Mineflayer.", "license": "MIT", @@ -16,7 +16,7 @@ "tui": "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/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/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/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/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/persona/chatter.test.js scripts/edit-scope.test.js scripts/lint-patch.test.js" }, "dependencies": { "better-sqlite3": "^11.10.0", diff --git a/runtime/coach/reflect.js b/runtime/coach/reflect.js index e1f3376..16033bb 100644 --- a/runtime/coach/reflect.js +++ b/runtime/coach/reflect.js @@ -17,6 +17,7 @@ import { resolve } from "node:path"; import { isAvailable as knowledgeAvailable, record as recordLesson } from "../knowledge/index.js"; import { isRegistered, skillRegistryPrompt } from "../skill-registry.js"; +import { pickActiveNeed } from "../manifesto/state.js"; import { info, warn } from "../log.js"; // Mode-name allow-list, mirrors postmortem.js (advice.js maps them to @@ -76,8 +77,9 @@ export async function runOnce({ stateDir, askPi, getSnapshot, force = false } = const scenarios = readScenarioTail(stateDir); const diary = readDiaryTail(stateDir); const plan = readPlan(stateDir); + const activeNeed = pickActiveNeed(snap); - const prompt = buildPrompt({ snap, journal, scenarios, diary, plan }); + const prompt = buildPrompt({ snap, journal, scenarios, diary, plan, activeNeed }); _piCallTimes.push(now); const reply = await askPiOnce({ askPi, prompt }); @@ -153,10 +155,13 @@ function readPlan(stateDir) { try { return readFileSync(f, "utf8"); } catch { return ""; } } -function buildPrompt({ snap, journal, scenarios, diary, plan }) { +function buildPrompt({ snap, journal, scenarios, diary, plan, activeNeed }) { const pos = snap?.position; const inv = snap?.inventory ? Object.keys(snap.inventory).slice(0, 12).join(", ") : "(empty)"; const lastResult = snap?.lastResult ? JSON.stringify(snap.lastResult).slice(0, 200) : "(none)"; + const needLine = activeNeed + ? `L${activeNeed.need.level} ${activeNeed.need.id} → ${activeNeed.skillId} (${activeNeed.need.title})` + : "(satisfied through L10 / no active need)"; return [ "You are pepa, an autonomous Minecraft survival bot, reflecting on your own progress.", "Look at the last ~30 minutes of activity below. Answer honestly: are you actually making progress, or stuck in a loop?", @@ -168,6 +173,7 @@ function buildPrompt({ snap, journal, scenarios, diary, plan }) { `- hp: ${snap?.health ?? "?"} food: ${snap?.food ?? "?"} day: ${snap?.isDay ? "yes" : "no"}`, `- runtimeState: ${snap?.runtimeState ?? "?"}`, `- activeSkill: ${snap?.activeSkill ?? "(idle)"}`, + `- activeNeed (Maslow ladder L0-L10): ${needLine}`, `- currentMilestone: ${snap?.currentMilestone ?? "?"}`, `- noProgressReason: ${snap?.noProgressReason ?? "(none)"}`, `- lastResult: ${lastResult}`, diff --git a/runtime/manifesto/needs.js b/runtime/manifesto/needs.js new file mode 100644 index 0000000..750ab45 --- /dev/null +++ b/runtime/manifesto/needs.js @@ -0,0 +1,314 @@ +// Hierarchical needs ladder (Maslow-like). Each need has: +// id stable kebab-case +// level 0-10, ascending priority (0 = most urgent) +// title Russian short label for chat narration +// detect(s) → bool, true means need is already satisfied +// pursue(s) → { skillId, args? } | null, what to do RIGHT NOW +// +// Need ordering matters: state.js picks the LOWEST-level unsatisfied +// need. If pursue() returns null we move on to the next level — that's +// how "I want armour but can't craft it yet" gracefully degrades to +// "go gather more iron". +// +// Snapshot shape comes from runtime/perceive.js#snapshot(). + +const PICKAXE_WOOD = ["wooden_pickaxe"]; +const PICKAXE_STONE = ["stone_pickaxe"]; +const PICKAXE_IRON = ["iron_pickaxe", "diamond_pickaxe", "netherite_pickaxe"]; +const AXE_WOOD = ["wooden_axe"]; +const AXE_STONE = ["stone_axe"]; +const AXE_IRON = ["iron_axe", "diamond_axe", "netherite_axe"]; +const SWORD_WOOD = ["wooden_sword"]; +const SWORD_STONE = ["stone_sword"]; +const SWORD_IRON = ["iron_sword", "diamond_sword", "netherite_sword"]; +const FOOD_ITEMS = [ + "bread", "cooked_beef", "cooked_porkchop", "cooked_chicken", "cooked_mutton", + "cooked_rabbit", "cooked_cod", "cooked_salmon", "baked_potato", + "apple", "carrot", "potato", "beetroot", "melon_slice", "sweet_berries", + "golden_apple", "golden_carrot", +]; +const ARMOR_CHEST_ANY = [ + "leather_chestplate", "iron_chestplate", "golden_chestplate", + "diamond_chestplate", "netherite_chestplate", "chainmail_chestplate", +]; +const ARMOR_IRON_CHEST = ["iron_chestplate"]; +const BED_ITEMS = [ + "white_bed", "orange_bed", "magenta_bed", "light_blue_bed", "yellow_bed", + "lime_bed", "pink_bed", "gray_bed", "light_gray_bed", "cyan_bed", + "purple_bed", "blue_bed", "brown_bed", "green_bed", "red_bed", "black_bed", +]; + +function hasAny(inv, names) { + if (!inv) return false; + for (const n of names) { + if ((inv[n] ?? 0) > 0) return true; + } + return false; +} + +function countAny(inv, names) { + if (!inv) return 0; + let total = 0; + for (const n of names) total += inv[n] ?? 0; + return total; +} + +function countLogs(inv) { + if (!inv) return 0; + let total = 0; + for (const [name, count] of Object.entries(inv)) { + if (name.endsWith("_log")) total += count; + } + return total; +} + +function countPlanks(inv) { + if (!inv) return 0; + let total = 0; + for (const [name, count] of Object.entries(inv)) { + if (name.endsWith("_planks")) total += count; + } + return total; +} + +function hostileImminent(s) { + const h = s?.closestHostile; + if (!h) return false; + return (h.distance ?? Infinity) < 8; +} + +function aliveDetect(s) { + if (!s?.connected) return true; // not connected, nothing to do + const hp = s.health ?? 20; + const food = s.food ?? 20; + if (hp <= 5) return false; + if (food <= 0) return false; + if (s.hazards?.inFluid && s.hazards?.footBlock === "lava") return false; + if (hostileImminent(s) && hp <= 10) return false; + return true; +} + +function alivePursue(s) { + const hp = s.health ?? 20; + const food = s.food ?? 20; + if (s.hazards?.footBlock === "lava") { + return { skillId: "recovery.tunnel-out", args: { reason: "lava" } }; + } + if (food <= 0 && s.hasFood) { + return { skillId: "survive.eat" }; + } + if (food <= 0 && !s.hasFood) { + return { skillId: "survive.acquire-food" }; + } + if (hostileImminent(s)) { + return { skillId: "survive.flee" }; + } + if (hp <= 5) { + return { skillId: "survive.flee" }; + } + return null; +} + +function foodDetect(s) { + if (!s?.connected) return true; + if ((s.food ?? 20) >= 18 && countAny(s.inventory, FOOD_ITEMS) >= 1) return true; + return countAny(s.inventory, FOOD_ITEMS) >= 6; +} + +function foodPursue(s) { + if ((s.food ?? 20) < 16 && s.hasFood) { + return { skillId: "survive.eat" }; + } + return { skillId: "survive.acquire-food" }; +} + +function toolsWoodDetect(s) { + const inv = s?.inventory; + if (!inv) return false; + return hasAny(inv, PICKAXE_WOOD) && hasAny(inv, AXE_WOOD) && hasAny(inv, SWORD_WOOD); +} + +function toolsWoodPursue(s) { + const inv = s.inventory ?? {}; + const planks = countPlanks(inv); + const logs = countLogs(inv); + const sticks = inv.stick ?? 0; + const hasWb = (inv.crafting_table ?? 0) > 0 + || (s.nearbyBlocks?.craftingTable ?? 0) > 0; + + if (logs < 2 && planks < 4 && !hasWb) { + return { skillId: "gather.logs" }; + } + if (planks < 4) { + return { skillId: "craft.planks" }; + } + if (sticks < 2) { + return { skillId: "craft.sticks" }; + } + if (!hasAny(inv, PICKAXE_WOOD)) { + return { skillId: "craft.wooden-pickaxe" }; + } + if (!hasAny(inv, AXE_WOOD)) { + return { skillId: "craft.wooden-axe" }; + } + if (!hasAny(inv, SWORD_WOOD)) { + return { skillId: "craft.wooden-sword" }; + } + return null; +} + +function shelterBasicDetect(s) { + const inv = s?.inventory ?? {}; + const bedPlaced = (s.nearbyBlocks?.beds ?? 0) > 0; + return bedPlaced || hasAny(inv, BED_ITEMS); +} + +function shelterBasicPursue(s) { + const inv = s.inventory ?? {}; + if (!hasAny(inv, BED_ITEMS)) { + const wool = countAny(inv, [ + "white_wool", "orange_wool", "magenta_wool", "light_blue_wool", + "yellow_wool", "lime_wool", "pink_wool", "gray_wool", + "light_gray_wool", "cyan_wool", "purple_wool", "blue_wool", + "brown_wool", "green_wool", "red_wool", "black_wool", + ]); + if (wool >= 3 && countPlanks(inv) >= 3) { + return { skillId: "craft.bed" }; + } + if (wool < 3) { + return { skillId: "gather.wool" }; + } + return { skillId: "gather.logs" }; + } + // Have bed but no shelter — pick a base and build. + const blocksForShelter = countPlanks(inv) + (inv.cobblestone ?? 0) + (inv.dirt ?? 0); + if (blocksForShelter < 12) { + return { skillId: "gather.stone" }; + } + return { skillId: "village.build-shelter" }; +} + +function toolsStoneDetect(s) { + const inv = s?.inventory; + if (!inv) return false; + return hasAny(inv, PICKAXE_STONE) && hasAny(inv, AXE_STONE) && hasAny(inv, SWORD_STONE); +} + +function toolsStonePursue(s) { + const inv = s.inventory ?? {}; + const cobble = inv.cobblestone ?? 0; + const sticks = inv.stick ?? 0; + if (cobble < 4) { + return { skillId: "gather.stone" }; + } + if (sticks < 2) { + return { skillId: "craft.sticks" }; + } + if (!hasAny(inv, PICKAXE_STONE)) { + return { skillId: "craft.stone-pickaxe" }; + } + if (!hasAny(inv, AXE_STONE)) { + return { skillId: "craft.stone-axe" }; + } + if (!hasAny(inv, SWORD_STONE)) { + return { skillId: "craft.stone-sword" }; + } + return null; +} + +function armorBasicDetect(s) { + const equip = s?.equipment ?? {}; + if (equip.torso && ARMOR_CHEST_ANY.includes(equip.torso)) return true; + return hasAny(s.inventory, ARMOR_CHEST_ANY); +} + +function armorBasicPursue(_s) { + // No armor crafting skills registered yet (v0.3.x roadmap). Don't + // stall the ladder — let later needs drive activity. + return null; +} + +function foodSecurityDetect(s) { + return countAny(s?.inventory, FOOD_ITEMS) >= 16; +} + +function foodSecurityPursue(s) { + if ((s.inventory?.wheat_seeds ?? 0) > 0 && (s.nearbyBlocks?.crops ?? 0) > 0) { + return { skillId: "farm.wheat" }; + } + return { skillId: "survive.acquire-food" }; +} + +function toolsIronDetect(s) { + const inv = s?.inventory; + if (!inv) return false; + return hasAny(inv, PICKAXE_IRON) && hasAny(inv, AXE_IRON) && hasAny(inv, SWORD_IRON); +} + +function toolsIronPursue(_s) { + // No iron-tool craft skills registered yet. Direct the bot to keep + // mining — the registry will gain craft.iron-* in a later iteration. + return { skillId: "gather.stone" }; +} + +function armorIronDetect(s) { + const equip = s?.equipment ?? {}; + if (equip.torso === "iron_chestplate") return true; + return hasAny(s.inventory, ARMOR_IRON_CHEST); +} + +function armorIronPursue(_s) { + return null; +} + +function villageSeedDetect(s) { + // Heuristic: at least one chest placed AND one bed placed within + // nearby radius. Tightens later (POIs of kind "structure"). + const nb = s?.nearbyBlocks ?? {}; + return (nb.storage ?? 0) >= 1 && (nb.beds ?? 0) >= 1; +} + +function villageSeedPursue(s) { + const inv = s.inventory ?? {}; + if ((inv.chest ?? 0) === 0 && countPlanks(inv) >= 8) { + return { skillId: "craft.chest" }; + } + if ((inv.chest ?? 0) > 0) { + return { skillId: "village.deposit-surplus" }; + } + return { skillId: "village.build-shelter" }; +} + +function villageFullDetect(_s) { + // Always false — it's the global goal. + return false; +} + +function villageFullPursue(_s) { + // Let the curriculum tackle it (fallback chain). + return null; +} + +export const NEEDS = Object.freeze([ + { id: "alive", level: 0, title: "Остаться живым", detect: aliveDetect, pursue: alivePursue }, + { id: "food", level: 1, title: "Найти еду", detect: foodDetect, pursue: foodPursue }, + { id: "tools_wood", level: 2, title: "Деревянные орудия", detect: toolsWoodDetect, pursue: toolsWoodPursue }, + { id: "shelter_basic", level: 3, title: "Простой шелтер", detect: shelterBasicDetect, pursue: shelterBasicPursue }, + { id: "tools_stone", level: 4, title: "Каменные орудия", detect: toolsStoneDetect, pursue: toolsStonePursue }, + { id: "armor_basic", level: 5, title: "Базовая броня", detect: armorBasicDetect, pursue: armorBasicPursue }, + { id: "food_security", level: 6, title: "Запас еды", detect: foodSecurityDetect, pursue: foodSecurityPursue }, + { id: "tools_iron", level: 7, title: "Железные орудия", detect: toolsIronDetect, pursue: toolsIronPursue }, + { id: "armor_iron", level: 8, title: "Железная броня", detect: armorIronDetect, pursue: armorIronPursue }, + { id: "village_seed", level: 9, title: "Зачаток деревни", detect: villageSeedDetect, pursue: villageSeedPursue }, + { id: "village_full", level: 10, title: "Полная деревня", detect: villageFullDetect, pursue: villageFullPursue }, +]); + +export function getNeed(id) { + return NEEDS.find((n) => n.id === id) ?? null; +} + +// Test exports +export const __testing = { + hasAny, countAny, countLogs, countPlanks, + FOOD_ITEMS, BED_ITEMS, ARMOR_CHEST_ANY, +}; diff --git a/runtime/manifesto/needs.test.js b/runtime/manifesto/needs.test.js new file mode 100644 index 0000000..63e1b88 --- /dev/null +++ b/runtime/manifesto/needs.test.js @@ -0,0 +1,198 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { NEEDS, getNeed, __testing } from "./needs.js"; + +function snap(overrides = {}) { + return { + connected: true, + health: 20, + food: 20, + hasFood: false, + inventory: {}, + equipment: { hand: null, head: null, torso: null, legs: null, feet: null }, + nearbyBlocks: {}, + hazards: { lavaNearby: false, inFluid: false, footBlock: "grass_block", belowBlock: "dirt", headBlock: "air" }, + isDay: true, + hostileCount: 0, + closestHostile: null, + ...overrides, + }; +} + +test("ladder: 11 levels in order, ids unique", () => { + assert.equal(NEEDS.length, 11); + for (let i = 0; i < NEEDS.length; i++) { + assert.equal(NEEDS[i].level, i); + } + const ids = NEEDS.map((n) => n.id); + assert.equal(new Set(ids).size, ids.length); +}); + +test("getNeed: lookup by id", () => { + assert.equal(getNeed("tools_wood").level, 2); + assert.equal(getNeed("doesnt-exist"), null); +}); + +test("L0 alive: full HP and food → satisfied", () => { + const n = getNeed("alive"); + assert.equal(n.detect(snap()), true); + assert.equal(n.pursue(snap()), null); +}); + +test("L0 alive: low HP with close hostile → flee", () => { + const n = getNeed("alive"); + const s = snap({ health: 4, closestHostile: { name: "zombie", distance: 3 } }); + assert.equal(n.detect(s), false); + assert.equal(n.pursue(s).skillId, "survive.flee"); +}); + +test("L0 alive: zero food and have food → eat", () => { + const n = getNeed("alive"); + const s = snap({ food: 0, hasFood: true, inventory: { bread: 3 } }); + assert.equal(n.detect(s), false); + assert.equal(n.pursue(s).skillId, "survive.eat"); +}); + +test("L0 alive: zero food and no food → acquire", () => { + const n = getNeed("alive"); + const s = snap({ food: 0, hasFood: false }); + assert.equal(n.detect(s), false); + assert.equal(n.pursue(s).skillId, "survive.acquire-food"); +}); + +test("L1 food: 6+ food items → satisfied", () => { + const n = getNeed("food"); + assert.equal(n.detect(snap({ food: 10, inventory: { bread: 6 } })), true); + assert.equal(n.detect(snap({ food: 10, inventory: { bread: 3 } })), false); +}); + +test("L1 food: full saturation + any food → satisfied (no panic gathering)", () => { + const n = getNeed("food"); + // food=20 means belly is full; 3 bread is enough until we get hungry again + assert.equal(n.detect(snap({ food: 20, inventory: { bread: 3 } })), true); +}); + +test("L2 tools_wood: starts with no logs → gather.logs", () => { + const n = getNeed("tools_wood"); + const s = snap(); + assert.equal(n.detect(s), false); + assert.equal(n.pursue(s).skillId, "gather.logs"); +}); + +test("L2 tools_wood: has logs but no planks → craft.planks", () => { + const n = getNeed("tools_wood"); + const s = snap({ inventory: { oak_log: 3 } }); + assert.equal(n.pursue(s).skillId, "craft.planks"); +}); + +test("L2 tools_wood: progression to pickaxe → axe → sword", () => { + const n = getNeed("tools_wood"); + // has planks + sticks but no pickaxe + let s = snap({ inventory: { oak_planks: 8, stick: 4 } }); + assert.equal(n.pursue(s).skillId, "craft.wooden-pickaxe"); + // has pickaxe but no axe + s = snap({ inventory: { oak_planks: 8, stick: 4, wooden_pickaxe: 1 } }); + assert.equal(n.pursue(s).skillId, "craft.wooden-axe"); + // pickaxe + axe but no sword + s = snap({ inventory: { oak_planks: 8, stick: 4, wooden_pickaxe: 1, wooden_axe: 1 } }); + assert.equal(n.pursue(s).skillId, "craft.wooden-sword"); + // all three + s = snap({ inventory: { wooden_pickaxe: 1, wooden_axe: 1, wooden_sword: 1 } }); + assert.equal(n.detect(s), true); +}); + +test("L3 shelter_basic: bed nearby → satisfied", () => { + const n = getNeed("shelter_basic"); + assert.equal(n.detect(snap({ nearbyBlocks: { beds: 1 } })), true); + assert.equal(n.detect(snap({ inventory: { red_bed: 1 } })), true); + assert.equal(n.detect(snap()), false); +}); + +test("L3 shelter_basic: no wool → gather.wool", () => { + const n = getNeed("shelter_basic"); + const s = snap({ inventory: { oak_planks: 3 } }); + assert.equal(n.pursue(s).skillId, "gather.wool"); +}); + +test("L3 shelter_basic: enough wool + planks → craft.bed", () => { + const n = getNeed("shelter_basic"); + const s = snap({ inventory: { white_wool: 3, oak_planks: 3 } }); + assert.equal(n.pursue(s).skillId, "craft.bed"); +}); + +test("L4 tools_stone: needs cobblestone first", () => { + const n = getNeed("tools_stone"); + const s = snap({ inventory: { wooden_pickaxe: 1 } }); + assert.equal(n.detect(s), false); + assert.equal(n.pursue(s).skillId, "gather.stone"); +}); + +test("L4 tools_stone: cobble + sticks → craft.stone-pickaxe", () => { + const n = getNeed("tools_stone"); + const s = snap({ inventory: { cobblestone: 6, stick: 4 } }); + assert.equal(n.pursue(s).skillId, "craft.stone-pickaxe"); +}); + +test("L5 armor_basic: torso equipped → satisfied", () => { + const n = getNeed("armor_basic"); + const s = snap({ equipment: { torso: "leather_chestplate" } }); + assert.equal(n.detect(s), true); +}); + +test("L5 armor_basic: no craft skill yet → pursue returns null", () => { + const n = getNeed("armor_basic"); + const s = snap(); + assert.equal(n.detect(s), false); + assert.equal(n.pursue(s), null); +}); + +test("L6 food_security: ≥16 food → satisfied", () => { + const n = getNeed("food_security"); + assert.equal(n.detect(snap({ inventory: { bread: 16 } })), true); + assert.equal(n.detect(snap({ inventory: { bread: 10 } })), false); +}); + +test("L7 tools_iron: always pursues gather.stone (no craft.iron-* yet)", () => { + const n = getNeed("tools_iron"); + const s = snap(); + assert.equal(n.detect(s), false); + assert.equal(n.pursue(s).skillId, "gather.stone"); +}); + +test("L8 armor_iron: iron_chestplate equipped → satisfied", () => { + const n = getNeed("armor_iron"); + assert.equal(n.detect(snap({ equipment: { torso: "iron_chestplate" } })), true); + assert.equal(n.detect(snap({ equipment: { torso: "leather_chestplate" } })), false); +}); + +test("L9 village_seed: bed + storage nearby → satisfied", () => { + const n = getNeed("village_seed"); + assert.equal(n.detect(snap({ nearbyBlocks: { beds: 1, storage: 1 } })), true); +}); + +test("L9 village_seed: no chest → craft.chest if enough planks", () => { + const n = getNeed("village_seed"); + const s = snap({ inventory: { oak_planks: 10 } }); + assert.equal(n.pursue(s).skillId, "craft.chest"); +}); + +test("L10 village_full: never satisfied (global goal)", () => { + const n = getNeed("village_full"); + assert.equal(n.detect(snap()), false); + assert.equal(n.pursue(snap()), null); +}); + +test("helpers: hasAny / countAny work over inventory", () => { + const { hasAny, countAny } = __testing; + const inv = { bread: 3, cooked_beef: 1 }; + assert.equal(hasAny(inv, ["bread", "apple"]), true); + assert.equal(hasAny(inv, ["apple"]), false); + assert.equal(countAny(inv, ["bread", "cooked_beef"]), 4); +}); + +test("helpers: countLogs / countPlanks sum across variants", () => { + const { countLogs, countPlanks } = __testing; + assert.equal(countLogs({ oak_log: 3, birch_log: 2, dirt: 5 }), 5); + assert.equal(countPlanks({ oak_planks: 4, birch_planks: 2 }), 6); +}); diff --git a/runtime/manifesto/state.js b/runtime/manifesto/state.js new file mode 100644 index 0000000..d0e46db --- /dev/null +++ b/runtime/manifesto/state.js @@ -0,0 +1,91 @@ +// Manifesto state: cached "active need" for the current tick. +// +// Each reflex pass calls pickActiveNeed(snapshot) — it walks the +// needs ladder from level 0 upward and returns the FIRST need whose +// detect() is false AND whose pursue() returns a non-null skill id. +// Needs whose pursue() returns null (e.g. armour while we lack craft +// skills) are recorded as "blocked at this level" but the ladder +// continues — that way the bot still makes progress on lower-priority +// concerns instead of stalling. + +import { NEEDS, getNeed } from "./needs.js"; +import { isRegistered } from "../skill-registry.js"; +import { info } from "../log.js"; + +const CACHE_TTL_MS = 3_000; + +let _cache = null; +let _lastNeedId = null; + +export function _resetForTest() { + _cache = null; + _lastNeedId = null; +} + +/** + * pickActiveNeed(snapshot) → + * { + * need: { id, level, title }, + * skillId: string, // dispatch this skill + * args: object | undefined, + * blockedNeeds: Array<{id, level}> // needs above this one whose pursue=null + * } | null + * + * Returns null only when *every* need is satisfied (i.e. village_full + * is detected, which is never true in practice — global goal). In + * that case callers should fall back to the curriculum. + */ +export function pickActiveNeed(snapshot) { + if (!snapshot?.connected) return null; + const now = Date.now(); + if (_cache && _cache.snapshot === snapshot && now - _cache.ts < CACHE_TTL_MS) { + return _cache.result; + } + const blocked = []; + let chosen = null; + for (const need of NEEDS) { + let satisfied; + try { + satisfied = !!need.detect(snapshot); + } catch (e) { + info("manifesto", `need ${need.id}.detect threw: ${e?.message ?? e}`); + satisfied = true; + } + if (satisfied) continue; + let plan; + try { + plan = need.pursue(snapshot); + } catch (e) { + info("manifesto", `need ${need.id}.pursue threw: ${e?.message ?? e}`); + plan = null; + } + if (!plan || !plan.skillId) { + blocked.push({ id: need.id, level: need.level }); + continue; + } + if (!isRegistered(plan.skillId)) { + info("manifesto", `need ${need.id}: pursue suggested unknown skill ${plan.skillId}; skipping`); + blocked.push({ id: need.id, level: need.level }); + continue; + } + chosen = { need: { id: need.id, level: need.level, title: need.title }, skillId: plan.skillId, args: plan.args, blockedNeeds: blocked }; + break; + } + if (chosen) { + if (_lastNeedId !== chosen.need.id) { + info("manifesto", `active need: L${chosen.need.level} ${chosen.need.id} → ${chosen.skillId}`); + _lastNeedId = chosen.need.id; + } + } + _cache = { snapshot, ts: now, result: chosen }; + return chosen; +} + +export function describeActiveNeed(snapshot) { + const a = pickActiveNeed(snapshot); + if (!a) return null; + return `L${a.need.level} ${a.need.id} → ${a.skillId}`; +} + +// Re-export ladder for callers that want to enumerate. +export { NEEDS, getNeed }; diff --git a/runtime/manifesto/state.test.js b/runtime/manifesto/state.test.js new file mode 100644 index 0000000..22960f2 --- /dev/null +++ b/runtime/manifesto/state.test.js @@ -0,0 +1,116 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { pickActiveNeed, describeActiveNeed, _resetForTest } from "./state.js"; + +function snap(overrides = {}) { + return { + connected: true, + health: 20, + food: 20, + hasFood: false, + inventory: {}, + equipment: { hand: null, head: null, torso: null, legs: null, feet: null }, + nearbyBlocks: {}, + hazards: { footBlock: "grass_block", belowBlock: "dirt", headBlock: "air" }, + isDay: true, + hostileCount: 0, + closestHostile: null, + ...overrides, + }; +} + +test("pickActiveNeed: disconnected → null", () => { + _resetForTest(); + assert.equal(pickActiveNeed({ connected: false }), null); + assert.equal(pickActiveNeed(null), null); +}); + +test("pickActiveNeed: fresh spawn → L0 alive if zero food", () => { + _resetForTest(); + const a = pickActiveNeed(snap({ food: 0 })); + assert.equal(a.need.id, "alive"); + assert.equal(a.skillId, "survive.acquire-food"); +}); + +test("pickActiveNeed: hp ok, no food in inventory → L1 food (acquire)", () => { + _resetForTest(); + const a = pickActiveNeed(snap()); + assert.equal(a.need.id, "food"); + assert.equal(a.skillId, "survive.acquire-food"); +}); + +test("pickActiveNeed: food covered → L2 tools_wood (gather logs)", () => { + _resetForTest(); + const a = pickActiveNeed(snap({ inventory: { bread: 8 } })); + assert.equal(a.need.id, "tools_wood"); + assert.equal(a.skillId, "gather.logs"); +}); + +test("pickActiveNeed: tools wood done → L3 shelter (gather wool)", () => { + _resetForTest(); + const a = pickActiveNeed(snap({ + inventory: { + bread: 8, + wooden_pickaxe: 1, wooden_axe: 1, wooden_sword: 1, + }, + })); + assert.equal(a.need.id, "shelter_basic"); + // no wool, no bed → gather.wool + assert.equal(a.skillId, "gather.wool"); +}); + +test("pickActiveNeed: shelter done → L4 tools_stone", () => { + _resetForTest(); + const a = pickActiveNeed(snap({ + nearbyBlocks: { beds: 1 }, + inventory: { + bread: 8, + wooden_pickaxe: 1, wooden_axe: 1, wooden_sword: 1, + }, + })); + assert.equal(a.need.id, "tools_stone"); + assert.equal(a.skillId, "gather.stone"); +}); + +test("pickActiveNeed: armor pursue=null → ladder skips to food_security", () => { + _resetForTest(); + // Everything up through tools_stone satisfied, no armor (pursue=null). + // Should advance to food_security, not stall. + const a = pickActiveNeed(snap({ + nearbyBlocks: { beds: 1 }, + inventory: { + bread: 8, + wooden_pickaxe: 1, wooden_axe: 1, wooden_sword: 1, + stone_pickaxe: 1, stone_axe: 1, stone_sword: 1, + }, + })); + assert.equal(a.need.id, "food_security"); + assert.ok(a.blockedNeeds.some((b) => b.id === "armor_basic"), "armor_basic recorded as blocked"); +}); + +test("pickActiveNeed: hostile imminent + low HP → L0 takes over", () => { + _resetForTest(); + const a = pickActiveNeed(snap({ + health: 6, + closestHostile: { name: "creeper", distance: 3 }, + inventory: { bread: 8, wooden_pickaxe: 1, wooden_axe: 1, wooden_sword: 1 }, + nearbyBlocks: { beds: 1 }, + })); + assert.equal(a.need.id, "alive"); + assert.equal(a.skillId, "survive.flee"); +}); + +test("describeActiveNeed: returns 'L '", () => { + _resetForTest(); + const s = describeActiveNeed(snap()); + assert.match(s, /^L1 food → /); +}); + +test("pickActiveNeed: caches within TTL — same snapshot ref returns same result", () => { + _resetForTest(); + const s = snap(); + const a = pickActiveNeed(s); + const b = pickActiveNeed(s); + assert.equal(a, b, "second call returns cached object"); +}); diff --git a/runtime/reflex.js b/runtime/reflex.js index 420292f..e1009c1 100644 --- a/runtime/reflex.js +++ b/runtime/reflex.js @@ -26,6 +26,7 @@ import { } from "./actions.js"; import { runSkill, getSkill } from "./skills/index.js"; import { consult as consultAdvice, reportOutcome as reportAdviceOutcome } from "./coach/advice.js"; +import { pickActiveNeed } from "./manifesto/state.js"; import { situationHash } from "./scenario-memory.js"; import { tickModes } from "./modes.js"; @@ -438,6 +439,22 @@ function curriculumReflex(ctx) { const plan = s.curriculum?.plan; const wanderHintUntil = ctx.skillBackoff?.["__wander_hint__"] ?? 0; const wantWander = wanderHintUntil && Date.now() < wanderHintUntil; + + // v0.3.0-rc.2 — manifesto layer. Walk the L0-L10 needs ladder; the + // lowest unsatisfied need dictates the planned skill. The curriculum + // plan is used as a fallback when the manifesto has nothing concrete + // (e.g. armour pursue=null, or village_full with no specific next + // step). This is what makes the bot pursue tangible intermediate + // goals (tools_wood → shelter → tools_stone → ...) instead of + // wandering in the same quadrant. + // + // Tests can pass ctx.disableManifesto=true to exercise the curriculum + // branch in isolation without having to construct a full snapshot. + const activeNeed = ctx.disableManifesto ? null : pickActiveNeed(s); + if (activeNeed) { + ctx.activeNeed = activeNeed; + } + const manifestoSkillId = activeNeed?.skillId ?? null; const metricRecovery = metricRecoverySkill(ctx, plan?.skillId); if (metricRecovery) { ctx.lastCurriculumAt = Date.now(); @@ -476,7 +493,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 || wantWander) { + if ((!plan?.skillId && !manifestoSkillId) || 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 @@ -511,15 +528,18 @@ function curriculumReflex(ctx) { return { action: "dispatched", kind: "curriculum-wander", label: "wander" }; } - const skillId = plan.skillId; + // Pick what to dispatch: manifesto wins over curriculum plan because + // it expresses concrete needs rather than abstract "next milestone". + const skillId = manifestoSkillId ?? plan.skillId; + const skillSource = manifestoSkillId ? `manifesto:${activeNeed.need.id}` : "curriculum"; const skill = getSkill(skillId); if (!skill) { - // Curriculum suggested a skill that isn't registered yet — fall back + // Suggested a skill that isn't registered yet — fall back // to wander rather than spinning. This is the right behaviour for // future milestones we haven't wired (e.g. shelter blueprints). ctx.lastCurriculumAt = Date.now(); ctx.dispatch(() => wander(ctx.bot, 16), "wander", {}); - return { action: "dispatched", kind: "curriculum-wander", label: `wander (no skill ${skillId})` }; + return { action: "dispatched", kind: "curriculum-wander", label: `wander (no skill ${skillId}; source ${skillSource})` }; } // Per-skill backoff: if this exact skill failed with a non-recoverable @@ -558,7 +578,10 @@ function curriculumReflex(ctx) { } ctx.lastCurriculumAt = Date.now(); - ctx.dispatch(() => runSkill(dispatchSkillId, ctx), dispatchSkillId, { + const dispatchArgs = (manifestoSkillId && manifestoSkillId === dispatchSkillId) + ? (activeNeed.args ?? {}) + : {}; + ctx.dispatch(() => runSkill(dispatchSkillId, ctx, dispatchArgs), dispatchSkillId, { onComplete: (res) => { ctx.skillBackoff = ctx.skillBackoff ?? {}; if (advice.lessonId) reportAdviceOutcome({ lessonId: advice.lessonId, succeeded: !!res?.ok }); @@ -582,7 +605,7 @@ function curriculumReflex(ctx) { } }, }); - return { action: "dispatched", kind: "curriculum-skill", label: dispatchSkillId }; + return { action: "dispatched", kind: "curriculum-skill", label: dispatchSkillId, source: skillSource }; } // ---- idle ------------------------------------------------------------------ diff --git a/runtime/reflex.test.js b/runtime/reflex.test.js index 2b2c87f..e5b8772 100644 --- a/runtime/reflex.test.js +++ b/runtime/reflex.test.js @@ -46,6 +46,9 @@ function makeCtx({ lastSleepAttemptAt = 0, lastCurriculumAt = 0, metrics, + disableManifesto = true, // curriculum branch tests don't construct + // full snapshots; manifesto is exercised by + // runtime/manifesto/state.test.js separately. } = {}) { const dispatches = []; const ctx = { @@ -58,6 +61,7 @@ function makeCtx({ lastCurriculumAt, skillBackoff, metrics, + disableManifesto, dispatch(fn, label, opts = {}) { dispatches.push({ fn, label, opts }); }, @@ -233,6 +237,55 @@ test("curriculum dispatches suggested skill by id", () => { assert.ok(typeof dispatches[0].opts.onComplete === "function"); }); +test("manifesto: hungry bot with no food drives survive.acquire-food (overrides curriculum plan)", () => { + const { ctx, dispatches } = makeCtx({ + disableManifesto: false, + snapshot: { + connected: true, + health: 20, + food: 12, + hasFood: false, + inventory: {}, // no food, no tools + equipment: {}, + nearbyBlocks: {}, + hazards: { footBlock: "grass_block", belowBlock: "dirt", headBlock: "air" }, + isDay: true, + curriculum: { plan: { skillId: "gather.logs" } }, + }, + }); + const out = runTick(ctx); + assert.equal(out.reflex, "curriculum"); + assert.equal(dispatches[0].label, "survive.acquire-food", "manifesto L1 food took over"); + assert.equal(ctx.activeNeed?.need?.id, "food"); +}); + +test("manifesto: well-fed bot with all wood tools defers to curriculum plan", () => { + const { ctx, dispatches } = makeCtx({ + disableManifesto: false, + snapshot: { + connected: true, + health: 20, + food: 20, + hasFood: true, + inventory: { + bread: 8, + wooden_pickaxe: 1, wooden_axe: 1, wooden_sword: 1, + white_bed: 1, + }, + equipment: {}, + nearbyBlocks: { beds: 1 }, + hazards: { footBlock: "grass_block", belowBlock: "dirt", headBlock: "air" }, + isDay: true, + curriculum: { plan: { skillId: "gather.logs" } }, + }, + }); + const out = runTick(ctx); + assert.equal(out.reflex, "curriculum"); + // L4 tools_stone unmet → gather.stone takes precedence even if curriculum says logs + assert.equal(dispatches[0].label, "gather.stone"); + assert.equal(ctx.activeNeed?.need?.id, "tools_stone"); +}); + test("curriculum falls back to wander when no plan", () => { const { ctx, dispatches } = makeCtx({ snapshot: {