Files
pepa-pi-bot/runtime/skills/relocate.js
T
mayatnikovandClaude Opus 4.7 08ac2e1bc1 feat(v0.3.1): real survival patterns — biome-aware scout, wedge-relocate, escape-pit-safe
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>
2026-05-27 23:01:39 +03:00

140 lines
5.1 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// village.relocate — commit-and-walk skill that breaks the bot out of
// "I've been wandering the same 50×50 area for 2 hours" failure mode.
//
// Heuristic: when the wedge-detector says we're stuck, this skill is
// dispatched with no biome preference; it picks the least-recently-
// visited cardinal (or any cardinal if no history) and walks ~300
// blocks toward it, with a hard time budget. While it runs, the
// reflex's wedge-detector knows a relocation is in flight and won't
// fire another one on top.
//
// The skill ignores the active need entirely for its duration — its
// only job is to displace the bot far enough that the surrounding
// biome is fresh and skills like survive.acquire-food / gather.logs
// have new local context to work with.
import pathfinderPkg from "mineflayer-pathfinder";
const { pathfinder, goals, Movements } = pathfinderPkg;
import { info } from "../log.js";
import { markRelocationStarted } from "../awareness/wedge-detector.js";
const CARDINALS = [
{ name: "N", dx: 0, dz: -1 },
{ name: "E", dx: 1, dz: 0 },
{ name: "S", dx: 0, dz: 1 },
{ name: "W", dx: -1, dz: 0 },
];
const DEFAULT_DISTANCE = 300;
const STEP_BLOCKS = 32; // re-path every N blocks for liveness
const STEP_TIMEOUT_MS = 30_000;
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 = false;
m.allow1by1towers = false;
bot.pathfinder.setMovements(m);
}
function pickCardinal(ctx, args) {
// Explicit override wins
if (args?.heading) {
const found = CARDINALS.find((c) => c.name === args.heading);
if (found) return found;
}
// Otherwise pick a cardinal not recently used. ctx may carry a
// recentRelocations array {name, ts}; default = N.
const recent = new Set((ctx?.recentRelocations ?? []).map((r) => r.name));
const untried = CARDINALS.filter((c) => !recent.has(c.name));
return untried[0] ?? CARDINALS[Math.floor(Math.random() * CARDINALS.length)];
}
export const skill = Object.freeze({
id: "village.relocate",
title: "Walk 300 blocks in a fresh cardinal to break a wedge",
timeoutMs: 180_000,
preconditions(ctx) {
if (!ctx?.bot?.entity?.position) {
return { ok: false, code: "no_bot", detail: "bot or entity missing" };
}
return { ok: true };
},
async execute(ctx, args = {}) {
const bot = ctx.bot;
const distance = Math.max(64, Math.min(args?.distance ?? DEFAULT_DISTANCE, 600));
const cardinal = pickCardinal(ctx, args);
const start = { x: bot.entity.position.x, z: bot.entity.position.z };
markRelocationStarted({ x: start.x, z: start.z, heading: cardinal });
ctx.recentRelocations = (ctx.recentRelocations ?? []).slice(-3);
ctx.recentRelocations.push({ name: cardinal.name, ts: Date.now() });
ensurePathfinder(bot);
setMovementsForTravel(bot);
info("action", `relocate: heading ${cardinal.name} for ${distance}b from (${Math.round(start.x)}, ${Math.round(start.z)})`);
let travelled = 0;
const errors = [];
while (travelled < distance) {
if (ctx?.abortSignal?.aborted) {
return {
ok: travelled >= distance / 2, // partial counts if we got at least half
code: travelled >= distance / 2 ? "partial" : "preempted",
detail: { travelled: Math.round(travelled), heading: cardinal.name },
worldDelta: { moved: Math.round(travelled), heading: cardinal.name },
};
}
const stepDist = Math.min(STEP_BLOCKS, distance - travelled);
const targetX = start.x + cardinal.dx * (travelled + stepDist);
const targetZ = start.z + cardinal.dz * (travelled + stepDist);
const targetY = Math.floor(bot.entity.position.y);
try {
await Promise.race([
bot.pathfinder.goto(new goals.GoalNear(Math.floor(targetX), targetY, Math.floor(targetZ), 4)),
new Promise((_, rej) => setTimeout(() => rej(new Error("step timeout")), STEP_TIMEOUT_MS)),
]);
} catch (e) {
errors.push(e?.message ?? String(e));
if (errors.length >= 3) break;
// brief pause then keep trying
await new Promise((r) => setTimeout(r, 500));
}
// Measure actual progress (pathfinder might have routed around)
const dx = bot.entity.position.x - start.x;
const dz = bot.entity.position.z - start.z;
travelled = Math.hypot(dx, dz);
}
const endPos = bot.entity.position;
const finalDist = Math.hypot(endPos.x - start.x, endPos.z - start.z);
if (finalDist < 50) {
return {
ok: false,
code: "stuck_in_place",
detail: { travelled: Math.round(finalDist), heading: cardinal.name, errors: errors.slice(0, 3) },
worldDelta: { moved: Math.round(finalDist) },
};
}
return {
ok: true,
code: "done",
detail: { travelled: Math.round(finalDist), heading: cardinal.name },
worldDelta: { moved: Math.round(finalDist), heading: cardinal.name, mode: "relocate" },
};
},
recover(ctx, result) {
if (result.code === "stuck_in_place") {
return { hint: "wander", reason: "relocate could not gain ground; let wander try a new tactic" };
}
return null;
},
});
// Test exports
export const __testing = { CARDINALS, DEFAULT_DISTANCE, pickCardinal };