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
+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 been at the
// same coarse position for ≥ 60s AND the runtime says we're stuck
// ("wedged", "no_reachable_target"), AND there's no immediate hostile
// (defendReflex would have handled it), AND we have a placeable block
// in inventory, dispatch survive.pillar-up to climb vertically out of
// pit terrain. This breaks the tunnel-out-fail-fall-back-to-flee loop
// observed live in the rc.2 deploy.
const WEDGED_MIN_MS = 60_000;
const WEDGED_REASONS = new Set(["no_reachable_target", "awaiting_action_cooldown", "planner_empty"]);
function wedgedEscapeSkill(ctx) {
const s = ctx.snapshot;
if (!s) return null;
if (s.closestHostile && (s.closestHostile.distance ?? Infinity) < 6) return null;
if (!WEDGED_REASONS.has(s.noProgressReason)) return null;
const lastMove = ctx.lastSignificantMoveAt ?? 0;
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" };
}