diff --git a/package.json b/package.json index 57356de..d07bf00 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/runtime/coach/advice.js b/runtime/coach/advice.js index ce7ff37..47c19ec 100644 --- a/runtime/coach/advice.js +++ b/runtime/coach/advice.js @@ -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 diff --git a/runtime/coach/postmortem.js b/runtime/coach/postmortem.js index d7142b1..f26495e 100644 --- a/runtime/coach/postmortem.js +++ b/runtime/coach/postmortem.js @@ -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}`); } diff --git a/runtime/reflex.js b/runtime/reflex.js index 388ee95..50b3bae 100644 --- a/runtime/reflex.js +++ b/runtime/reflex.js @@ -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" }; } diff --git a/runtime/skills/index.js b/runtime/skills/index.js index e4961d6..0fcc65d 100644 --- a/runtime/skills/index.js +++ b/runtime/skills/index.js @@ -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); diff --git a/runtime/skills/pillar-up.js b/runtime/skills/pillar-up.js new file mode 100644 index 0000000..b7ec43a --- /dev/null +++ b/runtime/skills/pillar-up.js @@ -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 }; diff --git a/runtime/skills/pillar-up.test.js b/runtime/skills/pillar-up.test.js new file mode 100644 index 0000000..e8e4ec4 --- /dev/null +++ b/runtime/skills/pillar-up.test.js @@ -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); +});