chore: snapshot pre-v0.2.0 WIP (pathfinder/reflex/metrics/skills improvements)

Baseline for the v0.2.0 self-learning iteration. All 205 tests pass on this
state. Subsequent commits in this branch layer the knowledge base,
post-mortem coach, and persona narration on top.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-27 13:05:12 +03:00
co-authored by Claude Opus 4.7
parent 3ee2ab886e
commit 86e5294bb8
30 changed files with 1023 additions and 130 deletions
+163
View File
@@ -0,0 +1,163 @@
// survive.acquire-food — turn "hungry and no edible item" into a concrete
// world action. The first implementation is intentionally conservative:
// pick up nearby drops if they are already visible, otherwise hunt a nearby
// passive animal. It does not harvest player-looking crops.
import pathfinderPkg from "mineflayer-pathfinder";
const { pathfinder, goals, Movements } = pathfinderPkg;
import { info, warn } from "../log.js";
import { foods } from "./groups.js";
const PASSIVE_FOOD_MOBS = new Set(["cow", "pig", "chicken", "sheep", "rabbit", "mooshroom"]);
let pluginLoaded = new WeakSet();
function ensurePathfinder(bot) {
if (pluginLoaded.has(bot)) return;
bot.loadPlugin(pathfinder);
pluginLoaded.add(bot);
}
function setMovementsForTravel(bot) {
const m = new Movements(bot);
m.canDig = true;
m.allow1by1towers = false;
bot.pathfinder.setMovements(m);
}
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 foodCount(bot) {
const allowed = foods(bot);
return bot.inventory.items().reduce((sum, item) => allowed.has(item.name) ? sum + item.count : sum, 0);
}
function nearestPassiveFoodMob(bot, maxDistance = 32) {
const here = bot?.entity?.position;
if (!here) return null;
let best = null;
for (const e of Object.values(bot.entities ?? {})) {
if (!e?.position || !PASSIVE_FOOD_MOBS.has(e.name)) continue;
const d = e.position.distanceTo(here);
if (d > maxDistance) continue;
if (!best || d < best.distance) best = { entity: e, distance: d };
}
return best;
}
function nearbyDroppedItems(bot, maxDistance = 8) {
const here = bot?.entity?.position;
if (!here) return [];
return Object.values(bot.entities ?? {})
.filter((e) => e?.position && (e.type === "object" || e.name === "item"))
.map((e) => ({ entity: e, distance: e.position.distanceTo(here) }))
.filter((e) => e.distance <= maxDistance)
.sort((a, b) => a.distance - b.distance);
}
async function pickupNearbyDrops(bot) {
ensurePathfinder(bot);
setMovementsForTravel(bot);
let picked = 0;
for (const { entity } of nearbyDroppedItems(bot, 8).slice(0, 6)) {
try {
await withTimeout(
bot.pathfinder.goto(new goals.GoalNear(entity.position.x, entity.position.y, entity.position.z, 1)),
8_000,
"gotoDrop",
);
picked++;
} catch {}
}
if (picked > 0) await new Promise((r) => setTimeout(r, 600));
return picked;
}
export const skill = Object.freeze({
id: "survive.acquire-food",
title: "Acquire a basic food item",
timeoutMs: 75_000,
preconditions(ctx) {
if (!ctx?.bot) return { ok: false, code: "no_bot", detail: "bot missing" };
if (foodCount(ctx.bot) > 0) return { ok: false, code: "already_have", detail: "already carrying edible food" };
if (nearestPassiveFoodMob(ctx.bot) || nearbyDroppedItems(ctx.bot, 8).length > 0) return { ok: true };
return { ok: false, code: "no_target", detail: "no nearby food drops or passive food mobs" };
},
async execute(ctx) {
const bot = ctx.bot;
const before = foodCount(bot);
const picked = await pickupNearbyDrops(bot);
if (foodCount(bot) > before) {
return {
ok: true,
code: "done",
detail: { source: "drop", picked },
worldDelta: { acquiredFood: foodCount(bot) - before, source: "drop" },
};
}
const target = nearestPassiveFoodMob(bot);
if (!target) return { ok: false, code: "no_target", detail: "no passive food mob visible", worldDelta: null };
ensurePathfinder(bot);
setMovementsForTravel(bot);
try {
await withTimeout(
bot.pathfinder.goto(new goals.GoalFollow(target.entity, 2)),
30_000,
"pathToFoodMob",
);
} catch (e) {
return { ok: false, code: "no_path", detail: e.message, worldDelta: null };
}
info("action", `survive.acquire-food: hunting ${target.entity.name} (${target.distance.toFixed(1)}m)`);
try {
for (let i = 0; i < 8; i++) {
const current = Object.values(bot.entities ?? {}).find((e) => e.id === target.entity.id);
if (!current) break;
if (current.position.distanceTo(bot.entity.position) > 4) {
try {
await withTimeout(
bot.pathfinder.goto(new goals.GoalFollow(current, 2)),
8_000,
"repathFoodMob",
);
} catch {}
}
bot.attack(current);
await new Promise((r) => setTimeout(r, 700));
}
await new Promise((r) => setTimeout(r, 1_000));
await pickupNearbyDrops(bot);
const after = foodCount(bot);
if (after <= before) {
return { ok: false, code: "no_drop", detail: `hunted ${target.entity.name} but found no edible drop`, worldDelta: null };
}
return {
ok: true,
code: "done",
detail: { source: "hunt", mob: target.entity.name, gained: after - before },
worldDelta: { acquiredFood: after - before, source: "hunt", mob: target.entity.name },
};
} catch (e) {
warn("action", `survive.acquire-food failed: ${e.message}`);
return { ok: false, code: "failed", detail: e.message, worldDelta: null };
}
},
recover(ctx, result) {
if (result.code === "no_target" || result.code === "no_path") {
return { hint: "wander", reason: "need to search for passive food mobs" };
}
return null;
},
});
export const _internal = { foodCount, nearestPassiveFoodMob };
+3 -2
View File
@@ -121,6 +121,7 @@ export const skill = Object.freeze({
},
async execute(ctx, { owned } = {}) {
const bot = ctx.bot;
const ownedLedger = owned ?? ctx.owned;
const planks = pickBuildPlanks(bot);
const base = getLocation("base") ?? getLocation(SHELTER_NAME);
const center = { x: base.x, y: base.y, z: base.z };
@@ -158,8 +159,8 @@ export const skill = Object.freeze({
}
try {
await withTimeout(bot.placeBlock(place.ref, place.face), 5000, "placeBlock");
if (owned?.markPlaced) {
owned.markPlaced({
if (ownedLedger?.markPlaced) {
ownedLedger.markPlaced({
x: target.x, y: target.y, z: target.z,
blockType: planks.name,
skill: "village.build-shelter",
+1 -1
View File
@@ -23,7 +23,7 @@ export const skill = Object.freeze({
return { ok: true };
},
async execute(ctx) {
const result = scoreCurrentPosition(ctx.bot);
const result = scoreCurrentPosition(ctx.bot, { isOwned: ctx.owned?.isOwned });
if (!result?.position) {
return { ok: false, code: "no_position", detail: "bot has no position", worldDelta: null };
}
+23 -26
View File
@@ -8,7 +8,7 @@
// patch.
import pathfinderPkg from "mineflayer-pathfinder";
const { pathfinder, goals, Movements } = pathfinderPkg;
const { pathfinder, Movements } = pathfinderPkg;
import { info, warn } from "../log.js";
import { digEscapeTunnel } from "./recovery-tunnel-out.js";
@@ -69,6 +69,7 @@ export const skill = Object.freeze({
// this server mean we can't trust GoalNear; cardinal probing
// gives us a free-direction signal cheaply.
const dist = Math.max(24, args.distance ?? 48);
const beforeProbe = clonePos(bot.entity.position);
const trials = await probeCardinalStep(bot, 800);
const movable = trials.filter((t) => t.dist > 0.5);
@@ -89,6 +90,16 @@ export const skill = Object.freeze({
}
info("action", `explore.far: cardinal probe trials=${trials.map((t) => `${t.name}:${t.dist.toFixed(1)}`).join(" ")} best=${best.name}`);
const probeMoved = horizontalDistance(beforeProbe, bot.entity.position);
if (probeMoved >= 2) {
return {
ok: true,
code: "done",
detail: { mode: "probe-moved", dir: best.name, moved: probeMoved },
worldDelta: { movedTo: clonePos(bot.entity.position) },
};
}
if (best.dist < 0.5) {
// All cardinals blocked. Try the cheap vertical escape first; if it
// does not actually move us, carve a short horizontal tunnel. The
@@ -107,33 +118,19 @@ export const skill = Object.freeze({
const tx = Math.round(here.x + Math.sin(-best.yaw) * dist);
const tz = Math.round(here.z + Math.cos(-best.yaw) * dist);
const ty = Math.round(here.y);
info("action", `explore.far: walking ${best.name} ${tx},${ty},${tz}`);
try {
await withTimeout(
bot.pathfinder.goto(new goals.GoalNear(tx, ty, tz, 4)),
45_000,
`explore.far(${tx},${tz})`,
);
return {
ok: true, code: "done",
detail: { to: { x: tx, y: ty, z: tz }, dir: best.name },
worldDelta: { movedTo: { x: tx, y: ty, z: tz } },
};
} catch (e) {
warn("action", `explore.far pathfinder failed: ${e.message} — continuing blind`);
return blindWalkOrTunnelOut(bot, {
yaw: best.yaw,
dirName: best.name,
blindMs: args.blindMs ?? 7_000,
tunnelPushMs: args.tunnelPushMs,
reason: `explore.far blind ${best.name}`,
});
}
info("action", `explore.far: blind-walking ${best.name} toward ${tx},${ty},${tz}`);
return blindWalkOrTunnelOut(bot, {
yaw: best.yaw,
dirName: best.name,
blindMs: args.blindMs ?? 7_000,
tunnelPushMs: args.tunnelPushMs,
reason: `explore.far blind ${best.name}`,
intended: { x: tx, y: ty, z: tz },
});
},
});
async function blindWalkOrTunnelOut(bot, { yaw, dirName, blindMs = 7_000, minMove = 0.75, tunnelPushMs, reason = "blind fallback" } = {}) {
async function blindWalkOrTunnelOut(bot, { yaw, dirName, blindMs = 7_000, minMove = 0.75, tunnelPushMs, reason = "blind fallback", intended = null } = {}) {
const before = clonePos(bot.entity.position);
try { await bot.look(yaw, 0, true); } catch {}
bot.setControlState("forward", true);
@@ -150,7 +147,7 @@ async function blindWalkOrTunnelOut(bot, { yaw, dirName, blindMs = 7_000, minMov
return {
ok: true,
code: "done",
detail: { mode: "blind-moved", previousMode: "blind", dir: dirName, moved },
detail: { mode: "blind-moved", previousMode: "blind", dir: dirName, moved, intended },
worldDelta: { movedTo: clonePos(bot.entity.position) },
};
}
+61
View File
@@ -0,0 +1,61 @@
// survive.flee — emergency retreat from the nearest hostile. Unlike
// explore.far, this skill explicitly moves away from the hostile entity
// that triggered the mode.
import { fleeFrom } from "../actions.js";
const HOSTILE = new Set([
"zombie", "skeleton", "creeper", "spider", "witch", "pillager",
"vindicator", "husk", "stray", "drowned", "phantom", "enderman",
"slime", "magma_cube", "hoglin", "piglin_brute", "ravager", "warden",
"breeze", "bogged",
]);
function nearestHostile(bot, { hostileName } = {}) {
const here = bot?.entity?.position;
if (!here) return null;
let best = null;
for (const e of Object.values(bot.entities ?? {})) {
if (!e?.position) continue;
const name = (e.name || "").toLowerCase();
if (hostileName && name !== String(hostileName).toLowerCase()) continue;
if (!hostileName && !HOSTILE.has(name)) continue;
const d = e.position.distanceTo(here);
if (!best || d < best.distance) best = { entity: e, distance: d };
}
return best;
}
export const skill = Object.freeze({
id: "survive.flee",
title: "Retreat from the nearest hostile",
timeoutMs: 40_000,
preconditions(ctx, args = {}) {
if (!ctx?.bot) return { ok: false, code: "no_bot", detail: "bot missing" };
const hit = nearestHostile(ctx.bot, args);
if (!hit) return { ok: false, code: "no_hostile", detail: "no matching hostile entity" };
return { ok: true };
},
async execute(ctx, args = {}) {
const hit = nearestHostile(ctx.bot, args);
if (!hit) return { ok: false, code: "no_hostile", detail: "no matching hostile after precondition", worldDelta: null };
const res = await fleeFrom(ctx.bot, hit.entity, args.distance ?? 16);
if (res.ok) {
return {
ok: true,
code: "done",
detail: { ...res.detail, from: hit.entity.name, distance: Math.round(hit.distance * 10) / 10 },
worldDelta: { fledTo: res.detail?.to ?? null },
};
}
const msg = String(res.detail ?? "");
const code = msg.includes("timed out") ? "timeout" : "failed";
return { ok: false, code, detail: res.detail, worldDelta: null };
},
recover(ctx, result) {
if (result.code === "timeout") return { hint: "tunnel-out", reason: "flee path timed out" };
return null;
},
});
export const _internal = { nearestHostile };
+8
View File
@@ -26,13 +26,17 @@ 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 exploreFar } from "./explore-far.js";
import { skill as flee } from "./flee.js";
import { skill as sleep } from "./sleep.js";
import { skill as tunnelOut } from "./recovery-tunnel-out.js";
import { skill as diagPhysics } from "./diagnose-physics.js";
import { skill as diagScan, matchSkill as diagMatch } from "./diagnose-scan.js";
import { skill as gatherStone } from "./gather-stone.js";
import { skill as gatherWool } from "./gather-wool.js";
import { skill as acquireFood } from "./acquire-food.js";
import { skill as chooseBase } from "./choose-base.js";
import { skill as buildShelter } from "./build-shelter.js";
import { skill as placeChest } from "./place-chest.js";
import { skill as depositSurplus } from "./deposit-surplus.js";
import { skill as farmWheat } from "./farm-wheat.js";
import {
@@ -65,14 +69,18 @@ register(chopLogs);
register(eat);
register(wander);
register(exploreFar);
register(flee);
register(sleep);
register(tunnelOut);
register(diagPhysics);
register(diagScan);
register(diagMatch);
register(gatherStone);
register(gatherWool);
register(acquireFood);
register(chooseBase);
register(buildShelter);
register(placeChest);
register(depositSurplus);
register(farmWheat);
register(craftPlanksSkill);
+94
View File
@@ -0,0 +1,94 @@
// village.place-chest — place the carried chest near the base/current
// footing and register it as "chest" in locations.json. This turns the
// storage milestone from "I crafted a chest item" into "I have a usable
// storage location".
import { setLocation, getLocation } from "../locations.js";
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 carriedChest(bot) {
return bot.inventory.items().find((i) => i.name === "chest" || i.name === "trapped_chest");
}
function isEmpty(block) {
return !block || block.boundingBox === "empty" || block.name === "air" || block.name === "cave_air" || block.name === "void_air";
}
function placementCandidate(bot) {
const here = bot.entity.position.floored ? bot.entity.position.floored() : bot.entity.position;
const offsets = [
{ x: 1, z: 0 },
{ x: -1, z: 0 },
{ x: 0, z: 1 },
{ x: 0, z: -1 },
{ x: 2, z: 0 },
{ x: 0, z: 2 },
];
for (const off of offsets) {
const ref = bot.blockAt({ x: Math.round(here.x + off.x), y: Math.round(here.y - 1), z: Math.round(here.z + off.z) });
const target = bot.blockAt({ x: Math.round(here.x + off.x), y: Math.round(here.y), z: Math.round(here.z + off.z) });
if (ref?.boundingBox === "block" && isEmpty(target)) {
return { ref, face: { x: 0, y: 1, z: 0 }, at: { x: ref.position.x, y: ref.position.y + 1, z: ref.position.z } };
}
}
return null;
}
export const skill = Object.freeze({
id: "village.place-chest",
title: "Place a personal chest",
timeoutMs: 30_000,
preconditions(ctx) {
if (!ctx?.bot) return { ok: false, code: "no_bot", detail: "bot missing" };
if (getLocation("chest")) return { ok: false, code: "already_have", detail: "chest location already exists" };
if (!carriedChest(ctx.bot)) return { ok: false, code: "missing_material", detail: "no chest item in inventory" };
if (!placementCandidate(ctx.bot)) return { ok: false, code: "no_space", detail: "no adjacent placeable slot" };
return { ok: true };
},
async execute(ctx) {
const bot = ctx.bot;
const item = carriedChest(bot);
if (!item) return { ok: false, code: "missing_material", detail: "no chest item after precondition", worldDelta: null };
const place = placementCandidate(bot);
if (!place) return { ok: false, code: "no_space", detail: "no adjacent placeable slot", worldDelta: null };
try {
await withTimeout(bot.equip(item, "hand"), 3_000, "equip chest");
await withTimeout(bot.placeBlock(place.ref, place.face), 5_000, "place chest");
const loc = setLocation("chest", {
x: place.at.x,
y: place.at.y,
z: place.at.z,
dimension: ctx.snapshot?.dimension ?? "overworld",
radius: 2,
note: "auto-placed storage chest",
});
ctx.owned?.markPlaced?.({
x: loc.x,
y: loc.y,
z: loc.z,
dimension: loc.dimension,
blockType: item.name,
skill: "village.place-chest",
});
return {
ok: true,
code: "done",
detail: { location: loc, item: item.name },
worldDelta: { chestAt: { x: loc.x, y: loc.y, z: loc.z }, placedType: item.name },
};
} catch (e) {
const msg = String(e?.message ?? "");
const code = msg.includes("timed out") ? "timeout" : "failed";
return { ok: false, code, detail: e.message, worldDelta: null };
}
},
});
export const _internal = { placementCandidate };
+25 -6
View File
@@ -280,7 +280,8 @@ export function rankTunnelDirections(bot, maxSteps = 3) {
async function digOne(bot, block) {
if (isPassableBlock(block)) return false;
await equipLikelyTool(bot, block.name);
const tool = await equipLikelyTool(bot, block.name);
const timeoutMs = digTimeoutMs(block.name, tool);
try {
if (typeof bot.lookAt === "function") {
await withTimeout(bot.lookAt(centerOf(block.position), true), 1_500, `lookAt(${block.name})`);
@@ -288,7 +289,7 @@ async function digOne(bot, block) {
} catch {
// Dig may still work; do not abort on look jitter.
}
await withTimeout(bot.dig(block), 10_000, `dig(${block.name})`);
await withTimeout(bot.dig(block), timeoutMs, `dig(${block.name})`);
const after = bot.blockAt(block.position);
if (after && !isPassableBlock(after) && after.name === block.name) {
throw new Error(`block still present after dig: ${block.name}`);
@@ -296,6 +297,16 @@ async function digOne(bot, block) {
return true;
}
function digTimeoutMs(blockName, equippedTool) {
const kind = toolKindFor(blockName);
if (!kind) return 12_000;
if (equippedTool?.includes(kind)) return 12_000;
if (kind === "pickaxe") return 25_000;
if (kind === "axe") return 18_000;
if (kind === "shovel") return 15_000;
return 12_000;
}
async function pushForward(bot, yaw, ms) {
try { await bot.look(yaw, 0, true); } catch {}
bot.setControlState("forward", true);
@@ -329,8 +340,16 @@ export async function digEscapeTunnel(bot, { maxSteps = 3, minMove = 0.75, pushM
const before = posClone(bot.entity.position);
info("action", `tunnel-out: ${reason}${dir.name} (${dir.digTargets.length} blocks to clear)`);
try {
for (const target of dir.digTargets) {
await digOne(bot, target.block);
let dug = 0;
let lastStep = 0;
const byStep = [...dir.digTargets]
.sort((a, b) => (a.step - b.step) || (a.kind === "feet" ? -1 : 1));
for (const target of byStep) {
if (target.step !== lastStep && lastStep > 0) {
await pushForward(bot, dir.yaw, Math.min(pushMs, 900));
}
lastStep = target.step;
if (await digOne(bot, target.block)) dug++;
}
await pushForward(bot, dir.yaw, pushMs);
const moved = horizontalDistance(before, bot.entity.position);
@@ -340,7 +359,7 @@ export async function digEscapeTunnel(bot, { maxSteps = 3, minMove = 0.75, pushM
return {
ok: true,
code: "done",
detail: { mode: "tunnel-out", dir: dir.name, moved, movedY, dug: dir.digTargets.length },
detail: { mode: "tunnel-out", dir: dir.name, moved, movedY, dug },
worldDelta: { mode: "tunnel-out", movedTo },
};
}
@@ -363,7 +382,7 @@ export async function digEscapeTunnel(bot, { maxSteps = 3, minMove = 0.75, pushM
export const skill = Object.freeze({
id: "recovery.tunnel-out",
title: "Tunnel out of a wedged 1x1 hole",
timeoutMs: 45_000,
timeoutMs: 120_000,
preconditions(ctx) {
if (!ctx?.bot) return { ok: false, code: "no_bot", detail: "bot missing" };
return { ok: true };
@@ -100,6 +100,38 @@ test("tunnel-out does not count jumping in place as escape", async () => {
assert.match(res.detail.error, /moved only 0\.00 horizontally/);
});
test("tunnel-out digs one reachable layer at a time", async () => {
const blocks = {};
for (let step = 1; step <= 3; step++) {
blocks[`${step},64,0`] = "stone";
blocks[`${step},65,0`] = "stone";
blocks[`${step},63,0`] = "stone";
}
blocks["0,64,-1"] = "oak_planks";
blocks["0,64,1"] = "oak_planks";
blocks["-1,64,0"] = "oak_planks";
const bot = makeBot(blocks);
bot.look = async () => {};
bot.lookAt = async () => {};
bot.dig = async (block) => {
const dist = Math.hypot(block.position.x - bot.entity.position.x, block.position.z - bot.entity.position.z);
if (dist > 1.5) throw new Error(`too far: ${dist.toFixed(1)}`);
blocks[`${block.position.x},${block.position.y},${block.position.z}`] = "air";
};
bot.setControlState = (control, on) => {
if (control === "forward" && !on) {
bot.entity.position = makePos(bot.entity.position.x + 1, bot.entity.position.y, bot.entity.position.z);
}
};
const res = await digEscapeTunnel(bot, { maxSteps: 3, minMove: 0.75, pushMs: 0 });
assert.equal(res.ok, true);
assert.equal(res.detail.dir, "E");
assert.equal(res.detail.dug, 6);
assert.equal(Math.round(bot.entity.position.x), 3);
});
test("explore.far blind fallback does not report done when position is unchanged", async () => {
const blocks = {};
const bot = makeBot(blocks);
+44
View File
@@ -0,0 +1,44 @@
// survive.sleep — use the action-layer bed primitive through the skill
// contract so priority modes can sleep without bypassing metrics,
// scenario-memory, current-task, and self-improvement evidence.
import { sleepInBed } from "../actions.js";
export const skill = Object.freeze({
id: "survive.sleep",
title: "Sleep in or place a carried bed",
timeoutMs: 45_000,
preconditions(ctx) {
if (!ctx?.bot) return { ok: false, code: "no_bot", detail: "bot missing" };
if (ctx.snapshot?.isDay) return { ok: false, code: "daytime", detail: "not night" };
const inv = ctx.snapshot?.inventory ?? {};
const hasBed = Object.keys(inv).some((n) => /_bed$/.test(n));
const knownBed = ctx.snapshot?.locations?.shelter || ctx.snapshot?.locations?.base;
if (!hasBed && !knownBed) {
return { ok: false, code: "missing_bed", detail: "no bed in inventory or known shelter" };
}
return { ok: true };
},
async execute(ctx) {
const res = await sleepInBed(ctx.bot);
if (res.ok) {
return {
ok: true,
code: "done",
detail: res.detail,
worldDelta: { sleptAt: res.detail?.bedAt ?? ctx.snapshot?.position ?? null },
};
}
const msg = String(res.detail ?? "");
const code = msg.includes("no bed")
? "missing_bed"
: msg.includes("timed out")
? "timeout"
: "failed";
return { ok: false, code, detail: res.detail, worldDelta: null };
},
recover(ctx, result) {
if (result.code === "missing_bed") return { hint: "curriculum", reason: "need bed milestone" };
return null;
},
});