feat(runtime): skill substrate + dynamic groups (Phase 2) #14

Merged
halofourteen merged 1 commits from feat/survival-pivot-phase-2 into main 2026-05-25 22:15:57 +03:00
9 changed files with 705 additions and 1 deletions
+43
View File
@@ -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=<port>` in `.env` to launch
+2 -1
View File
@@ -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",
+54
View File
@@ -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;
},
});
+157
View File
@@ -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();
}
});
+46
View File
@@ -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;
},
});
+123
View File
@@ -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<string>. 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]));
}
+67
View File
@@ -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"]);
});
+179
View File
@@ -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);
}
+34
View File
@@ -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 };
},
});