diff --git a/docs/runtime.md b/docs/runtime.md index 8dfc042..ec2acb4 100644 --- a/docs/runtime.md +++ b/docs/runtime.md @@ -246,6 +246,56 @@ in-process. The package is **not** a default dep — install it explicitly (`npm i prismarine-viewer`) before enabling. If missing, the runtime logs a warning and continues. +### Scheduler driven by the curriculum (2026-05-26) + +The reflex chain is now: `defend → eat → sleep → curriculum → idle`. + +`curriculumReflex` reads `snapshot.curriculum.plan.skillId` (populated +by `runtime/curriculum.js` each tick) and dispatches it via +`runSkill(skillId, ctx)`. Recovery hints flow back through +`ctx.skillBackoff`: + +- If the skill's `recover()` returns `{ hint: "wander" }` (e.g. + `gather.logs` returns `code: "no_target"` because there's no tree + within 32m), the curriculum reflex swaps to `wander` for ~60 s. +- If the result code is `missing_tool` / `missing_material` / + `no_target` / `no_food_source` / `unsupported_version`, that + specific skill backs off for 60 s instead of retrying every tick. +- Unknown `skillId` (curriculum suggested something that isn't + registered yet) falls through to `wander` — useful while we wire + future skills like `village.build-shelter`. + +The old `techTreeReflex` and `autonomousReflex` were removed — the +curriculum + craft skills cover their territory, and unit tests in +`runtime/reflex.test.js` exercise the new dispatch paths. + +### Chat banter escalation to Pi (2026-05-26) + +When `social/intent.js` classifies an inbound line as +`ADDRESSED_BANTER` and templates can't answer, `bot.js` spawns a +one-shot `askPi` with the bot's runtime state + the last 5 lines from +that speaker (redacted via `chatMemory`). The reply is capped at 200 +chars and sent as a single chat line. + +Hard rate limit so banter can't drain the LLM budget: +**6 calls per hour, minimum 90 s between calls.** Suppressed escalations log +once and silently drop. + +### Base-site scoring + locations (2026-05-26) + +- **`runtime/locations.js`** — atomic JSON store at + `state//locations.json`. `setLocation(name, {x,y,z,…})`, + `getLocation(name)`, `nearestLocation({x,z})`. +- **`runtime/base-site.js`** — `scoreCurrentPosition(bot)` returns + `{score, reasons, position}` based on wood/stone/water proximity, + surface flatness, distance to other players and absence of foreign + builds (man-made blocks not in the owned-blocks ledger). +- **`runtime/skills/choose-base.js`** — `village.choose-base` scores + the current spot; if `score ≥ 8` it writes `locations.base`, + otherwise emits `code: "too_weak"` with a `wander` recover hint. +- The curriculum has a new final milestone `village.base-site` that + fires `village.choose-base` until a base is established. + ### Compatibility hardening (Phase 7) Several modules now guard against the live regressions PRD §7 Phase 7 diff --git a/package.json b/package.json index 88326cc..ed4b39f 100644 --- a/package.json +++ b/package.json @@ -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/curriculum.test.js runtime/social/social.test.js runtime/stuck-incident.test.js runtime/compat.test.js scripts/edit-scope.test.js" + "test": "node --test runtime/skills/contract.test.js runtime/skills/groups.test.js runtime/skills/compat.test.js runtime/curriculum.test.js runtime/social/social.test.js runtime/stuck-incident.test.js runtime/compat.test.js runtime/reflex.test.js runtime/base-site.test.js runtime/locations.test.js scripts/edit-scope.test.js" }, "dependencies": { "dotenv": "^16.4.5", diff --git a/runtime/base-site.js b/runtime/base-site.js new file mode 100644 index 0000000..39ad3a5 --- /dev/null +++ b/runtime/base-site.js @@ -0,0 +1,158 @@ +// Base-site scoring. Given a snapshot of nearby blocks + entities, score +// the bot's *current* position as a candidate base site. Higher is +// better. The scheduler can call this when curriculum reaches the +// "find a base" milestone — we deliberately don't scan the whole world +// from a tick; the bot scores wherever it currently stands. +// +// Score axes (all weighted equally so easy to tune): +// * has wood within 16 blocks → +3 (we don't want to commute for trees) +// * has stone within 12 blocks → +2 +// * has water within 24 blocks → +2 +// * surface is "flat-ish" (sample 5 y-values at radius 4, max-min ≤ 2) → +2 +// * no nearby player entities (>32 blocks to closest player) → +2 +// * no man-made blocks within 16 (claim-avoidance hit) → +3 (else -10) +// +// Returns { score, reasons: string[], position }. + +import { isManMadeBlockName } from "./claim-avoidance.js"; + +const WOOD_RADIUS = 16; +const STONE_RADIUS = 12; +const WATER_RADIUS = 24; +const FLATNESS_RADIUS = 4; +const FLATNESS_TOLERANCE = 2; +const PLAYER_AVOID = 32; +const CLAIM_RADIUS = 16; + +function isLogName(name) { + return name && (name.endsWith("_log") || name.endsWith("_stem")); +} + +function isStoneName(name) { + return name === "stone" || name === "cobblestone" || name === "deepslate" || name === "andesite" || name === "diorite" || name === "granite"; +} + +function isWaterName(name) { + return name === "water" || name === "kelp" || name === "seagrass" || name === "tall_seagrass"; +} + +// scoreSite — takes a fact bundle so it can be unit-tested without a bot. +// The bundle: +// * blocks: Array<{ name, position: {x,y,z}, distance }> +// * surfaceYs: Array sampled around the bot's feet +// * players: Array<{ distance }> (excluding the bot itself) +// * position: { x, y, z } — the candidate location +// * isOwned: optional fn called for each man-made block; owned blocks +// don't count toward the claim-avoidance penalty. +export function scoreSite({ blocks, surfaceYs, players, position, isOwned }) { + const reasons = []; + let score = 0; + + const hasWood = blocks.some((b) => isLogName(b.name) && (b.distance ?? Infinity) <= WOOD_RADIUS); + if (hasWood) { score += 3; reasons.push("+3 wood nearby"); } + else reasons.push("0 no wood within " + WOOD_RADIUS); + + const hasStone = blocks.some((b) => isStoneName(b.name) && (b.distance ?? Infinity) <= STONE_RADIUS); + if (hasStone) { score += 2; reasons.push("+2 stone nearby"); } + else reasons.push("0 no stone within " + STONE_RADIUS); + + const hasWater = blocks.some((b) => isWaterName(b.name) && (b.distance ?? Infinity) <= WATER_RADIUS); + if (hasWater) { score += 2; reasons.push("+2 water nearby"); } + else reasons.push("0 no water within " + WATER_RADIUS); + + if (Array.isArray(surfaceYs) && surfaceYs.length >= 5) { + const min = Math.min(...surfaceYs); + const max = Math.max(...surfaceYs); + if (max - min <= FLATNESS_TOLERANCE) { + score += 2; reasons.push("+2 surface is flat"); + } else { + reasons.push(`0 surface bumpy (Δy=${max - min})`); + } + } + + const closestPlayer = (players ?? []).reduce( + (min, p) => (p.distance < min ? p.distance : min), + Infinity, + ); + if (closestPlayer >= PLAYER_AVOID) { + score += 2; reasons.push("+2 no players within " + PLAYER_AVOID); + } else { + reasons.push(`-0 player at ${Math.round(closestPlayer)}m (would shrink)`); + } + + const claimBlocks = blocks.filter( + (b) => isManMadeBlockName(b.name) && (b.distance ?? Infinity) <= CLAIM_RADIUS, + ); + const claimNonOwned = claimBlocks.filter( + (b) => !(typeof isOwned === "function" && b.position && isOwned(b.position)), + ); + if (claimNonOwned.length === 0) { + score += 3; reasons.push("+3 no foreign builds nearby"); + } else { + score -= 10; reasons.push(`-10 foreign builds nearby (${claimNonOwned.length})`); + } + + return { score, reasons, position }; +} + +// Score the bot's current position. Walks bot.findBlocks for cheap sets. +// Returns the same shape as scoreSite + the raw counts used. +export function scoreCurrentPosition(bot, { isOwned } = {}) { + if (!bot?.entity?.position) return { score: -100, reasons: ["no bot position"], position: null }; + const pos = bot.entity.position; + const here = { x: Math.round(pos.x), y: Math.round(pos.y), z: Math.round(pos.z) }; + + // Sample blocks via findBlocks where available; degrade gracefully + // when the registry is missing entries on older versions. + function findOnce(predicate, maxDistance, count = 6) { + try { + const ids = []; + const reg = bot.registry?.blocksByName ?? {}; + for (const name of Object.keys(reg)) if (predicate(name)) ids.push(reg[name].id); + if (ids.length === 0) return []; + const positions = bot.findBlocks({ matching: ids, maxDistance, count }); + return positions.map((p) => ({ + name: bot.blockAt(p)?.name ?? "?", + position: { x: p.x, y: p.y, z: p.z }, + distance: Math.hypot(p.x - here.x, p.z - here.z), + })); + } catch { + return []; + } + } + + const woods = findOnce(isLogName, WOOD_RADIUS); + const stones = findOnce(isStoneName, STONE_RADIUS); + const waters = findOnce(isWaterName, WATER_RADIUS); + const claims = findOnce( + (n) => isManMadeBlockName(n), + CLAIM_RADIUS, + 12, + ); + + // Sample surface heights at 5 points around the bot for flatness. + const surfaceYs = []; + const offsets = [ + [0, 0], [FLATNESS_RADIUS, 0], [-FLATNESS_RADIUS, 0], + [0, FLATNESS_RADIUS], [0, -FLATNESS_RADIUS], + ]; + for (const [dx, dz] of offsets) { + const block = bot.blockAt + ? bot.blockAt({ x: here.x + dx, y: here.y, z: here.z + dz }) + : null; + if (block?.position) surfaceYs.push(block.position.y); + else surfaceYs.push(here.y); + } + + const players = Object.values(bot.entities ?? {}) + .filter((e) => e.type === "player" && e.username && e.username !== bot.username && e.position) + .map((e) => ({ distance: Math.hypot(e.position.x - here.x, e.position.z - here.z) })); + + return scoreSite({ + blocks: [...woods, ...stones, ...waters, ...claims], + surfaceYs, + players, + position: here, + isOwned, + }); +} diff --git a/runtime/base-site.test.js b/runtime/base-site.test.js new file mode 100644 index 0000000..2241a12 --- /dev/null +++ b/runtime/base-site.test.js @@ -0,0 +1,91 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { scoreSite } from "./base-site.js"; + +function block(name, x, z, y = 64, distance = null) { + return { + name, + position: { x, y, z }, + distance: distance ?? Math.hypot(x, z), + }; +} + +test("scoreSite: empty surroundings → near-floor score", () => { + const out = scoreSite({ + blocks: [], + surfaceYs: [64, 64, 64, 64, 64], + players: [], + position: { x: 0, y: 64, z: 0 }, + }); + // no wood -0, no stone -0, no water -0, flat +2, no players +2, + // no foreign builds +3 → 7 + assert.equal(out.score, 7); +}); + +test("scoreSite: wood + stone + water + flat + no players + no claims → high", () => { + const out = scoreSite({ + blocks: [ + block("oak_log", 5, 5), + block("stone", 4, 0), + block("water", 10, 0), + ], + surfaceYs: [64, 64, 64, 64, 64], + players: [], + position: { x: 0, y: 64, z: 0 }, + }); + // wood +3, stone +2, water +2, flat +2, no players +2, no claims +3 → 14 + assert.equal(out.score, 14); +}); + +test("scoreSite: foreign build subtracts heavily", () => { + const out = scoreSite({ + blocks: [ + block("oak_log", 5, 5), + block("oak_planks", 6, 0), // man-made, not owned + ], + surfaceYs: [64, 64, 64, 64, 64], + players: [], + position: { x: 0, y: 64, z: 0 }, + isOwned: () => false, + }); + // wood +3, no stone 0, no water 0, flat +2, no players +2, foreign -10 → -3 + assert.equal(out.score, -3); +}); + +test("scoreSite: bot-owned man-made blocks don't penalise", () => { + const out = scoreSite({ + blocks: [ + block("oak_log", 5, 5), + block("crafting_table", 2, 0), + ], + surfaceYs: [64, 64, 64, 64, 64], + players: [], + position: { x: 0, y: 64, z: 0 }, + isOwned: () => true, + }); + // wood +3, flat +2, no players +2, no foreign +3 → 10 + assert.equal(out.score, 10); +}); + +test("scoreSite: bumpy surface loses the flatness +2", () => { + const out = scoreSite({ + blocks: [], + surfaceYs: [60, 64, 64, 68, 65], + players: [], + position: { x: 0, y: 64, z: 0 }, + }); + // flat 0, no players +2, no foreign +3 → 5 + assert.equal(out.score, 5); +}); + +test("scoreSite: nearby player loses the +2", () => { + const out = scoreSite({ + blocks: [], + surfaceYs: [64, 64, 64, 64, 64], + players: [{ distance: 10 }], + position: { x: 0, y: 64, z: 0 }, + }); + // flat +2, players 0, no foreign +3 → 5 + assert.equal(out.score, 5); +}); diff --git a/runtime/bot.js b/runtime/bot.js index d6c0802..cc2748d 100644 --- a/runtime/bot.js +++ b/runtime/bot.js @@ -41,6 +41,7 @@ import { computeState, STATES } from "./state.js"; import { createNoProgressDetector } from "./no-progress.js"; import { maybeStartViewer } from "./viewer.js"; import { nextMilestone as nextCurriculumMilestone } from "./curriculum.js"; +import { listLocations } from "./locations.js"; import { classifyIntent, INTENTS } from "./social/intent.js"; import { generateReply } from "./social/reply.js"; import { createChatMemory } from "./social/memory.js"; @@ -358,6 +359,79 @@ let lastChatReplyAt = 0; const CHAT_REPLY_COOLDOWN_MS = 30_000; const chatMemory = createChatMemory(); +// Rate-limit Pi escalations from chat. The chat path is the cheapest +// way to burn an LLM call accidentally — every addressed banter line +// would otherwise spawn `pi -p`. We cap at MAX_PI_CHAT_PER_HOUR with a +// hard minimum gap of MIN_PI_CHAT_GAP_MS between calls. +const MAX_PI_CHAT_PER_HOUR = 6; +const MIN_PI_CHAT_GAP_MS = 90_000; +const recentPiChatTs = []; +let lastPiChatAt = 0; + +function piChatAllowed(now = Date.now()) { + while (recentPiChatTs.length && now - recentPiChatTs[0] > 3600_000) recentPiChatTs.shift(); + if (recentPiChatTs.length >= MAX_PI_CHAT_PER_HOUR) return false; + if (now - lastPiChatAt < MIN_PI_CHAT_GAP_MS) return false; + return true; +} + +function escalateChatToPi({ speaker, text, intent }) { + if (!piChatAllowed()) { + info("chat", `pi escalation suppressed (rate limit) for ${speaker}`); + return; + } + lastPiChatAt = Date.now(); + recentPiChatTs.push(lastPiChatAt); + + // Compose a slim context — recent chat from this speaker, the bot's + // own state, and an explicit dialog-only reminder so Pi doesn't try + // to "act" on a player request via its output. We never write Pi's + // output to the world; the only side-effect is a single chat line. + const speakerTail = chatMemory.tail(speaker, 5).map((e) => `${speaker}: ${e.text}`).join("\n"); + const stateLine = JSON.stringify({ + runtimeState: lastSnapshot?.runtimeState, + activeSkill: lastSnapshot?.activeSkill, + currentMilestone: lastSnapshot?.currentMilestone, + noProgressReason: lastSnapshot?.noProgressReason, + hp: lastSnapshot?.health, + food: lastSnapshot?.food, + }); + const prompt = [ + `You are the social cortex for an autonomous Minecraft bot named "${bot?.username}".`, + `The bot's primary loop ignores chat commands — MC chat is dialog-only.`, + `Your ONLY output is one short chat line (<= 140 chars) the bot will say to ${speaker}.`, + `No code, no JSON, no quoting. Just the message. Use the language ${speaker} used.`, + ``, + `Bot's current state:`, + stateLine, + ``, + `Recent chat from ${speaker}:`, + speakerTail || `(no prior lines)`, + ``, + `Latest line (intent=${intent}): ${text}`, + ].join("\n"); + + let buf = ""; + askPi({ + prompt, + onChunk: (chunk) => { + if (chunk?.stream === "stdout") buf += chunk.text; + }, + onDone: (result) => { + info("chat", `pi banter reply done code=${result.code} dur=${result.durationMs}ms len=${buf.length}`); + if (result.code !== 0) return; + const line = buf.trim().split("\n").find((l) => l.trim()) ?? ""; + if (!line) return; + // Be defensive — drop the bot's own name prefix Pi sometimes + // adds, and cap to 200 chars so we never burn the rate-limit + // with a wall of text. + const cleaned = line.replace(/^[`"']+|[`"']+$/g, "").slice(0, 200); + lastChatReplyAt = Date.now(); + botChat(`${speaker}: ${cleaned}`); + }, + }); +} + function isOperator(username) { if (!username) return false; return config.operators.includes(username.toLowerCase()); @@ -417,9 +491,12 @@ function handleChat(username, text) { botChat(result.send); return; } - // Templates didn't fit and the bot was addressed (ADDRESSED_BANTER) — - // escalation to Pi is allowed but not done from here; future work will - // route through a rate-limited askPi with prompt-cached context. + if (result?.escalate) { + // Templates didn't fit AND the bot was addressed → ask Pi for a + // one-liner. Hard rate-limited so addressed-banter lines can't + // drain the LLM budget. + escalateChatToPi({ speaker: username, text: trimmed, intent }); + } } // ---- connect --------------------------------------------------------------- @@ -604,6 +681,13 @@ function tick() { // - curriculum.js (deterministic early-game progression) // The TUI prefers the curriculum's structured milestone (it has a // suggested skill); falls back to the planner line for late-game. + // locations.json drives the village.* milestones — read each tick + // (cheap: a small JSON file, no parse on cold cache). + try { + lastSnapshot.locations = listLocations(); + } catch { + lastSnapshot.locations = {}; + } const curriculum = nextCurriculumMilestone(lastSnapshot); lastSnapshot.curriculum = curriculum; lastSnapshot.currentMilestone = curriculum?.milestone?.title ?? cachedMilestone; diff --git a/runtime/curriculum.js b/runtime/curriculum.js index 1428bfb..2c2d929 100644 --- a/runtime/curriculum.js +++ b/runtime/curriculum.js @@ -130,6 +130,16 @@ const MILESTONES = [ isDone: (inv) => has(inv, "torch", 4), suggest: () => ({ skillId: "craft.torch" }), }, + { + id: "village.base-site", + title: "Pick a base site", + // We treat this as done when a "base" location exists in + // locations.json. The curriculum can't read that file from here + // (would couple it to disk), so we expose a snapshot hint: + // `snapshot.locations?.base` is filled by bot.js. + isDone: (_inv, snap) => !!snap?.locations?.base, + suggest: () => ({ skillId: "village.choose-base" }), + }, ]; export function isInventoryFull(snapshot) { diff --git a/runtime/curriculum.test.js b/runtime/curriculum.test.js index ca28915..478fd97 100644 --- a/runtime/curriculum.test.js +++ b/runtime/curriculum.test.js @@ -122,7 +122,10 @@ test("all done → null", () => { stone_axe: 1, stone_pickaxe: 1, stone_sword: 1, furnace: 1, bread: 4, chest: 1, torch: 8, }; - assert.equal(nextMilestone(snap(inv, { food: 20 })), null); + assert.equal( + nextMilestone(snap(inv, { food: 20, locations: { base: { x: 0, y: 64, z: 0 } } })), + null, + ); }); test("isInventoryFull threshold = 32 distinct stacks", () => { @@ -146,7 +149,7 @@ test("inventoryFull flag is returned alongside milestone, not as override", () = test("listMilestones exposes ordered ids for diary/TUI", () => { const ms = listMilestones(); assert.equal(ms[0].id, "wood.16"); - assert.equal(ms[ms.length - 1].id, "shelter.torch"); + assert.equal(ms[ms.length - 1].id, "village.base-site"); for (const m of ms) { assert.equal(typeof m.id, "string"); assert.equal(typeof m.title, "string"); diff --git a/runtime/locations.js b/runtime/locations.js new file mode 100644 index 0000000..61eb13a --- /dev/null +++ b/runtime/locations.js @@ -0,0 +1,93 @@ +// Named locations persisted under state//locations.json. The bot +// records places it cares about: "base", "wood-spot", "stone-spot", +// "wheat-farm", "chest-1". The format is a flat dict keyed by name; the +// value carries the integer block coordinates, an optional radius (for +// "the wood-spot is somewhere in this 16-block square"), the dimension +// and a free-form note for diary readability. +// +// All writes are sync — the file is small (a few dozen entries at most). +// We write atomically (tmp + rename) so a crash mid-write doesn't leave +// a half-written JSON. + +import fs from "node:fs"; +import path from "node:path"; +import { stateDir } from "./config.js"; + +const LOCATIONS_PATH = path.join(stateDir, "locations.json"); + +function ensureDir() { + try { fs.mkdirSync(stateDir, { recursive: true }); } catch {} +} + +function loadAll() { + try { + const raw = fs.readFileSync(LOCATIONS_PATH, "utf8").trim(); + if (!raw) return {}; + const parsed = JSON.parse(raw); + return parsed && typeof parsed === "object" ? parsed : {}; + } catch (e) { + if (e.code === "ENOENT") return {}; + return {}; + } +} + +function saveAll(map) { + ensureDir(); + const tmp = `${LOCATIONS_PATH}.tmp`; + fs.writeFileSync(tmp, JSON.stringify(map, null, 2)); + fs.renameSync(tmp, LOCATIONS_PATH); +} + +export function listLocations() { + return loadAll(); +} + +export function getLocation(name) { + const all = loadAll(); + return all[name] ?? null; +} + +export function setLocation(name, { + x, y, z, + dimension = "overworld", + radius = 0, + note = "", +}) { + if (!name) throw new Error("setLocation: name required"); + if (typeof x !== "number" || typeof y !== "number" || typeof z !== "number") { + throw new Error("setLocation: x/y/z must be numbers"); + } + const all = loadAll(); + all[name] = { + x: Math.round(x), + y: Math.round(y), + z: Math.round(z), + dimension, + radius, + note, + ts: new Date().toISOString(), + }; + saveAll(all); + return all[name]; +} + +export function removeLocation(name) { + const all = loadAll(); + if (!(name in all)) return false; + delete all[name]; + saveAll(all); + return true; +} + +// Find the named location closest to (x,y,z) — useful for "go back to +// base" when there are multiple shelters. +export function nearestLocation({ x, z }) { + const all = loadAll(); + let best = null; + for (const [name, loc] of Object.entries(all)) { + if (typeof loc?.x !== "number") continue; + const d = Math.hypot(loc.x - x, loc.z - z); + if (!best || d < best.d) best = { name, loc, d }; + } + return best; +} diff --git a/runtime/locations.test.js b/runtime/locations.test.js new file mode 100644 index 0000000..0052bd2 --- /dev/null +++ b/runtime/locations.test.js @@ -0,0 +1,78 @@ +// locations.js writes to the real state// on disk via the +// project's config.stateDir, so these tests run end-to-end against the +// active dev state dir. We pick obviously-fake location names with a +// timestamp suffix and clean up after ourselves so we never leave junk +// in the real state. + +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { setLocation, getLocation, listLocations, removeLocation, nearestLocation } from "./locations.js"; + +function tag() { + return `__test_${Date.now()}_${Math.floor(Math.random() * 1e6)}`; +} + +test("setLocation + getLocation round-trip", () => { + const name = tag(); + try { + const stored = setLocation(name, { x: 100, y: 64, z: -200, note: "smoke" }); + assert.equal(stored.x, 100); + assert.equal(stored.note, "smoke"); + const got = getLocation(name); + assert.equal(got.x, 100); + assert.equal(got.z, -200); + } finally { + removeLocation(name); + } +}); + +test("setLocation rounds floats", () => { + const name = tag(); + try { + const stored = setLocation(name, { x: 100.4, y: 64.7, z: -200.5 }); + assert.equal(stored.x, 100); + assert.equal(stored.y, 65); + assert.equal(stored.z, -200); + } finally { + removeLocation(name); + } +}); + +test("setLocation rejects missing coords", () => { + assert.throws(() => setLocation("bad", { x: 1, y: 2 }), /x\/y\/z/); + assert.throws(() => setLocation(null, { x: 0, y: 0, z: 0 }), /name required/); +}); + +test("removeLocation returns false when absent", () => { + assert.equal(removeLocation("__definitely_not_set"), false); +}); + +test("listLocations includes everything we put in", () => { + const a = tag(); + const b = tag(); + try { + setLocation(a, { x: 1, y: 64, z: 1 }); + setLocation(b, { x: 2, y: 64, z: 2 }); + const all = listLocations(); + assert.ok(all[a]); + assert.ok(all[b]); + } finally { + removeLocation(a); + removeLocation(b); + } +}); + +test("nearestLocation picks the closer of two", () => { + const a = tag(); + const b = tag(); + try { + setLocation(a, { x: 0, y: 64, z: 0 }); + setLocation(b, { x: 100, y: 64, z: 100 }); + const near = nearestLocation({ x: 5, z: 5 }); + assert.equal(near.name, a); + } finally { + removeLocation(a); + removeLocation(b); + } +}); diff --git a/runtime/reflex.js b/runtime/reflex.js index 36d31e6..2984c8c 100644 --- a/runtime/reflex.js +++ b/runtime/reflex.js @@ -1,10 +1,20 @@ -// Reflex layer: priority-ordered list of pure-script behaviors. Each reflex -// inspects the latest snapshot and either returns a no-op, dispatches an -// async action via ctx.dispatch, or completes synchronously. +// Reflex layer: priority-ordered behaviours that decide what the bot does +// each tick. The LLM is NOT called here. If every reflex declines, the tick +// yields and we try again next interval. The bot.js layer tracks consecutive +// noops and escalates to Pi after a threshold (see ESCALATE_AFTER_NOOPS). // -// LLM is NOT called here. If every reflex declines, the tick yields and we -// try again next interval. The bot.js layer tracks consecutive noops and -// escalates to Pi after a threshold (see ESCALATE_AFTER_NOOPS). +// Chain (top to bottom — first to dispatch wins): +// +// defend event-driven, hostile in melee or low HP + close +// eat event-driven, food bar low +// sleep event-driven, night without hostile in reach +// curriculum the new scheduler — reads snapshot.curriculum and dispatches +// via runtime/skills/runSkill. Replaces the old ad-hoc +// tech-tree + autonomous chop/wander branches. +// idle heartbeat logger +// +// Operator chat does NOT create tasks (Phase 0 pivot). TUI pause/stop is +// the only local override. import { info, warn } from "./log.js"; import { @@ -12,16 +22,9 @@ import { fleeFrom, eatBestFood, sleepInBed, - chopNearestTree, wander, - craftPlanks, - craftSticks, - placeCraftingTable, - craftWoodenAxe, - craftWoodenPickaxe, - craftWoodenSword, - inv, } from "./actions.js"; +import { runSkill, getSkill } from "./skills/index.js"; const REFLEX_LOG = "reflex"; @@ -32,10 +35,6 @@ const REFLEX_LOG = "reflex"; // Reflexes must NEVER throw — they log and return noop on failure. // ---- defend ---------------------------------------------------------------- -// -// Note: MC chat is dialog-only as of Phase 0 of the survival-bot PRD. There is -// no operator-goal reflex anymore — operator/player chat cannot create a -// movement/build/mining task. TUI is the only local control plane. function defendReflex(ctx) { const s = ctx.snapshot; @@ -132,131 +131,84 @@ function sleepReflex(ctx) { return { action: "dispatched", kind: "sleep", label: "night" }; } -// ---- autonomous "live your best life" -------------------------------------- - -// Triggered when no reactive reflex (operator/defend/eat/sleep) wants to act. -// Picks ONE small proactive action and runs it. Cooldown so we don't fire on -// every 3s tick — actions take 15-45s themselves and we want some breathing -// room between them. -const AUTONOMOUS_COOLDOWN_MS = 10_000; - -function autonomousReflex(ctx) { - const s = ctx.snapshot; - if (!s.connected) return { action: "noop" }; - - // Only suppress autonomous work at night when a hostile is in actual reach - // (within 16m). Distant mobs the perception layer happens to enumerate - // don't count — at this spawn there can be 80+ mobs visible but irrelevant - // to local action. - const nightClose = !s.isDay && s.closestHostile && s.closestHostile.distance <= 16; - if (nightClose) return { action: "noop" }; - - const since = Date.now() - (ctx.lastAutonomousAt ?? 0); - if (since < AUTONOMOUS_COOLDOWN_MS) return { action: "noop" }; - ctx.lastAutonomousAt = Date.now(); - - // What to do: gather wood until we have a small stockpile, then wander a - // bit to find new chunks. If a recent chop attempt reported "no reachable - // log", switch to wander for the next 60s — chopping the same not-found - // position over and over is what the user observed live. - const inv = s.inventory ?? {}; - const logCount = Object.entries(inv) - .filter(([name]) => name.endsWith("_log")) - .reduce((sum, [, n]) => sum + n, 0); - - const noTreesRecently = ctx.noTreesUntil && Date.now() < ctx.noTreesUntil; - const wantChop = logCount < 16 && !noTreesRecently; - if (wantChop) { - ctx.dispatch(() => chopNearestTree(ctx.bot), "chop tree", { - onComplete: (res) => { - if (res.ok) { - info(REFLEX_LOG, `chopped ${res.detail?.logType ?? "log"}`); - ctx.noTreesUntil = 0; // success ⇒ trees exist around us - } else if (typeof res.detail === "string" && res.detail.includes("no reachable")) { - // No log within 32 blocks of the current position. Don't try - // again for 60s — wander first to find a new biome / chunk. - ctx.noTreesUntil = Date.now() + 60_000; - } - }, - }); - return { action: "dispatched", kind: "autonomous-chop", label: "chop tree" }; - } - ctx.dispatch(() => wander(ctx.bot, 16), "wander", {}); - return { action: "dispatched", kind: "autonomous-wander", label: "wander" }; -} - -// ---- tech-tree progression ------------------------------------------------- +// ---- curriculum ------------------------------------------------------------ // -// 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) +// The new scheduler. Reads snapshot.curriculum (produced by runtime/ +// curriculum.js in bot.js's tick) and dispatches the suggested skill +// via runSkill. Falls back to wander when: +// * no curriculum result (curriculum says "everything done — late game"), +// * suggested skill is unknown to the registry, +// * recent recover() hint asked us to wander (e.g. no_target from +// gather.logs / gather.stone — same heuristic the old autonomous +// reflex used). // -// Each step is cheap and idempotent: if it can't act it returns noop. +// Stone-tier locks: gather.stone needs a pickaxe; the skill's own +// preconditions will reject otherwise. When that happens we record a +// short backoff so we don't dispatch-and-fail every tick. -const TECH_TREE_COOLDOWN_MS = 5_000; +const CURRICULUM_COOLDOWN_MS = 4_000; +const SKILL_BACKOFF_MS = 60_000; -function techTreeReflex(ctx) { +function curriculumReflex(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 since = Date.now() - (ctx.lastCurriculumAt ?? 0); + if (since < CURRICULUM_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, - ); + const plan = s.curriculum?.plan; + const wanderHintUntil = ctx.skillBackoff?.["__wander_hint__"] ?? 0; + const wantWander = wanderHintUntil && Date.now() < wanderHintUntil; - // 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" }; + // No skill plan from curriculum OR a recent skill asked us to wander — + // dispatch a wander fallback so we keep moving. + if (!plan?.skillId || wantWander) { + ctx.lastCurriculumAt = Date.now(); + ctx.dispatch(() => wander(ctx.bot, 16), "wander", {}); + return { action: "dispatched", kind: "curriculum-wander", label: "wander" }; } - return { action: "noop" }; + const skillId = plan.skillId; + const skill = getSkill(skillId); + if (!skill) { + // Curriculum 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})` }; + } + + // Per-skill backoff: if this exact skill failed with a non-recoverable + // reason recently (missing_tool, missing_material, no_target) we give it + // breathing room rather than retrying every cooldown. + const backoffUntil = ctx.skillBackoff?.[skillId] ?? 0; + if (Date.now() < backoffUntil) return { action: "noop" }; + + ctx.lastCurriculumAt = Date.now(); + ctx.dispatch(() => runSkill(skillId, ctx), skillId, { + onComplete: (res) => { + ctx.skillBackoff = ctx.skillBackoff ?? {}; + if (res?.recovery?.hint === "wander") { + // Same fix the old autonomous reflex applied for "no reachable + // log" — switch to exploration for a minute. + ctx.skillBackoff["__wander_hint__"] = Date.now() + SKILL_BACKOFF_MS; + } + if (!res?.ok) { + // missing_tool / missing_material / no_target shouldn't be + // retried on the very next tick. Hold for SKILL_BACKOFF_MS. + const cooldownCodes = new Set(["missing_tool", "missing_material", "no_target", "no_food_source", "unsupported_version"]); + if (cooldownCodes.has(res?.code)) { + ctx.skillBackoff[skillId] = Date.now() + SKILL_BACKOFF_MS; + } + } else { + // Success clears the wander hint immediately. + ctx.skillBackoff["__wander_hint__"] = 0; + } + }, + }); + return { action: "dispatched", kind: "curriculum-skill", label: skillId }; } // ---- idle ------------------------------------------------------------------ @@ -277,8 +229,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: "curriculum", fn: curriculumReflex }, { name: "idle", fn: idleReflex }, ]; @@ -301,3 +252,6 @@ export function runTick(ctx) { } return null; } + +// Exposed for tests. +export const _internal = { curriculumReflex, defendReflex, eatReflex, sleepReflex }; diff --git a/runtime/reflex.test.js b/runtime/reflex.test.js new file mode 100644 index 0000000..02ca1c2 --- /dev/null +++ b/runtime/reflex.test.js @@ -0,0 +1,227 @@ +// Tests for the new curriculum-driven scheduler in runtime/reflex.js. +// We exercise runTick with synthetic snapshots + a recording ctx so we can +// assert which reflex fires and what gets dispatched. + +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { runTick } from "./reflex.js"; + +function makeCtx({ + snapshot, + busy = false, + bot = { entities: {}, entity: { position: { x: 0, y: 64, z: 0 } } }, + skillBackoff, + lastEatAt = 0, + lastSleepAttemptAt = 0, + lastCurriculumAt = 0, +} = {}) { + const dispatches = []; + const ctx = { + bot, + snapshot, + busy, + currentActionLabel: null, + lastEatAt, + lastSleepAttemptAt, + lastCurriculumAt, + skillBackoff, + dispatch(fn, label, opts = {}) { + dispatches.push({ label, opts }); + }, + }; + return { ctx, dispatches }; +} + +test("busy ctx returns skipped without dispatching", () => { + const { ctx, dispatches } = makeCtx({ + snapshot: { connected: true, curriculum: { plan: { skillId: "gather.logs" } } }, + busy: true, + currentActionLabel: "chopping", + }); + const out = runTick(ctx); + assert.equal(out.reflex, "busy"); + assert.equal(out.action, "skipped"); + assert.equal(dispatches.length, 0); +}); + +test("disconnected snapshot → no dispatch", () => { + const { ctx, dispatches } = makeCtx({ snapshot: { connected: false } }); + const out = runTick(ctx); + assert.equal(out, null); + assert.equal(dispatches.length, 0); +}); + +test("defend wins over curriculum when hostile in melee", () => { + const { ctx, dispatches } = makeCtx({ + snapshot: { + connected: true, + health: 20, + closestHostile: { name: "zombie", distance: 3 }, + curriculum: { plan: { skillId: "gather.logs" } }, + }, + }); + const out = runTick(ctx); + assert.equal(out.reflex, "defend"); + assert.match(dispatches[0].label, /attack zombie/); +}); + +test("eat wins over curriculum when food low and bot has food", () => { + const { ctx, dispatches } = makeCtx({ + snapshot: { + connected: true, + health: 20, + food: 10, + curriculum: { plan: { skillId: "gather.logs" } }, + }, + }); + const out = runTick(ctx); + assert.equal(out.reflex, "eat"); + assert.equal(dispatches[0].label, "eat"); +}); + +test("curriculum dispatches suggested skill by id", () => { + const { ctx, dispatches } = makeCtx({ + snapshot: { + connected: true, + health: 20, + food: 20, + isDay: true, + curriculum: { plan: { skillId: "gather.logs" } }, + }, + }); + const out = runTick(ctx); + assert.equal(out.reflex, "curriculum"); + assert.equal(dispatches[0].label, "gather.logs"); + assert.ok(typeof dispatches[0].opts.onComplete === "function"); +}); + +test("curriculum falls back to wander when no plan", () => { + const { ctx, dispatches } = makeCtx({ + snapshot: { + connected: true, + health: 20, + food: 20, + isDay: true, + curriculum: null, + }, + }); + const out = runTick(ctx); + assert.equal(out.reflex, "curriculum"); + assert.equal(dispatches[0].label, "wander"); +}); + +test("curriculum falls back to wander when skill id is unknown", () => { + const { ctx, dispatches } = makeCtx({ + snapshot: { + connected: true, + health: 20, + food: 20, + isDay: true, + curriculum: { plan: { skillId: "shelter.assemble" } }, // not registered yet + }, + }); + const out = runTick(ctx); + assert.equal(out.reflex, "curriculum"); + assert.match(dispatches[0].label, /^wander/); +}); + +test("curriculum honours per-skill backoff", () => { + const future = Date.now() + 30_000; + const { ctx, dispatches } = makeCtx({ + snapshot: { + connected: true, + health: 20, + food: 20, + isDay: true, + curriculum: { plan: { skillId: "gather.stone" } }, + }, + skillBackoff: { "gather.stone": future }, + }); + const out = runTick(ctx); + // Backoff blocks the curriculum reflex; idle still won't fire on this + // tick (idleCounter not at the 20-tick mark) so runTick yields. + assert.equal(out, null); + assert.equal(dispatches.length, 0); +}); + +test("wander-hint backoff swaps skill for wander on the next tick", () => { + const { ctx, dispatches } = makeCtx({ + snapshot: { + connected: true, + health: 20, + food: 20, + isDay: true, + curriculum: { plan: { skillId: "gather.logs" } }, + }, + skillBackoff: { __wander_hint__: Date.now() + 30_000 }, + }); + const out = runTick(ctx); + assert.equal(out.reflex, "curriculum"); + assert.equal(dispatches[0].label, "wander"); +}); + +test("onComplete sets wander hint when skill recovery says so", () => { + const { ctx, dispatches } = makeCtx({ + snapshot: { + connected: true, + health: 20, + food: 20, + isDay: true, + curriculum: { plan: { skillId: "gather.logs" } }, + }, + }); + runTick(ctx); + const cb = dispatches[0].opts.onComplete; + cb({ ok: false, code: "no_target", recovery: { hint: "wander" } }); + assert.ok((ctx.skillBackoff?.__wander_hint__ ?? 0) > Date.now()); +}); + +test("onComplete clears wander hint on success", () => { + const { ctx, dispatches } = makeCtx({ + snapshot: { + connected: true, + health: 20, + food: 20, + isDay: true, + curriculum: { plan: { skillId: "gather.logs" } }, + }, + skillBackoff: { __wander_hint__: Date.now() + 30_000 }, + }); + // With wander hint active, first tick will choose wander, not gather.logs. + runTick(ctx); + // Simulate the wander completing and clearing the hint manually since + // wander is dispatched via actions.js directly (no recover hint). + ctx.skillBackoff.__wander_hint__ = 0; + + // Next tick — past the curriculum cooldown. + ctx.lastCurriculumAt = 0; + const { ctx: ctx2, dispatches: dispatches2 } = makeCtx({ + snapshot: { + connected: true, + health: 20, + food: 20, + isDay: true, + curriculum: { plan: { skillId: "gather.logs" } }, + }, + }); + runTick(ctx2); + dispatches2[0].opts.onComplete({ ok: true, code: "done" }); + assert.equal(ctx2.skillBackoff.__wander_hint__, 0); +}); + +test("onComplete sets per-skill backoff on cooldown-class failures", () => { + const { ctx, dispatches } = makeCtx({ + snapshot: { + connected: true, + health: 20, + food: 20, + isDay: true, + curriculum: { plan: { skillId: "gather.stone" } }, + }, + }); + runTick(ctx); + const cb = dispatches[0].opts.onComplete; + cb({ ok: false, code: "missing_tool", detail: "no pickaxe" }); + assert.ok((ctx.skillBackoff?.["gather.stone"] ?? 0) > Date.now()); +}); diff --git a/runtime/skills/choose-base.js b/runtime/skills/choose-base.js new file mode 100644 index 0000000..7c3d64c --- /dev/null +++ b/runtime/skills/choose-base.js @@ -0,0 +1,58 @@ +// village.choose-base — score the bot's current position as a candidate +// base site, and if it clears the minimum bar, persist it under +// state//locations.json as "base". The skill is intentionally +// shallow: a single tick at the bot's current footing, no global scan. +// The curriculum can dispatch it repeatedly while the bot wanders, and +// the threshold means most calls will return `code: "too_weak"` and +// move on. + +import { scoreCurrentPosition } from "../base-site.js"; +import { setLocation, getLocation } from "../locations.js"; + +const MIN_BASE_SCORE = 8; // out of ~14 max; tuned to "good enough" + +export const skill = Object.freeze({ + id: "village.choose-base", + title: "Score the current spot as a base candidate", + timeoutMs: 5_000, + preconditions(ctx) { + if (!ctx?.bot) return { ok: false, code: "no_bot", detail: "bot missing" }; + // If we already have a base, this skill is a no-op the curriculum + // shouldn't be asking for. Defer gracefully. + if (getLocation("base")) return { ok: false, code: "already_have_base", detail: "base location already set" }; + return { ok: true }; + }, + async execute(ctx) { + const result = scoreCurrentPosition(ctx.bot); + if (!result?.position) { + return { ok: false, code: "no_position", detail: "bot has no position", worldDelta: null }; + } + if (result.score < MIN_BASE_SCORE) { + return { + ok: false, + code: "too_weak", + detail: { score: result.score, reasons: result.reasons }, + worldDelta: null, + }; + } + const loc = setLocation("base", { + x: result.position.x, + y: result.position.y, + z: result.position.z, + dimension: ctx.snapshot?.dimension ?? "overworld", + radius: 8, + note: `auto-chosen base, score=${result.score}`, + }); + return { + ok: true, + code: "done", + detail: { location: loc, score: result.score, reasons: result.reasons }, + worldDelta: { baseAt: { x: loc.x, y: loc.y, z: loc.z }, score: result.score }, + }; + }, + recover(ctx, result) { + // Most failures (too_weak) want us to wander and re-evaluate. + if (result.code === "too_weak") return { hint: "wander", reason: "current spot doesn't pass base threshold" }; + return null; + }, +}); diff --git a/runtime/skills/index.js b/runtime/skills/index.js index 56b1d7e..a3b7616 100644 --- a/runtime/skills/index.js +++ b/runtime/skills/index.js @@ -26,6 +26,7 @@ import { skill as chopLogs } from "./chop-logs.js"; import { skill as eat } from "./eat.js"; import { skill as wander } from "./wander.js"; import { skill as gatherStone } from "./gather-stone.js"; +import { skill as chooseBase } from "./choose-base.js"; import { craftPlanksSkill, craftSticksSkill, @@ -55,6 +56,7 @@ register(chopLogs); register(eat); register(wander); register(gatherStone); +register(chooseBase); register(craftPlanksSkill); register(craftSticksSkill); register(craftWoodenAxeSkill);