From 0834bb6b735c77ced595a67c2a636f85f749aab8 Mon Sep 17 00:00:00 2001 From: Yuriy Mayatnikov Date: Mon, 25 May 2026 22:15:24 +0300 Subject: [PATCH] feat(runtime): skill substrate + dynamic groups + reference skills (Phase 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 of plans/autonomous-survival-bot-prd.md. Establishes the composable skill contract from PRD §5.2 and ports three reference skills so future phases can layer survival behaviour on top instead of adding more ad-hoc branches to reflex.js. New: runtime/skills/ - index.js: skill registry + runSkill(id, ctx, args) wrapper. Enforces preconditions, hard timeout, normalises {ok, code, detail, worldDelta} on every result, runs validate() and calls recover() on failure. Stable failure codes live in RUNNER_CODES (unknown_skill, precondition_failed, timeout, threw, validation_failed, done). - groups.js: registry-derived item/block sets — logs/planks/sticks/beds derived by suffix; foods intersects a curated allowlist with the live bot.registry; axes/pickaxes/swords scoped to whatever the connected server's item table actually ships. Empty set instead of throwing on missing registry, so skills can emit code:"unsupported_version". - chop-logs.js: gather.logs reference skill (wraps chopNearestTree). - eat.js: survive.eat (wraps eatBestFood, preconditions check carrying edible food from the registry-derived set). - wander.js: explore.wander (wraps wander). - contract.test.js + groups.test.js: 14 tests covering precondition gating, timeout firing recover(), execute exceptions, validate flipping ok→false, dynamic group filtering across mock registries. package.json: `npm test` runs the new contract + groups suites. docs/runtime.md: documents the skill contract, runner, dynamic groups and the reference skills. Reflex.js still calls actions.js directly — wiring the scheduler to runSkill() lands in later phases when the survival curriculum kicks in. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/runtime.md | 43 ++++++++ package.json | 3 +- runtime/skills/chop-logs.js | 54 ++++++++++ runtime/skills/contract.test.js | 157 ++++++++++++++++++++++++++++ runtime/skills/eat.js | 46 ++++++++ runtime/skills/groups.js | 123 ++++++++++++++++++++++ runtime/skills/groups.test.js | 67 ++++++++++++ runtime/skills/index.js | 179 ++++++++++++++++++++++++++++++++ runtime/skills/wander.js | 34 ++++++ 9 files changed, 705 insertions(+), 1 deletion(-) create mode 100644 runtime/skills/chop-logs.js create mode 100644 runtime/skills/contract.test.js create mode 100644 runtime/skills/eat.js create mode 100644 runtime/skills/groups.js create mode 100644 runtime/skills/groups.test.js create mode 100644 runtime/skills/index.js create mode 100644 runtime/skills/wander.js diff --git a/docs/runtime.md b/docs/runtime.md index be06b7d..92dba12 100644 --- a/docs/runtime.md +++ b/docs/runtime.md @@ -167,6 +167,49 @@ parsing the log stream: | `lastEscalation` | `{ ts, ageMs }` of the most recent Pi auto-escalation. | | `reflexPaused` | mirror of the local pause flag (so TUI shows the right state immediately). | +### Skill substrate (Phase 2) + +Lives under `runtime/skills/`. A **skill** is a small composable unit of +survival behaviour with a uniform contract: + +```js +export const skill = { + id: "namespace.action", + title: "Human label", + timeoutMs: 45_000, + preconditions(ctx) -> { ok, code?, detail? } + async execute(ctx, args) -> { ok, code, detail, worldDelta } + validate?(ctx, result) -> boolean // optional + recover?(ctx, result) -> any | null // optional +} +``` + +Registered skills are dispatched via `runSkill(id, ctx, args)` from +`runtime/skills/index.js`. The runner enforces the timeout, normalises +the result shape, runs `validate()` and calls `recover()` on failure +so the scheduler can act on the hint (e.g. "switch to wander"). Stable +failure codes the runner itself emits live in `RUNNER_CODES` +(`unknown_skill`, `precondition_failed`, `timeout`, `threw`, +`validation_failed`, `done`). + +Item/block groups are dynamic: `runtime/skills/groups.js` exposes +`logs(bot)`, `planks(bot)`, `sticks(bot)`, `beds(bot)`, `foods(bot)`, +`axes(bot)`, `pickaxes(bot)`, `swords(bot)` — every group is derived +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. + +Run the contract + groups tests: + +```bash +npm test +``` + ### Optional: prismarine-viewer Set `VIEWER_PORT=` in `.env` to launch diff --git a/package.json b/package.json index ed24a63..fe9146f 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,8 @@ "bot:bare": "node runtime/bot.js", "tui": "tsx tui/tui.tsx", "propose:apply": "node scripts/propose-apply.js", - "stop": "bash scripts/stop.sh" + "stop": "bash scripts/stop.sh", + "test": "node --test runtime/skills/contract.test.js runtime/skills/groups.test.js" }, "dependencies": { "dotenv": "^16.4.5", diff --git a/runtime/skills/chop-logs.js b/runtime/skills/chop-logs.js new file mode 100644 index 0000000..c6b3ab2 --- /dev/null +++ b/runtime/skills/chop-logs.js @@ -0,0 +1,54 @@ +// gather.logs — find the nearest log block matching the server's log +// registry, path to it, mine it. Wraps the existing actions.js primitive +// while exposing the survival-skill contract (preconditions, timeout, +// structured worldDelta, recovery hint). + +import { chopNearestTree } from "../actions.js"; +import { logs as logBlocks } from "./groups.js"; + +export const skill = Object.freeze({ + id: "gather.logs", + title: "Gather logs", + timeoutMs: 60_000, + preconditions(ctx) { + if (!ctx?.bot) return { ok: false, code: "no_bot", detail: "bot missing" }; + const known = logBlocks(ctx.bot); + if (known.size === 0) { + return { ok: false, code: "unsupported_version", detail: "no log blocks in registry" }; + } + return { ok: true }; + }, + async execute(ctx) { + const res = await chopNearestTree(ctx.bot); + if (res.ok) { + return { + ok: true, + code: "done", + detail: res.detail, + worldDelta: { + choppedAt: res.detail?.at ?? null, + logType: res.detail?.logType ?? null, + }, + }; + } + const msg = String(res.detail ?? ""); + const code = msg.includes("no reachable log") + ? "no_target" + : msg.includes("timed out") + ? "timeout" + : "failed"; + return { ok: false, code, detail: res.detail, worldDelta: null }; + }, + validate(ctx, result) { + return result.ok && !!result.worldDelta?.logType; + }, + recover(ctx, result) { + // Tell the caller: if there was no reachable log, switching to wander + // for ~60 s is the right next move — same heuristic the autonomous + // reflex already uses. + if (result.code === "no_target") { + return { hint: "wander", reason: "no log within 32 blocks" }; + } + return null; + }, +}); diff --git a/runtime/skills/contract.test.js b/runtime/skills/contract.test.js new file mode 100644 index 0000000..0891b1e --- /dev/null +++ b/runtime/skills/contract.test.js @@ -0,0 +1,157 @@ +// Contract tests for runtime/skills/index.js. Run with: node --test runtime/skills +// +// We avoid spinning up a real mineflayer bot — synthetic skills are +// registered via _registerForTest and exercise every branch of runSkill: +// preconditions, timeout, exception, validate, recover. + +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { runSkill, RUNNER_CODES, _registerForTest } from "./index.js"; + +const ctx = {}; // skills under test ignore ctx fully + +test("unknown skill returns unknown_skill code", async () => { + const res = await runSkill("does.not.exist", ctx); + assert.equal(res.ok, false); + assert.equal(res.code, RUNNER_CODES.UNKNOWN_SKILL); +}); + +test("preconditions gate execution", async () => { + const teardown = _registerForTest({ + id: "test.precondition-block", + title: "blocked", + timeoutMs: 1000, + preconditions: () => ({ ok: false, code: "missing_x", detail: "no x" }), + execute: async () => { + throw new Error("should not run"); + }, + }); + try { + const res = await runSkill("test.precondition-block", ctx); + assert.equal(res.ok, false); + assert.equal(res.code, "missing_x"); + assert.equal(res.detail, "no x"); + } finally { + teardown(); + } +}); + +test("preconditions that throw produce precondition_failed", async () => { + const teardown = _registerForTest({ + id: "test.precondition-throw", + preconditions: () => { + throw new Error("kaboom"); + }, + execute: async () => ({ ok: true }), + }); + try { + const res = await runSkill("test.precondition-throw", ctx); + assert.equal(res.ok, false); + assert.equal(res.code, RUNNER_CODES.PRECONDITION_FAILED); + assert.match(res.detail, /kaboom/); + } finally { + teardown(); + } +}); + +test("timeout fires and recover is called", async () => { + let recovered = false; + const teardown = _registerForTest({ + id: "test.timeout", + timeoutMs: 50, + preconditions: () => ({ ok: true }), + execute: () => + new Promise((resolve) => { + // never resolves within the timeout + setTimeout(() => resolve({ ok: true }), 500); + }), + recover: () => { + recovered = true; + return { hint: "retry-later" }; + }, + }); + try { + const res = await runSkill("test.timeout", ctx); + assert.equal(res.ok, false); + assert.equal(res.code, RUNNER_CODES.TIMEOUT); + assert.equal(recovered, true); + assert.deepEqual(res.recovery, { hint: "retry-later" }); + } finally { + teardown(); + } +}); + +test("execute throw -> threw code with recovery hint", async () => { + const teardown = _registerForTest({ + id: "test.throws", + preconditions: () => ({ ok: true }), + execute: async () => { + throw new Error("oops"); + }, + recover: (ctx, result) => ({ saw: result.code }), + }); + try { + const res = await runSkill("test.throws", ctx); + assert.equal(res.ok, false); + assert.equal(res.code, RUNNER_CODES.THREW); + assert.match(res.detail, /oops/); + assert.deepEqual(res.recovery, { saw: RUNNER_CODES.THREW }); + } finally { + teardown(); + } +}); + +test("happy path returns done with worldDelta", async () => { + const teardown = _registerForTest({ + id: "test.happy", + preconditions: () => ({ ok: true }), + execute: async () => ({ ok: true, code: "done", detail: { count: 4 }, worldDelta: { gathered: 4 } }), + }); + try { + const res = await runSkill("test.happy", ctx); + assert.equal(res.ok, true); + assert.equal(res.code, "done"); + assert.deepEqual(res.worldDelta, { gathered: 4 }); + } finally { + teardown(); + } +}); + +test("validate failure flips ok to false with validation_failed", async () => { + let recoverArgs = null; + const teardown = _registerForTest({ + id: "test.validate-fail", + preconditions: () => ({ ok: true }), + execute: async () => ({ ok: true, code: "done", worldDelta: { x: 1 } }), + validate: () => false, + recover: (ctx, result) => { + recoverArgs = result; + return null; + }, + }); + try { + const res = await runSkill("test.validate-fail", ctx); + assert.equal(res.ok, false); + assert.equal(res.code, RUNNER_CODES.VALIDATION_FAILED); + assert.ok(recoverArgs); + assert.equal(recoverArgs.code, RUNNER_CODES.VALIDATION_FAILED); + } finally { + teardown(); + } +}); + +test("result missing code defaults to runner DONE on success", async () => { + const teardown = _registerForTest({ + id: "test.no-code", + preconditions: () => ({ ok: true }), + execute: async () => ({ ok: true }), + }); + try { + const res = await runSkill("test.no-code", ctx); + assert.equal(res.ok, true); + assert.equal(res.code, RUNNER_CODES.DONE); + } finally { + teardown(); + } +}); diff --git a/runtime/skills/eat.js b/runtime/skills/eat.js new file mode 100644 index 0000000..cd94da2 --- /dev/null +++ b/runtime/skills/eat.js @@ -0,0 +1,46 @@ +// survive.eat — consume the best available food. Picks from the registry- +// derived foods() set rather than a hard-coded list, so a 1.20 server +// without "glow_berries" won't trip the skill. + +import { eatBestFood } from "../actions.js"; +import { foods } from "./groups.js"; + +export const skill = Object.freeze({ + id: "survive.eat", + title: "Eat best food", + timeoutMs: 20_000, + preconditions(ctx) { + if (!ctx?.bot) return { ok: false, code: "no_bot", detail: "bot missing" }; + const food = (ctx.snapshot?.food ?? 20); + if (food >= 18) return { ok: false, code: "not_hungry", detail: `food=${food}` }; + const allowed = foods(ctx.bot); + if (allowed.size === 0) { + return { ok: false, code: "unsupported_version", detail: "no foods in registry" }; + } + const inv = ctx.snapshot?.inventory ?? {}; + const carrying = Object.keys(inv).some((name) => allowed.has(name)); + if (!carrying) return { ok: false, code: "no_food_source", detail: "no edible item in inventory" }; + return { ok: true }; + }, + async execute(ctx) { + const res = await eatBestFood(ctx.bot); + if (res.ok) { + return { + ok: true, + code: "done", + detail: res.detail, + worldDelta: { ate: res.detail?.ate ?? null }, + }; + } + const msg = String(res.detail ?? ""); + const code = msg.includes("no food in inventory") + ? "no_food_source" + : msg.includes("timed out") + ? "timeout" + : "failed"; + return { ok: false, code, detail: res.detail, worldDelta: null }; + }, + validate(ctx, result) { + return result.ok && !!result.worldDelta?.ate; + }, +}); diff --git a/runtime/skills/groups.js b/runtime/skills/groups.js new file mode 100644 index 0000000..170a548 --- /dev/null +++ b/runtime/skills/groups.js @@ -0,0 +1,123 @@ +// Dynamic item/block groups derived from bot.registry. The goal is to never +// hard-code a Minecraft version's item table into skill code: skills ask for +// "logs" or "planks" or "beds", and this module returns the set of names that +// actually exist on the connected server. +// +// All helpers are pure: given a bot they return a Set. They tolerate +// missing registries (returning an empty set) so the skill code can degrade +// to `code: "unsupported_version"` rather than crash. + +function isItemRegistry(reg) { + return reg && reg.itemsByName && typeof reg.itemsByName === "object"; +} + +function isBlockRegistry(reg) { + return reg && reg.blocksByName && typeof reg.blocksByName === "object"; +} + +function pickItems(bot, predicate) { + const reg = bot?.registry; + if (!isItemRegistry(reg)) return new Set(); + const out = new Set(); + for (const name of Object.keys(reg.itemsByName)) { + if (predicate(name)) out.add(name); + } + return out; +} + +function pickBlocks(bot, predicate) { + const reg = bot?.registry; + if (!isBlockRegistry(reg)) return new Set(); + const out = new Set(); + for (const name of Object.keys(reg.blocksByName)) { + if (predicate(name)) out.add(name); + } + return out; +} + +// Wood + stem logs of every available species. The `*_stem` suffix covers +// crimson/warped logs; the `_log` suffix covers regular trees and pale_oak. +export function logs(bot) { + return pickBlocks(bot, (n) => n.endsWith("_log") || n.endsWith("_stem")); +} + +export function planks(bot) { + return pickItems(bot, (n) => n.endsWith("_planks")); +} + +export function sticks(bot) { + const reg = bot?.registry; + const out = new Set(); + if (isItemRegistry(reg) && reg.itemsByName.stick) out.add("stick"); + return out; +} + +export function beds(bot) { + return pickBlocks(bot, (n) => n.endsWith("_bed")); +} + +// Conservative food allow-list. We could derive this from +// minecraft-data's foodsByName, but that includes spider_eye and other +// hazardous items. Until we have an explicit unsafe-food blacklist, keep +// the named cooked/raw/farm staples here and intersect with what exists in +// the connected server's item registry — so pale_oak-era new items don't +// surprise us and pre-1.13 servers don't blow up on missing entries. +const FOOD_ALLOWLIST = [ + "bread", + "cooked_beef", + "cooked_chicken", + "cooked_porkchop", + "cooked_mutton", + "cooked_rabbit", + "cooked_salmon", + "cooked_cod", + "baked_potato", + "apple", + "golden_apple", + "carrot", + "beetroot", + "melon_slice", + "sweet_berries", + "glow_berries", + "mushroom_stew", + "rabbit_stew", + "beetroot_soup", + "suspicious_stew", + "dried_kelp", + "pumpkin_pie", + "beef", + "chicken", + "porkchop", + "mutton", +]; + +export function foods(bot) { + const reg = bot?.registry; + if (!isItemRegistry(reg)) return new Set(); + const out = new Set(); + for (const name of FOOD_ALLOWLIST) { + if (reg.itemsByName[name]) out.add(name); + } + return out; +} + +export function axes(bot) { + const tools = ["wooden_axe", "stone_axe", "iron_axe", "golden_axe", "diamond_axe", "netherite_axe"]; + const reg = bot?.registry; + if (!isItemRegistry(reg)) return new Set(); + return new Set(tools.filter((n) => reg.itemsByName[n])); +} + +export function pickaxes(bot) { + const tools = ["wooden_pickaxe", "stone_pickaxe", "iron_pickaxe", "golden_pickaxe", "diamond_pickaxe", "netherite_pickaxe"]; + const reg = bot?.registry; + if (!isItemRegistry(reg)) return new Set(); + return new Set(tools.filter((n) => reg.itemsByName[n])); +} + +export function swords(bot) { + const tools = ["wooden_sword", "stone_sword", "iron_sword", "golden_sword", "diamond_sword", "netherite_sword"]; + const reg = bot?.registry; + if (!isItemRegistry(reg)) return new Set(); + return new Set(tools.filter((n) => reg.itemsByName[n])); +} diff --git a/runtime/skills/groups.test.js b/runtime/skills/groups.test.js new file mode 100644 index 0000000..d430891 --- /dev/null +++ b/runtime/skills/groups.test.js @@ -0,0 +1,67 @@ +// Tests for runtime/skills/groups.js. We synthesise fake registries that +// stand in for what mineflayer ships via bot.registry — small enough to be +// hand-tested across "modern" and "old" Minecraft shapes. + +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { logs, planks, sticks, beds, foods, axes, pickaxes, swords } from "./groups.js"; + +function makeBot({ items = [], blocks = [] } = {}) { + const itemsByName = Object.fromEntries(items.map((name) => [name, { id: 1, name }])); + const blocksByName = Object.fromEntries(blocks.map((name) => [name, { id: 1, name }])); + return { registry: { itemsByName, blocksByName } }; +} + +test("logs() picks every _log and _stem block from registry", () => { + const bot = makeBot({ + blocks: ["oak_log", "dark_oak_log", "crimson_stem", "warped_stem", "stone", "dirt"], + }); + const got = logs(bot); + assert.deepEqual( + Array.from(got).sort(), + ["crimson_stem", "dark_oak_log", "oak_log", "warped_stem"], + ); +}); + +test("logs() returns empty when registry missing", () => { + assert.equal(logs(null).size, 0); + assert.equal(logs({}).size, 0); + assert.equal(logs({ registry: {} }).size, 0); +}); + +test("planks/sticks/beds match by suffix or exact name", () => { + const bot = makeBot({ + items: ["oak_planks", "stick", "dirt"], + blocks: ["red_bed", "white_bed", "stone"], + }); + assert.deepEqual(Array.from(planks(bot)).sort(), ["oak_planks"]); + assert.deepEqual(Array.from(sticks(bot)), ["stick"]); + assert.deepEqual(Array.from(beds(bot)).sort(), ["red_bed", "white_bed"]); +}); + +test("foods() intersects the allowlist with the registry", () => { + const bot = makeBot({ + items: ["bread", "apple", "spider_eye", "rotten_flesh", "cooked_beef", "glow_berries"], + }); + const got = foods(bot); + // Allowlist members present in this registry only — spider_eye and + // rotten_flesh are explicitly NOT in the allowlist and must be excluded. + assert.deepEqual( + Array.from(got).sort(), + ["apple", "bread", "cooked_beef", "glow_berries"], + ); +}); + +test("foods() returns empty on missing registry", () => { + assert.equal(foods(null).size, 0); +}); + +test("axes/pickaxes/swords scoped to what the version actually ships", () => { + const bot = makeBot({ + items: ["wooden_axe", "stone_axe", "iron_pickaxe", "diamond_sword"], + }); + assert.deepEqual(Array.from(axes(bot)).sort(), ["stone_axe", "wooden_axe"]); + assert.deepEqual(Array.from(pickaxes(bot)).sort(), ["iron_pickaxe"]); + assert.deepEqual(Array.from(swords(bot)).sort(), ["diamond_sword"]); +}); diff --git a/runtime/skills/index.js b/runtime/skills/index.js new file mode 100644 index 0000000..46feb92 --- /dev/null +++ b/runtime/skills/index.js @@ -0,0 +1,179 @@ +// Skill substrate. A skill is a small, self-contained, composable unit of +// survival behaviour that the scheduler (today: reflex.js) can call with a +// uniform contract. The contract — required by every skill in this folder: +// +// { +// id: "namespace.action", // stable, machine-readable, e.g. "gather.logs" +// title: "Human label", +// timeoutMs: 45_000, // hard ceiling on execute() +// preconditions(ctx) -> { ok, code?, detail? } +// async execute(ctx, args) -> { ok, code, detail, worldDelta } +// validate?(ctx, result) -> boolean // optional gate after execute +// recover?(ctx, result) -> any | null // optional follow-up hint +// } +// +// The runSkill() wrapper enforces the timeout, normalises the result shape +// (so any caller can rely on the five required fields), runs validate(), and +// calls recover() on failure for the scheduler to consume. +// +// Skills are pure with respect to the runtime — they never read or write +// state-store directly; their `worldDelta` is the only way they communicate +// observed changes back to the scheduler, which then decides what to log. + +import { info, warn } from "../log.js"; + +import { skill as chopLogs } from "./chop-logs.js"; +import { skill as eat } from "./eat.js"; +import { skill as wander } from "./wander.js"; + +const SKILLS = new Map(); + +function register(skill) { + if (!skill || typeof skill !== "object") throw new Error("skill: not an object"); + if (!skill.id || typeof skill.id !== "string") throw new Error("skill: missing id"); + if (typeof skill.execute !== "function") throw new Error(`skill ${skill.id}: missing execute`); + if (typeof skill.preconditions !== "function") throw new Error(`skill ${skill.id}: missing preconditions`); + if (SKILLS.has(skill.id)) throw new Error(`skill ${skill.id}: already registered`); + SKILLS.set(skill.id, skill); +} + +register(chopLogs); +register(eat); +register(wander); + +export function listSkills() { + return Array.from(SKILLS.values()).map((s) => ({ + id: s.id, + title: s.title ?? s.id, + timeoutMs: s.timeoutMs ?? 30_000, + })); +} + +export function getSkill(id) { + return SKILLS.get(id) ?? null; +} + +// Stable failure codes the wrapper itself can emit. Skills may emit any +// additional codes — but these are the ones runSkill produces. +export const RUNNER_CODES = Object.freeze({ + UNKNOWN_SKILL: "unknown_skill", + PRECONDITION_FAILED: "precondition_failed", + TIMEOUT: "timeout", + THREW: "threw", + VALIDATION_FAILED: "validation_failed", + DONE: "done", +}); + +function normaliseResult(res, fallbackCode) { + const ok = !!res?.ok; + return { + ok, + code: res?.code ?? (ok ? RUNNER_CODES.DONE : fallbackCode ?? "failed"), + detail: res?.detail ?? null, + worldDelta: res?.worldDelta ?? null, + }; +} + +function withTimeout(promise, ms, label) { + let timer; + const timeout = new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms); + }); + return Promise.race([promise, timeout]).finally(() => clearTimeout(timer)); +} + +// Drive one skill through its full lifecycle. The caller (typically reflex.js +// or, eventually, a higher-level scheduler) decides when to invoke; runSkill +// only owns the contract enforcement. +export async function runSkill(id, ctx, args = {}) { + const skill = SKILLS.get(id); + if (!skill) { + warn("skill", `unknown skill ${id}`); + return { ok: false, code: RUNNER_CODES.UNKNOWN_SKILL, detail: id, worldDelta: null }; + } + + let pre; + try { + pre = skill.preconditions(ctx, args) ?? { ok: true }; + } catch (e) { + return { + ok: false, + code: RUNNER_CODES.PRECONDITION_FAILED, + detail: `preconditions threw: ${e.message}`, + worldDelta: null, + }; + } + if (!pre.ok) { + return { + ok: false, + code: pre.code ?? RUNNER_CODES.PRECONDITION_FAILED, + detail: pre.detail ?? "preconditions failed", + worldDelta: null, + }; + } + + const timeoutMs = skill.timeoutMs ?? 30_000; + let raw; + try { + raw = await withTimeout(skill.execute(ctx, args), timeoutMs, `skill(${id})`); + } catch (e) { + const isTimeout = /timed out after/.test(e.message); + const result = { + ok: false, + code: isTimeout ? RUNNER_CODES.TIMEOUT : RUNNER_CODES.THREW, + detail: e.message, + worldDelta: null, + }; + if (typeof skill.recover === "function") { + try { + result.recovery = skill.recover(ctx, result) ?? null; + } catch (recoverErr) { + warn("skill", `${id}.recover threw: ${recoverErr.message}`); + } + } + return result; + } + + const result = normaliseResult(raw); + if (result.ok && typeof skill.validate === "function") { + let valid; + try { + valid = skill.validate(ctx, result); + } catch (e) { + warn("skill", `${id}.validate threw: ${e.message}`); + valid = false; + } + if (!valid) { + const failed = { + ok: false, + code: RUNNER_CODES.VALIDATION_FAILED, + detail: result.detail, + worldDelta: result.worldDelta, + }; + if (typeof skill.recover === "function") { + try { + failed.recovery = skill.recover(ctx, failed) ?? null; + } catch (e) { + warn("skill", `${id}.recover threw: ${e.message}`); + } + } + return failed; + } + } + if (!result.ok && typeof skill.recover === "function") { + try { + result.recovery = skill.recover(ctx, result) ?? null; + } catch (e) { + warn("skill", `${id}.recover threw: ${e.message}`); + } + } + info("skill", `${id} → ${result.code}${result.detail ? ` (${JSON.stringify(result.detail).slice(0, 80)})` : ""}`); + return result; +} + +// For tests: lets a unit test register a synthetic skill without touching +// the production registry. Returns a teardown function. +export function _registerForTest(skill) { + register(skill); + return () => SKILLS.delete(skill.id); +} diff --git a/runtime/skills/wander.js b/runtime/skills/wander.js new file mode 100644 index 0000000..3f9644c --- /dev/null +++ b/runtime/skills/wander.js @@ -0,0 +1,34 @@ +// explore.wander — pick a random nearby point and path to it. Used as a +// fallback when gather skills can't find a target nearby — better to keep +// the bot moving than to dispatch the same failing skill on every tick. + +import { wander } from "../actions.js"; + +export const skill = Object.freeze({ + id: "explore.wander", + title: "Wander to a nearby point", + timeoutMs: 45_000, + preconditions(ctx) { + if (!ctx?.bot) return { ok: false, code: "no_bot", detail: "bot missing" }; + return { ok: true }; + }, + async execute(ctx, args = {}) { + const radius = Math.max(6, args.radius ?? 16); + const res = await wander(ctx.bot, radius); + if (res.ok) { + return { + ok: true, + code: "done", + detail: res.detail, + worldDelta: { movedTo: res.detail?.to ?? null }, + }; + } + const msg = String(res.detail ?? ""); + const code = msg.includes("timed out") + ? "timeout" + : msg.includes("No path") + ? "no_safe_path" + : "failed"; + return { ok: false, code, detail: res.detail, worldDelta: null }; + }, +}); -- 2.54.0