v0.3.0-rc.2: manifesto / needs ladder L0-L10 #25
+53
-1
@@ -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
|
||||
|
||||
+2
-2
@@ -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",
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 };
|
||||
@@ -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
@@ -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 ------------------------------------------------------------------
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user