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:
@@ -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
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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 };
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
@@ -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;
|
||||
},
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user