v0.3.0-rc.2: manifesto / needs ladder L0-L10

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 <noreply@anthropic.com>
This commit is contained in:
2026-05-27 17:50:05 +03:00
co-authored by Claude Opus 4.7
parent fcfa2277ba
commit ddc67a5031
9 changed files with 864 additions and 11 deletions
+8 -2
View File
@@ -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}`,
+314
View File
@@ -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,
};
+198
View File
@@ -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);
});
+91
View File
@@ -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 };
+116
View File
@@ -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<n> <id> → <skill>'", () => {
_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");
});
+29 -6
View File
@@ -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 ------------------------------------------------------------------
+53
View File
@@ -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: {