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>
This commit is contained in:
2026-05-27 14:19:44 +03:00
co-authored by Claude Opus 4.7
parent e84148d189
commit ae8def708a
7 changed files with 409 additions and 3 deletions
+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);
});