Operator reported the bot wandered the same 50×50 patch for 2 hours
without making any progress toward food. Diagnosis showed three root
causes; this commit addresses all five open improvement_requests
the LLM (postmortem + tuner) flagged automatically.
Research basis (`Voyager`, `Plan4MC`, `GITM`, `Mindcraft`):
- Coverage / commit-to-cardinal exploration when local scan fails
- Biome-aware strategy switching using a static affordance table
- Wedge detector above the skill layer that triggers RELOCATE not
RETRY (per-skill stuck checks reset on re-entry — useless)
- Time-in-region bbox heuristic + need-duration AND skill-cycle gate
Concrete changes:
1. `runtime/goal/storyline.js`
- orient_self.completed: added timeout fallback (HP=full + session
>120s → done) so barren biomes don't block the bot on step 1.
Closes improvement #2 'Нет навыка оценки когда сменить район'.
- first_food.suggestSkill: now picks survive.scout-food (new) when
no passive mob is nearby; falls back to survive.acquire-food only
when something is in immediate range.
2. `runtime/biome-affordances.js` (new)
- Static table: 40+ biomes → {has_passive_mobs, has_trees,
has_water, has_crops, livable}.
- Unknown biomes return optimistic defaults to avoid regressions.
- Closes improvement #1 'Нет навыка целевого поиска еды по биому'.
3. `runtime/skills/scout-food.js` (new — survive.scout-food)
- Tiered strategy: biome check → scan 32 → scan 64 → commit a
cardinal for 200 blocks rescanning every 16. On cardinal
exhaustion, returns code:"exhausted" so the curriculum can
escalate to village.relocate.
- In barren biomes (desert/ocean/snowy_plains) the scan is
SKIPPED — bot walks straight toward the nearest neighbour
biome that affords passive mobs (8-direction biome probe at
radius 64).
4. `runtime/awareness/wedge-detector.js` (new)
- Rolling 10-min position bbox tracker. observe() called every
tick; isWedged() returns true when bbox<50 AND active need
unmet >5min AND skill cycles ≥3.
- markRelocationStarted() suppresses further wedge firings
until the bot has displaced ≥200b — prevents stack overflow
of relocate calls.
- Lives ABOVE the skill layer (in runtime/reflex.js), because
any per-skill stuck check resets on re-entry.
5. `runtime/skills/relocate.js` (new — village.relocate)
- 300-block walk in least-recently-used cardinal (per-incident
memory in ctx.recentRelocations).
- Re-paths every 32 blocks, soft-tolerates pathfinder failures
(3 consecutive throws → exit with code:"stuck_in_place").
- Closes improvement #2 + #4 ('low success rate trigger').
6. `runtime/skills/escape-pit-safe.js` (new — recovery.escape-pit-safe)
- Surveys 4 cardinals AND ceiling height before committing.
Picks the direction with most open blocks (≥3, no lava).
Falls through to pillar-up only if ceiling clear ≥4b. Returns
code:"no_strategy" if both blocked so curriculum can escalate
to relocate.
- Closes improvement #3 'Нет навыка для безопасного выхода'.
7. `runtime/reflex.js`
- Wedge detector wired before manifesto/storyline. If wedge.wedged
is true, dispatches village.relocate directly and returns —
bypasses every other branch.
- ctx.disableWedge flag for tests.
8. `runtime/coach/advisor-trigger.js`
- LLM provider outage backoff: 3 consecutive http_400 / timeout /
network_error → suppress advisor for 10 min. Today's TimeWeb
gpt-5.4-mini was 400'ing for an hour straight; we were spending
trigger budget on dead calls. Closes improvement implicit gap
in #5.
9. `runtime/bot.js`
- Tracks botSpawnedAt; snapshot._sessionMs exposed for storyline
orient_self timeout fallback.
Tests: 396 green (was 378, +18):
- runtime/biome-affordances.test.js — 8 tests
- runtime/awareness/wedge-detector.test.js — 9 tests
- runtime/goal/storyline.test.js — 1 new test (orient_self timeout)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
160 lines
6.0 KiB
JavaScript
160 lines
6.0 KiB
JavaScript
// recovery.escape-pit-safe — multi-strategy escape from a hole / pit.
|
||
//
|
||
// Why this exists alongside recovery.tunnel-out and survive.pillar-up:
|
||
//
|
||
// tunnel-out tries to dig sideways through walls — fails on
|
||
// unbreakable terrain or when there's no clear horizontal exit.
|
||
// pillar-up places blocks under the bot and jumps — fails when
|
||
// there's a ceiling block above the column.
|
||
//
|
||
// Both fail silently in a deep cave or 2×2 hole with a ceiling.
|
||
// escape-pit-safe surveys options first, then commits:
|
||
//
|
||
// 1. Scan 4 cardinals at head height + foot height. Pick the one
|
||
// with the closest open path to surface (defined as: column
|
||
// with sky visibility above OR ≥3 air blocks horizontal followed
|
||
// by stairs / slope up).
|
||
// 2. If a clear horizontal path exists → recovery.tunnel-out toward it.
|
||
// 3. If no horizontal path BUT ceiling is open above us → pillar-up.
|
||
// 4. If both blocked → return "stuck" and let the LLM-advisor flag a
|
||
// genuine improvement (e.g. "need water-bucket-MLG", "need to
|
||
// mine through stone").
|
||
|
||
import { info, warn } from "../log.js";
|
||
import { runSkill } from "./index.js";
|
||
|
||
const SCAN_RADIUS = 6;
|
||
const HEAD_OFFSET_Y = 1;
|
||
|
||
function blockNameAt(bot, x, y, z) {
|
||
try {
|
||
const b = bot.blockAt?.({ x: Math.floor(x), y: Math.floor(y), z: Math.floor(z) });
|
||
return b?.name ?? null;
|
||
} catch { return null; }
|
||
}
|
||
|
||
function isAir(name) { return name === "air" || name === "cave_air" || name === "void_air"; }
|
||
function isWater(name) { return name === "water" || name === "flowing_water"; }
|
||
function isLava(name) { return name === "lava" || name === "flowing_lava"; }
|
||
function isPassable(name) { return isAir(name) || (name && /carpet|button|torch|grass$/.test(name)); }
|
||
|
||
// Look up to 32 blocks straight up from (x,y,z). Return distance to first
|
||
// non-air block, or Infinity if open all the way (this is a sky-visible
|
||
// column we could pillar-up out of).
|
||
function ceilingDistance(bot, x, y, z) {
|
||
for (let dy = HEAD_OFFSET_Y + 1; dy <= 32; dy++) {
|
||
const n = blockNameAt(bot, x, y + dy, z);
|
||
if (!n) return dy; // unloaded chunk = no info; treat conservatively
|
||
if (!isAir(n)) return dy;
|
||
}
|
||
return Infinity;
|
||
}
|
||
|
||
// Scan SCAN_RADIUS blocks in each cardinal at head height. Return
|
||
// summary per direction: open-block count, lava/water hits, first
|
||
// non-air block name.
|
||
function scanCardinals(bot) {
|
||
const pos = bot?.entity?.position;
|
||
if (!pos) return [];
|
||
const head = pos.offset?.(0, HEAD_OFFSET_Y, 0) ?? { x: pos.x, y: pos.y + HEAD_OFFSET_Y, z: pos.z };
|
||
const dirs = [
|
||
{ name: "N", dx: 0, dz: -1 },
|
||
{ name: "E", dx: 1, dz: 0 },
|
||
{ name: "S", dx: 0, dz: 1 },
|
||
{ name: "W", dx: -1, dz: 0 },
|
||
];
|
||
return dirs.map((d) => {
|
||
let openBlocks = 0;
|
||
let lavaAt = -1;
|
||
let waterAt = -1;
|
||
let firstBlock = null;
|
||
for (let r = 1; r <= SCAN_RADIUS; r++) {
|
||
const x = head.x + d.dx * r;
|
||
const z = head.z + d.dz * r;
|
||
const n = blockNameAt(bot, x, head.y, z);
|
||
if (isLava(n) && lavaAt < 0) lavaAt = r;
|
||
if (isWater(n) && waterAt < 0) waterAt = r;
|
||
if (isPassable(n)) {
|
||
openBlocks++;
|
||
if (firstBlock === null) firstBlock = "(air)";
|
||
} else {
|
||
if (firstBlock === null) firstBlock = n ?? "(unknown)";
|
||
break;
|
||
}
|
||
}
|
||
return { ...d, openBlocks, lavaAt, waterAt, firstBlock };
|
||
});
|
||
}
|
||
|
||
export const skill = Object.freeze({
|
||
id: "recovery.escape-pit-safe",
|
||
title: "Escape a pit — survey directions and pick the safest exit",
|
||
timeoutMs: 90_000,
|
||
preconditions(ctx) {
|
||
if (!ctx?.bot?.entity?.position) return { ok: false, code: "no_bot", detail: "bot missing" };
|
||
return { ok: true };
|
||
},
|
||
async execute(ctx) {
|
||
const bot = ctx.bot;
|
||
const pos = bot.entity.position;
|
||
const dirs = scanCardinals(bot);
|
||
const ceiling = ceilingDistance(bot, pos.x, pos.y, pos.z);
|
||
|
||
info(
|
||
"action",
|
||
`escape-pit-safe: cardinals=${dirs.map((d) => `${d.name}:${d.openBlocks}/${d.firstBlock}`).join(" ")} ceiling=${ceiling === Infinity ? "open" : ceiling + "b"}`,
|
||
);
|
||
|
||
// 1. Choose the direction with most open blocks (≥3) and no lava.
|
||
const horizontal = dirs
|
||
.filter((d) => d.openBlocks >= 3 && d.lavaAt < 0)
|
||
.sort((a, b) => b.openBlocks - a.openBlocks)[0];
|
||
|
||
if (horizontal) {
|
||
info("action", `escape-pit-safe: walking out via ${horizontal.name} (${horizontal.openBlocks}b clear)`);
|
||
// Delegate to wander step or simple controlled walk. Easier:
|
||
// invoke recovery.tunnel-out with a hint of the chosen direction.
|
||
const res = await runSkill("recovery.tunnel-out", ctx, { preferredDir: horizontal.name });
|
||
return {
|
||
ok: !!res?.ok,
|
||
code: res?.code ?? (res?.ok ? "done" : "tunnel_failed"),
|
||
detail: { strategy: "horizontal_walk", direction: horizontal.name, tunnelResult: res?.detail },
|
||
worldDelta: res?.worldDelta ?? { strategy: "horizontal_walk", direction: horizontal.name },
|
||
};
|
||
}
|
||
|
||
// 2. No horizontal exit — try pillar-up only if ceiling is open
|
||
// for the bot's height (≥3 blocks: head + 1 build + 1 ceiling slack).
|
||
if (ceiling >= 4) {
|
||
info("action", `escape-pit-safe: ceiling clear (${ceiling}b), trying pillar-up`);
|
||
const res = await runSkill("survive.pillar-up", ctx);
|
||
return {
|
||
ok: !!res?.ok,
|
||
code: res?.code ?? (res?.ok ? "done" : "pillar_failed"),
|
||
detail: { strategy: "pillar_up", ceiling, pillarResult: res?.detail },
|
||
worldDelta: res?.worldDelta ?? { strategy: "pillar_up" },
|
||
};
|
||
}
|
||
|
||
// 3. Both blocked — surrender. The LLM-advisor will see this in
|
||
// recent dispatches and can suggest village.relocate or flag a
|
||
// new skill request.
|
||
warn("action", `escape-pit-safe: no viable strategy (horizontal blocked + ceiling ${ceiling}b)`);
|
||
return {
|
||
ok: false,
|
||
code: "no_strategy",
|
||
detail: { horizontal: dirs.map((d) => ({ d: d.name, open: d.openBlocks, lava: d.lavaAt })), ceiling },
|
||
worldDelta: null,
|
||
};
|
||
},
|
||
recover(ctx, result) {
|
||
if (result.code === "no_strategy") {
|
||
return { hint: "wander", reason: "no viable pit-escape; let curriculum try a different action" };
|
||
}
|
||
return null;
|
||
},
|
||
});
|
||
|
||
// Test exports
|
||
export const __testing = { scanCardinals, ceilingDistance, isAir, isPassable, SCAN_RADIUS };
|