From 86e5294bb84e22adf12750c8338c81d083c5c9c8 Mon Sep 17 00:00:00 2001 From: Yuriy Mayatnikov Date: Wed, 27 May 2026 13:05:12 +0300 Subject: [PATCH] chore: snapshot pre-v0.2.0 WIP (pathfinder/reflex/metrics/skills improvements) Baseline for the v0.2.0 self-learning iteration. All 205 tests pass on this state. Subsequent commits in this branch layer the knowledge base, post-mortem coach, and persona narration on top. Co-Authored-By: Claude Opus 4.7 --- README.md | 7 +- docs/runtime.md | 94 +++++++----- runtime/actions.js | 5 + runtime/auto-improve.js | 6 +- runtime/bot.js | 17 ++- runtime/config.js | 12 ++ runtime/curriculum.js | 9 +- runtime/curriculum.test.js | 17 +++ runtime/modes.js | 26 +++- runtime/modes.test.js | 6 +- runtime/pathfinder-watchdog.js | 41 ++++-- runtime/pathfinder-watchdog.test.js | 20 ++- runtime/perceive.js | 128 +++++++++++++++- runtime/reflex.js | 79 +++++++++- runtime/reflex.test.js | 87 +++++++++++ runtime/scenario-memory.js | 7 +- runtime/skill-library.js | 5 +- runtime/skill-metrics.js | 66 +++++++-- runtime/skills/acquire-food.js | 163 +++++++++++++++++++++ runtime/skills/build-shelter.js | 5 +- runtime/skills/choose-base.js | 2 +- runtime/skills/explore-far.js | 49 +++---- runtime/skills/flee.js | 61 ++++++++ runtime/skills/index.js | 8 + runtime/skills/place-chest.js | 94 ++++++++++++ runtime/skills/recovery-tunnel-out.js | 31 +++- runtime/skills/recovery-tunnel-out.test.js | 32 ++++ runtime/skills/sleep.js | 44 ++++++ runtime/stuck-incident.test.js | 2 +- tui/tui.tsx | 30 +++- 30 files changed, 1023 insertions(+), 130 deletions(-) create mode 100644 runtime/skills/acquire-food.js create mode 100644 runtime/skills/flee.js create mode 100644 runtime/skills/place-chest.js create mode 100644 runtime/skills/sleep.js diff --git a/README.md b/README.md index ca898c2..ae34cdf 100644 --- a/README.md +++ b/README.md @@ -83,7 +83,7 @@ pi /login # OAuth flow — ChatGPT Pro / Claude Max # Terminal 1: the daemon (logs in stdout, persists state under state//) npm run bot -# Terminal 2: the dashboard (Ink TUI). Hotkeys: p/s/r/c/a/q. +# Terminal 2: the dashboard (Ink TUI). Hotkeys: p/s/r/c/a/k/v/!/y/q. npm run tui ``` @@ -105,6 +105,9 @@ The TUI auto-reconnects to the bot if you restart it. Press `q` to leave the TUI | `r` | Force a fresh status snapshot. | | `c` | Send a chat message into MC. | | `a` | Ask Pi (one-shot subprocess). | +| `k` | Run one registered skill with optional JSON args. | +| `v` | Capture a viewer screenshot for debugging. | +| `!` | Force a critic-backed incident/proposal. | | `y` | Open the latest pending proposal (badge appears in status bar). In the panel: `y` approve, `n`/Esc close. | | `q` | Quit the TUI — bot keeps running. | @@ -213,7 +216,7 @@ These are mirrored in `AGENTS.md` and re-stated at the top of any system prompt 🌱 **Phase 3 — Goal-driven autonomy** seeded: [`docs/memory-model.md`](./docs/memory-model.md) defines shared-knowledge vs personal-memory; per-server `goal.md` / `plan.md` / `current-task.json` / `diary/` shape autonomous behaviour. -🌿 **Survival-bot pivot (2026-05-25)** — the bot is becoming a self-sufficient survival resident of the configured server. **MC chat is dialog-only**; operator/player chat commands are recorded but not dispatched (TUI is the only local control plane). Full plan: `plans/autonomous-survival-bot-prd.md` (local-only, gitignored). Phase 0 (chat-control cleanup, version auto-detect) is done; Phase 1+ (observability, skill substrate, survival curriculum, base/village loop) is the next focus. +🌿 **Survival-bot pivot (2026-05-25)** — the bot is becoming a self-sufficient survival resident of the configured server. **MC chat is dialog-only**; operator/player chat commands are recorded but not dispatched (TUI is the only local control plane). The hybrid runtime now has enriched perception, priority modes, a skill-driven curriculum, food acquisition, bed/sleep, base/chest/shelter/farm skills, persistent skill metrics, scenario memory, and a scoped auto-patch loop with `npm test` smoke gating. Full plan: `plans/autonomous-survival-bot-prd.md` (local-only, gitignored). Full plan: [`docs/roadmap.md`](./docs/roadmap.md). Memory layout: [`docs/memory-model.md`](./docs/memory-model.md). Day-to-day judgement: "Operating principles" in [`AGENTS.md`](./AGENTS.md). diff --git a/docs/runtime.md b/docs/runtime.md index 4159be9..2c2b73d 100644 --- a/docs/runtime.md +++ b/docs/runtime.md @@ -96,6 +96,9 @@ TUI; the bot is unaffected. | `r` | Force-broadcast a status snapshot now. | | `c` | Enter **chat mode** — type a message, Enter sends it into MC chat. | | `a` | Enter **ask-Pi mode** — type a prompt, Enter spawns `pi -p` and streams output into the Pi panel. | +| `k` | Enter **run-skill mode** — type `skill.id` plus optional JSON args; dispatches once through `cmd:run-skill`. | +| `v` | Capture a headless viewer screenshot into `state//screenshots/` when viewer support is available. | +| `!` | Force a stuck incident proposal through the critic/auto-improve path for diagnostics. | | `y` | Open the latest pending proposal. In the proposal panel: `y` approves, `n`/Esc closes. | | `q` | Quit TUI only. Bot keeps running. | @@ -106,19 +109,20 @@ TUI; the bot is unaffected. The chain (highest priority first), wired and dispatching real Mineflayer actions: -1. **`defendReflex`** — closest hostile within 4 m → `attackNearest` - (equips best melee). Within 12 m + low HP or ≥3 hostiles → `fleeFrom` - along the away-vector. -2. **`eatReflex`** — food < 16 → `eatBestFood` (picks from - FOOD_PRIORITY list, equip + consume). 5 s cooldown. -3. **`sleepReflex`** — night + no hostile within 8 m → `sleepInBed` - (finds nearest placed bed within 16 blocks, paths there, sleeps). - 5 min cooldown on failures. -4. **`techTreeReflex`** — deterministic crafting progression - (planks → sticks → wooden axe → pickaxe → sword) when prerequisites - are in inventory. -5. **`autonomousReflex`** — when nothing reactive fires: chop trees until - ~16 logs, then wander to discover new chunks. +1. **Modes** — Mindcraft-style interrupts run first: + `self_preservation`, `hunger`, `night_shelter`. They dispatch + registered skills (`survive.flee`, `survive.eat`, `survive.sleep`) + and therefore feed metrics, scenario memory and current-task state. +2. **`defendReflex`** — closest hostile within 4 m → `attackNearest` + (equips best melee). Low HP / close hostile → flee. +3. **`eatReflex`** — food < 16 and edible item is carried → + `eatBestFood`. No-food states fall through to the curriculum instead + of eat-spamming. +4. **`sleepReflex`** — night + no hostile within 8 m + bed available → + `sleepInBed`. Impossible states are skipped before dispatch. +5. **`curriculumReflex`** — dispatches `snapshot.curriculum.plan.skillId` + through `runSkill`. Inventory pressure can insert + `village.deposit-surplus` before continuing. 6. **`idleReflex`** — every 20th tick, log heartbeat (HP / food / pos). There is **no operator-goal reflex anymore.** MC chat does not create @@ -199,10 +203,14 @@ from `bot.registry`, so a version-sensitive item that doesn't exist on the connected server simply doesn't appear in the set and skills return `code: "unsupported_version"` instead of crashing. -Reference skills shipped today: `gather.logs`, `survive.eat`, -`explore.wander`. The reflex loop still calls the older -`runtime/actions.js` primitives directly — porting more behaviours to -skills lands in later phases. +Reference skills shipped today include `gather.logs`, `gather.stone`, +`gather.wool`, `survive.eat`, `survive.flee`, `survive.sleep`, +`survive.acquire-food`, `explore.wander`, `explore.far`, +`village.choose-base`, `village.place-chest`, +`village.deposit-surplus`, `village.build-shelter`, `farm.wheat`, and +the `craft.*` progression. Some low-level action primitives still live +in `runtime/actions.js`, but they are increasingly called behind skill +contracts so metrics and self-improvement see them. Run the contract + groups + curriculum tests: @@ -216,7 +224,8 @@ npm test ``` wood.16 → wood.planks-and-sticks → wood.tools → -stone.32 → stone.tools → food.basic → storage.chest → shelter.torch +survive.bed → stone.32 → stone.tools → food.basic → +storage.chest → shelter.torch → village.base-site → village.shelter ``` Each milestone has an `isDone(inventory, snapshot)` predicate and a @@ -229,14 +238,13 @@ planks). The current curriculum result is on every snapshot as `snapshot.curriculum = { milestone, plan, inventoryFull }` so the TUI can show what the bot is working on and which skill should drive it. -Wiring the scheduler to actually call `runSkill(plan.skillId, …)` in -the reflex loop is a Phase 4 task; today the reflex still uses -`actions.js` directly. +The scheduler now calls `runSkill(plan.skillId, …)` directly from +`curriculumReflex`; no LLM is in the hot path. Inventory pressure: `isInventoryFull(snapshot)` is exposed on every -curriculum result; the TUI surfaces `[inventory full]` next to the -milestone label so the operator can see when a deposit step is needed -before progress continues. +curriculum result; the TUI surfaces `[inventory full]`, and when a +known/nearby chest exists the scheduler tries `village.deposit-surplus` +before the next milestone. ### Optional: prismarine-viewer @@ -259,10 +267,15 @@ Two persistent stores under `state//`: Pruned at 6 h age + 10k line ceiling. `leanestQuadrant({x, z})` returns the cardinal quadrant the bot has explored LEAST — used by `explore.far` to circle rather than retread the same patch. +- **`skill-metrics.json`** — persistent per-skill ok/fail counters, + last code and duration. `snapshot.skillMetrics` exposes the loaded + totals so proposals learn from previous runs, not only the current + process lifetime. - **`scenarios.jsonl`** — sliding window of `(skillId, situationHash, code, ok, detail, ts)` tuples. `situationHash` is a coarse fingerprint of where + how the bot was (16-cell + 8y bucket, day/night, food - bucket, hp bucket, inventory key set, closest hostile name). The + bucket, hp bucket, biome, nearby block groups, inventory key set, + closest hostile name). The curriculum reflex calls `memory.shouldSkip({skillId, situation})` — ≥3 failures of the same `(skill, situation)` within 30 min and the reflex auto-converts into a wander hint instead of re-dispatching the @@ -276,6 +289,7 @@ based on what's actually been tried, not just one snapshot. ### Scheduler driven by the curriculum (2026-05-26) The reflex chain is now: `defend → eat → sleep → curriculum → idle`. +Modes run before that chain and dispatch their own skills immediately. `curriculumReflex` reads `snapshot.curriculum.plan.skillId` (populated by `runtime/curriculum.js` each tick) and dispatches it via @@ -286,7 +300,8 @@ by `runtime/curriculum.js` each tick) and dispatches it via `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 + `no_target` / `no_food_source` / `unsupported_version` / storage + blockers, 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 @@ -360,13 +375,16 @@ Two classes of proposals now land in `state//proposals/`: Both kinds now persist an **`editScope`** in their frontmatter — an array of repo-relative path prefixes the auto-patcher is allowed to -modify. `state-store.readProposalEditScope(filename)` reads it back; -hooking `scripts/auto-patch.js` to refuse cherry-picks that touch -other areas is the remaining follow-up. +modify. `scripts/auto-patch.js` enforces that scope, runs the cheap +`scripts/lint-patch.js` gate, then runs `npm test` before cherry-pick. -Per-skill metrics live in memory only (best-effort) but are surfaced -on `snapshot.skillMetrics = { [skillId]: { ok, fail, lastTs } }` so -the TUI can show which skills are reliable and which keep failing. +Learning speed is configurable: + +- `PEPA_LEARNING_MODE=fast` or `dev` lowers stuck detection and + auto-improve cooldowns for active training sessions. +- `PEPA_STUCK_THRESHOLD_SECONDS`, `PEPA_STUCK_COOLDOWN_SECONDS`, + `PEPA_AUTO_IMPROVE_COOLDOWN_SECONDS`, + `PEPA_AUTO_IMPROVE_MAX_PER_HOUR` override those defaults directly. ### Social layer (Phase 5) @@ -442,6 +460,9 @@ shutdown). Framing: one JSON object per line. | `cmd:chat` | `{ text }` | Sends text into MC chat (rate-limited). | | `cmd:ask-pi` | `{ prompt }` | Spawns `pi -p ""`. | | `cmd:snapshot` | `{}` | Force a `status` event now. | +| `cmd:run-skill` | `{ skillId, args? }` | Pauses the reflex loop long enough to dispatch one registered skill. | +| `cmd:screenshot` | `{ reason?, frames? }` | Captures a headless viewer screenshot for visual debugging. | +| `cmd:force-incident` | `{ kind?, reason? }` | Forces a critic-backed proposal, useful for testing self-improvement. | The protocol is intentionally tiny — anyone can write a second client (a Telegram bridge, a web UI, a one-shot CLI) by reading @@ -470,7 +491,8 @@ if they break things. The flow: - moves proposal pending → approved/ (audit trail) - creates branch auto/ off main - runs `pi -p ""` with 10-min timeout - - if Pi committed AND only touched runtime/ → cherry-pick onto main + - if Pi committed AND changed only the proposal editScope + (+ runtime/**/*.test.js) AND lint/npm-test pass → cherry-pick onto main - else → discard branch, exit non-zero 6. supervisor's runtime/*.js watcher fires the moment the cherry-pick lands → child restarts on the new code @@ -483,8 +505,10 @@ if they break things. The flow: ### Rate limits - **Proposal cooldown**: 30 min between proposal files of any kind. -- **Auto-improve cooldown**: 15 min between finished `auto-patch.js` runs. -- **Hourly cap**: max 4 auto-patches per hour, even if cooldown allows. +- **Auto-improve cooldown**: default 15 min between finished + `auto-patch.js` runs; `PEPA_LEARNING_MODE=fast|dev` lowers this to + 5 min unless overridden. +- **Hourly cap**: default max 4 auto-patches per hour; fast/dev default is 8. - **Rollback cap**: 3 rollbacks per supervisor lifetime; after that the supervisor exits and waits for human review. diff --git a/runtime/actions.js b/runtime/actions.js index 2503b43..5da9715 100644 --- a/runtime/actions.js +++ b/runtime/actions.js @@ -67,6 +67,10 @@ function forceStopCollectBlock(bot) { try { bot.pathfinder?.stop?.(); } catch {} } +function clearPathfinderGoal(bot) { + try { bot.pathfinder?.setGoal?.(null); } catch {} +} + // 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, @@ -476,6 +480,7 @@ export async function wander(bot, radius = 12) { return { ok: true, detail: { to: { x: tx, y: ty, z: tz }, via: best.name } }; } catch (e) { warn("action", `wander pathfinder failed: ${e.message} — continuing blind in ${best.name}`); + clearPathfinderGoal(bot); const beforeBlind = clonePos(bot.entity.position); try { await bot.look(best.yaw, 0, true); } catch {} bot.setControlState("forward", true); diff --git a/runtime/auto-improve.js b/runtime/auto-improve.js index 7796f7f..82e7380 100644 --- a/runtime/auto-improve.js +++ b/runtime/auto-improve.js @@ -15,7 +15,7 @@ import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; -import { stateDir } from "./config.js"; +import { config, stateDir } from "./config.js"; import { info, warn } from "./log.js"; const __filename = fileURLToPath(import.meta.url); @@ -25,8 +25,8 @@ const PATCH_SCRIPT = path.join(REPO_ROOT, "scripts", "auto-patch.js"); const PROPOSALS_DIR = path.join(stateDir, "proposals"); const DEBOUNCE_MS = 10_000; -const COOLDOWN_MS = 15 * 60 * 1000; -const MAX_TOTAL_PER_HOUR = 4; +const COOLDOWN_MS = config.autoImproveCooldownMs; +const MAX_TOTAL_PER_HOUR = config.autoImproveMaxPerHour; let inFlight = false; let lastFinishedAt = 0; diff --git a/runtime/bot.js b/runtime/bot.js index f4912cc..018c38c 100644 --- a/runtime/bot.js +++ b/runtime/bot.js @@ -56,6 +56,7 @@ import { requestCritique } from "./critic.js"; import { createSkillMetrics } from "./skill-metrics.js"; import { createWorldJournal } from "./world-journal.js"; import { createScenarioMemory, situationHash } from "./scenario-memory.js"; +import { createOwnedBlocksLedger } from "./owned-blocks.js"; fs.mkdirSync(stateDir, { recursive: true }); const JOINED_FLAG = path.join(stateDir, "joined-before.flag"); @@ -81,10 +82,14 @@ let lastEscalationAt = 0; // future Telegram/diary surfaces) can answer "what is the bot doing and why // isn't it doing more?" without parsing the log stream. const noProgress = createNoProgressDetector(); -const stuckIncident = createStuckIncidentDetector(); +const stuckIncident = createStuckIncidentDetector({ + thresholdMs: config.stuckThresholdMs, + cooldownMs: config.stuckCooldownMs, +}); const skillMetrics = createSkillMetrics(); const worldJournal = createWorldJournal(); const scenarioMemory = createScenarioMemory(); +const ownedBlocks = createOwnedBlocksLedger(); let lastResult = null; // { label, ok, code, detail, ts } let lastFailureAt = 0; let lastPlanReadAt = 0; @@ -109,6 +114,8 @@ const reflexCtx = { dispatch: dispatchAction, journal: worldJournal, memory: scenarioMemory, + metrics: skillMetrics, + owned: ownedBlocks, }; let chatTimestamps = []; @@ -186,6 +193,9 @@ function recordWorldDeltaToJournal(label, res, snapshot) { if (wd.placedAt) worldJournal.append({ kind: "placed", name: wd.placedType ?? "block", at: wd.placedAt }); if (wd.baseAt) worldJournal.append({ kind: "base", name: "base", at: wd.baseAt }); if (wd.shelterAt) worldJournal.append({ kind: "shelter", name: "shelter", at: wd.shelterAt }); + if (wd.chestAt) worldJournal.append({ kind: "chest", name: "storage", at: wd.chestAt }); + if (wd.fledTo) worldJournal.append({ kind: "retreat", name: label, at: wd.fledTo }); + if (wd.acquiredFood && snapshot?.position) worldJournal.append({ kind: "food", name: wd.source ?? "food", at: snapshot.position }); if (wd.plantedAt) worldJournal.append({ kind: "farm", name: "planted", at: wd.plantedAt }); if (wd.harvestedAt) worldJournal.append({ kind: "farm", name: "harvested", at: wd.harvestedAt }); if (wd.tilledAt) worldJournal.append({ kind: "farm", name: "tilled", at: wd.tilledAt }); @@ -218,6 +228,7 @@ function dispatchAction(fn, label, opts = {}) { } reflexCtx.busy = true; reflexCtx.currentActionLabel = label; + const startedAt = Date.now(); // Capture the situation hash BEFORE the action runs so a failure is // attributable to the state at dispatch time, not the state after the // (partial) effect. @@ -243,7 +254,7 @@ function dispatchAction(fn, label, opts = {}) { detail: res?.detail, ts: Date.now(), }; - skillMetrics.record(label, ok); + skillMetrics.record(label, ok, { code: lastResult.code, durationMs: Date.now() - startedAt }); scenarioMemory.record({ skillId: label, situation: startSituation, @@ -278,7 +289,7 @@ function dispatchAction(fn, label, opts = {}) { detail: String(e?.message ?? e), ts: Date.now(), }; - skillMetrics.record(label, false); + skillMetrics.record(label, false, { code: "threw", durationMs: Date.now() - startedAt }); scenarioMemory.record({ skillId: label, situation: startSituation, diff --git a/runtime/config.js b/runtime/config.js index e362f4d..2afe768 100644 --- a/runtime/config.js +++ b/runtime/config.js @@ -18,9 +18,16 @@ function opt(name, fallback = "") { return process.env[name]?.trim() || fallback; } +function optInt(name, fallback) { + const raw = Number.parseInt(opt(name, String(fallback)), 10); + return Number.isFinite(raw) ? raw : fallback; +} + const host = req("MC_HOST"); const port = Number.parseInt(opt("MC_PORT", "25565"), 10); const username = req("MC_USERNAME"); +const learningMode = opt("PEPA_LEARNING_MODE", "normal").toLowerCase(); +const fastLearning = learningMode === "fast" || learningMode === "dev"; // MC_VERSION: "auto" (or empty) lets mineflayer auto-detect from the server // handshake — the right default per the survival-bot PRD (no hard-coded modern @@ -44,6 +51,11 @@ export const config = Object.freeze({ .filter(Boolean), tickIntervalMs: Math.max(1, Number.parseInt(opt("TICK_INTERVAL_SECONDS", "3"), 10)) * 1000, chatRateLimitPerMin: Number.parseInt(opt("CHAT_RATE_LIMIT_PER_MIN", "15"), 10), + learningMode, + stuckThresholdMs: Math.max(15, optInt("PEPA_STUCK_THRESHOLD_SECONDS", fastLearning ? 60 : 300)) * 1000, + stuckCooldownMs: Math.max(60, optInt("PEPA_STUCK_COOLDOWN_SECONDS", fastLearning ? 600 : 1800)) * 1000, + autoImproveCooldownMs: Math.max(60, optInt("PEPA_AUTO_IMPROVE_COOLDOWN_SECONDS", fastLearning ? 300 : 900)) * 1000, + autoImproveMaxPerHour: Math.max(1, optInt("PEPA_AUTO_IMPROVE_MAX_PER_HOUR", fastLearning ? 8 : 4)), // Optional prismarine-viewer port for local visual debugging. 0/empty = off. viewerPort: (() => { const v = Number.parseInt(opt("VIEWER_PORT", "0"), 10); diff --git a/runtime/curriculum.js b/runtime/curriculum.js index b967a2b..df09b55 100644 --- a/runtime/curriculum.js +++ b/runtime/curriculum.js @@ -157,13 +157,16 @@ const MILESTONES = [ ); return carrying || (snap?.food ?? 20) >= 18; }, - suggest: () => ({ skillId: "survive.eat" }), // best-effort; richer "find food" skill lands later + suggest: () => ({ skillId: "survive.acquire-food" }), }, { id: "storage.chest", title: "Place a personal chest", - isDone: (inv) => has(inv, "chest"), - suggest: () => ({ skillId: "craft.chest" }), + isDone: (_inv, snap) => !!snap?.locations?.chest, + suggest: (inv) => { + if (!has(inv, "chest")) return { skillId: "craft.chest" }; + return { skillId: "village.place-chest" }; + }, }, { id: "shelter.torch", diff --git a/runtime/curriculum.test.js b/runtime/curriculum.test.js index 80d8ecf..3a8ac9f 100644 --- a/runtime/curriculum.test.js +++ b/runtime/curriculum.test.js @@ -154,6 +154,7 @@ test("all done → null", () => { nextMilestone(snap(inv, { food: 20, locations: { + chest: { x: 1, y: 64, z: 0 }, base: { x: 0, y: 64, z: 0 }, shelter: { x: 0, y: 64, z: 0 }, }, @@ -189,3 +190,19 @@ test("listMilestones exposes ordered ids for diary/TUI", () => { assert.equal(typeof m.title, "string"); } }); + +test("food.basic with no carried food suggests acquire-food", () => { + const got = nextMilestone(snapAfter("stone.tools", {}, { food: 8 })); + assert.equal(got.milestone.id, "food.basic"); + assert.equal(got.plan.skillId, "survive.acquire-food"); +}); + +test("storage.chest crafts first, then places carried chest", () => { + const needCraft = nextMilestone(snapAfter("food.basic", { chest: 0 })); + assert.equal(needCraft.milestone.id, "storage.chest"); + assert.equal(needCraft.plan.skillId, "craft.chest"); + + const needPlace = nextMilestone(snapAfter("food.basic", { chest: 1 })); + assert.equal(needPlace.milestone.id, "storage.chest"); + assert.equal(needPlace.plan.skillId, "village.place-chest"); +}); diff --git a/runtime/modes.js b/runtime/modes.js index d7eebf3..f3e30e5 100644 --- a/runtime/modes.js +++ b/runtime/modes.js @@ -81,15 +81,23 @@ registerMode({ const snap = ctx?.snapshot; if (!snap) return null; const hp = snap.health ?? 20; - const food = snap.food ?? 20; + const hasFood = !!snap.hasFood; + if (ctx.modeCooldown?.self_preservation && Date.now() < ctx.modeCooldown.self_preservation) return null; // HP critically low + we have food → eat NOW - if (hp < 6 && food > 0 && snap.hasFood) { - return { action: { skillId: "eat" }, detail: { reason: "hp<6", hp } }; + if (hp < 6 && hasFood) { + ctx.modeCooldown = ctx.modeCooldown ?? {}; + ctx.modeCooldown.self_preservation = Date.now() + 5_000; + return { action: { skillId: "survive.eat" }, detail: { reason: "hp<6", hp } }; } // Hostile within reach and HP low → flee const ch = snap.closestHostile; if (ch && typeof ch.distance === "number" && ch.distance < 6 && hp < 10) { - return { action: { skillId: "explore.far" }, detail: { reason: "hp<10 near-hostile", hp, dist: ch.distance } }; + ctx.modeCooldown = ctx.modeCooldown ?? {}; + ctx.modeCooldown.self_preservation = Date.now() + 8_000; + return { + action: { skillId: "survive.flee", args: { hostileName: ch.name } }, + detail: { reason: "hp<10 near-hostile", hp, dist: ch.distance }, + }; } return null; }, @@ -102,8 +110,11 @@ registerMode({ update(ctx) { const snap = ctx?.snapshot; if (!snap) return null; + if (ctx.modeCooldown?.hunger && Date.now() < ctx.modeCooldown.hunger) return null; if ((snap.food ?? 20) < 14 && snap.hasFood) { - return { action: { skillId: "eat" }, detail: { reason: "food<14", food: snap.food } }; + ctx.modeCooldown = ctx.modeCooldown ?? {}; + ctx.modeCooldown.hunger = Date.now() + 5_000; + return { action: { skillId: "survive.eat" }, detail: { reason: "food<14", food: snap.food } }; } return null; }, @@ -116,11 +127,14 @@ registerMode({ update(ctx) { const snap = ctx?.snapshot; if (!snap) return null; + if (ctx.modeCooldown?.night_shelter && Date.now() < ctx.modeCooldown.night_shelter) return null; // Only at night and only if we actually carry / can place a bed if (snap.isDay) return null; const inv = snap.inventory || {}; const hasBed = Object.keys(inv).some((n) => /_bed$/.test(n)); if (!hasBed) return null; - return { action: { skillId: "sleep" }, detail: { reason: "night with bed in hand" } }; + ctx.modeCooldown = ctx.modeCooldown ?? {}; + ctx.modeCooldown.night_shelter = Date.now() + 30_000; + return { action: { skillId: "survive.sleep" }, detail: { reason: "night with bed in hand" } }; }, }); diff --git a/runtime/modes.test.js b/runtime/modes.test.js index 3f1c8b1..71e9544 100644 --- a/runtime/modes.test.js +++ b/runtime/modes.test.js @@ -70,14 +70,14 @@ test("self_preservation: low-HP + food + hasFood → eat", async () => { const mod = await import(`./modes.js?cb=${Date.now() + 1}`); const out = mod.tickModes({ snapshot: { health: 4, food: 10, hasFood: true } }); assert.equal(out.mode, "self_preservation"); - assert.equal(out.action.skillId, "eat"); + assert.equal(out.action.skillId, "survive.eat"); }); test("hunger: food below 14 with food → eat", async () => { _resetModes(); const mod = await import(`./modes.js?cb=${Date.now() + 2}`); const out = mod.tickModes({ snapshot: { health: 20, food: 12, hasFood: true } }); - assert.equal(out.action.skillId, "eat"); + assert.equal(out.action.skillId, "survive.eat"); }); test("night_shelter: day → null (skip)", async () => { @@ -91,5 +91,5 @@ test("night_shelter: night + bed in hand → sleep", async () => { _resetModes(); const mod = await import(`./modes.js?cb=${Date.now() + 4}`); const out = mod.tickModes({ snapshot: { isDay: false, food: 20, hasFood: false, inventory: { red_bed: 1 } } }); - assert.equal(out.action.skillId, "sleep"); + assert.equal(out.action.skillId, "survive.sleep"); }); diff --git a/runtime/pathfinder-watchdog.js b/runtime/pathfinder-watchdog.js index fa11616..8762e0d 100644 --- a/runtime/pathfinder-watchdog.js +++ b/runtime/pathfinder-watchdog.js @@ -35,6 +35,19 @@ function hdist(a, b) { return Math.hypot(a.x - b.x, a.z - b.z); } +function collectBlockOwnsPathfinder(bot) { + try { + const targets = bot.collectBlock?.targets; + if (!targets) return false; + if (typeof targets.empty === "boolean") return targets.empty === false; + if (Array.isArray(targets.targets)) return targets.targets.length > 0; + if (typeof targets.size === "number") return targets.size > 0; + return false; + } catch { + return false; + } +} + export function createPathfinderWatchdog(bot, { intervalMs = WATCH_INTERVAL_MS, windowMs = STUCK_WINDOW_MS, @@ -71,6 +84,15 @@ export function createPathfinderWatchdog(bot, { return; } if (Date.now() - goalStartedAt < MIN_TRAVEL_TIME_MS) return; + if (collectBlockOwnsPathfinder(bot)) { + // mineflayer-collectblock treats pathfinder goal changes as a + // hard cancellation ("The goal was changed before it could be + // completed"). Let the collect skill's own timeout/blacklist + // handle these paths instead of invalidating the current dig. + lastSeenPos = hpos(bot.entity?.position); + lastSeenAt = Date.now(); + return; + } const now = Date.now(); const here = hpos(bot.entity?.position); @@ -81,8 +103,11 @@ export function createPathfinderWatchdog(bot, { } if (now - lastSeenAt < windowMs) return; - // Stuck. Force a replan — clear the goal, then re-set the same - // goal so pathfinder rebuilds the graph against the current world. + // Stuck. Force a replan by setting the exact same goal object again. + // mineflayer-pathfinder emits goal_updated on every setGoal() call + // and rebuilds the graph, but goto() only rejects as GoalChanged when + // the new goal object is different. Clearing to null first breaks the + // caller, as observed live with gather.logs/explore.far. if (replansThisGoal >= maxReplans) { warn("pathfinder", `stuck > ${windowMs / 1000}s and hit ${maxReplans} replans; giving up — caller's timeout will fire`); lastSeenAt = now; // throttle further warnings within this window @@ -92,15 +117,7 @@ export function createPathfinderWatchdog(bot, { info("pathfinder", `stuck for ${Math.round((now - lastSeenAt) / 1000)}s at (${Math.round(here?.x ?? 0)},${Math.round(here?.z ?? 0)}) — forcing replan #${replansThisGoal}`); try { const goalCopy = goal; - // setGoal(null) cancels the current pathing without bubbling - // an error to the awaiting goto() promise. - pf.setGoal(null); - // Re-set immediately. mineflayer-pathfinder will compute a - // fresh path off the latest world snapshot. - setTimeout(() => { - if (stopped) return; - try { pf.setGoal(goalCopy); } catch (e) { warn("pathfinder", `replan setGoal failed: ${e.message}`); } - }, 250); + pf.setGoal(goalCopy); lastSeenAt = now; // reset window } catch (e) { warn("pathfinder", `replan failed: ${e.message}`); @@ -118,4 +135,4 @@ export function createPathfinderWatchdog(bot, { } // Pure helpers for tests. -export const _internal = { hpos, hdist }; +export const _internal = { hpos, hdist, collectBlockOwnsPathfinder }; diff --git a/runtime/pathfinder-watchdog.test.js b/runtime/pathfinder-watchdog.test.js index aff54ce..87cbea3 100644 --- a/runtime/pathfinder-watchdog.test.js +++ b/runtime/pathfinder-watchdog.test.js @@ -55,10 +55,21 @@ test("replan fires after stuck window elapses", async () => { const wd = createPathfinderWatchdog(bot, { intervalMs: 50, windowMs: 100, delta: 0.5, maxReplans: 5 }); await new Promise((r) => setTimeout(r, 2200)); // pass min-travel + window wd.stop(); - // At least one setGoal(null) call. + // At least one same-object setGoal(goal) call. assert.ok(bot.setGoalCalls.length >= 1, `expected ≥1 setGoal call, got ${bot.setGoalCalls.length}`); - // First call is setGoal(null). - assert.equal(bot.setGoalCalls[0], null); + assert.equal(bot.setGoalCalls[0], goal); +}); + +test("does not replan while collectBlock owns pathfinder", async () => { + const goal = { id: "g1" }; + const bot = makeBot({ goalRef: goal, pos: { x: 0, y: 64, z: 0 } }); + bot.pathfinder._owner = bot; + bot.collectBlock = { targets: { empty: false, targets: [{}] } }; + const wd = createPathfinderWatchdog(bot, { intervalMs: 50, windowMs: 100, delta: 0.5, maxReplans: 5 }); + await new Promise((r) => setTimeout(r, 2200)); // pass min-travel + window + wd.stop(); + assert.equal(_internal.collectBlockOwnsPathfinder(bot), true); + assert.equal(bot.setGoalCalls.length, 0); }); test("respects maxReplans cap", async () => { @@ -68,8 +79,7 @@ test("respects maxReplans cap", async () => { const wd = createPathfinderWatchdog(bot, { intervalMs: 50, windowMs: 80, delta: 0.5, maxReplans: 2 }); await new Promise((r) => setTimeout(r, 5000)); wd.stop(); - // Each replan = setGoal(null) + delayed setGoal(goal). So 2 replans ≤ 4 calls. - assert.ok(bot.setGoalCalls.length <= 4, `expected ≤4 setGoal calls, got ${bot.setGoalCalls.length}`); + assert.ok(bot.setGoalCalls.length <= 2, `expected ≤2 setGoal calls, got ${bot.setGoalCalls.length}`); }); test("resets counters when goal changes", async () => { diff --git a/runtime/perceive.js b/runtime/perceive.js index 27aee97..8c3944c 100644 --- a/runtime/perceive.js +++ b/runtime/perceive.js @@ -1,6 +1,9 @@ // Build a compact, JSON-safe snapshot of the world around the bot. Used both // for reflex decisions and for periodic IPC STATUS events. +import { foods } from "./skills/groups.js"; +import { findBlocksByName } from "./perception.js"; + function vec3ToObj(v) { if (!v) return null; return { x: Math.round(v.x * 100) / 100, y: Math.round(v.y * 100) / 100, z: Math.round(v.z * 100) / 100 }; @@ -29,6 +32,95 @@ const HOSTILE = new Set([ "bogged", ]); +const PASSIVE = new Set([ + "cow", + "pig", + "chicken", + "sheep", + "rabbit", + "mooshroom", + "cod", + "salmon", +]); + +const INTERESTING_BLOCK_GROUPS = Object.freeze({ + logs: ["oak_log", "dark_oak_log", "spruce_log", "birch_log", "jungle_log", "acacia_log", "mangrove_log", "cherry_log", "pale_oak_log"], + stone: ["stone", "cobblestone", "deepslate", "cobbled_deepslate", "andesite", "diorite", "granite"], + water: ["water"], + lava: ["lava", "fire", "soul_fire"], + beds: ["white_bed", "orange_bed", "magenta_bed", "light_blue_bed", "yellow_bed", "lime_bed", "pink_bed", "gray_bed", "light_gray_bed", "cyan_bed", "purple_bed", "blue_bed", "brown_bed", "green_bed", "red_bed", "black_bed"], + storage: ["chest", "trapped_chest", "barrel"], + wool: ["white_wool", "orange_wool", "magenta_wool", "light_blue_wool", "yellow_wool", "lime_wool", "pink_wool", "gray_wool", "light_gray_wool", "cyan_wool", "purple_wool", "blue_wool", "brown_wool", "green_wool", "red_wool", "black_wool"], + crops: ["wheat", "carrots", "potatoes", "beetroots", "sweet_berry_bush"], + coal: ["coal_ore", "deepslate_coal_ore"], +}); + +function inventoryCounts(bot) { + return (bot.inventory?.items?.() ?? []).reduce((acc, item) => { + acc[item.name] = (acc[item.name] ?? 0) + item.count; + return acc; + }, {}); +} + +function countInterestingBlocks(bot, pos, radius = 16) { + const out = {}; + for (const [kind, names] of Object.entries(INTERESTING_BLOCK_GROUPS)) { + let positions = []; + try { + positions = findBlocksByName(bot, names, { maxDistance: radius, count: 16 }); + } catch { + positions = []; + } + if (positions.length === 0) continue; + const nearest = positions + .map((p) => ({ position: vec3ToObj(p), distance: Math.round(Math.hypot(p.x - pos.x, p.z - pos.z) * 10) / 10 })) + .sort((a, b) => a.distance - b.distance)[0]; + out[kind] = { count: positions.length, nearest }; + } + return out; +} + +function carriedEquipment(bot) { + const slots = bot.inventory?.slots ?? []; + return { + hand: bot.heldItem?.name ?? null, + head: slots[5]?.name ?? null, + torso: slots[6]?.name ?? null, + legs: slots[7]?.name ?? null, + feet: slots[8]?.name ?? null, + }; +} + +function hasEdibleFood(bot, inventory) { + const allowed = foods(bot); + return Object.keys(inventory ?? {}).some((name) => allowed.has(name)); +} + +function entityDistance(e, pos) { + try { + return Math.round(e.position.distanceTo(pos) * 10) / 10; + } catch { + return null; + } +} + +function entitySnapshot(e, pos) { + return { + name: e.username ?? e.name ?? e.displayName ?? "?", + type: e.type ?? null, + distance: entityDistance(e, pos), + position: vec3ToObj(e.position), + }; +} + +function blockNameAt(bot, pos) { + try { + return bot.blockAt(pos)?.name ?? null; + } catch { + return null; + } +} + export function snapshot(bot) { if (!bot || !bot.entity) { return { connected: false }; @@ -42,10 +134,29 @@ export function snapshot(bot) { return !best || d < best.d ? { d, e } : best; }, null); - const inventory = (bot.inventory?.items?.() ?? []).reduce((acc, item) => { - acc[item.name] = (acc[item.name] ?? 0) + item.count; - return acc; - }, {}); + const inventory = inventoryCounts(bot); + const nearbyBlocks = countInterestingBlocks(bot, pos); + const droppedItems = entities + .filter((e) => e.type === "object" || e.name === "item") + .filter((e) => e.position && e.position.distanceTo(pos) <= 24) + .map((e) => entitySnapshot(e, pos)) + .sort((a, b) => (a.distance ?? Infinity) - (b.distance ?? Infinity)) + .slice(0, 12); + const passives = entities + .filter((e) => PASSIVE.has((e.name || "").toLowerCase()) && e.position) + .map((e) => entitySnapshot(e, pos)) + .sort((a, b) => (a.distance ?? Infinity) - (b.distance ?? Infinity)) + .slice(0, 12); + const footBlock = blockNameAt(bot, pos); + const belowBlock = blockNameAt(bot, pos.offset(0, -1, 0)); + const headBlock = blockNameAt(bot, pos.offset(0, 1, 0)); + const hazards = { + lavaNearby: !!nearbyBlocks.lava, + inFluid: footBlock === "water" || footBlock === "lava", + footBlock, + belowBlock, + headBlock, + }; return { connected: true, @@ -60,6 +171,15 @@ export function snapshot(bot) { weather: { rain: bot.isRaining, thunder: bot.thundering }, dimension: bot.game?.dimension, inventory, + hasFood: hasEdibleFood(bot, inventory), + equipment: carriedEquipment(bot), + nearbyBlocks, + nearbyEntities: { + passives, + droppedItems, + }, + hazards, + biome: bot.blockAt?.(pos)?.biome?.name ?? bot.blockAt?.(pos)?.biome ?? null, players: players.map((p) => ({ name: p.username, distance: Math.round(p.position.distanceTo(pos)) })), hostileCount: hostiles.length, closestHostile: closestHostile diff --git a/runtime/reflex.js b/runtime/reflex.js index 256e926..acefcd9 100644 --- a/runtime/reflex.js +++ b/runtime/reflex.js @@ -311,6 +311,45 @@ function sleepReflex(ctx) { const CURRICULUM_COOLDOWN_MS = 4_000; const SKILL_BACKOFF_MS = 60_000; +const METRIC_BACKOFF_MS = 10 * 60_000; +const METRIC_BAD_CODES = new Set(["timeout", "failed", "wedged", "silent_dig_failure", "validation_failed"]); + +function isRecentBadMetric(metric, { minFails = 2, maxAgeMs = METRIC_BACKOFF_MS } = {}) { + if (!metric) return false; + if ((metric.fail ?? 0) < minFails) return false; + if (!METRIC_BAD_CODES.has(metric.lastCode)) return false; + if ((metric.ok ?? 0) > 0 && metric.lastCode !== "timeout") return false; + return Date.now() - (metric.lastTs ?? 0) < maxAgeMs; +} + +function recentSuccessAfter(metric, ts) { + if (!metric || !ts) return false; + if ((metric.ok ?? 0) <= 0) return false; + return (metric.lastTs ?? 0) > ts && metric.lastCode === "done"; +} + +function metricRecoverySkill(ctx, plannedSkillId) { + let metrics = null; + try { + metrics = ctx.metrics?.snapshot?.() ?? null; + } catch { + return null; + } + if (!metrics) return null; + const badExplore = isRecentBadMetric(metrics["explore.far"], { minFails: 1 }); + const badWander = isRecentBadMetric(metrics.wander, { minFails: 2 }); + const lastMovementBadTs = Math.max( + badExplore ? (metrics["explore.far"]?.lastTs ?? 0) : 0, + badWander ? (metrics.wander?.lastTs ?? 0) : 0, + ); + if ((badExplore || badWander) && !recentSuccessAfter(metrics["recovery.tunnel-out"], lastMovementBadTs)) { + return { skillId: "recovery.tunnel-out", reason: "recent movement recovery failures" }; + } + if (plannedSkillId && isRecentBadMetric(metrics[plannedSkillId], { minFails: 2 })) { + return { skillId: "explore.far", reason: `${plannedSkillId} recently failed repeatedly` }; + } + return null; +} function curriculumReflex(ctx) { const s = ctx.snapshot; @@ -323,6 +362,39 @@ function curriculumReflex(ctx) { const plan = s.curriculum?.plan; const wanderHintUntil = ctx.skillBackoff?.["__wander_hint__"] ?? 0; const wantWander = wanderHintUntil && Date.now() < wanderHintUntil; + const metricRecovery = metricRecoverySkill(ctx, plan?.skillId); + if (metricRecovery) { + ctx.lastCurriculumAt = Date.now(); + ctx.skillBackoff = ctx.skillBackoff ?? {}; + if (plan?.skillId) ctx.skillBackoff[plan.skillId] = Date.now() + SKILL_BACKOFF_MS; + const args = metricRecovery.skillId === "recovery.tunnel-out" + ? { reason: metricRecovery.reason, maxSteps: 3 } + : {}; + ctx.dispatch(() => runSkill(metricRecovery.skillId, ctx, args), metricRecovery.skillId, {}); + return { + action: "dispatched", + kind: "curriculum-metric-recovery", + label: metricRecovery.skillId, + }; + } + if (s.curriculum?.inventoryFull) { + const depositId = s.locations?.chest || s.nearbyBlocks?.storage ? "village.deposit-surplus" : null; + if (depositId) { + const depositBackoff = ctx.skillBackoff?.[depositId] ?? 0; + if (Date.now() >= depositBackoff) { + ctx.lastCurriculumAt = Date.now(); + ctx.dispatch(() => runSkill(depositId, ctx), depositId, { + onComplete: (res) => { + if (!res?.ok) { + ctx.skillBackoff = ctx.skillBackoff ?? {}; + ctx.skillBackoff[depositId] = Date.now() + SKILL_BACKOFF_MS; + } + }, + }); + return { action: "dispatched", kind: "curriculum-deposit", label: depositId }; + } + } + } // No skill plan from curriculum OR a recent skill asked us to wander. // First hint → small wander (might just be 32-block reach issue). @@ -382,7 +454,7 @@ function curriculumReflex(ctx) { 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"]); + const cooldownCodes = new Set(["missing_tool", "missing_material", "no_target", "no_food_source", "unsupported_version", "no_chest", "no_space", "nothing_to_deposit"]); if (cooldownCodes.has(res?.code)) { ctx.skillBackoff[skillId] = Date.now() + SKILL_BACKOFF_MS; } @@ -432,11 +504,12 @@ export function runTick(ctx) { if (modeHit?.action?.skillId) { const fn = () => runSkill(modeHit.action.skillId, ctx, modeHit.action.args ?? {}); ctx.lastReflex = { name: `mode:${modeHit.mode}`, label: modeHit.action.skillId, ts: Date.now() }; + ctx.dispatch(fn, modeHit.action.skillId, {}); return { reflex: `mode:${modeHit.mode}`, - action: "dispatch", + action: "dispatched", + kind: `mode:${modeHit.mode}`, label: modeHit.action.skillId, - fn, detail: modeHit.detail, }; } diff --git a/runtime/reflex.test.js b/runtime/reflex.test.js index 234acec..2b2c87f 100644 --- a/runtime/reflex.test.js +++ b/runtime/reflex.test.js @@ -45,6 +45,7 @@ function makeCtx({ lastEatAt = 0, lastSleepAttemptAt = 0, lastCurriculumAt = 0, + metrics, } = {}) { const dispatches = []; const ctx = { @@ -56,6 +57,7 @@ function makeCtx({ lastSleepAttemptAt, lastCurriculumAt, skillBackoff, + metrics, dispatch(fn, label, opts = {}) { dispatches.push({ fn, label, opts }); }, @@ -82,6 +84,23 @@ test("disconnected snapshot → no dispatch", () => { assert.equal(dispatches.length, 0); }); +test("mode hit dispatches the returned skill immediately", () => { + const { ctx, dispatches } = makeCtx({ + snapshot: { + connected: true, + health: 4, + food: 10, + hasFood: true, + inventory: { bread: 1 }, + curriculum: { plan: { skillId: "gather.logs" } }, + }, + }); + const out = runTick(ctx); + assert.equal(out.reflex, "mode:self_preservation"); + assert.equal(out.action, "dispatched"); + assert.equal(dispatches[0].label, "survive.eat"); +}); + test("defend wins over curriculum when hostile in melee", () => { const { ctx, dispatches } = makeCtx({ snapshot: { @@ -279,6 +298,74 @@ test("wander-hint backoff swaps skill for wander on the next tick", () => { assert.equal(dispatches[0].label, "wander"); }); +test("recent repeated skill timeouts trigger metric recovery", () => { + const now = Date.now(); + const { ctx, dispatches } = makeCtx({ + snapshot: { + connected: true, + health: 20, + food: 20, + isDay: true, + curriculum: { plan: { skillId: "gather.logs" } }, + }, + metrics: { + snapshot: () => ({ + "gather.logs": { ok: 0, fail: 3, lastTs: now, lastCode: "timeout" }, + }), + }, + }); + const out = runTick(ctx); + assert.equal(out.reflex, "curriculum"); + assert.equal(out.kind, "curriculum-metric-recovery"); + assert.equal(dispatches[0].label, "explore.far"); + assert.ok((ctx.skillBackoff?.["gather.logs"] ?? 0) > Date.now()); +}); + +test("recent movement timeouts trigger tunnel recovery", () => { + const now = Date.now(); + const { ctx, dispatches } = makeCtx({ + snapshot: { + connected: true, + health: 20, + food: 20, + isDay: true, + curriculum: { plan: { skillId: "gather.logs" } }, + }, + metrics: { + snapshot: () => ({ + "explore.far": { ok: 0, fail: 1, lastTs: now, lastCode: "timeout" }, + }), + }, + }); + const out = runTick(ctx); + assert.equal(out.reflex, "curriculum"); + assert.equal(out.kind, "curriculum-metric-recovery"); + assert.equal(dispatches[0].label, "recovery.tunnel-out"); +}); + +test("successful tunnel recovery clears movement-timeout trigger", () => { + const now = Date.now(); + const { ctx, dispatches } = makeCtx({ + snapshot: { + connected: true, + health: 20, + food: 20, + isDay: true, + curriculum: { plan: { skillId: "gather.logs" } }, + }, + metrics: { + snapshot: () => ({ + "explore.far": { ok: 0, fail: 1, lastTs: now - 5_000, lastCode: "timeout" }, + "recovery.tunnel-out": { ok: 1, fail: 0, lastTs: now, lastCode: "done" }, + }), + }, + }); + const out = runTick(ctx); + assert.equal(out.reflex, "curriculum"); + assert.equal(out.kind, "curriculum-skill"); + assert.equal(dispatches[0].label, "gather.logs"); +}); + test("onComplete sets wander hint when skill recovery says so", () => { const { ctx, dispatches } = makeCtx({ snapshot: { diff --git a/runtime/scenario-memory.js b/runtime/scenario-memory.js index 8dc7eca..95e647f 100644 --- a/runtime/scenario-memory.js +++ b/runtime/scenario-memory.js @@ -45,13 +45,18 @@ export function situationHash(snapshot) { const hostile = snapshot.closestHostile && snapshot.closestHostile.distance < 24 ? snapshot.closestHostile.name : "-"; + const biome = snapshot.biome ?? "-"; + const blocks = Object.keys(snapshot.nearbyBlocks ?? {}) + .sort() + .slice(0, 8) + .join(",") || "-"; // Inventory keys, sorted — we lose counts but keep "what kind of stuff do // I have". Limited to first 10 names for hash stability. const invKeys = Object.keys(snapshot.inventory ?? {}) .sort() .slice(0, 10) .join(",") || "-"; - return `${cx},${cy},${cz}|${day}|${food}|${hp}|host:${hostile}|inv:${invKeys}`; + return `${cx},${cy},${cz}|${day}|${food}|${hp}|bio:${biome}|blocks:${blocks}|host:${hostile}|inv:${invKeys}`; } function loadScenarios() { diff --git a/runtime/skill-library.js b/runtime/skill-library.js index 1991f2b..312abfc 100644 --- a/runtime/skill-library.js +++ b/runtime/skill-library.js @@ -42,11 +42,14 @@ function extractHeaderComment(src) { function skillFilePath(id) { const slug = id.replace(/\./g, "-"); + const tail = id.split(".").slice(1).join("-"); const candidates = [ path.join(__dirname, "skills", `${slug}.js`), path.join(__dirname, "skills", `${slug.replace(/-/g, "_")}.js`), + tail ? path.join(__dirname, "skills", `${tail}.js`) : null, + tail ? path.join(__dirname, "skills", `${tail.replace(/-/g, "_")}.js`) : null, ]; - for (const p of candidates) if (fs.existsSync(p)) return p; + for (const p of candidates) if (p && fs.existsSync(p)) return p; return null; } diff --git a/runtime/skill-metrics.js b/runtime/skill-metrics.js index d28abb2..4ac3a0e 100644 --- a/runtime/skill-metrics.js +++ b/runtime/skill-metrics.js @@ -1,27 +1,75 @@ -// Per-skill ok/fail counters. Aggregated for the lifetime of the bot -// process (best-effort persistence is left for a future iteration — -// today's counters reset on restart, which keeps the data store -// simple while still being useful for incident bodies and the TUI). +// Per-skill ok/fail counters. Persisted under state// so the bot's +// next run and auto-improvement prompts can learn from prior attempts, +// not only the current process lifetime. -export function createSkillMetrics() { - const counts = new Map(); // id → { ok, fail, lastTs } +import fs from "node:fs"; +import path from "node:path"; +import { stateDir } from "./config.js"; - function record(id, ok) { - const cur = counts.get(id) ?? { ok: 0, fail: 0, lastTs: 0 }; +const METRICS_PATH = path.join(stateDir, "skill-metrics.json"); + +function loadMetrics() { + const counts = new Map(); + try { + const raw = fs.readFileSync(METRICS_PATH, "utf8"); + const parsed = JSON.parse(raw); + for (const [id, m] of Object.entries(parsed ?? {})) { + counts.set(id, { + ok: Number(m.ok ?? 0), + fail: Number(m.fail ?? 0), + lastTs: Number(m.lastTs ?? 0), + lastCode: m.lastCode ?? null, + lastDurationMs: Number(m.lastDurationMs ?? 0), + totalDurationMs: Number(m.totalDurationMs ?? 0), + }); + } + } catch {} + return counts; +} + +function saveMetrics(counts) { + try { + fs.mkdirSync(stateDir, { recursive: true }); + const out = {}; + for (const [id, m] of counts) out[id] = { ...m }; + const tmp = `${METRICS_PATH}.tmp`; + fs.writeFileSync(tmp, JSON.stringify(out, null, 2)); + fs.renameSync(tmp, METRICS_PATH); + } catch {} +} + +export function createSkillMetrics({ persist = true } = {}) { + const counts = persist ? loadMetrics() : new Map(); // id → { ok, fail, lastTs } + + function record(id, ok, { code = null, durationMs = 0 } = {}) { + const cur = counts.get(id) ?? { ok: 0, fail: 0, lastTs: 0, lastCode: null, lastDurationMs: 0, totalDurationMs: 0 }; if (ok) cur.ok++; else cur.fail++; cur.lastTs = Date.now(); + cur.lastCode = code ?? cur.lastCode ?? null; + cur.lastDurationMs = Math.max(0, Math.round(durationMs || 0)); + cur.totalDurationMs += cur.lastDurationMs; counts.set(id, cur); + if (persist) saveMetrics(counts); } function snapshot() { const out = {}; - for (const [id, m] of counts) out[id] = { ...m }; + for (const [id, m] of counts) { + const total = (m.ok ?? 0) + (m.fail ?? 0); + out[id] = { + ...m, + avgDurationMs: total > 0 ? Math.round((m.totalDurationMs ?? 0) / total) : 0, + }; + } return out; } function reset() { counts.clear(); + if (persist) { + try { fs.unlinkSync(METRICS_PATH); } catch {} + } } return { record, snapshot, reset }; diff --git a/runtime/skills/acquire-food.js b/runtime/skills/acquire-food.js new file mode 100644 index 0000000..4b2a317 --- /dev/null +++ b/runtime/skills/acquire-food.js @@ -0,0 +1,163 @@ +// survive.acquire-food — turn "hungry and no edible item" into a concrete +// world action. The first implementation is intentionally conservative: +// pick up nearby drops if they are already visible, otherwise hunt a nearby +// passive animal. It does not harvest player-looking crops. + +import pathfinderPkg from "mineflayer-pathfinder"; +const { pathfinder, goals, Movements } = pathfinderPkg; + +import { info, warn } from "../log.js"; +import { foods } from "./groups.js"; + +const PASSIVE_FOOD_MOBS = new Set(["cow", "pig", "chicken", "sheep", "rabbit", "mooshroom"]); + +let pluginLoaded = new WeakSet(); +function ensurePathfinder(bot) { + if (pluginLoaded.has(bot)) return; + bot.loadPlugin(pathfinder); + pluginLoaded.add(bot); +} + +function setMovementsForTravel(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 foodCount(bot) { + const allowed = foods(bot); + return bot.inventory.items().reduce((sum, item) => allowed.has(item.name) ? sum + item.count : sum, 0); +} + +function nearestPassiveFoodMob(bot, maxDistance = 32) { + const here = bot?.entity?.position; + if (!here) return null; + let best = null; + for (const e of Object.values(bot.entities ?? {})) { + if (!e?.position || !PASSIVE_FOOD_MOBS.has(e.name)) continue; + const d = e.position.distanceTo(here); + if (d > maxDistance) continue; + if (!best || d < best.distance) best = { entity: e, distance: d }; + } + return best; +} + +function nearbyDroppedItems(bot, maxDistance = 8) { + const here = bot?.entity?.position; + if (!here) return []; + return Object.values(bot.entities ?? {}) + .filter((e) => e?.position && (e.type === "object" || e.name === "item")) + .map((e) => ({ entity: e, distance: e.position.distanceTo(here) })) + .filter((e) => e.distance <= maxDistance) + .sort((a, b) => a.distance - b.distance); +} + +async function pickupNearbyDrops(bot) { + ensurePathfinder(bot); + setMovementsForTravel(bot); + let picked = 0; + for (const { entity } of nearbyDroppedItems(bot, 8).slice(0, 6)) { + try { + await withTimeout( + bot.pathfinder.goto(new goals.GoalNear(entity.position.x, entity.position.y, entity.position.z, 1)), + 8_000, + "gotoDrop", + ); + picked++; + } catch {} + } + if (picked > 0) await new Promise((r) => setTimeout(r, 600)); + return picked; +} + +export const skill = Object.freeze({ + id: "survive.acquire-food", + title: "Acquire a basic food item", + timeoutMs: 75_000, + preconditions(ctx) { + if (!ctx?.bot) return { ok: false, code: "no_bot", detail: "bot missing" }; + if (foodCount(ctx.bot) > 0) return { ok: false, code: "already_have", detail: "already carrying edible food" }; + if (nearestPassiveFoodMob(ctx.bot) || nearbyDroppedItems(ctx.bot, 8).length > 0) return { ok: true }; + return { ok: false, code: "no_target", detail: "no nearby food drops or passive food mobs" }; + }, + async execute(ctx) { + const bot = ctx.bot; + const before = foodCount(bot); + + const picked = await pickupNearbyDrops(bot); + if (foodCount(bot) > before) { + return { + ok: true, + code: "done", + detail: { source: "drop", picked }, + worldDelta: { acquiredFood: foodCount(bot) - before, source: "drop" }, + }; + } + + const target = nearestPassiveFoodMob(bot); + if (!target) return { ok: false, code: "no_target", detail: "no passive food mob visible", worldDelta: null }; + + ensurePathfinder(bot); + setMovementsForTravel(bot); + try { + await withTimeout( + bot.pathfinder.goto(new goals.GoalFollow(target.entity, 2)), + 30_000, + "pathToFoodMob", + ); + } catch (e) { + return { ok: false, code: "no_path", detail: e.message, worldDelta: null }; + } + + info("action", `survive.acquire-food: hunting ${target.entity.name} (${target.distance.toFixed(1)}m)`); + try { + for (let i = 0; i < 8; i++) { + const current = Object.values(bot.entities ?? {}).find((e) => e.id === target.entity.id); + if (!current) break; + if (current.position.distanceTo(bot.entity.position) > 4) { + try { + await withTimeout( + bot.pathfinder.goto(new goals.GoalFollow(current, 2)), + 8_000, + "repathFoodMob", + ); + } catch {} + } + bot.attack(current); + await new Promise((r) => setTimeout(r, 700)); + } + await new Promise((r) => setTimeout(r, 1_000)); + await pickupNearbyDrops(bot); + const after = foodCount(bot); + if (after <= before) { + return { ok: false, code: "no_drop", detail: `hunted ${target.entity.name} but found no edible drop`, worldDelta: null }; + } + return { + ok: true, + code: "done", + detail: { source: "hunt", mob: target.entity.name, gained: after - before }, + worldDelta: { acquiredFood: after - before, source: "hunt", mob: target.entity.name }, + }; + } catch (e) { + warn("action", `survive.acquire-food failed: ${e.message}`); + return { ok: false, code: "failed", detail: e.message, worldDelta: null }; + } + }, + recover(ctx, result) { + if (result.code === "no_target" || result.code === "no_path") { + return { hint: "wander", reason: "need to search for passive food mobs" }; + } + return null; + }, +}); + +export const _internal = { foodCount, nearestPassiveFoodMob }; diff --git a/runtime/skills/build-shelter.js b/runtime/skills/build-shelter.js index c0a70a2..3d6260f 100644 --- a/runtime/skills/build-shelter.js +++ b/runtime/skills/build-shelter.js @@ -121,6 +121,7 @@ export const skill = Object.freeze({ }, async execute(ctx, { owned } = {}) { const bot = ctx.bot; + const ownedLedger = owned ?? ctx.owned; const planks = pickBuildPlanks(bot); const base = getLocation("base") ?? getLocation(SHELTER_NAME); const center = { x: base.x, y: base.y, z: base.z }; @@ -158,8 +159,8 @@ export const skill = Object.freeze({ } try { await withTimeout(bot.placeBlock(place.ref, place.face), 5000, "placeBlock"); - if (owned?.markPlaced) { - owned.markPlaced({ + if (ownedLedger?.markPlaced) { + ownedLedger.markPlaced({ x: target.x, y: target.y, z: target.z, blockType: planks.name, skill: "village.build-shelter", diff --git a/runtime/skills/choose-base.js b/runtime/skills/choose-base.js index 7c3d64c..30055e1 100644 --- a/runtime/skills/choose-base.js +++ b/runtime/skills/choose-base.js @@ -23,7 +23,7 @@ export const skill = Object.freeze({ return { ok: true }; }, async execute(ctx) { - const result = scoreCurrentPosition(ctx.bot); + const result = scoreCurrentPosition(ctx.bot, { isOwned: ctx.owned?.isOwned }); if (!result?.position) { return { ok: false, code: "no_position", detail: "bot has no position", worldDelta: null }; } diff --git a/runtime/skills/explore-far.js b/runtime/skills/explore-far.js index 96dd371..75f04c9 100644 --- a/runtime/skills/explore-far.js +++ b/runtime/skills/explore-far.js @@ -8,7 +8,7 @@ // patch. import pathfinderPkg from "mineflayer-pathfinder"; -const { pathfinder, goals, Movements } = pathfinderPkg; +const { pathfinder, Movements } = pathfinderPkg; import { info, warn } from "../log.js"; import { digEscapeTunnel } from "./recovery-tunnel-out.js"; @@ -69,6 +69,7 @@ export const skill = Object.freeze({ // this server mean we can't trust GoalNear; cardinal probing // gives us a free-direction signal cheaply. const dist = Math.max(24, args.distance ?? 48); + const beforeProbe = clonePos(bot.entity.position); const trials = await probeCardinalStep(bot, 800); const movable = trials.filter((t) => t.dist > 0.5); @@ -89,6 +90,16 @@ export const skill = Object.freeze({ } info("action", `explore.far: cardinal probe trials=${trials.map((t) => `${t.name}:${t.dist.toFixed(1)}`).join(" ")} best=${best.name}`); + const probeMoved = horizontalDistance(beforeProbe, bot.entity.position); + if (probeMoved >= 2) { + return { + ok: true, + code: "done", + detail: { mode: "probe-moved", dir: best.name, moved: probeMoved }, + worldDelta: { movedTo: clonePos(bot.entity.position) }, + }; + } + if (best.dist < 0.5) { // All cardinals blocked. Try the cheap vertical escape first; if it // does not actually move us, carve a short horizontal tunnel. The @@ -107,33 +118,19 @@ export const skill = Object.freeze({ const tx = Math.round(here.x + Math.sin(-best.yaw) * dist); const tz = Math.round(here.z + Math.cos(-best.yaw) * dist); const ty = Math.round(here.y); - info("action", `explore.far: walking ${best.name} → ${tx},${ty},${tz}`); - - try { - await withTimeout( - bot.pathfinder.goto(new goals.GoalNear(tx, ty, tz, 4)), - 45_000, - `explore.far(${tx},${tz})`, - ); - return { - ok: true, code: "done", - detail: { to: { x: tx, y: ty, z: tz }, dir: best.name }, - worldDelta: { movedTo: { x: tx, y: ty, z: tz } }, - }; - } catch (e) { - warn("action", `explore.far pathfinder failed: ${e.message} — continuing blind`); - return blindWalkOrTunnelOut(bot, { - yaw: best.yaw, - dirName: best.name, - blindMs: args.blindMs ?? 7_000, - tunnelPushMs: args.tunnelPushMs, - reason: `explore.far blind ${best.name}`, - }); - } + info("action", `explore.far: blind-walking ${best.name} toward ${tx},${ty},${tz}`); + return blindWalkOrTunnelOut(bot, { + yaw: best.yaw, + dirName: best.name, + blindMs: args.blindMs ?? 7_000, + tunnelPushMs: args.tunnelPushMs, + reason: `explore.far blind ${best.name}`, + intended: { x: tx, y: ty, z: tz }, + }); }, }); -async function blindWalkOrTunnelOut(bot, { yaw, dirName, blindMs = 7_000, minMove = 0.75, tunnelPushMs, reason = "blind fallback" } = {}) { +async function blindWalkOrTunnelOut(bot, { yaw, dirName, blindMs = 7_000, minMove = 0.75, tunnelPushMs, reason = "blind fallback", intended = null } = {}) { const before = clonePos(bot.entity.position); try { await bot.look(yaw, 0, true); } catch {} bot.setControlState("forward", true); @@ -150,7 +147,7 @@ async function blindWalkOrTunnelOut(bot, { yaw, dirName, blindMs = 7_000, minMov return { ok: true, code: "done", - detail: { mode: "blind-moved", previousMode: "blind", dir: dirName, moved }, + detail: { mode: "blind-moved", previousMode: "blind", dir: dirName, moved, intended }, worldDelta: { movedTo: clonePos(bot.entity.position) }, }; } diff --git a/runtime/skills/flee.js b/runtime/skills/flee.js new file mode 100644 index 0000000..90a33f3 --- /dev/null +++ b/runtime/skills/flee.js @@ -0,0 +1,61 @@ +// survive.flee — emergency retreat from the nearest hostile. Unlike +// explore.far, this skill explicitly moves away from the hostile entity +// that triggered the mode. + +import { fleeFrom } from "../actions.js"; + +const HOSTILE = new Set([ + "zombie", "skeleton", "creeper", "spider", "witch", "pillager", + "vindicator", "husk", "stray", "drowned", "phantom", "enderman", + "slime", "magma_cube", "hoglin", "piglin_brute", "ravager", "warden", + "breeze", "bogged", +]); + +function nearestHostile(bot, { hostileName } = {}) { + const here = bot?.entity?.position; + if (!here) return null; + let best = null; + for (const e of Object.values(bot.entities ?? {})) { + if (!e?.position) continue; + const name = (e.name || "").toLowerCase(); + if (hostileName && name !== String(hostileName).toLowerCase()) continue; + if (!hostileName && !HOSTILE.has(name)) continue; + const d = e.position.distanceTo(here); + if (!best || d < best.distance) best = { entity: e, distance: d }; + } + return best; +} + +export const skill = Object.freeze({ + id: "survive.flee", + title: "Retreat from the nearest hostile", + timeoutMs: 40_000, + preconditions(ctx, args = {}) { + if (!ctx?.bot) return { ok: false, code: "no_bot", detail: "bot missing" }; + const hit = nearestHostile(ctx.bot, args); + if (!hit) return { ok: false, code: "no_hostile", detail: "no matching hostile entity" }; + return { ok: true }; + }, + async execute(ctx, args = {}) { + const hit = nearestHostile(ctx.bot, args); + if (!hit) return { ok: false, code: "no_hostile", detail: "no matching hostile after precondition", worldDelta: null }; + const res = await fleeFrom(ctx.bot, hit.entity, args.distance ?? 16); + if (res.ok) { + return { + ok: true, + code: "done", + detail: { ...res.detail, from: hit.entity.name, distance: Math.round(hit.distance * 10) / 10 }, + worldDelta: { fledTo: res.detail?.to ?? null }, + }; + } + const msg = String(res.detail ?? ""); + const code = msg.includes("timed out") ? "timeout" : "failed"; + return { ok: false, code, detail: res.detail, worldDelta: null }; + }, + recover(ctx, result) { + if (result.code === "timeout") return { hint: "tunnel-out", reason: "flee path timed out" }; + return null; + }, +}); + +export const _internal = { nearestHostile }; diff --git a/runtime/skills/index.js b/runtime/skills/index.js index 38a8b0f..e4961d6 100644 --- a/runtime/skills/index.js +++ b/runtime/skills/index.js @@ -26,13 +26,17 @@ 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 exploreFar } from "./explore-far.js"; +import { skill as flee } from "./flee.js"; +import { skill as sleep } from "./sleep.js"; import { skill as tunnelOut } from "./recovery-tunnel-out.js"; import { skill as diagPhysics } from "./diagnose-physics.js"; import { skill as diagScan, matchSkill as diagMatch } from "./diagnose-scan.js"; import { skill as gatherStone } from "./gather-stone.js"; import { skill as gatherWool } from "./gather-wool.js"; +import { skill as acquireFood } from "./acquire-food.js"; import { skill as chooseBase } from "./choose-base.js"; import { skill as buildShelter } from "./build-shelter.js"; +import { skill as placeChest } from "./place-chest.js"; import { skill as depositSurplus } from "./deposit-surplus.js"; import { skill as farmWheat } from "./farm-wheat.js"; import { @@ -65,14 +69,18 @@ register(chopLogs); register(eat); register(wander); register(exploreFar); +register(flee); +register(sleep); register(tunnelOut); register(diagPhysics); register(diagScan); register(diagMatch); register(gatherStone); register(gatherWool); +register(acquireFood); register(chooseBase); register(buildShelter); +register(placeChest); register(depositSurplus); register(farmWheat); register(craftPlanksSkill); diff --git a/runtime/skills/place-chest.js b/runtime/skills/place-chest.js new file mode 100644 index 0000000..c5a2f68 --- /dev/null +++ b/runtime/skills/place-chest.js @@ -0,0 +1,94 @@ +// village.place-chest — place the carried chest near the base/current +// footing and register it as "chest" in locations.json. This turns the +// storage milestone from "I crafted a chest item" into "I have a usable +// storage location". + +import { setLocation, getLocation } from "../locations.js"; + +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 carriedChest(bot) { + return bot.inventory.items().find((i) => i.name === "chest" || i.name === "trapped_chest"); +} + +function isEmpty(block) { + return !block || block.boundingBox === "empty" || block.name === "air" || block.name === "cave_air" || block.name === "void_air"; +} + +function placementCandidate(bot) { + const here = bot.entity.position.floored ? bot.entity.position.floored() : bot.entity.position; + const offsets = [ + { x: 1, z: 0 }, + { x: -1, z: 0 }, + { x: 0, z: 1 }, + { x: 0, z: -1 }, + { x: 2, z: 0 }, + { x: 0, z: 2 }, + ]; + for (const off of offsets) { + const ref = bot.blockAt({ x: Math.round(here.x + off.x), y: Math.round(here.y - 1), z: Math.round(here.z + off.z) }); + const target = bot.blockAt({ x: Math.round(here.x + off.x), y: Math.round(here.y), z: Math.round(here.z + off.z) }); + if (ref?.boundingBox === "block" && isEmpty(target)) { + return { ref, face: { x: 0, y: 1, z: 0 }, at: { x: ref.position.x, y: ref.position.y + 1, z: ref.position.z } }; + } + } + return null; +} + +export const skill = Object.freeze({ + id: "village.place-chest", + title: "Place a personal chest", + timeoutMs: 30_000, + preconditions(ctx) { + if (!ctx?.bot) return { ok: false, code: "no_bot", detail: "bot missing" }; + if (getLocation("chest")) return { ok: false, code: "already_have", detail: "chest location already exists" }; + if (!carriedChest(ctx.bot)) return { ok: false, code: "missing_material", detail: "no chest item in inventory" }; + if (!placementCandidate(ctx.bot)) return { ok: false, code: "no_space", detail: "no adjacent placeable slot" }; + return { ok: true }; + }, + async execute(ctx) { + const bot = ctx.bot; + const item = carriedChest(bot); + if (!item) return { ok: false, code: "missing_material", detail: "no chest item after precondition", worldDelta: null }; + const place = placementCandidate(bot); + if (!place) return { ok: false, code: "no_space", detail: "no adjacent placeable slot", worldDelta: null }; + try { + await withTimeout(bot.equip(item, "hand"), 3_000, "equip chest"); + await withTimeout(bot.placeBlock(place.ref, place.face), 5_000, "place chest"); + const loc = setLocation("chest", { + x: place.at.x, + y: place.at.y, + z: place.at.z, + dimension: ctx.snapshot?.dimension ?? "overworld", + radius: 2, + note: "auto-placed storage chest", + }); + ctx.owned?.markPlaced?.({ + x: loc.x, + y: loc.y, + z: loc.z, + dimension: loc.dimension, + blockType: item.name, + skill: "village.place-chest", + }); + return { + ok: true, + code: "done", + detail: { location: loc, item: item.name }, + worldDelta: { chestAt: { x: loc.x, y: loc.y, z: loc.z }, placedType: item.name }, + }; + } catch (e) { + const msg = String(e?.message ?? ""); + const code = msg.includes("timed out") ? "timeout" : "failed"; + return { ok: false, code, detail: e.message, worldDelta: null }; + } + }, +}); + +export const _internal = { placementCandidate }; diff --git a/runtime/skills/recovery-tunnel-out.js b/runtime/skills/recovery-tunnel-out.js index 84ebd47..c286467 100644 --- a/runtime/skills/recovery-tunnel-out.js +++ b/runtime/skills/recovery-tunnel-out.js @@ -280,7 +280,8 @@ export function rankTunnelDirections(bot, maxSteps = 3) { async function digOne(bot, block) { if (isPassableBlock(block)) return false; - await equipLikelyTool(bot, block.name); + const tool = await equipLikelyTool(bot, block.name); + const timeoutMs = digTimeoutMs(block.name, tool); try { if (typeof bot.lookAt === "function") { await withTimeout(bot.lookAt(centerOf(block.position), true), 1_500, `lookAt(${block.name})`); @@ -288,7 +289,7 @@ async function digOne(bot, block) { } catch { // Dig may still work; do not abort on look jitter. } - await withTimeout(bot.dig(block), 10_000, `dig(${block.name})`); + await withTimeout(bot.dig(block), timeoutMs, `dig(${block.name})`); const after = bot.blockAt(block.position); if (after && !isPassableBlock(after) && after.name === block.name) { throw new Error(`block still present after dig: ${block.name}`); @@ -296,6 +297,16 @@ async function digOne(bot, block) { return true; } +function digTimeoutMs(blockName, equippedTool) { + const kind = toolKindFor(blockName); + if (!kind) return 12_000; + if (equippedTool?.includes(kind)) return 12_000; + if (kind === "pickaxe") return 25_000; + if (kind === "axe") return 18_000; + if (kind === "shovel") return 15_000; + return 12_000; +} + async function pushForward(bot, yaw, ms) { try { await bot.look(yaw, 0, true); } catch {} bot.setControlState("forward", true); @@ -329,8 +340,16 @@ export async function digEscapeTunnel(bot, { maxSteps = 3, minMove = 0.75, pushM const before = posClone(bot.entity.position); info("action", `tunnel-out: ${reason} → ${dir.name} (${dir.digTargets.length} blocks to clear)`); try { - for (const target of dir.digTargets) { - await digOne(bot, target.block); + let dug = 0; + let lastStep = 0; + const byStep = [...dir.digTargets] + .sort((a, b) => (a.step - b.step) || (a.kind === "feet" ? -1 : 1)); + for (const target of byStep) { + if (target.step !== lastStep && lastStep > 0) { + await pushForward(bot, dir.yaw, Math.min(pushMs, 900)); + } + lastStep = target.step; + if (await digOne(bot, target.block)) dug++; } await pushForward(bot, dir.yaw, pushMs); const moved = horizontalDistance(before, bot.entity.position); @@ -340,7 +359,7 @@ export async function digEscapeTunnel(bot, { maxSteps = 3, minMove = 0.75, pushM return { ok: true, code: "done", - detail: { mode: "tunnel-out", dir: dir.name, moved, movedY, dug: dir.digTargets.length }, + detail: { mode: "tunnel-out", dir: dir.name, moved, movedY, dug }, worldDelta: { mode: "tunnel-out", movedTo }, }; } @@ -363,7 +382,7 @@ export async function digEscapeTunnel(bot, { maxSteps = 3, minMove = 0.75, pushM export const skill = Object.freeze({ id: "recovery.tunnel-out", title: "Tunnel out of a wedged 1x1 hole", - timeoutMs: 45_000, + timeoutMs: 120_000, preconditions(ctx) { if (!ctx?.bot) return { ok: false, code: "no_bot", detail: "bot missing" }; return { ok: true }; diff --git a/runtime/skills/recovery-tunnel-out.test.js b/runtime/skills/recovery-tunnel-out.test.js index 1905276..161d969 100644 --- a/runtime/skills/recovery-tunnel-out.test.js +++ b/runtime/skills/recovery-tunnel-out.test.js @@ -100,6 +100,38 @@ test("tunnel-out does not count jumping in place as escape", async () => { assert.match(res.detail.error, /moved only 0\.00 horizontally/); }); +test("tunnel-out digs one reachable layer at a time", async () => { + const blocks = {}; + for (let step = 1; step <= 3; step++) { + blocks[`${step},64,0`] = "stone"; + blocks[`${step},65,0`] = "stone"; + blocks[`${step},63,0`] = "stone"; + } + blocks["0,64,-1"] = "oak_planks"; + blocks["0,64,1"] = "oak_planks"; + blocks["-1,64,0"] = "oak_planks"; + + const bot = makeBot(blocks); + bot.look = async () => {}; + bot.lookAt = async () => {}; + bot.dig = async (block) => { + const dist = Math.hypot(block.position.x - bot.entity.position.x, block.position.z - bot.entity.position.z); + if (dist > 1.5) throw new Error(`too far: ${dist.toFixed(1)}`); + blocks[`${block.position.x},${block.position.y},${block.position.z}`] = "air"; + }; + bot.setControlState = (control, on) => { + if (control === "forward" && !on) { + bot.entity.position = makePos(bot.entity.position.x + 1, bot.entity.position.y, bot.entity.position.z); + } + }; + + const res = await digEscapeTunnel(bot, { maxSteps: 3, minMove: 0.75, pushMs: 0 }); + assert.equal(res.ok, true); + assert.equal(res.detail.dir, "E"); + assert.equal(res.detail.dug, 6); + assert.equal(Math.round(bot.entity.position.x), 3); +}); + test("explore.far blind fallback does not report done when position is unchanged", async () => { const blocks = {}; const bot = makeBot(blocks); diff --git a/runtime/skills/sleep.js b/runtime/skills/sleep.js new file mode 100644 index 0000000..b779f3e --- /dev/null +++ b/runtime/skills/sleep.js @@ -0,0 +1,44 @@ +// survive.sleep — use the action-layer bed primitive through the skill +// contract so priority modes can sleep without bypassing metrics, +// scenario-memory, current-task, and self-improvement evidence. + +import { sleepInBed } from "../actions.js"; + +export const skill = Object.freeze({ + id: "survive.sleep", + title: "Sleep in or place a carried bed", + timeoutMs: 45_000, + preconditions(ctx) { + if (!ctx?.bot) return { ok: false, code: "no_bot", detail: "bot missing" }; + if (ctx.snapshot?.isDay) return { ok: false, code: "daytime", detail: "not night" }; + const inv = ctx.snapshot?.inventory ?? {}; + const hasBed = Object.keys(inv).some((n) => /_bed$/.test(n)); + const knownBed = ctx.snapshot?.locations?.shelter || ctx.snapshot?.locations?.base; + if (!hasBed && !knownBed) { + return { ok: false, code: "missing_bed", detail: "no bed in inventory or known shelter" }; + } + return { ok: true }; + }, + async execute(ctx) { + const res = await sleepInBed(ctx.bot); + if (res.ok) { + return { + ok: true, + code: "done", + detail: res.detail, + worldDelta: { sleptAt: res.detail?.bedAt ?? ctx.snapshot?.position ?? null }, + }; + } + const msg = String(res.detail ?? ""); + const code = msg.includes("no bed") + ? "missing_bed" + : msg.includes("timed out") + ? "timeout" + : "failed"; + return { ok: false, code, detail: res.detail, worldDelta: null }; + }, + recover(ctx, result) { + if (result.code === "missing_bed") return { hint: "curriculum", reason: "need bed milestone" }; + return null; + }, +}); diff --git a/runtime/stuck-incident.test.js b/runtime/stuck-incident.test.js index 04d2311..6bdc4b2 100644 --- a/runtime/stuck-incident.test.js +++ b/runtime/stuck-incident.test.js @@ -74,7 +74,7 @@ test("cooldown prevents back-to-back firings", () => { }); test("skill metrics record ok/fail and expose snapshot", () => { - const m = createSkillMetrics(); + const m = createSkillMetrics({ persist: false }); m.record("gather.logs", true); m.record("gather.logs", true); m.record("gather.logs", false); diff --git a/tui/tui.tsx b/tui/tui.tsx index 54b1c42..22f8e42 100644 --- a/tui/tui.tsx +++ b/tui/tui.tsx @@ -275,7 +275,7 @@ function PiPanel({ piStream, piRunning }: { piStream: string; piRunning: boolean ); } -type Mode = "idle" | "chat" | "ask-pi"; +type Mode = "idle" | "chat" | "ask-pi" | "run-skill" | "incident"; function App() { const { exit } = useApp(); @@ -366,6 +366,9 @@ function App() { } if (input === "c") setMode("chat"); if (input === "a") setMode("ask-pi"); + if (input === "k") setMode("run-skill"); + if (input === "v") client.send(COMMAND_TYPES.SCREENSHOT, { reason: "tui", frames: 1 }); + if (input === "!") setMode("incident"); if (input === "y") client.send(COMMAND_TYPES.PROPOSAL_LATEST, {}); }); @@ -377,14 +380,33 @@ function App() { if (!text) return; if (m === "chat") client.send(COMMAND_TYPES.CHAT, { text }); else if (m === "ask-pi") client.send(COMMAND_TYPES.ASK_PI, { prompt: text }); + else if (m === "run-skill") { + const [skillId, ...rest] = text.split(/\s+/); + let args = {}; + const json = rest.join(" ").trim(); + if (json) { + try { args = JSON.parse(json); } + catch { + client.send(COMMAND_TYPES.ASK_PI, { prompt: `Parse this run-skill argument JSON for ${skillId}: ${json}` }); + return; + } + } + client.send(COMMAND_TYPES.RUN_SKILL, { skillId, args }); + } else if (m === "incident") { + client.send(COMMAND_TYPES.FORCE_INCIDENT, { reason: text, kind: "operator-forced" }); + } } const hotkeyHint = mode === "idle" - ? "[p]ause/resume [s]top [r]efresh [c]hat [a]sk-pi [y] proposals [q]uit" + ? "[p]ause/resume [s]top [r]efresh [c]hat [a]sk-pi [k] skill [v] screenshot [!] incident [y] proposals [q]uit" : mode === "chat" ? "chat → MC (Enter to send, Esc to cancel)" - : "ask-pi → spawn pi -p (Enter to send)"; + : mode === "ask-pi" + ? "ask-pi → spawn pi -p (Enter to send)" + : mode === "run-skill" + ? 'run-skill → skill.id {"arg":true}' + : "incident → reason for critic/auto-improve proposal"; return ( @@ -412,7 +434,7 @@ function App() { ) : ( <> - {mode === "chat" ? "chat> " : "pi> "} + {mode === "chat" ? "chat> " : mode === "ask-pi" ? "pi> " : mode === "run-skill" ? "skill> " : "incident> "}