feat(runtime): village progression — crafting, tech-tree, LLM planner #11

Merged
halofourteen merged 1 commits from feat/village-tech-tree-planner into main 2026-05-25 17:00:07 +03:00
4 changed files with 410 additions and 1 deletions
+145
View File
@@ -359,6 +359,151 @@ export async function wander(bot, radius = 12) {
}
}
// ---- crafting --------------------------------------------------------------
//
// Mineflayer's crafting API is two-step: find a recipe with bot.recipesFor()
// (which considers what's in inventory + nearby crafting tables), then call
// bot.craft(recipe, count, tableBlock?). For "needs a table" items the
// caller must place one within ~3 blocks first or pass the table block.
//
// We keep the actions small and explicit so the tech-tree reflex can compose
// them: chop → planks → sticks → table → axe → keep chopping (now faster).
function getItemCount(bot, name) {
return bot.inventory.items().reduce((sum, i) => (i.name === name ? sum + i.count : sum), 0);
}
function getAnyPlanksCount(bot) {
return bot.inventory
.items()
.reduce((sum, i) => (i.name.endsWith("_planks") ? sum + i.count : sum), 0);
}
function getAnyLogCount(bot) {
return bot.inventory.items().reduce((sum, i) => (i.name.endsWith("_log") ? sum + i.count : sum), 0);
}
// Pick a recipe by item-name regardless of table presence.
function findRecipe(bot, itemName, tableBlock = null) {
const mcdata = bot.registry ?? null;
const itemId = mcdata?.itemsByName?.[itemName]?.id;
if (itemId == null) return null;
const recipes = bot.recipesFor(itemId, null, 1, tableBlock);
return recipes[0] ?? null;
}
async function craftRecipe(bot, itemName, count = 1, tableBlock = null) {
const recipe = findRecipe(bot, itemName, tableBlock);
if (!recipe) {
return { ok: false, detail: `no recipe for ${itemName} (have: ${summarizeInv(bot)})` };
}
try {
await withTimeout(bot.craft(recipe, count, tableBlock), 15_000, `craft(${itemName})`);
return { ok: true, detail: { item: itemName, count } };
} catch (e) {
return { ok: false, detail: `craft(${itemName}): ${e.message}` };
}
}
function summarizeInv(bot) {
const items = bot.inventory.items();
if (!items.length) return "empty";
return items
.slice(0, 5)
.map((i) => `${i.name}×${i.count}`)
.join(",");
}
// Convert any wood logs into planks (4 planks per log). Picks the first log
// type we have. Most recipes don't need a table.
export async function craftPlanks(bot, count = 4) {
const log = bot.inventory.items().find((i) => i.name.endsWith("_log"));
if (!log) return { ok: false, detail: "no log in inventory" };
const planks = log.name.replace("_log", "_planks");
info("action", `craft: ${count} ${planks} (from ${log.name})`);
return craftRecipe(bot, planks, Math.ceil(count / 4));
}
// 2 planks → 4 sticks. No table needed.
export async function craftSticks(bot, count = 4) {
if (getAnyPlanksCount(bot) < 2) return { ok: false, detail: "need 2 planks" };
info("action", `craft: ${count} sticks`);
return craftRecipe(bot, "stick", Math.ceil(count / 4));
}
// Place a crafting table at the bot's feet+1 (or near). Returns the placed
// block so subsequent craft calls can pass it as tableBlock.
export async function placeCraftingTable(bot) {
// Already placed nearby?
const existing = bot.findBlock({
matching: (b) => b && b.name === "crafting_table",
maxDistance: 4,
});
if (existing) return { ok: true, detail: { at: existing.position, reused: true }, block: existing };
// Need to craft one first if we don't have it.
if (getItemCount(bot, "crafting_table") === 0) {
if (getAnyPlanksCount(bot) < 4) {
return { ok: false, detail: "need 4 planks to craft a table" };
}
const craftRes = await craftRecipe(bot, "crafting_table", 1);
if (!craftRes.ok) return craftRes;
}
// Pick a placement target — block beside the bot at foot level + 1 face.
const here = bot.entity.position;
const referenceBlock = bot.blockAt(here.offset(0, -1, 0));
if (!referenceBlock) return { ok: false, detail: "no reference block beneath bot" };
const tableItem = bot.inventory.items().find((i) => i.name === "crafting_table");
try {
await withTimeout(bot.equip(tableItem, "hand"), 3000, "equip table");
await withTimeout(
bot.placeBlock(referenceBlock, { x: 0, y: 1, z: 0 }),
5000,
"placeBlock(table)",
);
} catch (e) {
return { ok: false, detail: `place table: ${e.message}` };
}
const placed = bot.findBlock({
matching: (b) => b && b.name === "crafting_table",
maxDistance: 4,
});
info("action", `craft: placed crafting_table at ${placed?.position}`);
return { ok: true, detail: { at: placed?.position, reused: false }, block: placed };
}
export async function craftWoodenAxe(bot) {
const tableRes = await placeCraftingTable(bot);
if (!tableRes.ok) return tableRes;
if (getAnyPlanksCount(bot) < 3) return { ok: false, detail: "need 3 planks" };
if (getItemCount(bot, "stick") < 2) return { ok: false, detail: "need 2 sticks" };
info("action", `craft: wooden_axe`);
return craftRecipe(bot, "wooden_axe", 1, tableRes.block);
}
export async function craftWoodenPickaxe(bot) {
const tableRes = await placeCraftingTable(bot);
if (!tableRes.ok) return tableRes;
if (getAnyPlanksCount(bot) < 3) return { ok: false, detail: "need 3 planks" };
if (getItemCount(bot, "stick") < 2) return { ok: false, detail: "need 2 sticks" };
info("action", `craft: wooden_pickaxe`);
return craftRecipe(bot, "wooden_pickaxe", 1, tableRes.block);
}
export async function craftWoodenSword(bot) {
const tableRes = await placeCraftingTable(bot);
if (!tableRes.ok) return tableRes;
if (getAnyPlanksCount(bot) < 2) return { ok: false, detail: "need 2 planks" };
if (getItemCount(bot, "stick") < 1) return { ok: false, detail: "need 1 stick" };
info("action", `craft: wooden_sword`);
return craftRecipe(bot, "wooden_sword", 1, tableRes.block);
}
// Re-exported helpers for the reflex layer.
export const inv = { getItemCount, getAnyPlanksCount, getAnyLogCount };
// ---- navigation (for operator come/follow) --------------------------------
export async function goTo(bot, x, y, z, minDistance = 2) {
+2
View File
@@ -31,6 +31,7 @@ import {
approveProposal,
} from "./state-store.js";
import { startAutoImprover } from "./auto-improve.js";
import { startPlanner } from "./planner.js";
fs.mkdirSync(stateDir, { recursive: true });
const JOINED_FLAG = path.join(stateDir, "joined-before.flag");
@@ -658,3 +659,4 @@ ipc = createIpcServer({
connect();
startTickLoop();
startAutoImprover();
startPlanner(() => lastSnapshot);
+172
View File
@@ -0,0 +1,172 @@
// LLM planner: every PLANNER_INTERVAL_MS (15 min) reads goal.md +
// state/<host>/plan.md + a slim snapshot and asks Pi to update plan.md
// with the current set of milestones. Reflex layer treats plan.md as
// advisory hints (we don't auto-execute LLM-written code from here).
//
// The plan is markdown for two reasons:
// 1. It's small enough to put in a prompt and read back from Pi.
// 2. The operator can edit it directly with $EDITOR if Pi drifts.
//
// We don't block on this. spawnPlanner runs as a detached promise; if Pi
// is slow or down, the reflex layer keeps doing what it was doing.
import fs from "node:fs";
import path from "node:path";
import { spawn } from "node:child_process";
import { stateDir } from "./config.js";
import { info, warn } from "./log.js";
const GOAL_PATH = path.join(stateDir, "goal.md");
const PLAN_PATH = path.join(stateDir, "plan.md");
const PLANNER_INTERVAL_MS = 15 * 60_000;
const PLANNER_TIMEOUT_MS = 5 * 60_000;
const MAX_PLAN_BYTES = 16_000;
let planTimer = null;
let planInFlight = false;
function readOr(file, fallback = "") {
try {
return fs.readFileSync(file, "utf8");
} catch (e) {
if (e.code === "ENOENT") return fallback;
throw e;
}
}
function buildPrompt({ goal, plan, snapshot }) {
const slim = snapshot
? {
position: snapshot.position,
health: snapshot.health,
food: snapshot.food,
isDay: snapshot.isDay,
inventory: snapshot.inventory,
dimension: snapshot.dimension,
}
: null;
return [
"You are the long-horizon PLANNER for an autonomous Minecraft farmer bot.",
"",
"Read the goal.md, the current plan.md (if any), and the bot's latest snapshot.",
"Output an UPDATED plan.md to stdout. No markdown code fences, no preamble —",
"the entire stdout will be written verbatim to plan.md.",
"",
"## Constraints",
"",
"- Format: numbered milestones, each on one short line. Optional sub-bullets allowed.",
"- Each milestone must be concrete, measurable, and within reach of the current",
" inventory + reflex capabilities (chop, craft planks/sticks/wooden tools,",
" wander, defend, eat, sleep, goto, place a single block).",
"- Mark completed milestones with a leading '✓ '. Keep them in the list as history.",
"- The first uncompleted milestone is the NEXT thing the bot will work on.",
"- Cap the file at 80 lines / ~3 KB. If older milestones aren't useful to keep,",
" drop them.",
"- Do NOT propose anything outside the overworld; no nether, no end, no PvP.",
"- Do NOT propose anything that requires OP, /commands, or external services.",
"- If the goal already looks satisfied, write the next maintenance cycle.",
"",
"## goal.md",
"",
goal || "(empty — the operator has not seeded a long-term goal yet)",
"",
"## Current plan.md",
"",
plan || "(no plan yet — start from scratch)",
"",
"## Snapshot (slim)",
"",
"```json",
JSON.stringify(slim, null, 2),
"```",
"",
"Now output the new plan.md content. Plain markdown only.",
].join("\n");
}
function tick(getSnapshot) {
if (planInFlight) return;
const goal = readOr(GOAL_PATH);
if (!goal.trim()) {
// Nothing to plan toward. Skip silently.
return;
}
const plan = readOr(PLAN_PATH);
const snapshot = getSnapshot();
planInFlight = true;
info("planner", "asking Pi for an updated plan.md");
const prompt = buildPrompt({ goal, plan, snapshot });
const child = spawn("pi", ["-p", prompt], {
stdio: ["ignore", "pipe", "pipe"],
env: { ...process.env, CI: "1" },
});
let stdout = "";
let stderr = "";
child.stdout.on("data", (c) => {
stdout += c.toString();
if (stdout.length > MAX_PLAN_BYTES * 2) {
// Pi went off — truncate further reads.
stdout = stdout.slice(0, MAX_PLAN_BYTES * 2);
}
});
child.stderr.on("data", (c) => {
stderr += c.toString();
});
const killTimer = setTimeout(() => {
warn("planner", "Pi timed out — killing");
child.kill("SIGTERM");
}, PLANNER_TIMEOUT_MS);
child.on("exit", (code) => {
clearTimeout(killTimer);
planInFlight = false;
if (code !== 0) {
warn("planner", `pi exited ${code}: ${stderr.split("\n")[0]}`);
return;
}
const cleaned = stripCodeFences(stdout).trim();
if (!cleaned) {
warn("planner", "pi produced empty output");
return;
}
if (cleaned.length > MAX_PLAN_BYTES) {
warn("planner", `plan too large (${cleaned.length}B) — truncating`);
}
try {
fs.writeFileSync(PLAN_PATH, cleaned.slice(0, MAX_PLAN_BYTES));
info("planner", `plan.md updated (${cleaned.length}B)`);
} catch (e) {
warn("planner", `could not write plan.md: ${e.message}`);
}
});
}
function stripCodeFences(s) {
// Pi sometimes wraps the entire output in ```markdown … ``` even when
// told not to. Strip outer fences if present.
const trimmed = s.trim();
const m = trimmed.match(/^```[a-z]*\n([\s\S]*?)\n```$/);
return m ? m[1] : trimmed;
}
export function startPlanner(getSnapshot) {
if (planTimer) return;
info("planner", `scheduling every ${PLANNER_INTERVAL_MS / 60_000} min`);
// Run once on startup so we don't wait 15 min for the first plan.
setTimeout(() => tick(getSnapshot), 30_000);
planTimer = setInterval(() => tick(getSnapshot), PLANNER_INTERVAL_MS);
}
export function stopPlanner() {
if (planTimer) clearInterval(planTimer);
planTimer = null;
}
export function readCurrentPlan() {
return readOr(PLAN_PATH);
}
+91 -1
View File
@@ -7,7 +7,22 @@
// escalates to Pi after a threshold (see ESCALATE_AFTER_NOOPS).
import { info, warn } from "./log.js";
import { attackNearest, fleeFrom, eatBestFood, sleepInBed, goTo, chopNearestTree, wander } from "./actions.js";
import {
attackNearest,
fleeFrom,
eatBestFood,
sleepInBed,
goTo,
chopNearestTree,
wander,
craftPlanks,
craftSticks,
placeCraftingTable,
craftWoodenAxe,
craftWoodenPickaxe,
craftWoodenSword,
inv,
} from "./actions.js";
const REFLEX_LOG = "reflex";
@@ -194,6 +209,80 @@ function autonomousReflex(ctx) {
return { action: "dispatched", kind: "autonomous-wander", label: "wander" };
}
// ---- tech-tree progression -------------------------------------------------
//
// Scripted progression toward the long-term goal (small farm + village). Runs
// between the autonomous wood-gathering reflex and idle. Order:
// 1. have ≥4 logs but 0 planks → craft planks
// 2. have ≥2 planks but 0 sticks → craft sticks
// 3. have planks+sticks but no axe → craft wooden_axe (places a table)
// 4. have axe but no pickaxe → craft wooden_pickaxe
// 5. have pickaxe but no sword → craft wooden_sword
// 6. tools done — fall through to autonomous (chop more, then mine stone)
//
// Each step is cheap and idempotent: if it can't act it returns noop.
const TECH_TREE_COOLDOWN_MS = 5_000;
function techTreeReflex(ctx) {
const s = ctx.snapshot;
if (!s.connected) return { action: "noop" };
if (!ctx.bot) return { action: "noop" };
const since = Date.now() - (ctx.lastTechTreeAt ?? 0);
if (since < TECH_TREE_COOLDOWN_MS) return { action: "noop" };
const logs = inv.getAnyLogCount(ctx.bot);
const planks = inv.getAnyPlanksCount(ctx.bot);
const sticks = inv.getItemCount(ctx.bot, "stick");
const hasAxe = ["wooden_axe", "stone_axe", "iron_axe", "diamond_axe", "netherite_axe"].some(
(n) => inv.getItemCount(ctx.bot, n) > 0,
);
const hasPickaxe = [
"wooden_pickaxe",
"stone_pickaxe",
"iron_pickaxe",
"diamond_pickaxe",
"netherite_pickaxe",
].some((n) => inv.getItemCount(ctx.bot, n) > 0);
const hasSword = ["wooden_sword", "stone_sword", "iron_sword", "diamond_sword", "netherite_sword"].some(
(n) => inv.getItemCount(ctx.bot, n) > 0,
);
// Step 1: planks
if (logs >= 1 && planks < 4) {
ctx.lastTechTreeAt = Date.now();
ctx.dispatch(() => craftPlanks(ctx.bot, 4), "craft planks");
return { action: "dispatched", kind: "tech-planks", label: `planks (have ${planks}/4)` };
}
// Step 2: sticks
if (planks >= 2 && sticks < 4) {
ctx.lastTechTreeAt = Date.now();
ctx.dispatch(() => craftSticks(ctx.bot, 4), "craft sticks");
return { action: "dispatched", kind: "tech-sticks", label: `sticks (have ${sticks}/4)` };
}
// Step 3: axe
if (planks >= 3 && sticks >= 2 && !hasAxe) {
ctx.lastTechTreeAt = Date.now();
ctx.dispatch(() => craftWoodenAxe(ctx.bot), "craft wooden_axe");
return { action: "dispatched", kind: "tech-axe", label: "wooden_axe" };
}
// Step 4: pickaxe
if (planks >= 3 && sticks >= 2 && hasAxe && !hasPickaxe) {
ctx.lastTechTreeAt = Date.now();
ctx.dispatch(() => craftWoodenPickaxe(ctx.bot), "craft wooden_pickaxe");
return { action: "dispatched", kind: "tech-pickaxe", label: "wooden_pickaxe" };
}
// Step 5: sword
if (planks >= 2 && sticks >= 1 && hasAxe && hasPickaxe && !hasSword) {
ctx.lastTechTreeAt = Date.now();
ctx.dispatch(() => craftWoodenSword(ctx.bot), "craft wooden_sword");
return { action: "dispatched", kind: "tech-sword", label: "wooden_sword" };
}
return { action: "noop" };
}
// ---- idle ------------------------------------------------------------------
function idleReflex(ctx) {
@@ -213,6 +302,7 @@ const REFLEXES = [
{ name: "defend", fn: defendReflex },
{ name: "eat", fn: eatReflex },
{ name: "sleep", fn: sleepReflex },
{ name: "tech-tree", fn: techTreeReflex },
{ name: "autonomous", fn: autonomousReflex },
{ name: "idle", fn: idleReflex },
];