feat(runtime): early-game survival curriculum (Phase 3) #15

Merged
halofourteen merged 1 commits from feat/survival-pivot-phase-3 into main 2026-05-25 22:23:33 +03:00
9 changed files with 785 additions and 3 deletions
+29 -1
View File
@@ -204,12 +204,40 @@ Reference skills shipped today: `gather.logs`, `survive.eat`,
`runtime/actions.js` primitives directly — porting more behaviours to
skills lands in later phases.
Run the contract + groups tests:
Run the contract + groups + curriculum tests:
```bash
npm test
```
### Survival curriculum (Phase 3)
`runtime/curriculum.js` exposes a deterministic early-game progression:
```
wood.16 → wood.planks-and-sticks → wood.tools →
stone.32 → stone.tools → food.basic → storage.chest → shelter.torch
```
Each milestone has an `isDone(inventory, snapshot)` predicate and a
`suggest(inventory, snapshot)` function that names the next skill to
dispatch. `isDone` is stateful in the sense that reaching a later tier
implies all earlier "gather" milestones are complete (so the bot
doesn't loop back to "gather 16 logs" after crafting them into
planks).
The current curriculum result is on every snapshot as
`snapshot.curriculum = { milestone, plan, inventoryFull }` so the TUI
can show what the bot is working on and which skill should drive it.
Wiring the scheduler to actually call `runSkill(plan.skillId, …)` in
the reflex loop is a Phase 4 task; today the reflex still uses
`actions.js` directly.
Inventory pressure: `isInventoryFull(snapshot)` is exposed on every
curriculum result; the TUI surfaces `[inventory full]` next to the
milestone label so the operator can see when a deposit step is needed
before progress continues.
### Optional: prismarine-viewer
Set `VIEWER_PORT=<port>` in `.env` to launch
+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"
"test": "node --test runtime/skills/contract.test.js runtime/skills/groups.test.js runtime/curriculum.test.js"
},
"dependencies": {
"dotenv": "^16.4.5",
+9 -1
View File
@@ -38,6 +38,7 @@ import { startPlanner, isPlannerBusy, readNextMilestone, planExists } from "./pl
import { computeState, STATES } from "./state.js";
import { createNoProgressDetector } from "./no-progress.js";
import { maybeStartViewer } from "./viewer.js";
import { nextMilestone as nextCurriculumMilestone } from "./curriculum.js";
fs.mkdirSync(stateDir, { recursive: true });
const JOINED_FLAG = path.join(stateDir, "joined-before.flag");
@@ -582,7 +583,14 @@ function tick() {
lastSnapshot.activeSkill = reflexCtx.busy
? reflexCtx.currentActionLabel
: reflexCtx.lastReflex?.label ?? null;
lastSnapshot.currentMilestone = cachedMilestone;
// Two sources of "next milestone":
// - planner.md (LLM-written, free-form, advisory)
// - 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.
const curriculum = nextCurriculumMilestone(lastSnapshot);
lastSnapshot.curriculum = curriculum;
lastSnapshot.currentMilestone = curriculum?.milestone?.title ?? cachedMilestone;
lastSnapshot.lastResult = lastResult;
lastSnapshot.noProgressReason = noProgressReason;
lastSnapshot.failuresByCode = failuresByCode();
+165
View File
@@ -0,0 +1,165 @@
// Deterministic early-game survival curriculum. Mirrors PRD §6 FR4:
// the bot should be able to progress from empty inventory to wooden tools,
// to a stone tier + furnace + chest, without an LLM call per tick.
//
// Each milestone exposes:
// id - stable string, used for diary/snapshot
// title - human label, used by TUI
// isDone - (inventoryCounts, snapshot) => boolean
// suggest - (inventoryCounts, snapshot) => skill plan
// where a plan is { skillId, args? } or null if the
// milestone requires a precondition the scheduler must
// set up via an earlier milestone.
//
// The chooser walks milestones in order and returns the first one whose
// `isDone` returns false. That milestone is "current". Its `suggest()`
// tells the scheduler what skill to dispatch right now.
//
// The curriculum is pure: it never touches the bot, the world or the
// state-store. The scheduler is responsible for dispatch, retries and
// no-progress handling.
const INVENTORY_FULL_DISTINCT_STACKS = 32;
function totalLogs(inv) {
return Object.entries(inv ?? {})
.filter(([k]) => k.endsWith("_log") || k.endsWith("_stem"))
.reduce((sum, [, n]) => sum + n, 0);
}
function totalPlanks(inv) {
return Object.entries(inv ?? {})
.filter(([k]) => k.endsWith("_planks"))
.reduce((sum, [, n]) => sum + n, 0);
}
function totalCobble(inv) {
return (inv?.cobblestone ?? 0) + (inv?.cobbled_deepslate ?? 0);
}
function has(inv, name, n = 1) {
return (inv?.[name] ?? 0) >= n;
}
function hasAny(inv, names) {
return names.some((n) => (inv?.[n] ?? 0) > 0);
}
const WOODEN_TOOLS = ["wooden_axe", "wooden_pickaxe", "wooden_sword"];
const STONE_TOOLS = ["stone_axe", "stone_pickaxe", "stone_sword"];
// A "stage reached" predicate: once the bot has wooden tools, wood.16 is
// implicitly considered done even if the log stack is now empty (the bot
// burned through them to craft planks/sticks/tools). Without this, the
// curriculum oscillates: chop → craft → "oh, I have no logs again, go
// chop". Each milestone keeps its raw-resource predicate AND an
// "advanced past this tier" escape so the chooser monotonically advances.
function hasWoodenTier(inv) {
return WOODEN_TOOLS.some((n) => has(inv, n));
}
function hasStoneTier(inv) {
return STONE_TOOLS.some((n) => has(inv, n));
}
const MILESTONES = [
{
id: "wood.16",
title: "Gather 16 logs",
isDone: (inv) => totalLogs(inv) >= 16 || hasWoodenTier(inv),
suggest: () => ({ skillId: "gather.logs" }),
},
{
id: "wood.planks-and-sticks",
title: "Craft 4 planks and 4 sticks",
isDone: (inv) =>
(totalPlanks(inv) >= 4 && has(inv, "stick", 4)) || hasWoodenTier(inv),
suggest: (inv) => {
if (totalPlanks(inv) < 4) return { skillId: "craft.planks" };
return { skillId: "craft.sticks" };
},
},
{
id: "wood.tools",
title: "Craft wooden axe, pickaxe, sword",
isDone: (inv) => WOODEN_TOOLS.every((n) => has(inv, n)) || hasStoneTier(inv),
suggest: (inv) => {
if (!has(inv, "wooden_axe")) return { skillId: "craft.wooden-axe" };
if (!has(inv, "wooden_pickaxe")) return { skillId: "craft.wooden-pickaxe" };
if (!has(inv, "wooden_sword")) return { skillId: "craft.wooden-sword" };
return null;
},
},
{
id: "stone.32",
title: "Gather 32 cobblestone",
isDone: (inv) => totalCobble(inv) >= 32 || hasStoneTier(inv),
suggest: () => ({ skillId: "gather.stone" }),
},
{
id: "stone.tools",
title: "Craft stone axe, pickaxe, sword and a furnace",
isDone: (inv) => STONE_TOOLS.every((n) => has(inv, n)) && has(inv, "furnace"),
suggest: (inv) => {
if (!has(inv, "stone_axe")) return { skillId: "craft.stone-axe" };
if (!has(inv, "stone_pickaxe")) return { skillId: "craft.stone-pickaxe" };
if (!has(inv, "stone_sword")) return { skillId: "craft.stone-sword" };
if (!has(inv, "furnace")) return { skillId: "craft.furnace" };
return null;
},
},
{
id: "food.basic",
title: "Secure a basic food source",
isDone: (inv, snap) => {
const carrying = ["bread", "cooked_beef", "cooked_chicken", "cooked_porkchop", "apple", "carrot", "potato", "baked_potato"].some(
(n) => has(inv, n),
);
return carrying || (snap?.food ?? 20) >= 18;
},
suggest: () => ({ skillId: "survive.eat" }), // best-effort; richer "find food" skill lands later
},
{
id: "storage.chest",
title: "Place a personal chest",
isDone: (inv) => has(inv, "chest"),
suggest: () => ({ skillId: "craft.chest" }),
},
{
id: "shelter.torch",
title: "Have torches on hand for the perimeter",
isDone: (inv) => has(inv, "torch", 4),
suggest: () => ({ skillId: "craft.torch" }),
},
];
export function isInventoryFull(snapshot) {
const distinct = Object.keys(snapshot?.inventory ?? {}).length;
return distinct >= INVENTORY_FULL_DISTINCT_STACKS;
}
// Walk the curriculum and return the first uncompleted milestone, plus
// the suggested skill for it. If the inventory is full, the scheduler may
// choose to insert a deposit/drop step before continuing; we mark that
// in the result rather than skipping the milestone so the TUI can show
// the right blocker.
export function nextMilestone(snapshot) {
const inv = snapshot?.inventory ?? {};
for (const m of MILESTONES) {
if (m.isDone(inv, snapshot)) continue;
const plan = m.suggest(inv, snapshot);
return {
milestone: { id: m.id, title: m.title },
plan,
inventoryFull: isInventoryFull(snapshot),
};
}
return null; // every milestone done — bot has reached the end of the
// deterministic curriculum and base/village logic takes over (Phase 4).
}
export function listMilestones() {
return MILESTONES.map((m) => ({ id: m.id, title: m.title }));
}
// Exposed for tests.
export const _internal = { totalLogs, totalPlanks, totalCobble, has, hasAny, MILESTONES };
+154
View File
@@ -0,0 +1,154 @@
// Tests for runtime/curriculum.js. The chooser is pure, so we just feed
// snapshots and assert which milestone + skill comes back.
import { test } from "node:test";
import assert from "node:assert/strict";
import { nextMilestone, isInventoryFull, listMilestones } from "./curriculum.js";
function snap(inventory, extras = {}) {
return { connected: true, inventory, ...extras };
}
// A baseline of "everything before this stage is already done". Callers pass
// the extra items needed to exercise the milestone under test, so individual
// tests stay tiny and focused.
function snapAfter(stage, addInventory = {}, extras = {}) {
const baseline = {};
const layers = {
"wood.16": { oak_log: 16 },
"wood.planks-and-sticks": { oak_log: 16, oak_planks: 4, stick: 4 },
"wood.tools": {
oak_log: 16, oak_planks: 6, stick: 6,
wooden_axe: 1, wooden_pickaxe: 1, wooden_sword: 1,
},
"stone.32": {
oak_log: 16, oak_planks: 6, stick: 6,
wooden_axe: 1, wooden_pickaxe: 1, wooden_sword: 1,
cobblestone: 32,
},
"stone.tools": {
oak_log: 16, oak_planks: 6, stick: 8,
wooden_axe: 1, wooden_pickaxe: 1, wooden_sword: 1,
cobblestone: 8,
stone_axe: 1, stone_pickaxe: 1, stone_sword: 1, furnace: 1,
},
"food.basic": {
oak_log: 16, oak_planks: 6, stick: 8,
wooden_axe: 1, wooden_pickaxe: 1, wooden_sword: 1,
cobblestone: 8,
stone_axe: 1, stone_pickaxe: 1, stone_sword: 1, furnace: 1,
bread: 4,
},
"storage.chest": {
oak_log: 16, oak_planks: 6, stick: 8,
wooden_axe: 1, wooden_pickaxe: 1, wooden_sword: 1,
cobblestone: 8,
stone_axe: 1, stone_pickaxe: 1, stone_sword: 1, furnace: 1,
bread: 4, chest: 1,
},
};
Object.assign(baseline, layers[stage] ?? {}, addInventory);
return snap(baseline, extras);
}
test("empty inventory → first milestone is wood.16, suggests gather.logs", () => {
const got = nextMilestone(snap({}));
assert.equal(got.milestone.id, "wood.16");
assert.equal(got.plan.skillId, "gather.logs");
assert.equal(got.inventoryFull, false);
});
test("with 16 logs but no planks → wood.planks-and-sticks, craft.planks first", () => {
const got = nextMilestone(snap({ oak_log: 16 }));
assert.equal(got.milestone.id, "wood.planks-and-sticks");
assert.equal(got.plan.skillId, "craft.planks");
});
test("planks present but no sticks → craft.sticks", () => {
const got = nextMilestone(snapAfter("wood.16", { oak_planks: 4 }));
assert.equal(got.milestone.id, "wood.planks-and-sticks");
assert.equal(got.plan.skillId, "craft.sticks");
});
test("planks+sticks but no wooden tools → wood.tools, axe first", () => {
const got = nextMilestone(snapAfter("wood.planks-and-sticks"));
assert.equal(got.milestone.id, "wood.tools");
assert.equal(got.plan.skillId, "craft.wooden-axe");
});
test("wooden axe present, pickaxe missing → craft.wooden-pickaxe", () => {
const got = nextMilestone(snapAfter("wood.planks-and-sticks", { wooden_axe: 1 }));
assert.equal(got.milestone.id, "wood.tools");
assert.equal(got.plan.skillId, "craft.wooden-pickaxe");
});
test("wooden tools done, no cobble → stone.32, suggests gather.stone", () => {
const got = nextMilestone(snapAfter("wood.tools"));
assert.equal(got.milestone.id, "stone.32");
assert.equal(got.plan.skillId, "gather.stone");
});
test("32 cobble + wooden tools → stone.tools, stone_axe first", () => {
const got = nextMilestone(snapAfter("stone.32"));
assert.equal(got.milestone.id, "stone.tools");
assert.equal(got.plan.skillId, "craft.stone-axe");
});
test("stone tools present but no furnace → craft.furnace", () => {
const got = nextMilestone(snapAfter("stone.32", {
stone_axe: 1, stone_pickaxe: 1, stone_sword: 1,
cobblestone: 8, // need leftover for furnace
}));
assert.equal(got.milestone.id, "stone.tools");
assert.equal(got.plan.skillId, "craft.furnace");
});
test("food.basic satisfied by carrying bread", () => {
const got = nextMilestone(snapAfter("food.basic"));
assert.equal(got.milestone.id, "storage.chest");
});
test("food.basic satisfied by high food bar even without food item", () => {
const got = nextMilestone(snapAfter("stone.tools", {}, { food: 20 }));
assert.equal(got.milestone.id, "storage.chest");
});
test("all done → null", () => {
const inv = {
oak_log: 16, oak_planks: 8, stick: 8,
wooden_axe: 1, wooden_pickaxe: 1, wooden_sword: 1,
cobblestone: 32,
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);
});
test("isInventoryFull threshold = 32 distinct stacks", () => {
const inv = {};
for (let i = 0; i < 31; i++) inv[`stack_${i}`] = 1;
assert.equal(isInventoryFull(snap(inv)), false);
inv.stack_31 = 1;
assert.equal(isInventoryFull(snap(inv)), true);
});
test("inventoryFull flag is returned alongside milestone, not as override", () => {
const inv = {};
for (let i = 0; i < 40; i++) inv[`stack_${i}`] = 1;
const got = nextMilestone(snap(inv));
// First milestone (wood.16) still fires; scheduler can use inventoryFull to
// insert a deposit step.
assert.equal(got.milestone.id, "wood.16");
assert.equal(got.inventoryFull, true);
});
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");
for (const m of ms) {
assert.equal(typeof m.id, "string");
assert.equal(typeof m.title, "string");
}
});
+267
View File
@@ -0,0 +1,267 @@
// craft.* — thin skill wrappers around the existing actions.js crafting
// primitives. Each one is generated by makeCraftSkill so adding a new
// craftable is one entry in this file rather than a new module.
//
// All wood/stone crafts that need a workbench reuse actions.placeCraftingTable
// internally: the skill's preconditions guarantee the raw materials, then
// execute() defers to the actions.js implementation that handles
// table-placement, recipe lookup and bot.craft().
import {
craftPlanks,
craftSticks,
craftWoodenAxe,
craftWoodenPickaxe,
craftWoodenSword,
placeCraftingTable,
inv as invHelpers,
} from "../actions.js";
function totalPlanks(bot) {
return invHelpers.getAnyPlanksCount(bot);
}
function totalSticks(bot) {
return invHelpers.getItemCount(bot, "stick");
}
function totalLogs(bot) {
return invHelpers.getAnyLogCount(bot);
}
function count(bot, name) {
return invHelpers.getItemCount(bot, name);
}
function withTimeout(promise, ms, label) {
let timer;
const timeout = new Promise((_, reject) => {
timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms / 1000}s`)), ms);
});
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
}
function asResult(res, doneCode = "done") {
if (res?.ok) {
return { ok: true, code: doneCode, detail: res.detail, worldDelta: { crafted: res.detail?.item ?? doneCode } };
}
const msg = String(res?.detail ?? "");
const code = msg.includes("no recipe")
? "no_recipe"
: msg.includes("timed out")
? "timeout"
: "failed";
return { ok: false, code, detail: res?.detail, worldDelta: null };
}
// --- direct wrappers around existing actions ---------------------------------
export const craftPlanksSkill = Object.freeze({
id: "craft.planks",
title: "Craft planks from logs",
timeoutMs: 20_000,
preconditions(ctx) {
if (!ctx?.bot) return { ok: false, code: "no_bot", detail: "bot missing" };
if (totalLogs(ctx.bot) < 1) return { ok: false, code: "missing_material", detail: "no log in inventory" };
return { ok: true };
},
async execute(ctx, args = {}) {
return asResult(await craftPlanks(ctx.bot, args.count ?? 4));
},
});
export const craftSticksSkill = Object.freeze({
id: "craft.sticks",
title: "Craft sticks from planks",
timeoutMs: 20_000,
preconditions(ctx) {
if (!ctx?.bot) return { ok: false, code: "no_bot", detail: "bot missing" };
if (totalPlanks(ctx.bot) < 2) return { ok: false, code: "missing_material", detail: "need 2 planks" };
return { ok: true };
},
async execute(ctx, args = {}) {
return asResult(await craftSticks(ctx.bot, args.count ?? 4));
},
});
export const craftWoodenAxeSkill = Object.freeze({
id: "craft.wooden-axe",
title: "Craft wooden axe",
timeoutMs: 30_000,
preconditions(ctx) {
if (!ctx?.bot) return { ok: false, code: "no_bot", detail: "bot missing" };
if (totalPlanks(ctx.bot) < 3) return { ok: false, code: "missing_material", detail: "need 3 planks" };
if (totalSticks(ctx.bot) < 2) return { ok: false, code: "missing_material", detail: "need 2 sticks" };
return { ok: true };
},
async execute(ctx) {
return asResult(await craftWoodenAxe(ctx.bot));
},
});
export const craftWoodenPickaxeSkill = Object.freeze({
id: "craft.wooden-pickaxe",
title: "Craft wooden pickaxe",
timeoutMs: 30_000,
preconditions(ctx) {
if (!ctx?.bot) return { ok: false, code: "no_bot", detail: "bot missing" };
if (totalPlanks(ctx.bot) < 3) return { ok: false, code: "missing_material", detail: "need 3 planks" };
if (totalSticks(ctx.bot) < 2) return { ok: false, code: "missing_material", detail: "need 2 sticks" };
return { ok: true };
},
async execute(ctx) {
return asResult(await craftWoodenPickaxe(ctx.bot));
},
});
export const craftWoodenSwordSkill = Object.freeze({
id: "craft.wooden-sword",
title: "Craft wooden sword",
timeoutMs: 30_000,
preconditions(ctx) {
if (!ctx?.bot) return { ok: false, code: "no_bot", detail: "bot missing" };
if (totalPlanks(ctx.bot) < 2) return { ok: false, code: "missing_material", detail: "need 2 planks" };
if (totalSticks(ctx.bot) < 1) return { ok: false, code: "missing_material", detail: "need 1 stick" };
return { ok: true };
},
async execute(ctx) {
return asResult(await craftWoodenSword(ctx.bot));
},
});
// --- generic crafting (stone tools, furnace, chest, torch) -------------------
// Stone-tier and storage crafts share the same lookup-recipe-then-call-bot.craft
// shape as the wooden tools, but with different precondition counts and item
// names. We define a tiny factory.
//
// `requires` is a map from item name to required count. `needsTable: true`
// means we place / reuse a crafting table first (via actions.placeCraftingTable).
function makeRecipeSkill({ id, title, item, requires, needsTable, doneCode = "done" }) {
return Object.freeze({
id,
title,
timeoutMs: 30_000,
preconditions(ctx) {
if (!ctx?.bot) return { ok: false, code: "no_bot", detail: "bot missing" };
for (const [name, n] of Object.entries(requires)) {
const have = name === "planks"
? totalPlanks(ctx.bot)
: count(ctx.bot, name);
if (have < n) {
return { ok: false, code: "missing_material", detail: `need ${n} ${name} (have ${have})` };
}
}
return { ok: true };
},
async execute(ctx) {
const bot = ctx.bot;
let tableBlock = null;
if (needsTable) {
const tableRes = await placeCraftingTable(bot);
if (!tableRes.ok) {
const msg = String(tableRes.detail ?? "");
const code = msg.includes("timed out") ? "timeout" : "missing_table";
return { ok: false, code, detail: tableRes.detail, worldDelta: null };
}
tableBlock = tableRes.block;
}
const reg = bot?.registry;
const itemId = reg?.itemsByName?.[item]?.id;
if (itemId == null) {
return { ok: false, code: "unsupported_version", detail: `unknown item ${item}`, worldDelta: null };
}
const recipes = bot.recipesFor(itemId, null, 1, tableBlock);
const recipe = recipes[0];
if (!recipe) {
return { ok: false, code: "no_recipe", detail: `no recipe for ${item}`, worldDelta: null };
}
try {
await withTimeout(bot.craft(recipe, 1, tableBlock), 15_000, `craft(${item})`);
return { ok: true, code: doneCode, detail: { item }, worldDelta: { crafted: item } };
} catch (e) {
const msg = String(e?.message ?? "");
const code = msg.includes("timed out") ? "timeout" : "failed";
return { ok: false, code, detail: e.message, worldDelta: null };
}
},
validate(ctx, result) {
return result.ok && !!result.worldDelta?.crafted;
},
});
}
export const craftStoneAxeSkill = makeRecipeSkill({
id: "craft.stone-axe",
title: "Craft stone axe",
item: "stone_axe",
requires: { cobblestone: 3, stick: 2 },
needsTable: true,
});
export const craftStonePickaxeSkill = makeRecipeSkill({
id: "craft.stone-pickaxe",
title: "Craft stone pickaxe",
item: "stone_pickaxe",
requires: { cobblestone: 3, stick: 2 },
needsTable: true,
});
export const craftStoneSwordSkill = makeRecipeSkill({
id: "craft.stone-sword",
title: "Craft stone sword",
item: "stone_sword",
requires: { cobblestone: 2, stick: 1 },
needsTable: true,
});
export const craftFurnaceSkill = makeRecipeSkill({
id: "craft.furnace",
title: "Craft furnace",
item: "furnace",
requires: { cobblestone: 8 },
needsTable: true,
});
export const craftChestSkill = makeRecipeSkill({
id: "craft.chest",
title: "Craft chest",
item: "chest",
requires: { planks: 8 },
needsTable: true,
});
// Torch: 1 stick + 1 coal (or charcoal) → 4 torches. We accept either
// fuel via precondition shortcut: if no coal AND no charcoal, fail with
// missing_material so the curriculum surfaces the blocker rather than
// silently fail at recipe lookup.
export const craftTorchSkill = Object.freeze({
id: "craft.torch",
title: "Craft torches",
timeoutMs: 20_000,
preconditions(ctx) {
if (!ctx?.bot) return { ok: false, code: "no_bot", detail: "bot missing" };
if (totalSticks(ctx.bot) < 1) return { ok: false, code: "missing_material", detail: "need 1 stick" };
if (count(ctx.bot, "coal") < 1 && count(ctx.bot, "charcoal") < 1) {
return { ok: false, code: "missing_material", detail: "need 1 coal or charcoal" };
}
return { ok: true };
},
async execute(ctx) {
const bot = ctx.bot;
const reg = bot?.registry;
const itemId = reg?.itemsByName?.["torch"]?.id;
if (itemId == null) {
return { ok: false, code: "unsupported_version", detail: "no torch in registry", worldDelta: null };
}
const recipes = bot.recipesFor(itemId, null, 1, null);
const recipe = recipes[0];
if (!recipe) {
return { ok: false, code: "no_recipe", detail: "no recipe for torch (need coal+stick)", worldDelta: null };
}
try {
await withTimeout(bot.craft(recipe, 1, null), 15_000, "craft(torch)");
return { ok: true, code: "done", detail: { item: "torch" }, worldDelta: { crafted: "torch" } };
} catch (e) {
return { ok: false, code: "failed", detail: e.message, worldDelta: null };
}
},
});
+128
View File
@@ -0,0 +1,128 @@
// gather.stone — find a nearby stone/cobble/deepslate block, equip a
// pickaxe (best available), path to it and mine it. Stone-tier mining
// needs at least a wooden pickaxe — the preconditions enforce that.
import pathfinderPkg from "mineflayer-pathfinder";
const { pathfinder, goals, Movements } = pathfinderPkg;
import { pickaxes } from "./groups.js";
import { info, warn } from "../log.js";
const STONE_NAMES = ["stone", "cobblestone", "deepslate", "cobbled_deepslate", "andesite", "diorite", "granite"];
let pluginLoaded = new WeakSet();
function ensurePathfinder(bot) {
if (pluginLoaded.has(bot)) return;
bot.loadPlugin(pathfinder);
pluginLoaded.add(bot);
}
function setMovementsForGather(bot) {
const m = new Movements(bot);
m.canDig = true;
m.allow1by1towers = false;
bot.pathfinder.setMovements(m);
}
const PICKAXE_PRIORITY = ["netherite_pickaxe", "diamond_pickaxe", "iron_pickaxe", "stone_pickaxe", "wooden_pickaxe"];
async function equipBestPickaxe(bot) {
for (const name of PICKAXE_PRIORITY) {
const item = bot.inventory.items().find((i) => i.name === name);
if (item) {
try {
await bot.equip(item, "hand");
return name;
} catch {}
}
}
return null;
}
function withTimeout(promise, ms, label) {
let timer;
const timeout = new Promise((_, reject) => {
timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms / 1000}s`)), ms);
});
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
}
const stoneBlacklist = new WeakMap();
const BLACKLIST_TTL_MS = 5 * 60_000;
function getBlacklist(bot) {
let m = stoneBlacklist.get(bot);
if (!m) {
m = new Map();
stoneBlacklist.set(bot, m);
}
const now = Date.now();
for (const [k, exp] of m) if (exp < now) m.delete(k);
return m;
}
export const skill = Object.freeze({
id: "gather.stone",
title: "Gather cobblestone",
timeoutMs: 90_000,
preconditions(ctx) {
if (!ctx?.bot) return { ok: false, code: "no_bot", detail: "bot missing" };
const available = pickaxes(ctx.bot);
if (available.size === 0) {
return { ok: false, code: "unsupported_version", detail: "no pickaxes in registry" };
}
const owned = ctx.bot.inventory.items().some((i) => available.has(i.name));
if (!owned) {
return { ok: false, code: "missing_tool", detail: "no pickaxe in inventory" };
}
return { ok: true };
},
async execute(ctx) {
const bot = ctx.bot;
const blacklist = getBlacklist(bot);
const target = bot.findBlock({
matching: (b) => {
if (!b || !b.position || !STONE_NAMES.includes(b.name)) return false;
const key = `${b.position.x},${b.position.y},${b.position.z}`;
return !blacklist.has(key);
},
maxDistance: 32,
});
if (!target) {
return { ok: false, code: "no_target", detail: "no reachable stone within 32 blocks", worldDelta: null };
}
ensurePathfinder(bot);
setMovementsForGather(bot);
const pickaxe = await equipBestPickaxe(bot);
info("action", `gather.stone: ${target.name} at ${target.position.x},${target.position.y},${target.position.z} (tool=${pickaxe ?? "fists"})`);
try {
await withTimeout(
bot.pathfinder.goto(new goals.GoalGetToBlock(target.position.x, target.position.y, target.position.z)),
45_000,
"pathToStone",
);
await withTimeout(bot.dig(target), 30_000, "digStone");
await new Promise((r) => setTimeout(r, 1200));
return {
ok: true,
code: "done",
detail: { blockType: target.name, at: target.position },
worldDelta: { minedAt: target.position, blockType: target.name },
};
} catch (e) {
warn("action", `gather.stone failed: ${e.message}`);
const key = `${target.position.x},${target.position.y},${target.position.z}`;
blacklist.set(key, Date.now() + BLACKLIST_TTL_MS);
const msg = String(e?.message ?? "");
const code = msg.includes("timed out") ? "timeout" : "failed";
return { ok: false, code, detail: e.message, worldDelta: null };
}
},
validate(ctx, result) {
return result.ok && !!result.worldDelta?.blockType;
},
recover(ctx, result) {
if (result.code === "no_target") return { hint: "wander", reason: "no stone within 32 blocks" };
return null;
},
});
+26
View File
@@ -25,6 +25,20 @@ import { info, warn } from "../log.js";
import { skill as chopLogs } from "./chop-logs.js";
import { skill as eat } from "./eat.js";
import { skill as wander } from "./wander.js";
import { skill as gatherStone } from "./gather-stone.js";
import {
craftPlanksSkill,
craftSticksSkill,
craftWoodenAxeSkill,
craftWoodenPickaxeSkill,
craftWoodenSwordSkill,
craftStoneAxeSkill,
craftStonePickaxeSkill,
craftStoneSwordSkill,
craftFurnaceSkill,
craftChestSkill,
craftTorchSkill,
} from "./craft.js";
const SKILLS = new Map();
@@ -40,6 +54,18 @@ function register(skill) {
register(chopLogs);
register(eat);
register(wander);
register(gatherStone);
register(craftPlanksSkill);
register(craftSticksSkill);
register(craftWoodenAxeSkill);
register(craftWoodenPickaxeSkill);
register(craftWoodenSwordSkill);
register(craftStoneAxeSkill);
register(craftStonePickaxeSkill);
register(craftStoneSwordSkill);
register(craftFurnaceSkill);
register(craftChestSkill);
register(craftTorchSkill);
export function listSkills() {
return Array.from(SKILLS.values()).map((s) => ({
+6
View File
@@ -160,6 +160,12 @@ function StatusBar({ snapshot, paused, connectedToBot }: { snapshot: Snapshot; p
<Text>
<Text dimColor>milestone: </Text>
<Text>{milestone ?? <Text dimColor>(none planner_empty?)</Text>}</Text>
{snapshot.curriculum?.plan?.skillId ? (
<Text dimColor> suggested: {snapshot.curriculum.plan.skillId}</Text>
) : null}
{snapshot.curriculum?.inventoryFull ? (
<Text color="yellow"> [inventory full]</Text>
) : null}
</Text>
{reason ? (
<Text>