feat(runtime): village progression — crafting, tech-tree, LLM planner #11
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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 },
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user