Files
pepa-pi-bot/runtime/skills/pillar-up.js
T
865aae1213 v0.2.0-rc.3: pillar-up escape + advice in fallback + danger POI (#22)
* v0.2.0-rc.3: pillar-up escape + advice everywhere + danger POI

Closes the gap rc.2 left open. Live observation showed:
- Pi-coach extracted 5 high-quality lessons (do not explore.far at
  night near zombies, etc.) but none of them fired (applied_count=0
  across the board). Root cause: dispatcher consulted advice only on
  the main curriculum path; the bot was falling into the wander/
  explore.far FALLBACK after each gather attempt bailed, which
  bypassed consult().
- Bot was wedged in a pit on (608, 90) with stone walls. recovery.
  tunnel-out kept failing ("Digging aborted") because mining stone
  with fists takes ~10s/block; pathfinder watchdog kills it.

This patch:

1. survive.pillar-up (runtime/skills/pillar-up.js) — new escape skill.
   Places a placeable block under the bot and jumps onto it; repeats
   up to 8 steps. No pickaxe required. Works in dirt/cobble/planks/
   sand/gravel/wool/etc. The bot's vertical exit from any pit it can
   stand in.

2. Wedged-emergency reflex (runtime/reflex.js). At the top of
   curriculumReflex, if noProgressReason is wedged-like AND position
   hasn't shifted ≥16 blocks in 60s AND no hostile in 6m AND pillar
   block in inventory → dispatch survive.pillar-up. 2-min cooldown
   between attempts.

3. consult() now also runs on the WANDER/explore.far fallback path
   (runtime/reflex.js curriculumReflex). Pi-coach lessons can finally
   take effect. If the fallback skill is overridden to a non-eligible
   skill but the bot has a placeable block, falls back to pillar-up.
   Outcomes feed reportAdviceOutcome so confidence stays grounded.

4. recordPOI("danger") on death (runtime/coach/postmortem.js). Spatial
   memory now flags where the bot died, expires after 6h. POI table
   was empty in rc.2.

5. SAFE_OVERRIDES extended (runtime/coach/advice.js): adds
   survive.pillar-up and village.choose-base so coach lessons can
   route there.

Tests: 255/255 green (+9 pillar-up).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* v0.2.0-rc.3 fixup: relax wedged-escape trigger

Drop the WEDGED_REASONS check — noProgressReason is a string that
may or may not be set when the bot is stuck. Fire pillar-up purely on
"no horizontal progress ≥ 60s, no hostile in 6m, placeable block in
inv". Pillar-up is a constructive no-op when it's not needed (places
one dirt under self) so the false-positive cost is small.

Live observation: rc.3 was deployed and bot was wedged with tunnel-out
repeatedly aborted on stone, but wedged-escape never fired because
the runtime's noProgressReason wasn't in my whitelist. Removing the
gate lets the trigger actually engage.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Yuriy Mayatnikov <mayatnikov@me.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 15:31:21 +03:00

220 lines
7.1 KiB
JavaScript

