From ddc67a5031ce67a51b5724414fb4702ee78ee73c Mon Sep 17 00:00:00 2001 From: Yuriy Mayatnikov Date: Wed, 27 May 2026 17:50:05 +0300 Subject: [PATCH] v0.3.0-rc.2: manifesto / needs ladder L0-L10 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an explicit hierarchical needs catalogue that the reflex consults on every tick. The bot now pursues tangible intermediate goals (food, wood tools, shelter, stone tools, ...) instead of inheriting whatever the curriculum thought was "next". Ladder: L0 alive HP>5, food>0, not in lava, not panic-near hostile L1 food ≥6 food items in inventory (or sated + any food) L2 tools_wood wooden_pickaxe + wooden_axe + wooden_sword L3 shelter_basic bed placed nearby or in inventory L4 tools_stone stone-tier triplet L5 armor_basic any chestplate (pursue=null until craft.leather-* lands; ladder gracefully skips) L6 food_security ≥16 food items L7 tools_iron iron-tier triplet (pursue=gather.stone for now) L8 armor_iron iron chestplate (pursue=null for now) L9 village_seed bed + chest in nearby blocks L10 village_full never detected, falls through to curriculum Each need has detect(snapshot) → bool and pursue(snapshot) → {skillId, args} | null. The ladder picks the LOWEST unsatisfied pursuable need. Needs whose pursue is null get recorded as blockedNeeds and the walk continues — no stalling on missing skills. Wired into curriculumReflex: manifesto takes precedence over curriculum.plan when it has a concrete suggestion. Tests can pass ctx.disableManifesto=true to exercise the curriculum branch in isolation (existing reflex tests keep passing this way). Pi self-reflection prompt now includes "activeNeed (Maslow ladder L0-L10): L2 tools_wood → gather.logs" so Pi advises at the right level instead of giving generic guidance. skillId returned by pursue() is validated against the live registry (rc.1 plumbing) — manifesto cannot accidentally dispatch a hallucinated skill name. Tests: 315 green (was 279 on rc.1, +36 new): - runtime/manifesto/needs.test.js — 24 tests (per-need detect/pursue, helper sums) - runtime/manifesto/state.test.js — 10 tests (ladder walk, hostile takeover at L0, armor skipping, caching) - runtime/reflex.test.js — 2 integration tests (manifesto overrides curriculum plan; well-fed bot pursues tools_stone) Co-Authored-By: Claude Opus 4.7 --- dev/v0.3.0/STATUS.md | 54 +++++- package.json | 4 +- runtime/coach/reflect.js | 10 +- runtime/manifesto/needs.js | 314 ++++++++++++++++++++++++++++++++ runtime/manifesto/needs.test.js | 198 ++++++++++++++++++++ runtime/manifesto/state.js | 91 +++++++++ runtime/manifesto/state.test.js | 116 ++++++++++++ runtime/reflex.js | 35 +++- runtime/reflex.test.js | 53 ++++++ 9 files changed, 864 insertions(+), 11 deletions(-) create mode 100644 runtime/manifesto/needs.js create mode 100644 runtime/manifesto/needs.test.js create mode 100644 runtime/manifesto/state.js create mode 100644 runtime/manifesto/state.test.js 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: {