From 5fed12c2e462ec540592e1559f1c521aea834e3d Mon Sep 17 00:00:00 2001 From: Yuriy Mayatnikov Date: Mon, 25 May 2026 16:59:59 +0300 Subject: [PATCH] =?UTF-8?q?feat(runtime):=20village=20progression=20?= =?UTF-8?q?=E2=80=94=20crafting,=20tech-tree=20reflex,=20LLM=20planner?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the loop "стой и кидай proposals" → "копит ресурсы, строит, работает к глобальной цели". Three pieces: 1. Crafting primitives (runtime/actions.js). craftPlanks (4 per log, any wood type), craftSticks (4 per 2 planks), placeCraftingTable (crafts a table from planks if needed + places at reference block + reuses an existing table within 4 m), craftWoodenAxe, craftWoodenPickaxe, craftWoodenSword. Each uses bot.recipesFor() + bot.craft() with a 15s timeout. Returns the same {ok, detail} contract as the other actions. inv.{getItemCount, getAnyPlanksCount, getAnyLogCount} helpers exported so the reflex layer can read inventory cheaply without pulling mineflayer state through every reducer. 2. Tech-tree reflex (runtime/reflex.js). New techTreeReflex between sleep and autonomous. Inventory-driven progression: log+0 planks → planks; planks+0 sticks → sticks; planks+sticks+no axe → wooden_axe; +no pickaxe → wooden_pickaxe; +no sword → wooden_sword. 5 s cooldown so we don't fire on every tick. Pure script, no LLM. The progression is exactly what a player does in the first 10 min on a new world; making it scripted means the bot never burns tokens on it. 3. LLM planner (runtime/planner.js). Background timer (every 15 min, with a 30 s warm-up after start). Reads goal.md + plan.md + a slim snapshot, prompts Pi to output a fresh plan.md to stdout. Stripped of code fences and written verbatim to state//plan.md. Capped at 16 KB. The plan is markdown the operator can read or edit by hand. Numbered milestones, ✓ prefix for completed ones, kept short. The reflex layer doesn't auto-execute LLM text — but the planner sets the long-horizon shape that future reflexes (build house, plant farm) can read. 5 min timeout on the pi subprocess. If it crashes or times out, the next 15-min tick just retries — no propagation to the reflex loop. The progression now looks like, roughly: chop log (autonomous) → craft planks → craft sticks → wooden_axe (tech-tree) → chop faster (autonomous, has axe now) → wooden_pickaxe + wooden_sword (tech-tree) → mine stone … (next PR: stone tools, farm site selection, house frame) Smoke-tested: all three modules import cleanly, exports check out. Co-Authored-By: Claude Opus 4.7 (1M context) --- runtime/actions.js | 145 ++++++++++++++++++++++++++++++++++++++ runtime/bot.js | 2 + runtime/planner.js | 172 +++++++++++++++++++++++++++++++++++++++++++++ runtime/reflex.js | 92 +++++++++++++++++++++++- 4 files changed, 410 insertions(+), 1 deletion(-) create mode 100644 runtime/planner.js diff --git a/runtime/actions.js b/runtime/actions.js index adc7c24..f1bbf9f 100644 --- a/runtime/actions.js +++ b/runtime/actions.js @@ -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) { diff --git a/runtime/bot.js b/runtime/bot.js index 12ae0c3..aa21e9e 100644 --- a/runtime/bot.js +++ b/runtime/bot.js @@ -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); diff --git a/runtime/planner.js b/runtime/planner.js new file mode 100644 index 0000000..b3e6d76 --- /dev/null +++ b/runtime/planner.js @@ -0,0 +1,172 @@ +// LLM planner: every PLANNER_INTERVAL_MS (15 min) reads goal.md + +// state//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); +} diff --git a/runtime/reflex.js b/runtime/reflex.js index f2c1e4a..90ecbb7 100644 --- a/runtime/reflex.js +++ b/runtime/reflex.js @@ -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 }, ]; -- 2.54.0