// survive.pillar-up — escape a pit by placing blocks underneath the bot
// and jumping onto them. The classic Minecraft "pillaring" technique:
// look straight down, jump, place block at feet, repeat. Works with
// any solid placeable block (dirt, cobblestone, sand, etc.). Needs
// NO pickaxe — this is the bot's escape hatch when tunnel-out keeps
// failing because the surrounding terrain is unbreakable without tools.
//
// Strategy:
// 1. Find a placeable solid block in inventory (dirt > cobblestone >
// stone > netherrack > anything solid).
// 2. Equip it.
// 3. Look straight down.
// 4. Loop up to MAX_PILLAR steps:
// - Jump (control:on then off after delay).
// - In the jump apex, place block on the block below the bot.
// - Land on the new block.
// - Check we actually rose +1 in Y.
// - Stop early if we cleared open sky above (yaw test).
import pathfinderPkg from "mineflayer-pathfinder";
const { pathfinder } = pathfinderPkg;
import { info, warn } from "../log.js";
const PILLAR_PREFERENCE = [
"dirt", "cobblestone", "stone", "andesite", "diorite", "granite",
"cobbled_deepslate", "deepslate", "sand", "gravel",
"oak_planks", "spruce_planks", "birch_planks", "dark_oak_planks",
"jungle_planks", "acacia_planks", "mangrove_planks", "cherry_planks",
"netherrack",
];
let pathfinderLoaded = new WeakSet();
function ensurePathfinder(bot) {
if (pathfinderLoaded.has(bot)) return;
try { bot.loadPlugin(pathfinder); pathfinderLoaded.add(bot); } catch {}
}
function pickPillarBlock(bot) {
const items = bot.inventory?.items?.() ?? [];
for (const name of PILLAR_PREFERENCE) {
const found = items.find((i) => i.name === name && i.count > 0);
if (found) return found;
}
// Fallback: any block that looks placeable (has _block suffix or known names).
const fallback = items.find((i) =>
/^(.*_planks|.*_log|.*_wool|cobble|netherrack|stone|dirt|sand|gravel|terracotta|wood)$/i.test(i.name),
);
return fallback ?? null;
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function placeBlockAtFeet(bot) {
// Find the block directly below the bot (referenceBlock for placement).
const pos = bot.entity?.position;
if (!pos) return { ok: false, reason: "no position" };
const below = bot.blockAt(pos.offset(0, -1, 0));
if (!below) return { ok: false, reason: "no block below" };
if (below.name === "air" || below.name === "cave_air" || below.name === "void_air") {
// We're already mid-air — can't place on air. Need to land first.
return { ok: false, reason: "below is air" };
}
try {
// placeBlock direction: place on top face (vec3(0, 1, 0))
await bot.placeBlock(below, { x: 0, y: 1, z: 0 });
return { ok: true };
} catch (e) {
return { ok: false, reason: e?.message ?? String(e) };
}
}
async function pillarStep(bot) {
const startY = bot.entity?.position?.y ?? 0;
// Look straight down so placement reference is correct.
try { await bot.look(bot.entity.yaw, Math.PI / 2, true); } catch {}
// Jump — engage control, hold briefly, release. Mineflayer's setControlState
// handles the jump for us.
bot.setControlState("jump", true);
await sleep(80);
bot.setControlState("jump", false);
// In the apex (~200-300ms), try to place. Brief wait so we're airborne.
await sleep(200);
const place = await placeBlockAtFeet(bot);
if (!place.ok) {
// Likely either still on ground or in air — retry once with longer wait.
await sleep(150);
const retry = await placeBlockAtFeet(bot);
if (!retry.ok) return { ok: false, reason: retry.reason };
}
// Wait for the bot to settle on the new block.
await sleep(400);
const endY = bot.entity?.position?.y ?? startY;
const climbed = endY - startY;
return { ok: climbed >= 0.5, climbed };
}
function inPit(bot) {
// Heuristic "we're in a pit": there's a solid block within 3 blocks
// in at least 2 of the 4 cardinal directions at head height.
const pos = bot.entity?.position;
if (!pos) return false;
const head = pos.offset(0, 1, 0);
let walls = 0;
for (const [dx, dz] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
for (let r = 1; r <= 2; r++) {
const b = bot.blockAt?.(head.offset(dx * r, 0, dz * r));
if (b && b.name !== "air" && b.name !== "cave_air") {
walls += 1;
break;
}
}
}
return walls >= 2;
}
export const skill = Object.freeze({
id: "survive.pillar-up",
title: "Pillar up to escape a pit",
timeoutMs: 45_000,
preconditions(ctx) {
if (!ctx?.bot) return { ok: false, code: "no_bot", detail: "bot missing" };
const block = pickPillarBlock(ctx.bot);
if (!block) {
return { ok: false, code: "missing_material", detail: "no placeable block in inventory (dirt/cobble/planks/etc.)" };
}
return { ok: true };
},
async execute(ctx, args = {}) {
const bot = ctx.bot;
ensurePathfinder(bot);
// Stop any active pathing so we control movement.
try { bot.pathfinder?.setGoal?.(null); } catch {}
// Pick + equip a pillar block.
const block = pickPillarBlock(bot);
if (!block) {
return { ok: false, code: "missing_material", detail: "no placeable block", worldDelta: null };
}
try {
await bot.equip(block, "hand");
} catch (e) {
return { ok: false, code: "failed", detail: `equip failed: ${e?.message ?? e}`, worldDelta: null };
}
info("action", `pillar-up: using ${block.name} (x${block.count}) to climb`);
const maxSteps = Math.max(2, Math.min(args?.maxSteps ?? 8, 16));
const startY = bot.entity?.position?.y ?? 0;
let placed = 0;
let lastReason = null;
for (let i = 0; i < maxSteps; i++) {
const step = await pillarStep(bot);
if (step.ok) {
placed += 1;
const inv = bot.inventory?.items?.().find((it) => it.type === block.type);
if (!inv || inv.count <= 0) {
info("action", `pillar-up: out of ${block.name} after ${placed} steps`);
break;
}
} else {
lastReason = step.reason;
warn("action", `pillar-up: step ${i + 1} failed (${step.reason})`);
// Retry a couple times before giving up — placement timing is finicky.
if (i < 2) continue;
break;
}
}
try { bot.setControlState("jump", false); } catch {}
const endY = bot.entity?.position?.y ?? startY;
const climbed = endY - startY;
const stillPit = inPit(bot);
if (placed === 0) {
return {
ok: false,
code: "no_progress",
detail: lastReason ?? "could not place any blocks",
worldDelta: null,
};
}
if (stillPit && climbed < 2) {
return {
ok: false,
code: "still_pitted",
detail: { placed, climbed, lastReason },
worldDelta: null,
};
}
return {
ok: true,
code: "done",
detail: { placed, climbed, blockType: block.name },
worldDelta: { climbedY: climbed, mode: "pillar-up" },
};
},
validate(ctx, result) {
return result.ok && (result.worldDelta?.climbedY ?? 0) >= 1;
},
recover(ctx, result) {
if (result.code === "missing_material") {
return { hint: "wander", reason: "no pillar block; need to gather dirt or cobble" };
}
if (result.code === "still_pitted") {
return { hint: "wander", reason: "pillar-up didn't escape; try tunnel-out next" };
}
return null;
},
});
// Test exports
export const __testing = { pickPillarBlock, inPit, PILLAR_PREFERENCE };