diff --git a/package.json b/package.json index ed4b39f..175853f 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 runtime/reflex.test.js runtime/base-site.test.js runtime/locations.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 runtime/watch-filter.test.js scripts/edit-scope.test.js" }, "dependencies": { "dotenv": "^16.4.5", diff --git a/runtime/actions.js b/runtime/actions.js index f1bbf9f..e2392bf 100644 --- a/runtime/actions.js +++ b/runtime/actions.js @@ -5,6 +5,12 @@ import pathfinderPkg from "mineflayer-pathfinder"; const { pathfinder, goals, Movements } = pathfinderPkg; +import collectBlockPkg from "mineflayer-collectblock"; +const collectBlockPlugin = + collectBlockPkg.plugin ?? + collectBlockPkg.default?.plugin ?? + collectBlockPkg.default ?? + collectBlockPkg; import { info, warn } from "./log.js"; @@ -29,6 +35,19 @@ function ensurePathfinder(bot) { pluginLoaded.add(bot); } +// collectblock handles the full "find → approach → reposition → dig → +// pickup" cycle which raw bot.dig + pathfinder.goto does not. The old +// chop primitive "clicked once and stopped" because bot.dig requires a +// stable LoS that GoalGetToBlock doesn't always satisfy — bot ended up +// in leaves above the log and swung once with no progress. +let collectBlockLoaded = new WeakSet(); +function ensureCollectBlock(bot) { + ensurePathfinder(bot); + if (collectBlockLoaded.has(bot)) return; + bot.loadPlugin(collectBlockPlugin); + collectBlockLoaded.add(bot); +} + // Each action that uses pathfinder should set its own Movements profile // before calling goto — otherwise it inherits whatever the previous caller // left set, which has caused live regressions (e.g. flee setting canDig=false, @@ -203,11 +222,18 @@ const BED_NAMES = [ "black_bed", ]; +function carriedBedItem(bot) { + for (const item of bot.inventory.items()) { + if (BED_NAMES.includes(item.name)) return item; + } + return null; +} + export async function sleepInBed(bot) { // Already in a bed? if (bot.isSleeping) return { ok: true, detail: "already sleeping" }; - // Find a nearby placed bed first. + // 1. Find a nearby placed bed first. const bedBlock = bot.findBlock({ matching: (b) => BED_NAMES.includes(b?.name), maxDistance: 16, @@ -231,10 +257,46 @@ export async function sleepInBed(bot) { } } - // No placed bed — try placing one if we carry one. Skip — we don't want to - // invent a base location accidentally. Future: only place when at our base - // per locations.json. - return { ok: false, detail: "no bed in range and won't place blindly" }; + // 2. No placed bed — if we're carrying one, place it right next to us + // and sleep on it. This is critical so the bot stops blocking player + // night-skipping the moment it owns a bed. We pick a footing block at + // the bot's feet level + 1 in the +X direction. + const carried = carriedBedItem(bot); + if (carried) { + const here = bot.entity.position; + const referenceBlock = bot.blockAt(here.offset(1, -1, 0)); + const targetSlot = bot.blockAt(here.offset(1, 0, 0)); + if (!referenceBlock || !referenceBlock.boundingBox || referenceBlock.boundingBox === "empty") { + return { ok: false, detail: "no solid ground to place bed on" }; + } + if (targetSlot && targetSlot.boundingBox && targetSlot.boundingBox !== "empty") { + return { ok: false, detail: "no space to place bed" }; + } + try { + await withTimeout(bot.equip(carried, "hand"), 3000, "equip bed"); + await withTimeout( + bot.placeBlock(referenceBlock, { x: 0, y: 1, z: 0 }), + 5000, + "placeBlock(bed)", + ); + info("action", `sleep: placed ${carried.name} at ${referenceBlock.position.x + 0},${referenceBlock.position.y + 1},${referenceBlock.position.z + 0}`); + // Re-scan for the placed bed (its block name may differ from the + // item name slightly, e.g. on some servers, and the placement may + // have shifted to an adjacent slot for the bed's second half). + const placed = bot.findBlock({ + matching: (b) => BED_NAMES.includes(b?.name), + maxDistance: 4, + }); + if (!placed) return { ok: false, detail: "placed bed not found after placement" }; + await withTimeout(bot.sleep(placed), 10_000, "bot.sleep(placed)"); + return { ok: true, detail: { bedAt: placed.position, placed: true, name: carried.name } }; + } catch (e) { + warn("action", `sleep place+sleep failed: ${e.message}`); + return { ok: false, detail: e.message }; + } + } + + return { ok: false, detail: "no bed in inventory or nearby" }; } // ---- gathering ------------------------------------------------------------- @@ -306,7 +368,7 @@ export async function chopNearestTree(bot) { }); if (!log) return { ok: false, detail: "no reachable log within 32 blocks" }; - ensurePathfinder(bot); + ensureCollectBlock(bot); setMovementsForGather(bot); const axe = await equipBestAxe(bot); info( @@ -314,15 +376,7 @@ export async function chopNearestTree(bot) { `chop: ${log.name} at ${log.position.x},${log.position.y},${log.position.z} (tool=${axe ?? "fists"})`, ); try { - await withTimeout( - bot.pathfinder.goto(new goals.GoalGetToBlock(log.position.x, log.position.y, log.position.z)), - 45_000, - "pathToLog", - ); - await withTimeout(bot.dig(log), 30_000, "digLog"); - // Walk over the dropped item briefly (collectblock plugin would do this - // for us, but a simple sleep-then-resume is enough for now). - await new Promise((r) => setTimeout(r, 1200)); + await withTimeout(bot.collectBlock.collect(log), 60_000, "collectLog"); return { ok: true, detail: { logType: log.name, at: log.position } }; } catch (e) { warn("action", `chop failed: ${e.message}`); diff --git a/runtime/bot.js b/runtime/bot.js index cc2748d..ab0cd51 100644 --- a/runtime/bot.js +++ b/runtime/bot.js @@ -639,6 +639,17 @@ function tick() { lastSnapshot.busy = reflexCtx.busy ? { label: reflexCtx.currentActionLabel ?? "?" } : null; + // Curriculum + locations MUST be computed BEFORE runTick so the + // curriculum reflex sees the suggested skill in snapshot.curriculum. + // (Pre-2026-05-26 they were computed after — every tick fell through + // to the wander fallback because plan.skillId was undefined.) + try { + lastSnapshot.locations = listLocations(); + } catch { + lastSnapshot.locations = {}; + } + const curriculumEarly = nextCurriculumMilestone(lastSnapshot); + lastSnapshot.curriculum = curriculumEarly; reflexCtx.snapshot = lastSnapshot; if (!reflexPaused) { const result = runTick(reflexCtx); @@ -676,20 +687,9 @@ function tick() { lastSnapshot.activeSkill = reflexCtx.busy ? reflexCtx.currentActionLabel : reflexCtx.lastReflex?.label ?? null; - // Two sources of "next milestone": - // - planner.md (LLM-written, free-form, advisory) - // - 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; + // Curriculum + locations were already computed before runTick (above). + // Re-stamp the title here so observability fields stay together. + const curriculum = lastSnapshot.curriculum; lastSnapshot.currentMilestone = curriculum?.milestone?.title ?? cachedMilestone; lastSnapshot.lastResult = lastResult; lastSnapshot.noProgressReason = noProgressReason; diff --git a/runtime/curriculum.js b/runtime/curriculum.js index 2c2d929..b967a2b 100644 --- a/runtime/curriculum.js +++ b/runtime/curriculum.js @@ -37,6 +37,31 @@ function totalCobble(inv) { return (inv?.cobblestone ?? 0) + (inv?.cobbled_deepslate ?? 0); } +const BED_COLORS = [ + "white", "orange", "magenta", "light_blue", "yellow", "lime", "pink", + "gray", "light_gray", "cyan", "purple", "blue", "brown", "green", + "red", "black", +]; + +function hasAnyBed(inv) { + return BED_COLORS.some((c) => (inv?.[`${c}_bed`] ?? 0) > 0); +} + +function totalWool(inv) { + return Object.entries(inv ?? {}) + .filter(([k]) => k.endsWith("_wool")) + .reduce((s, [, n]) => s + n, 0); +} + +function maxSingleColourWool(inv) { + let best = 0; + for (const c of BED_COLORS) { + const n = inv?.[`${c}_wool`] ?? 0; + if (n > best) best = n; + } + return best; +} + function has(inv, name, n = 1) { return (inv?.[name] ?? 0) >= n; } @@ -89,6 +114,22 @@ const MILESTONES = [ return null; }, }, + // EARLY — before stone-tier work — get a bed so the bot can sleep at + // night and stop blocking other players from skipping night. Three + // substeps: (1) gather 3 wool of one colour, (2) craft.bed, (3) sleep + // in/on it (handled by the sleep reflex, which now places a carried + // bed). We use total wool ≥ 3 as the "have enough wool" proxy; the + // craft.bed skill itself enforces same-colour-wool requirement. + { + id: "survive.bed", + title: "Have a bed (sleep through the night)", + isDone: (inv) => hasAnyBed(inv) || hasStoneTier(inv) && totalCobble(inv) > 0, // either we have a bed, or we're already deep into stone tier (rare path where wool wasn't accessible) + suggest: (inv) => { + // Need same-colour wool stack of ≥3 + if (maxSingleColourWool(inv) < 3) return { skillId: "gather.wool" }; + return { skillId: "craft.bed" }; + }, + }, { id: "stone.32", title: "Gather 32 cobblestone", @@ -140,6 +181,12 @@ const MILESTONES = [ isDone: (_inv, snap) => !!snap?.locations?.base, suggest: () => ({ skillId: "village.choose-base" }), }, + { + id: "village.shelter", + title: "Build a tiny shelter at the base", + isDone: (_inv, snap) => !!snap?.locations?.shelter, + suggest: () => ({ skillId: "village.build-shelter" }), + }, ]; export function isInventoryFull(snapshot) { diff --git a/runtime/curriculum.test.js b/runtime/curriculum.test.js index 478fd97..80d8ecf 100644 --- a/runtime/curriculum.test.js +++ b/runtime/curriculum.test.js @@ -22,20 +22,31 @@ function snapAfter(stage, addInventory = {}, extras = {}) { oak_log: 16, oak_planks: 6, stick: 6, wooden_axe: 1, wooden_pickaxe: 1, wooden_sword: 1, }, + // New: survive.bed sits between wood.tools and stone.32, so every + // later-stage baseline carries a red_bed in inventory to mark the + // bed milestone as done. + "survive.bed": { + oak_log: 16, oak_planks: 6, stick: 6, + wooden_axe: 1, wooden_pickaxe: 1, wooden_sword: 1, + red_bed: 1, + }, "stone.32": { oak_log: 16, oak_planks: 6, stick: 6, wooden_axe: 1, wooden_pickaxe: 1, wooden_sword: 1, + red_bed: 1, cobblestone: 32, }, "stone.tools": { oak_log: 16, oak_planks: 6, stick: 8, wooden_axe: 1, wooden_pickaxe: 1, wooden_sword: 1, + red_bed: 1, cobblestone: 8, stone_axe: 1, stone_pickaxe: 1, stone_sword: 1, furnace: 1, }, "food.basic": { oak_log: 16, oak_planks: 6, stick: 8, wooden_axe: 1, wooden_pickaxe: 1, wooden_sword: 1, + red_bed: 1, cobblestone: 8, stone_axe: 1, stone_pickaxe: 1, stone_sword: 1, furnace: 1, bread: 4, @@ -43,6 +54,7 @@ function snapAfter(stage, addInventory = {}, extras = {}) { "storage.chest": { oak_log: 16, oak_planks: 6, stick: 8, wooden_axe: 1, wooden_pickaxe: 1, wooden_sword: 1, + red_bed: 1, cobblestone: 8, stone_axe: 1, stone_pickaxe: 1, stone_sword: 1, furnace: 1, bread: 4, chest: 1, @@ -83,8 +95,23 @@ test("wooden axe present, pickaxe missing → craft.wooden-pickaxe", () => { assert.equal(got.plan.skillId, "craft.wooden-pickaxe"); }); -test("wooden tools done, no cobble → stone.32, suggests gather.stone", () => { +// After wood tools the curriculum first asks for a bed (survive.bed, +// new 2026-05-26) — we need a bed before stone-tier so the bot can +// sleep through the night and stop blocking other players. +test("wooden tools done, no bed → survive.bed, suggests gather.wool", () => { const got = nextMilestone(snapAfter("wood.tools")); + assert.equal(got.milestone.id, "survive.bed"); + assert.equal(got.plan.skillId, "gather.wool"); +}); + +test("wool ready but no bed → survive.bed, suggests craft.bed", () => { + const got = nextMilestone(snapAfter("wood.tools", { red_wool: 3 })); + assert.equal(got.milestone.id, "survive.bed"); + assert.equal(got.plan.skillId, "craft.bed"); +}); + +test("bed acquired → curriculum advances to stone.32", () => { + const got = nextMilestone(snapAfter("survive.bed")); assert.equal(got.milestone.id, "stone.32"); assert.equal(got.plan.skillId, "gather.stone"); }); @@ -118,12 +145,19 @@ test("all done → null", () => { const inv = { oak_log: 16, oak_planks: 8, stick: 8, wooden_axe: 1, wooden_pickaxe: 1, wooden_sword: 1, + red_bed: 1, cobblestone: 32, stone_axe: 1, stone_pickaxe: 1, stone_sword: 1, furnace: 1, bread: 4, chest: 1, torch: 8, }; assert.equal( - nextMilestone(snap(inv, { food: 20, locations: { base: { x: 0, y: 64, z: 0 } } })), + nextMilestone(snap(inv, { + food: 20, + locations: { + base: { x: 0, y: 64, z: 0 }, + shelter: { x: 0, y: 64, z: 0 }, + }, + })), null, ); }); @@ -149,7 +183,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, "village.base-site"); + assert.equal(ms[ms.length - 1].id, "village.shelter"); for (const m of ms) { assert.equal(typeof m.id, "string"); assert.equal(typeof m.title, "string"); diff --git a/runtime/reflex.js b/runtime/reflex.js index 2984c8c..0a51452 100644 --- a/runtime/reflex.js +++ b/runtime/reflex.js @@ -84,24 +84,34 @@ function defendReflex(ctx) { // ---- eat ------------------------------------------------------------------- +// Lightweight food allowlist — must match what eatBestFood actually tries. +// Kept inline so reflex doesn't have to import the groups module. +const EAT_REFLEX_FOOD = new Set([ + "cooked_beef", "cooked_porkchop", "cooked_mutton", "cooked_chicken", + "cooked_rabbit", "cooked_salmon", "cooked_cod", "baked_potato", + "bread", "carrot", "apple", "sweet_berries", "melon_slice", + "beef", "porkchop", "chicken", "mutton", +]); + function eatReflex(ctx) { const s = ctx.snapshot; if (!s.connected) return { action: "noop" }; if (s.food === undefined || s.food >= 16) return { action: "noop" }; - // Don't eat if we just ate (the food bar refresh on the server has a - // small lag — re-firing within 5s would just fail bot.consume). + // Don't dispatch eat if there's literally no food in inventory — the + // previous version dispatched every tick, got "no food in inventory" and + // burned the whole reflex chain on a hopeless eat-spam. (Observed live + // 2026-05-26.) The curriculum has food.basic as a real milestone now; + // keep eat reflex strictly for "we have food, eat it" cases. + const inv = s.inventory ?? {}; + const hasFood = Object.keys(inv).some((n) => EAT_REFLEX_FOOD.has(n)); + if (!hasFood) return { action: "noop" }; + // Cooldown on attempts (not only successes). Bot.consume has lag on the + // server and re-firing within 5s would just fail. We update lastEatAt + // on EVERY dispatch so a failed attempt also respects the cooldown. const since = Date.now() - (ctx.lastEatAt ?? 0); if (since < 5000) return { action: "noop" }; - - ctx.dispatch( - () => eatBestFood(ctx.bot), - "eat", - { - onComplete: (res) => { - if (res.ok) ctx.lastEatAt = Date.now(); - }, - }, - ); + ctx.lastEatAt = Date.now(); + ctx.dispatch(() => eatBestFood(ctx.bot), "eat", {}); return { action: "dispatched", kind: "eat", label: `food=${s.food}` }; } diff --git a/runtime/skills/build-shelter.js b/runtime/skills/build-shelter.js new file mode 100644 index 0000000..c0a70a2 --- /dev/null +++ b/runtime/skills/build-shelter.js @@ -0,0 +1,188 @@ +// village.build-shelter — place a minimal 3×3×3 hut around the bot's +// recorded base (or current position if no base yet). The blueprint is +// computed once per call as a list of {x,y,z,blockType} targets, then +// the skill places them in order, marking each placed block in the +// owned-blocks ledger. Idempotent: any target that already holds the +// right block is skipped, so the skill is resumable across restarts. +// +// Walls use any *_planks the bot carries (we pick the most-common type). +// The interior keeps the bed slot empty (assumes the bed sits on +// (cx+1, cy, cz) — i.e. one step east of the centre). +// +// Out of scope here: door entity (mineflayer can't reliably place doors +// without recent version checks). The west wall has a 1-block opening +// at head-height the bot can step through. + +import { applyProfile, PROFILES } from "../movement-profiles.js"; +import { info, warn } from "../log.js"; +import { getLocation, setLocation } from "../locations.js"; + +const SHELTER_NAME = "shelter"; + +function withTimeout(promise, ms, label) { + let timer; + const timeout = new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms / 1000}s`)), ms); + }); + return Promise.race([promise, timeout]).finally(() => clearTimeout(timer)); +} + +function pickBuildPlanks(bot) { + const counts = new Map(); + for (const item of bot.inventory.items()) { + if (item.name.endsWith("_planks")) { + counts.set(item.name, (counts.get(item.name) ?? 0) + item.count); + } + } + let best = null; + for (const [name, n] of counts) { + if (!best || n > best.n) best = { name, n }; + } + return best; +} + +// Build a list of {x,y,z, name} targets for a 3×3 footprint × 3-tall +// shelter centred on (cx, cy, cz). Bed slot (cx+1, cy, cz) and head- +// height entry at the west wall (cx-1, cy+1, cz) are left empty. +function blueprint(center, plankName) { + const targets = []; + const { x: cx, y: cy, z: cz } = center; + // Floor: only the corners we don't already have (skip the bed slot). + for (let dx = -1; dx <= 1; dx++) { + for (let dz = -1; dz <= 1; dz++) { + targets.push({ x: cx + dx, y: cy - 1, z: cz + dz, name: plankName }); + } + } + // Walls (y = cy and y = cy+1). + for (let dy = 0; dy <= 1; dy++) { + for (let dx = -1; dx <= 1; dx++) { + for (let dz = -1; dz <= 1; dz++) { + const isCorner = Math.abs(dx) + Math.abs(dz) === 2; + const isWall = Math.abs(dx) === 1 || Math.abs(dz) === 1; + if (!isWall && !isCorner) continue; // skip interior column + // Leave one wall slot open as a doorway: west wall, head height. + if (dx === -1 && dz === 0 && dy === 1) continue; + // Bed occupies (cx+1, cy, cz) — its second half is at (cx+2, cy, cz) + // which is OUTSIDE this 3×3 footprint. So we don't need to clear it. + targets.push({ x: cx + dx, y: cy + dy, z: cz + dz, name: plankName }); + } + } + } + // Roof: full 3×3 at y = cy+2. + for (let dx = -1; dx <= 1; dx++) { + for (let dz = -1; dz <= 1; dz++) { + targets.push({ x: cx + dx, y: cy + 2, z: cz + dz, name: plankName }); + } + } + return targets; +} + +function blockMatchesName(block, name) { + return block && block.name === name; +} + +function findReferenceForPlacement(bot, target) { + // Try the block below the target first (most natural place to stack + // from). If it's air, try sides. + const offsets = [ + { x: 0, y: -1, z: 0, face: { x: 0, y: 1, z: 0 } }, + { x: -1, y: 0, z: 0, face: { x: 1, y: 0, z: 0 } }, + { x: 1, y: 0, z: 0, face: { x: -1, y: 0, z: 0 } }, + { x: 0, y: 0, z: -1, face: { x: 0, y: 0, z: 1 } }, + { x: 0, y: 0, z: 1, face: { x: 0, y: 0, z: -1 } }, + { x: 0, y: 1, z: 0, face: { x: 0, y: -1, z: 0 } }, + ]; + for (const off of offsets) { + const block = bot.blockAt({ + x: target.x + off.x, + y: target.y + off.y, + z: target.z + off.z, + }); + if (block && block.boundingBox === "block") return { ref: block, face: off.face }; + } + return null; +} + +export const skill = Object.freeze({ + id: "village.build-shelter", + title: "Build a tiny shelter around the bed", + timeoutMs: 5 * 60_000, + preconditions(ctx) { + if (!ctx?.bot) return { ok: false, code: "no_bot", detail: "bot missing" }; + const planks = pickBuildPlanks(ctx.bot); + if (!planks || planks.n < 18) { + return { ok: false, code: "missing_material", detail: `need ≥18 planks (have ${planks?.n ?? 0})` }; + } + // Need a base or at least a placed bed nearby; the chooseBase skill + // is responsible for picking the spot first. + const base = getLocation("base") ?? getLocation(SHELTER_NAME); + if (!base) return { ok: false, code: "no_base", detail: "no base location chosen yet" }; + return { ok: true }; + }, + async execute(ctx, { owned } = {}) { + const bot = ctx.bot; + const planks = pickBuildPlanks(bot); + const base = getLocation("base") ?? getLocation(SHELTER_NAME); + const center = { x: base.x, y: base.y, z: base.z }; + + applyProfile(PROFILES.BUILD, bot); + + const targets = blueprint(center, planks.name); + info("action", `village.build-shelter: ${targets.length} blocks (planks=${planks.name})`); + + let placed = 0; + let skipped = 0; + for (const target of targets) { + const existing = bot.blockAt(target); + if (blockMatchesName(existing, planks.name)) { + skipped++; + continue; + } + // Re-equip planks each iteration (the bot might have eaten / swapped). + const item = bot.inventory.items().find((i) => i.name === planks.name); + if (!item) { + warn("action", `village.build-shelter: ran out of ${planks.name} mid-build`); + break; + } + try { + await withTimeout(bot.equip(item, "hand"), 3000, "equip plank"); + } catch (e) { + warn("action", `village.build-shelter: equip failed: ${e.message}`); + continue; + } + + const place = findReferenceForPlacement(bot, target); + if (!place) { + warn("action", `village.build-shelter: no reference block for ${target.x},${target.y},${target.z}`); + continue; + } + try { + await withTimeout(bot.placeBlock(place.ref, place.face), 5000, "placeBlock"); + if (owned?.markPlaced) { + owned.markPlaced({ + x: target.x, y: target.y, z: target.z, + blockType: planks.name, + skill: "village.build-shelter", + }); + } + placed++; + } catch (e) { + warn("action", `village.build-shelter: place ${target.x},${target.y},${target.z} failed: ${e.message}`); + } + } + + // Record the shelter location so future skills can find it even if + // base gets re-scored. + setLocation(SHELTER_NAME, { x: center.x, y: center.y, z: center.z, radius: 2, note: `auto-built; ${placed} blocks placed` }); + + if (placed === 0 && skipped === 0) { + return { ok: false, code: "no_progress", detail: "could not place any blocks", worldDelta: null }; + } + return { + ok: true, + code: "done", + detail: { placed, skipped, total: targets.length, plankName: planks.name }, + worldDelta: { shelterAt: center, placed }, + }; + }, +}); diff --git a/runtime/skills/craft.js b/runtime/skills/craft.js index f5f968c..6b0f29f 100644 --- a/runtime/skills/craft.js +++ b/runtime/skills/craft.js @@ -229,6 +229,72 @@ export const craftChestSkill = makeRecipeSkill({ needsTable: true, }); +// Bed: 3 wool of one colour + 3 planks → 1 bed of that colour. We look +// for any colour we have ≥3 of, ditto planks, and craft that pairing. +// All bed recipes require a crafting table. +const BED_COLORS = [ + "white", "orange", "magenta", "light_blue", "yellow", "lime", "pink", + "gray", "light_gray", "cyan", "purple", "blue", "brown", "green", + "red", "black", +]; + +function countByName(bot, name) { + return bot.inventory.items().reduce((s, i) => (i.name === name ? s + i.count : s), 0); +} + +function bedColorWeCanCraft(bot) { + // Need 3 wool of a single colour. (Mixed-colour wool can't combine.) + for (const c of BED_COLORS) { + if (countByName(bot, `${c}_wool`) >= 3) return c; + } + return null; +} + +export const craftBedSkill = Object.freeze({ + id: "craft.bed", + title: "Craft a bed", + timeoutMs: 30_000, + preconditions(ctx) { + if (!ctx?.bot) return { ok: false, code: "no_bot", detail: "bot missing" }; + const color = bedColorWeCanCraft(ctx.bot); + if (!color) return { ok: false, code: "missing_material", detail: "need 3 wool of one colour" }; + if (totalPlanks(ctx.bot) < 3) return { ok: false, code: "missing_material", detail: "need 3 planks" }; + return { ok: true }; + }, + async execute(ctx) { + const bot = ctx.bot; + const color = bedColorWeCanCraft(bot); + const item = `${color}_bed`; + const tableRes = await placeCraftingTable(bot); + if (!tableRes.ok) { + const msg = String(tableRes.detail ?? ""); + const code = msg.includes("timed out") ? "timeout" : "missing_table"; + return { ok: false, code, detail: tableRes.detail, worldDelta: null }; + } + const reg = bot?.registry; + const itemId = reg?.itemsByName?.[item]?.id; + if (itemId == null) { + return { ok: false, code: "unsupported_version", detail: `no ${item} in registry`, worldDelta: null }; + } + const recipes = bot.recipesFor(itemId, null, 1, tableRes.block); + const recipe = recipes[0]; + if (!recipe) { + return { ok: false, code: "no_recipe", detail: `no recipe for ${item}`, worldDelta: null }; + } + try { + await withTimeout(bot.craft(recipe, 1, tableRes.block), 15_000, `craft(${item})`); + return { ok: true, code: "done", detail: { item }, worldDelta: { crafted: item } }; + } catch (e) { + const msg = String(e?.message ?? ""); + const code = msg.includes("timed out") ? "timeout" : "failed"; + return { ok: false, code, detail: e.message, worldDelta: null }; + } + }, + validate(ctx, result) { + return result.ok && !!result.worldDelta?.crafted; + }, +}); + // Torch: 1 stick + 1 coal (or charcoal) → 4 torches. We accept either // fuel via precondition shortcut: if no coal AND no charcoal, fail with // missing_material so the curriculum surfaces the blocker rather than diff --git a/runtime/skills/deposit-surplus.js b/runtime/skills/deposit-surplus.js new file mode 100644 index 0000000..e0796e1 --- /dev/null +++ b/runtime/skills/deposit-surplus.js @@ -0,0 +1,127 @@ +// village.deposit-surplus — find the nearest placed chest, open it, and +// transfer any stack the bot is over-carrying (logs, cobble, dirt, +// seeds). Keeps a small "essentials" reserve in inventory so the bot +// keeps its tools, food and bed. +// +// What counts as surplus: +// * any item whose count exceeds RESERVE_PER_NAME (default: keep 8 of +// each named item), UNLESS it's in KEEP_ALWAYS (tools/bed/food). +// * raw materials that look strictly storable (logs/cobble/dirt/sand). + +import pathfinderPkg from "mineflayer-pathfinder"; +const { pathfinder, goals, Movements } = pathfinderPkg; + +import { applyProfile, PROFILES } from "../movement-profiles.js"; +import { info, warn } from "../log.js"; + +const KEEP_ALWAYS_NAME_RE = /(_axe|_pickaxe|_sword|_shovel|_hoe|_bed|bread|cooked_|apple|carrot|potato|wheat_seeds)$/; +const STORABLE_NAME_RE = /(_log$|_stem$|cobblestone|cobbled_deepslate|deepslate|stone$|dirt|sand|gravel|wheat$|_planks$|stick$)/; +const RESERVE_PER_NAME = 8; + +let pluginLoaded = new WeakSet(); +function ensurePathfinder(bot) { + if (pluginLoaded.has(bot)) return; + bot.loadPlugin(pathfinder); + pluginLoaded.add(bot); +} + +function withTimeout(promise, ms, label) { + let timer; + const timeout = new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms / 1000}s`)), ms); + }); + return Promise.race([promise, timeout]).finally(() => clearTimeout(timer)); +} + +function pickSurplus(bot) { + const out = []; + // Group inventory items by name, then decide how much to deposit per name. + const grouped = new Map(); + for (const item of bot.inventory.items()) { + if (!grouped.has(item.name)) grouped.set(item.name, []); + grouped.get(item.name).push(item); + } + for (const [name, items] of grouped) { + if (KEEP_ALWAYS_NAME_RE.test(name)) continue; + const total = items.reduce((s, i) => s + i.count, 0); + const storable = STORABLE_NAME_RE.test(name); + const reserve = storable ? Math.min(RESERVE_PER_NAME, total) : 0; + const surplus = total - reserve; + if (surplus <= 0) continue; + out.push({ name, surplus, items }); + } + return out; +} + +export const skill = Object.freeze({ + id: "village.deposit-surplus", + title: "Deposit surplus items in a chest", + timeoutMs: 60_000, + preconditions(ctx) { + if (!ctx?.bot) return { ok: false, code: "no_bot", detail: "bot missing" }; + const surplus = pickSurplus(ctx.bot); + if (surplus.length === 0) return { ok: false, code: "nothing_to_deposit", detail: "no surplus stacks" }; + const chest = ctx.bot.findBlock({ + matching: (b) => b?.name === "chest" || b?.name === "trapped_chest", + maxDistance: 24, + }); + if (!chest) return { ok: false, code: "no_chest", detail: "no chest within 24 blocks" }; + return { ok: true }; + }, + async execute(ctx) { + const bot = ctx.bot; + const chest = bot.findBlock({ + matching: (b) => b?.name === "chest" || b?.name === "trapped_chest", + maxDistance: 24, + }); + if (!chest) return { ok: false, code: "no_chest", detail: "no chest after move", worldDelta: null }; + + ensurePathfinder(bot); + applyProfile(PROFILES.TRAVEL, bot); + try { + await withTimeout( + bot.pathfinder.goto(new goals.GoalNear(chest.position.x, chest.position.y, chest.position.z, 1)), + 30_000, + "goto chest", + ); + } catch (e) { + return { ok: false, code: "no_path", detail: e.message, worldDelta: null }; + } + + let chestHandle; + try { + chestHandle = await withTimeout(bot.openContainer(chest), 8_000, "openChest"); + } catch (e) { + return { ok: false, code: "open_failed", detail: e.message, worldDelta: null }; + } + + let deposited = 0; + const detail = []; + try { + for (const { name, surplus } of pickSurplus(bot)) { + const ref = bot.registry?.itemsByName?.[name]; + if (!ref) continue; + try { + await withTimeout(chestHandle.deposit(ref.id, null, surplus), 10_000, `deposit ${name}`); + deposited += surplus; + detail.push(`${name}×${surplus}`); + info("action", `village.deposit-surplus: ${name}×${surplus}`); + } catch (e) { + warn("action", `village.deposit-surplus: ${name} failed: ${e.message}`); + } + } + } finally { + try { await chestHandle.close(); } catch {} + } + + if (deposited === 0) { + return { ok: false, code: "deposit_failed", detail: "opened chest but deposited nothing", worldDelta: null }; + } + return { + ok: true, + code: "done", + detail: { deposited, items: detail }, + worldDelta: { depositedTotal: deposited }, + }; + }, +}); diff --git a/runtime/skills/farm-wheat.js b/runtime/skills/farm-wheat.js new file mode 100644 index 0000000..5e71e0b --- /dev/null +++ b/runtime/skills/farm-wheat.js @@ -0,0 +1,180 @@ +// farm.wheat — opportunistic wheat farming. The skill does ONE of: +// * plant a wheat seed on a nearby tilled farmland block, OR +// * till a grass/dirt block adjacent to water if we have a hoe and seeds, +// * harvest a fully-grown wheat block. +// +// We don't try to plan a full 3×3 plot in one call — the curriculum can +// dispatch the skill repeatedly and each call makes one block of progress. +// This is consistent with the gather.logs / gather.stone "one block at a +// time" rhythm and keeps each tick observable. + +import pathfinderPkg from "mineflayer-pathfinder"; +const { pathfinder, goals, Movements } = pathfinderPkg; + +import { applyProfile, PROFILES } from "../movement-profiles.js"; +import { info, warn } from "../log.js"; + +const HOE_NAMES = ["wooden_hoe", "stone_hoe", "iron_hoe", "diamond_hoe", "netherite_hoe", "golden_hoe"]; + +let pluginLoaded = new WeakSet(); +function ensurePathfinder(bot) { + if (pluginLoaded.has(bot)) return; + bot.loadPlugin(pathfinder); + pluginLoaded.add(bot); +} + +function withTimeout(promise, ms, label) { + let timer; + const timeout = new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms / 1000}s`)), ms); + }); + return Promise.race([promise, timeout]).finally(() => clearTimeout(timer)); +} + +function getCount(bot, name) { + return bot.inventory.items().reduce((s, i) => (i.name === name ? s + i.count : s), 0); +} + +function getItem(bot, name) { + return bot.inventory.items().find((i) => i.name === name) ?? null; +} + +function hasHoe(bot) { + return HOE_NAMES.some((n) => getCount(bot, n) > 0); +} + +function findHoe(bot) { + for (const n of HOE_NAMES) { + const item = getItem(bot, n); + if (item) return item; + } + return null; +} + +function isWaterNear(bot, pos, radius = 4) { + for (let dx = -radius; dx <= radius; dx++) { + for (let dz = -radius; dz <= radius; dz++) { + const b = bot.blockAt({ x: pos.x + dx, y: pos.y, z: pos.z + dz }); + if (b?.name === "water") return true; + } + } + return false; +} + +export const skill = Object.freeze({ + id: "farm.wheat", + title: "Make one step of wheat farming progress", + timeoutMs: 60_000, + preconditions(ctx) { + if (!ctx?.bot) return { ok: false, code: "no_bot", detail: "bot missing" }; + const bot = ctx.bot; + const hasSeeds = getCount(bot, "wheat_seeds") > 0; + // We're happy to dispatch when EITHER seeds+hoe+water available + // OR there's a ripe wheat block to harvest. + const ripeWheat = bot.findBlock({ + matching: (b) => b?.name === "wheat" && (b?.metadata === 7 || b?.getProperties?.()?.age === 7), + maxDistance: 24, + }); + if (ripeWheat) return { ok: true }; + if (!hasSeeds) return { ok: false, code: "no_seeds", detail: "no wheat_seeds in inventory" }; + if (!hasHoe(bot)) return { ok: false, code: "missing_tool", detail: "no hoe" }; + // Need at least one tillable + water-adjacent block within 16. + const tillable = bot.findBlock({ + matching: (b) => (b?.name === "grass_block" || b?.name === "dirt") && isWaterNear(bot, b.position, 4), + maxDistance: 16, + }); + if (!tillable) return { ok: false, code: "no_target", detail: "no tillable grass/dirt near water" }; + return { ok: true }; + }, + async execute(ctx) { + const bot = ctx.bot; + ensurePathfinder(bot); + applyProfile(PROFILES.GATHER, bot); + + // 1. Harvest ripe wheat if any. + const ripeWheat = bot.findBlock({ + matching: (b) => b?.name === "wheat" && (b?.metadata === 7 || b?.getProperties?.()?.age === 7), + maxDistance: 24, + }); + if (ripeWheat) { + try { + await withTimeout( + bot.pathfinder.goto(new goals.GoalGetToBlock(ripeWheat.position.x, ripeWheat.position.y, ripeWheat.position.z)), + 20_000, + "goto wheat", + ); + await withTimeout(bot.dig(ripeWheat), 8_000, "harvest wheat"); + info("action", `farm.wheat: harvested wheat at ${ripeWheat.position}`); + return { + ok: true, + code: "done", + detail: { phase: "harvest", at: ripeWheat.position }, + worldDelta: { harvestedAt: ripeWheat.position }, + }; + } catch (e) { + warn("action", `farm.wheat harvest failed: ${e.message}`); + return { ok: false, code: "failed", detail: e.message, worldDelta: null }; + } + } + + // 2. Plant on existing farmland if any (water-adjacent or not, just farmland exists). + const farmland = bot.findBlock({ + matching: (b) => b?.name === "farmland", + maxDistance: 16, + }); + const seedItem = getItem(bot, "wheat_seeds"); + if (farmland && seedItem) { + try { + await withTimeout( + bot.pathfinder.goto(new goals.GoalNear(farmland.position.x, farmland.position.y, farmland.position.z, 1)), + 20_000, + "goto farmland", + ); + await withTimeout(bot.equip(seedItem, "hand"), 3000, "equip seeds"); + await withTimeout( + bot.placeBlock(farmland, { x: 0, y: 1, z: 0 }), + 5000, + "placeBlock(seeds)", + ); + return { + ok: true, + code: "done", + detail: { phase: "plant", at: farmland.position }, + worldDelta: { plantedAt: farmland.position }, + }; + } catch (e) { + warn("action", `farm.wheat plant failed: ${e.message}`); + // fall through to tilling + } + } + + // 3. Till a grass/dirt block next to water with our hoe. + const tillable = bot.findBlock({ + matching: (b) => (b?.name === "grass_block" || b?.name === "dirt") && isWaterNear(bot, b.position, 4), + maxDistance: 16, + }); + if (!tillable) { + return { ok: false, code: "no_target", detail: "no tillable block remaining", worldDelta: null }; + } + const hoe = findHoe(bot); + try { + await withTimeout( + bot.pathfinder.goto(new goals.GoalNear(tillable.position.x, tillable.position.y, tillable.position.z, 2)), + 20_000, + "goto tillable", + ); + await withTimeout(bot.equip(hoe, "hand"), 3000, "equip hoe"); + // Activate the block (right-click): turns grass/dirt → farmland. + await withTimeout(bot.activateBlock(tillable), 5000, "till"); + return { + ok: true, + code: "done", + detail: { phase: "till", at: tillable.position }, + worldDelta: { tilledAt: tillable.position }, + }; + } catch (e) { + warn("action", `farm.wheat till failed: ${e.message}`); + return { ok: false, code: "failed", detail: e.message, worldDelta: null }; + } + }, +}); diff --git a/runtime/skills/gather-stone.js b/runtime/skills/gather-stone.js index 84ecce8..f64c5bc 100644 --- a/runtime/skills/gather-stone.js +++ b/runtime/skills/gather-stone.js @@ -4,6 +4,12 @@ import pathfinderPkg from "mineflayer-pathfinder"; const { pathfinder, goals, Movements } = pathfinderPkg; +import collectBlockPkg from "mineflayer-collectblock"; +const collectBlockPlugin = + collectBlockPkg.plugin ?? + collectBlockPkg.default?.plugin ?? + collectBlockPkg.default ?? + collectBlockPkg; import { pickaxes } from "./groups.js"; import { info, warn } from "../log.js"; @@ -17,6 +23,14 @@ function ensurePathfinder(bot) { pluginLoaded.add(bot); } +let collectBlockLoaded = new WeakSet(); +function ensureCollectBlock(bot) { + ensurePathfinder(bot); + if (collectBlockLoaded.has(bot)) return; + bot.loadPlugin(collectBlockPlugin); + collectBlockLoaded.add(bot); +} + function setMovementsForGather(bot) { const m = new Movements(bot); m.canDig = true; @@ -91,18 +105,12 @@ export const skill = Object.freeze({ return { ok: false, code: "no_target", detail: "no reachable stone within 32 blocks", worldDelta: null }; } - ensurePathfinder(bot); + ensureCollectBlock(bot); setMovementsForGather(bot); const pickaxe = await equipBestPickaxe(bot); info("action", `gather.stone: ${target.name} at ${target.position.x},${target.position.y},${target.position.z} (tool=${pickaxe ?? "fists"})`); try { - await withTimeout( - bot.pathfinder.goto(new goals.GoalGetToBlock(target.position.x, target.position.y, target.position.z)), - 45_000, - "pathToStone", - ); - await withTimeout(bot.dig(target), 30_000, "digStone"); - await new Promise((r) => setTimeout(r, 1200)); + await withTimeout(bot.collectBlock.collect(target), 60_000, "collectStone"); return { ok: true, code: "done", diff --git a/runtime/skills/gather-wool.js b/runtime/skills/gather-wool.js new file mode 100644 index 0000000..35ea334 --- /dev/null +++ b/runtime/skills/gather-wool.js @@ -0,0 +1,166 @@ +// gather.wool — get one block of wool, any colour. Three paths in order +// of preference: +// 1. Mine a placed wool block within 32 blocks (someone left one). +// 2. Shear a nearby sheep if we carry shears. +// 3. Attack a nearby sheep to drop wool (last resort; gives 1 wool). +// +// Wool is the only ingredient missing for a bed once we have planks, so +// this skill is the first real "go find an animal" task the bot has. + +import collectBlockPkg from "mineflayer-collectblock"; +import pathfinderPkg from "mineflayer-pathfinder"; + +import { info, warn } from "../log.js"; + +const { pathfinder, goals, Movements } = pathfinderPkg; +const collectBlockPlugin = + collectBlockPkg.plugin ?? + collectBlockPkg.default?.plugin ?? + collectBlockPkg.default ?? + collectBlockPkg; + +const WOOL_BLOCK_RE = /(?:^|_)wool$/; + +let pluginLoaded = new WeakSet(); +function ensurePlugins(bot) { + if (pluginLoaded.has(bot)) return; + bot.loadPlugin(pathfinder); + bot.loadPlugin(collectBlockPlugin); + pluginLoaded.add(bot); +} + +function setMovementsForGather(bot) { + const m = new Movements(bot); + m.canDig = true; + m.allow1by1towers = false; + bot.pathfinder.setMovements(m); +} + +function withTimeout(promise, ms, label) { + let timer; + const timeout = new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms / 1000}s`)), ms); + }); + return Promise.race([promise, timeout]).finally(() => clearTimeout(timer)); +} + +function woolCount(bot) { + return bot.inventory.items().reduce( + (sum, i) => (WOOL_BLOCK_RE.test(i.name) || i.name.endsWith("_wool") ? sum + i.count : sum), + 0, + ); +} + +function nearestSheep(bot) { + let best = null; + for (const e of Object.values(bot.entities)) { + if (e?.name !== "sheep" || !e.position) continue; + const d = e.position.distanceTo(bot.entity.position); + if (!best || d < best.d) best = { e, d }; + } + return best; +} + +export const skill = Object.freeze({ + id: "gather.wool", + title: "Gather one wool", + timeoutMs: 90_000, + preconditions(ctx) { + if (!ctx?.bot) return { ok: false, code: "no_bot", detail: "bot missing" }; + if (woolCount(ctx.bot) >= 3) { + return { ok: false, code: "already_have", detail: "already have ≥3 wool" }; + } + return { ok: true }; + }, + async execute(ctx) { + const bot = ctx.bot; + ensurePlugins(bot); + setMovementsForGather(bot); + + // 1. Placed wool block? + const woolBlock = bot.findBlock({ + matching: (b) => b?.name && (b.name.endsWith("_wool") || b.name === "wool"), + maxDistance: 32, + }); + if (woolBlock) { + info("action", `gather.wool: mining ${woolBlock.name} at ${woolBlock.position}`); + try { + await withTimeout(bot.collectBlock.collect(woolBlock), 45_000, "collectWool"); + return { + ok: true, + code: "done", + detail: { from: "block", name: woolBlock.name }, + worldDelta: { gotWool: 1, source: "block" }, + }; + } catch (e) { + warn("action", `gather.wool block-mine failed: ${e.message}`); + // fall through to sheep + } + } + + // 2/3. Sheep — shear if we have shears, otherwise attack. + const sheep = nearestSheep(bot); + if (!sheep) { + return { ok: false, code: "no_target", detail: "no wool block and no sheep within view", worldDelta: null }; + } + try { + await withTimeout( + bot.pathfinder.goto(new goals.GoalFollow(sheep.e, 2)), + 30_000, + "pathToSheep", + ); + } catch (e) { + return { ok: false, code: "no_path", detail: e.message, worldDelta: null }; + } + + const shears = bot.inventory.items().find((i) => i.name === "shears"); + if (shears) { + try { + await withTimeout(bot.equip(shears, "hand"), 3000, "equip shears"); + bot.activateEntity(sheep.e); // shears interaction + await new Promise((r) => setTimeout(r, 600)); + // Wait for the drop entity to spawn near the sheep, then pick it up + // by walking to it. Simplest: a brief wait — the bot is already next + // to the sheep, drops are auto-collected. + await new Promise((r) => setTimeout(r, 1200)); + return { + ok: true, + code: "done", + detail: { from: "shear", entityId: sheep.e.id }, + worldDelta: { gotWool: 1, source: "shear" }, + }; + } catch (e) { + warn("action", `gather.wool shear failed: ${e.message}`); + // fall through to attack + } + } + + try { + bot.attack(sheep.e); + await new Promise((r) => setTimeout(r, 800)); + // Mineflayer doesn't auto-loop attacks; re-fire until dead or out + // of reach. Up to 6 swings. + for (let i = 0; i < 6; i++) { + const still = Object.values(bot.entities).find((e) => e.id === sheep.e.id); + if (!still) break; + if (still.position.distanceTo(bot.entity.position) > 4) break; + bot.attack(still); + await new Promise((r) => setTimeout(r, 700)); + } + await new Promise((r) => setTimeout(r, 1200)); + return { + ok: true, + code: "done", + detail: { from: "kill", entityId: sheep.e.id }, + worldDelta: { gotWool: 1, source: "kill" }, + }; + } catch (e) { + warn("action", `gather.wool attack failed: ${e.message}`); + return { ok: false, code: "failed", detail: e.message, worldDelta: null }; + } + }, + recover(ctx, result) { + if (result.code === "no_target") return { hint: "wander", reason: "no sheep or wool block visible" }; + return null; + }, +}); diff --git a/runtime/skills/index.js b/runtime/skills/index.js index a3b7616..8f13b19 100644 --- a/runtime/skills/index.js +++ b/runtime/skills/index.js @@ -26,7 +26,11 @@ 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 gatherWool } from "./gather-wool.js"; import { skill as chooseBase } from "./choose-base.js"; +import { skill as buildShelter } from "./build-shelter.js"; +import { skill as depositSurplus } from "./deposit-surplus.js"; +import { skill as farmWheat } from "./farm-wheat.js"; import { craftPlanksSkill, craftSticksSkill, @@ -39,6 +43,7 @@ import { craftFurnaceSkill, craftChestSkill, craftTorchSkill, + craftBedSkill, } from "./craft.js"; const SKILLS = new Map(); @@ -56,7 +61,11 @@ register(chopLogs); register(eat); register(wander); register(gatherStone); +register(gatherWool); register(chooseBase); +register(buildShelter); +register(depositSurplus); +register(farmWheat); register(craftPlanksSkill); register(craftSticksSkill); register(craftWoodenAxeSkill); @@ -68,6 +77,7 @@ register(craftStoneSwordSkill); register(craftFurnaceSkill); register(craftChestSkill); register(craftTorchSkill); +register(craftBedSkill); export function listSkills() { return Array.from(SKILLS.values()).map((s) => ({ diff --git a/runtime/supervisor.js b/runtime/supervisor.js index 7966aa8..50ae9ce 100644 --- a/runtime/supervisor.js +++ b/runtime/supervisor.js @@ -17,6 +17,7 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import { stateDir } from "./config.js"; +import { isWatchableJs } from "./watch-filter.js"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -124,32 +125,40 @@ function spawnChild() { child.on("exit", (code, signal) => { console.log(`[supervisor] child exited code=${code} signal=${signal}`); const wantsRestart = code === RELOAD_EXIT_CODE || restartingDueToWatch; + // Capture BEFORE clearing — watch-triggered restarts are intentional + // and must not be counted toward the crash-loop rollback threshold. + const isWatchRestart = restartingDueToWatch; restartingDueToWatch = false; if (!wantsRestart) { // Clean exit (SIGINT/SIGTERM bubble) or crash — don't relaunch. process.exit(code ?? 0); } - // Rate-limit restarts so a crash loop doesn't burn CPU. - const now = nowMs(); - restartTimestamps.push(now); - while (restartTimestamps.length && now - restartTimestamps[0] > 60_000) restartTimestamps.shift(); - if (restartTimestamps.length > MAX_RESTARTS_PER_MINUTE) { - // Crash loop. If the last commit is young AND touched runtime/, it - // probably broke us — roll it back and try once more. - const ageMs = lastCommitAgeMs(); - if ( - ageMs < ROLLBACK_FRESHNESS_MS && - lastCommitTouchedRuntime() && - rollbackCount < MAX_ROLLBACKS && - rollbackLastCommit() - ) { - console.log(`[supervisor] auto-rollback ${rollbackCount}/${MAX_ROLLBACKS} applied; restart counters reset`); - restartTimestamps.length = 0; - setTimeout(spawnChild, 500); - return; + // Rate-limit restarts so a crash loop doesn't burn CPU. Watch-triggered + // restarts don't count — burned a working main once (2026-05-26) when + // edits to runtime/*.test.js looked like a crash loop and rolled back + // the scheduler PR. + if (!isWatchRestart) { + const now = nowMs(); + restartTimestamps.push(now); + while (restartTimestamps.length && now - restartTimestamps[0] > 60_000) restartTimestamps.shift(); + if (restartTimestamps.length > MAX_RESTARTS_PER_MINUTE) { + // Crash loop. If the last commit is young AND touched runtime/, it + // probably broke us — roll it back and try once more. + const ageMs = lastCommitAgeMs(); + if ( + ageMs < ROLLBACK_FRESHNESS_MS && + lastCommitTouchedRuntime() && + rollbackCount < MAX_ROLLBACKS && + rollbackLastCommit() + ) { + console.log(`[supervisor] auto-rollback ${rollbackCount}/${MAX_ROLLBACKS} applied; restart counters reset`); + restartTimestamps.length = 0; + setTimeout(spawnChild, 500); + return; + } + console.error(`[supervisor] too many restarts (${restartTimestamps.length} in 60s) — giving up`); + process.exit(1); } - console.error(`[supervisor] too many restarts (${restartTimestamps.length} in 60s) — giving up`); - process.exit(1); } console.log(`[supervisor] restarting in 500ms…`); setTimeout(spawnChild, 500); @@ -163,11 +172,11 @@ function spawnChild() { let debounceTimer = null; function watchRuntime() { - const watcher = fs.watch(RUNTIME_DIR, { recursive: false }, (eventType, filename) => { - if (!filename || !filename.endsWith(".js")) return; - // supervisor.js itself is excluded — restarting THIS process from - // inside itself would require a separate exec, which we don't do. - if (filename === "supervisor.js") return; + // recursive:true so edits to runtime/skills/*.js and runtime/social/*.js + // also restart the child. macOS + Linux support recursive fs.watch on + // Node 20+. + const watcher = fs.watch(RUNTIME_DIR, { recursive: true }, (eventType, filename) => { + if (!isWatchableJs(filename)) return; if (debounceTimer) clearTimeout(debounceTimer); debounceTimer = setTimeout(() => { console.log(`[supervisor] ${filename} changed — restarting child`); diff --git a/runtime/watch-filter.js b/runtime/watch-filter.js new file mode 100644 index 0000000..ab1f3ed --- /dev/null +++ b/runtime/watch-filter.js @@ -0,0 +1,20 @@ +// Pure predicate for the supervisor's fs.watch callback. Kept in its own +// module so it can be unit-tested without importing supervisor.js (which +// has top-level side effects like acquireLock). +// +// Returns true when a filename should trigger a child restart, false when +// the supervisor should ignore the event. + +export function isWatchableJs(filename) { + if (!filename) return false; + if (!filename.endsWith(".js")) return false; + // supervisor.js itself is excluded — restarting THIS process from + // inside itself would require a separate exec, which we don't do. + if (filename === "supervisor.js" || filename.endsWith("/supervisor.js")) return false; + // Test files mustn't trigger restarts. Adding/editing a *.test.js was + // causing the restart-storm observed live (2026-05-26): each new test + // landed during a session burned a slot in MAX_RESTARTS_PER_MINUTE and + // the supervisor would give up. + if (filename.endsWith(".test.js")) return false; + return true; +} diff --git a/runtime/watch-filter.test.js b/runtime/watch-filter.test.js new file mode 100644 index 0000000..b394caf --- /dev/null +++ b/runtime/watch-filter.test.js @@ -0,0 +1,33 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { isWatchableJs } from "./watch-filter.js"; + +test("isWatchableJs accepts top-level runtime js", () => { + assert.equal(isWatchableJs("bot.js"), true); + assert.equal(isWatchableJs("reflex.js"), true); +}); + +test("isWatchableJs accepts nested skills/social js (recursive watch)", () => { + assert.equal(isWatchableJs("skills/index.js"), true); + assert.equal(isWatchableJs("skills/chop-logs.js"), true); + assert.equal(isWatchableJs("social/intent.js"), true); +}); + +test("isWatchableJs rejects *.test.js everywhere (root of restart storm)", () => { + assert.equal(isWatchableJs("reflex.test.js"), false); + assert.equal(isWatchableJs("base-site.test.js"), false); + assert.equal(isWatchableJs("skills/contract.test.js"), false); + assert.equal(isWatchableJs("skills/compat.test.js"), false); +}); + +test("isWatchableJs rejects supervisor.js itself", () => { + assert.equal(isWatchableJs("supervisor.js"), false); +}); + +test("isWatchableJs rejects non-js / empty input", () => { + assert.equal(isWatchableJs(""), false); + assert.equal(isWatchableJs(null), false); + assert.equal(isWatchableJs("notes.md"), false); + assert.equal(isWatchableJs("config.json"), false); +});