feat(runtime): scheduler-via-runSkill + Pi banter escalation + base-site (follow-ups) (#20)

Three closures of remaining PRD follow-ups, one merge:

1. Reflex scheduler now drives behaviour from the curriculum.
   - reflex.js: replaced ad-hoc techTreeReflex + autonomousReflex with
     curriculumReflex that dispatches the skill suggested by
     snapshot.curriculum.plan via runSkill. Per-skill backoff for
     missing_tool / missing_material / no_target / no_food_source /
     unsupported_version. recover() hint with `{hint:"wander"}` swaps
     the next tick to wander for 60 s.
   - Chain is now: defend > eat > sleep > curriculum > idle.
   - reflex.test.js: 11 new tests covering busy/disconnected,
     defend/eat preemption, dispatch by id, unknown-skill fallback,
     per-skill + wander-hint backoffs, onComplete updating backoff.

2. Pi escalation for ADDRESSED_BANTER with hard rate limit.
   - bot.js: when generateReply returns {escalate:true}, spawn askPi
     with bot state + last 5 lines from that speaker (redacted via
     chatMemory). Reply capped at 200 chars, sent as one chat line.
   - Rate cap: 6 calls/hour, 90 s min gap. Suppressed escalations
     log once and silently drop.

3. Phase 4 substrate.
   - runtime/locations.js: atomic JSON store
     (state/<host>/locations.json) with setLocation / getLocation /
     nearestLocation / removeLocation; 6 tests.
   - runtime/base-site.js: scoreCurrentPosition(bot) + pure scoreSite
     bundle (wood / stone / water / flatness / no-players /
     no-foreign-builds, owned-blocks excluded from claim penalty);
     6 tests.
   - runtime/skills/choose-base.js: village.choose-base skill — scores
     the current spot, writes locations.base if score ≥ 8, otherwise
     returns code:"too_weak" with a wander recover hint.
   - curriculum.js: new final milestone village.base-site fires
     village.choose-base until a base location exists.
   - bot.js: stamps snapshot.locations from listLocations() each tick
     so the curriculum can read it without coupling to disk.

docs/runtime.md updated with three new sections.
npm test now 116/116.

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