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>
This commit was merged in pull request #22.
This commit is contained in:
Yuriy Mayatnikov
2026-05-27 15:31:21 +03:00
committed by GitHub
co-authored by Claude Opus 4.7 mayatnikov
parent e84148d189
commit 865aae1213
7 changed files with 409 additions and 3 deletions
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "pepa-pi-bot",
"version": "0.2.0-rc.2",
"version": "0.2.0-rc.3",
"private": true,
"description": "An autonomous, self-extending Minecraft player powered by Pi and Mineflayer.",
"license": "MIT",
@@ -16,7 +16,7 @@
"tui": "tsx tui/tui.tsx",
"propose:apply": "node scripts/propose-apply.js",
"stop": "bash scripts/stop.sh",
"test": "node --test runtime/skills/contract.test.js runtime/skills/groups.test.js runtime/skills/compat.test.js runtime/skills/recovery-tunnel-out.test.js runtime/curriculum.test.js runtime/social/social.test.js runtime/social/conversation.test.js runtime/social/chat-history.test.js runtime/social/reply-pi.test.js runtime/stuck-incident.test.js runtime/compat.test.js runtime/reflex.test.js runtime/base-site.test.js runtime/locations.test.js runtime/watch-filter.test.js runtime/world-journal.test.js runtime/scenario-memory.test.js runtime/critic.test.js runtime/skill-library.test.js runtime/modes.test.js runtime/pathfinder-watchdog.test.js runtime/knowledge/knowledge.test.js runtime/coach/postmortem.test.js runtime/coach/advice.test.js runtime/coach/reflect.test.js runtime/persona/chatter.test.js scripts/edit-scope.test.js scripts/lint-patch.test.js"
"test": "node --test runtime/skills/contract.test.js runtime/skills/groups.test.js runtime/skills/compat.test.js runtime/skills/recovery-tunnel-out.test.js runtime/skills/pillar-up.test.js runtime/curriculum.test.js runtime/social/social.test.js runtime/social/conversation.test.js runtime/social/chat-history.test.js runtime/social/reply-pi.test.js runtime/stuck-incident.test.js runtime/compat.test.js runtime/reflex.test.js runtime/base-site.test.js runtime/locations.test.js runtime/watch-filter.test.js runtime/world-journal.test.js runtime/scenario-memory.test.js runtime/critic.test.js runtime/skill-library.test.js runtime/modes.test.js runtime/pathfinder-watchdog.test.js runtime/knowledge/knowledge.test.js runtime/coach/postmortem.test.js runtime/coach/advice.test.js runtime/coach/reflect.test.js runtime/persona/chatter.test.js scripts/edit-scope.test.js scripts/lint-patch.test.js"
},
"dependencies": {
"better-sqlite3": "^11.10.0",
+2
View File
@@ -20,10 +20,12 @@ const SAFE_OVERRIDES = new Set([
"survive.flee",
"survive.sleep",
"survive.eat",
"survive.pillar-up",
"recovery.tunnel-out",
"explore.far",
"explore.wander",
"village.build-shelter",
"village.choose-base",
]);
// Pi-coach occasionally suggests prefer_skill values that are mode names
+13
View File
@@ -24,6 +24,7 @@ import {
unanalysedDeaths,
record as recordLesson,
poiNearby,
recordPOI,
} from "../knowledge/index.js";
import { info, warn } from "../log.js";
@@ -57,6 +58,18 @@ export function attach(bot, ctx = {}) {
if (!death) return;
const deathId = insertDeath(death);
info("coach", `death recorded id=${deathId ?? "-"} cause=${death.cause} hostile=${death.hostile ?? "?"} skill=${death.lastSkill ?? "?"}`);
// v0.2.0-rc.3 — mark this spot as a danger POI so spatial recall
// surfaces it next time the bot comes near. Expires after 6 hours
// so the danger doesn't outlive its relevance.
if (typeof death.x === "number" && typeof death.z === "number") {
recordPOI({
kind: "danger",
name: death.hostile ?? death.cause ?? "death",
x: death.x, y: death.y ?? 64, z: death.z,
expiresAt: Date.now() + 6 * 3600_000,
notes: `death id=${deathId} cause=${death.cause}`,
});
}
} catch (e) {
warn("coach", `captureDeath failed: ${e?.message ?? e}`);
}
+83 -1
View File
@@ -370,6 +370,52 @@ function metricRecoverySkill(ctx, plannedSkillId) {
return null;
}
// v0.2.0-rc.3 — wedged-emergency escape. When the bot has not made
// meaningful horizontal progress for ≥ 60s AND there's no immediate
// hostile (defendReflex would have handled it) AND a placeable block
// is in inventory, dispatch survive.pillar-up to climb vertically out
// of pit terrain. Breaks the tunnel-out-fail-fall-back-to-flee loop
// observed live in the rc.2 deploy. noProgressReason is a hint, not
// required — pillar-up only writes blocks underneath, so even if the
// real cause is something else, the worst case is +1 dirt placed.
const WEDGED_MIN_MS = 60_000;
function wedgedEscapeSkill(ctx) {
const s = ctx.snapshot;
if (!s) return null;
if (s.closestHostile && (s.closestHostile.distance ?? Infinity) < 6) return null;
const lastMove = ctx.lastSignificantMoveAt ?? 0;
if (!lastMove) return null; // need at least one tick of position tracking
if (Date.now() - lastMove < WEDGED_MIN_MS) return null;
// Last attempted escape was recent? give it room.
if (ctx.skillBackoff?.["survive.pillar-up"] && Date.now() < ctx.skillBackoff["survive.pillar-up"]) {
return null;
}
const pillar = getSkill("survive.pillar-up");
if (!pillar) return null;
const pre = pillar.preconditions(ctx);
if (!pre.ok) return null;
return "survive.pillar-up";
}
function trackSignificantMovement(ctx) {
const s = ctx.snapshot;
const pos = s?.position;
if (!pos) return;
const last = ctx.lastSignificantPos;
if (!last) {
ctx.lastSignificantPos = { x: pos.x, z: pos.z };
ctx.lastSignificantMoveAt = Date.now();
return;
}
const dx = pos.x - last.x;
const dz = pos.z - last.z;
if (dx * dx + dz * dz >= 16) {
ctx.lastSignificantPos = { x: pos.x, z: pos.z };
ctx.lastSignificantMoveAt = Date.now();
}
}
function curriculumReflex(ctx) {
const s = ctx.snapshot;
if (!s.connected) return { action: "noop" };
@@ -378,6 +424,17 @@ function curriculumReflex(ctx) {
const since = Date.now() - (ctx.lastCurriculumAt ?? 0);
if (since < CURRICULUM_COOLDOWN_MS) return { action: "noop" };
trackSignificantMovement(ctx);
const wedged = wedgedEscapeSkill(ctx);
if (wedged) {
ctx.lastCurriculumAt = Date.now();
ctx.skillBackoff = ctx.skillBackoff ?? {};
// Don't pillar-up every tick — cool off for 2 min after each attempt.
ctx.skillBackoff["survive.pillar-up"] = Date.now() + 2 * 60_000;
ctx.dispatch(() => runSkill(wedged, ctx), wedged, {});
return { action: "dispatched", kind: "curriculum-wedged-escape", label: wedged };
}
const plan = s.curriculum?.plan;
const wanderHintUntil = ctx.skillBackoff?.["__wander_hint__"] ?? 0;
const wantWander = wanderHintUntil && Date.now() < wanderHintUntil;
@@ -421,7 +478,32 @@ function curriculumReflex(ctx) {
// explore.far so the bot actually leaves the patch it's stuck in.
if (!plan?.skillId || wantWander) {
ctx.lastCurriculumAt = Date.now();
if (wantWander && consecutiveWanderHints >= 1) {
const fallbackId = wantWander && consecutiveWanderHints >= 1 ? "explore.far" : "wander";
// v0.2.0-rc.3 — consult advice on the FALLBACK dispatch too. Without
// this, Pi-coach lessons like "do not explore.far after a zombie
// sighting" never fire (the bot keeps falling into the fallback path
// after each curriculum skill bails on no_target / wander_hint).
const fbAdvice = consultAdvice({ plannedSkillId: fallbackId === "wander" ? "explore.far" : fallbackId, snapshot: ctx.snapshot });
if (fbAdvice.action === "override" && fbAdvice.overrideSkillId) {
ctx.dispatch(() => runSkill(fbAdvice.overrideSkillId, ctx), fbAdvice.overrideSkillId, {
onComplete: (res) => reportAdviceOutcome({ lessonId: fbAdvice.lessonId, succeeded: !!res?.ok }),
});
return { action: "dispatched", kind: "curriculum-fallback-advice-override", label: fbAdvice.overrideSkillId, lessonId: fbAdvice.lessonId };
}
if (fbAdvice.action === "avoid") {
// Lesson says don't do the fallback either. Try pillar-up as a
// constructive last resort if we have a placeable block — otherwise
// just idle for a tick so the next loop can re-evaluate.
const pillarSkill = getSkill("survive.pillar-up");
if (pillarSkill && pillarSkill.preconditions(ctx).ok) {
ctx.dispatch(() => runSkill("survive.pillar-up", ctx), "survive.pillar-up", {
onComplete: (res) => reportAdviceOutcome({ lessonId: fbAdvice.lessonId, succeeded: !!res?.ok }),
});
return { action: "dispatched", kind: "curriculum-fallback-pillar-up", label: "survive.pillar-up", lessonId: fbAdvice.lessonId };
}
return { action: "noop", kind: "curriculum-fallback-advice-avoid", label: fallbackId, lessonId: fbAdvice.lessonId };
}
if (fallbackId === "explore.far") {
ctx.dispatch(() => runSkill("explore.far", ctx), "explore.far", {});
return { action: "dispatched", kind: "curriculum-explore-far", label: "explore.far" };
}
+2
View File
@@ -29,6 +29,7 @@ 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 pillarUp } from "./pillar-up.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";
@@ -72,6 +73,7 @@ register(exploreFar);
register(flee);
register(sleep);
register(tunnelOut);
register(pillarUp);
register(diagPhysics);
register(diagScan);
register(diagMatch);
+219
View File
@@ -0,0 +1,219 @@
// 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 };
+88
View File
@@ -0,0 +1,88 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { skill, __testing } from "./pillar-up.js";
const { pickPillarBlock, inPit, PILLAR_PREFERENCE } = __testing;
function makePos(x, y, z) {
return {
x, y, z,
offset(dx, dy, dz) { return makePos(x + dx, y + dy, z + dz); },
};
}
function mockBot({ items = [], blocks = {}, pos = makePos(0, 64, 0) } = {}) {
const handlers = {};
return {
entity: { position: pos, yaw: 0, pitch: 0 },
inventory: { items: () => items },
blockAt(p) { return blocks[`${Math.floor(p.x)},${Math.floor(p.y)},${Math.floor(p.z)}`] ?? { name: "air" }; },
on(ev, fn) { handlers[ev] = fn; },
setControlState() {},
async equip() { return true; },
async look() { return true; },
async placeBlock() { return true; },
};
}
test("pickPillarBlock: chooses preferred block from inventory", () => {
const items = [
{ name: "stone", count: 4, type: 1 },
{ name: "dirt", count: 12, type: 2 },
];
const chosen = pickPillarBlock(mockBot({ items }));
assert.equal(chosen.name, "dirt", "prefers dirt over stone");
});
test("pickPillarBlock: returns null when nothing placeable", () => {
const chosen = pickPillarBlock(mockBot({ items: [{ name: "carrot", count: 3 }] }));
assert.equal(chosen, null);
});
test("pickPillarBlock: falls back to wood-like names", () => {
const chosen = pickPillarBlock(mockBot({ items: [{ name: "oak_planks", count: 5 }] }));
assert.equal(chosen.name, "oak_planks");
});
test("inPit: detects walls in cardinal directions", () => {
const blocks = {
"1,65,0": { name: "stone" },
"-1,65,0": { name: "stone" },
};
const bot = mockBot({ blocks });
assert.equal(inPit(bot), true, "two walls = pit");
const open = mockBot({ blocks: {} });
assert.equal(inPit(open), false);
});
test("inPit: detects walls 2 blocks away too", () => {
const blocks = {
"2,65,0": { name: "stone" },
"0,65,-2": { name: "stone" },
};
const bot = mockBot({ blocks });
assert.equal(inPit(bot), true);
});
test("PILLAR_PREFERENCE: dirt is highest priority", () => {
assert.equal(PILLAR_PREFERENCE[0], "dirt");
assert.ok(PILLAR_PREFERENCE.includes("cobblestone"));
});
test("skill: preconditions fail without placeable block", () => {
const ctx = { bot: mockBot({ items: [{ name: "carrot", count: 1 }] }) };
const pre = skill.preconditions(ctx);
assert.equal(pre.ok, false);
assert.equal(pre.code, "missing_material");
});
test("skill: preconditions pass with dirt", () => {
const ctx = { bot: mockBot({ items: [{ name: "dirt", count: 8 }] }) };
const pre = skill.preconditions(ctx);
assert.equal(pre.ok, true);
});
test("skill: id and timeout are sensible", () => {
assert.equal(skill.id, "survive.pillar-up");
assert.ok(skill.timeoutMs >= 30_000 && skill.timeoutMs <= 90_000);
});