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