Files
pepa-pi-bot/runtime/manifesto/needs.js
T
mayatnikovandClaude Opus 4.7 502d7ebae4 fix(v0.3.1): sated bot stops chasing food + perf-leak + fuzzy improvement dedup
Third day of "bot just walks back and forth burning tokens". Root
causes were mechanical, not the manifesto:

1. SATED BOT CHASING FOOD (the big one)
   Bot had food=17 (nearly full) but storyline first_food + manifesto
   L1 required 2+ food ITEMS in inventory, so it looped scout-food /
   acquire-food for hours instead of working. Now both treat a hunger
   bar >= 14 (SATED_FOOD) as satisfied even with empty food inventory —
   a full bot chops wood / makes tools and grabs food opportunistically,
   only hard-pursuing food when actually hungry (< 14).
   manifesto/needs.js foodDetect + goal/storyline.js first_food.completed.

2. perf_hooks MEMORY LEAK (overnight OOM suspect)
   "MaxPerformanceEntryBufferExceededWarning: 1,000,001 measure entries".
   mineflayer/pathfinder emit perf marks we never consume. Added a
   60s reaper in bot.js (performance.clearMeasures/clearMarks). unref'd.

3. IMPROVEMENT QUEUE SELF-DUPLICATING
   The LLM re-filed closed gaps with reworded titles (#5/#8/#9 were
   dupes of implemented #1/#2/#3). Exact-title dedup missed them.
   Replaced with token-set fuzzy match (isDuplicateTitle): jaccard>=0.75
   OR >=3 shared meaningful tokens with jaccard>=0.5. Also: a re-filed
   gap that's already implemented/rejected is NOT resurrected as a new
   open row. Cleared all 5 open requests (now genuinely implemented).

Also confirmed (no change needed):
- canDig=true is a DELIBERATE codebase-wide choice ("without it the bot
  gets permanently stuck", actions.js). The stale memory recommending
  canDig=false is updated. ViaBackwards dig works partially (dug:1
  moved:1.8 observed); false would trap the bot in every pit.
- scout-food already has blind/tunnel fallback + 12s step timeout
  (operator's earlier edits) so trapped-pathfinder degrades instead of
  hanging 30s.

Tests: 407 green (was 404). Updated needs/state/storyline tests for the
SATED_FOOD threshold; added fuzzy-dedup + tokenize/jaccard tests.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 09:34:42 +03:00

337 lines
11 KiB
JavaScript

// 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",
];
const PASSIVE_FOOD_MOBS = new Set(["cow", "pig", "chicken", "sheep", "rabbit", "mooshroom"]);
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 hasVisibleFoodTarget(s) {
const passives = s?.nearbyEntities?.passives ?? [];
return passives.some((e) => PASSIVE_FOOD_MOBS.has(e.name) && (e.distance ?? Infinity) <= 32);
}
function blockCount(s, kind) {
const v = s?.nearbyBlocks?.[kind];
if (typeof v === "number") return v;
if (v && typeof v.count === "number") return v.count;
return 0;
}
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: hasVisibleFoodTarget(s) ? "survive.acquire-food" : "survive.scout-food" };
}
if (hostileImminent(s)) {
return { skillId: "survive.flee" };
}
if (hp <= 5) {
return { skillId: "survive.flee" };
}
return null;
}
// SATED_FOOD — above this hunger level the bot is NOT hungry; chasing
// food (scout/acquire) is wasted motion. The bot should keep working
// toward wood/tools/shelter and pick up food opportunistically. It
// only becomes a hard need again when the bar drops below this.
const SATED_FOOD = 14;
function foodDetect(s) {
if (!s?.connected) return true;
// Comfortable hunger bar → treat L1 as satisfied even with an empty
// food inventory. (Was: required 6+ food items, which made a sated
// bot loop scout/acquire-food for hours doing nothing else.)
if ((s.food ?? 20) >= SATED_FOOD) 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: hasVisibleFoodTarget(s) ? "survive.acquire-food" : "survive.scout-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
|| blockCount(s, "craftingTable") > 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 = blockCount(s, "beds") > 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 && blockCount(s, "crops") > 0) {
return { skillId: "farm.wheat" };
}
return { skillId: hasVisibleFoodTarget(s) ? "survive.acquire-food" : "survive.scout-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 blockCount(s, "storage") >= 1 && blockCount(s, "beds") >= 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, hasVisibleFoodTarget, blockCount,
};