diff --git a/package.json b/package.json index f1f46c5..16626d5 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "tui:legacy": "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/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/skill-registry.test.js runtime/modes.test.js runtime/pathfinder-watchdog.test.js runtime/manifesto/needs.test.js runtime/manifesto/state.test.js runtime/goal/storyline.test.js runtime/awareness/events.test.js runtime/knowledge/knowledge.test.js runtime/llm/provider.test.js runtime/coach/postmortem.test.js runtime/coach/advice.test.js runtime/coach/reflect.test.js runtime/coach/fast-advisor.test.js runtime/coach/advisor-trigger.test.js runtime/coach/trigger-tuner.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/skill-registry.test.js runtime/modes.test.js runtime/pathfinder-watchdog.test.js runtime/manifesto/needs.test.js runtime/manifesto/state.test.js runtime/goal/storyline.test.js runtime/awareness/events.test.js runtime/awareness/wedge-detector.test.js runtime/biome-affordances.test.js runtime/knowledge/knowledge.test.js runtime/llm/provider.test.js runtime/coach/postmortem.test.js runtime/coach/advice.test.js runtime/coach/reflect.test.js runtime/coach/fast-advisor.test.js runtime/coach/advisor-trigger.test.js runtime/coach/trigger-tuner.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/awareness/wedge-detector.js b/runtime/awareness/wedge-detector.js new file mode 100644 index 0000000..efdd1fb --- /dev/null +++ b/runtime/awareness/wedge-detector.js @@ -0,0 +1,135 @@ +// Wedge detector — rolling-10min position bounding box. +// +// Sits above the skill layer (per the v0.3.1 design research). The +// reflex calls observe() with the latest position once per tick; on +// every call we evict older entries and recompute the bbox over the +// trailing window. If the bbox stays small for long enough AND a need +// has been unmet for long enough AND the same skill cycled enough +// times, isWedged() returns true and the reflex injects a `relocate` +// task that supersedes the current need until the bot has displaced +// ≥200 blocks from the wedge centre. +// +// This is intentionally NOT inside any single skill — every skill +// resets its own counters when re-entered, so per-skill stuck checks +// can't break a multi-skill cycle. + +const WINDOW_MS = 10 * 60 * 1000; // 10 minutes +const MIN_BBOX_FOR_WEDGE = 50; // <50 blocks max-dim → wedge +const MIN_UNMET_NEED_MS = 5 * 60 * 1000; // need unsatisfied for 5+ min +const MIN_SKILL_CYCLES = 3; // same need's skill restarted ≥3x + +let _samples = []; // { t, x, z } +let _activeRelocation = null; // { startedAt, fromCenter:{x,z}, headingName } +let _needStartedAt = new Map(); // needId → t when first detected +let _lastNeedId = null; + +export function _resetForTest() { + _samples = []; + _activeRelocation = null; + _needStartedAt = new Map(); + _lastNeedId = null; +} + +/** + * observe({ x, z, now, activeNeedId, recentSkillIds }) + * + * Lightweight: called every reflex tick (~1-2s). Updates sliding + * window and need-duration accounting. + */ +export function observe({ x, z, now = Date.now(), activeNeedId = null, recentSkillIds = [] } = {}) { + if (typeof x !== "number" || typeof z !== "number") return; + _samples.push({ t: now, x, z }); + // evict + const cutoff = now - WINDOW_MS; + while (_samples.length && _samples[0].t < cutoff) _samples.shift(); + + // Track need duration + if (activeNeedId !== _lastNeedId) { + _lastNeedId = activeNeedId; + if (activeNeedId && !_needStartedAt.has(activeNeedId)) { + _needStartedAt.set(activeNeedId, now); + } + } + if (activeNeedId && !_needStartedAt.has(activeNeedId)) { + _needStartedAt.set(activeNeedId, now); + } + + // If a relocation is in progress, check whether we've travelled + // far enough to clear it. + if (_activeRelocation && typeof _activeRelocation.fromCenter?.x === "number") { + const dx = x - _activeRelocation.fromCenter.x; + const dz = z - _activeRelocation.fromCenter.z; + if (Math.hypot(dx, dz) >= 200) { + _activeRelocation = null; + // Reset all need-duration timers so the post-relocation env + // gets a fair shot at being labelled satisfied / unsatisfied. + _needStartedAt = new Map(); + } + } +} + +/** + * isWedged({ activeNeedId, recentSkillIds, now }) + * → { wedged: true, bboxDim, needAgeMs, skillCycles, centerX, centerZ } | { wedged: false } + */ +export function isWedged({ activeNeedId, recentSkillIds = [], now = Date.now() } = {}) { + if (_activeRelocation) return { wedged: false, reason: "relocating" }; + if (_samples.length < 8) return { wedged: false, reason: "insufficient_samples" }; + + let xs = Infinity, xb = -Infinity, zs = Infinity, zb = -Infinity, cx = 0, cz = 0; + for (const s of _samples) { + if (s.x < xs) xs = s.x; + if (s.x > xb) xb = s.x; + if (s.z < zs) zs = s.z; + if (s.z > zb) zb = s.z; + cx += s.x; cz += s.z; + } + cx /= _samples.length; cz /= _samples.length; + const bboxDim = Math.max(xb - xs, zb - zs); + if (bboxDim >= MIN_BBOX_FOR_WEDGE) return { wedged: false, reason: "bbox_ok", bboxDim }; + + const needAgeMs = (activeNeedId && _needStartedAt.has(activeNeedId)) + ? now - _needStartedAt.get(activeNeedId) + : 0; + if (needAgeMs < MIN_UNMET_NEED_MS) return { wedged: false, reason: "need_recent", needAgeMs, bboxDim }; + + // Skill cycle count: how many distinct dispatches of the same skill + // appear in the recent rolling window. + const skillCycles = countCycles(recentSkillIds); + if (skillCycles < MIN_SKILL_CYCLES) return { wedged: false, reason: "few_cycles", skillCycles, bboxDim }; + + return { wedged: true, bboxDim, needAgeMs, skillCycles, centerX: cx, centerZ: cz }; +} + +/** + * markRelocationStarted({ x, z, heading }) + * The reflex calls this when it dispatches village.relocate. While + * a relocation is in flight, isWedged() returns false (relocating) + * so we don't fire a SECOND relocation on top. + */ +export function markRelocationStarted({ x, z, heading } = {}) { + _activeRelocation = { + startedAt: Date.now(), + fromCenter: { x, z }, + headingName: heading?.name ?? "?", + }; +} + +export function activeRelocation() { return _activeRelocation; } + +function countCycles(ids) { + if (!Array.isArray(ids) || ids.length === 0) return 0; + // A "cycle" = a transition like A → B → A. Count those. + let cycles = 0; + for (let i = 2; i < ids.length; i++) { + if (ids[i] === ids[i - 2] && ids[i] !== ids[i - 1]) cycles++; + if (ids[i] === ids[i - 1] && ids[i - 1] === ids[i - 2]) cycles++; + } + return cycles; +} + +// Test exports +export const __testing = { + WINDOW_MS, MIN_BBOX_FOR_WEDGE, MIN_UNMET_NEED_MS, MIN_SKILL_CYCLES, + countCycles, +}; diff --git a/runtime/awareness/wedge-detector.test.js b/runtime/awareness/wedge-detector.test.js new file mode 100644 index 0000000..c601f94 --- /dev/null +++ b/runtime/awareness/wedge-detector.test.js @@ -0,0 +1,104 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { + observe, + isWedged, + markRelocationStarted, + activeRelocation, + _resetForTest, + __testing, +} from "./wedge-detector.js"; + +const { WINDOW_MS, MIN_BBOX_FOR_WEDGE, MIN_UNMET_NEED_MS, MIN_SKILL_CYCLES, countCycles } = __testing; + +test("countCycles: empty / short → 0", () => { + assert.equal(countCycles([]), 0); + assert.equal(countCycles(["a"]), 0); + assert.equal(countCycles(["a", "b"]), 0); +}); + +test("countCycles: A→B→A counts as cycle", () => { + assert.equal(countCycles(["a", "b", "a"]), 1); + assert.equal(countCycles(["a", "b", "a", "b", "a"]), 3); +}); + +test("countCycles: same skill 3+ times in a row also counts", () => { + assert.equal(countCycles(["a", "a", "a"]), 1); + assert.equal(countCycles(["a", "a", "a", "a"]), 2); +}); + +test("isWedged: insufficient samples → not wedged", () => { + _resetForTest(); + const r = isWedged({ activeNeedId: "food" }); + assert.equal(r.wedged, false); + assert.equal(r.reason, "insufficient_samples"); +}); + +test("isWedged: large bbox → not wedged", () => { + _resetForTest(); + const t0 = 1_000_000_000_000; + // scatter across 200 blocks + for (let i = 0; i < 12; i++) { + observe({ x: i * 30, z: i * 25, now: t0 + i * 1000, activeNeedId: "food", recentSkillIds: [] }); + } + const r = isWedged({ activeNeedId: "food", recentSkillIds: ["a", "b", "a", "b"], now: t0 + 13_000 }); + assert.equal(r.wedged, false); + assert.equal(r.reason, "bbox_ok"); +}); + +test("isWedged: tight bbox + old need + cycles → WEDGED", () => { + _resetForTest(); + const t0 = 1_000_000_000_000; + // 12 samples within a 30-block bbox, spread across ~9 min so they + // stay inside the 10-min sliding window. + for (let i = 0; i < 12; i++) { + observe({ + x: 500 + (i % 4) * 8, + z: 500 + Math.floor(i / 4) * 8, + now: t0 + i * 45_000, + activeNeedId: "food", + recentSkillIds: ["acquire-food", "explore.far", "acquire-food", "explore.far", "acquire-food"], + }); + } + const r = isWedged({ + activeNeedId: "food", + recentSkillIds: ["acquire-food", "explore.far", "acquire-food", "explore.far", "acquire-food", "explore.far"], + now: t0 + 12 * 45_000, + }); + assert.equal(r.wedged, true, `expected wedged, got ${JSON.stringify(r)}`); + assert.ok(r.bboxDim < MIN_BBOX_FOR_WEDGE); + assert.ok(r.needAgeMs >= MIN_UNMET_NEED_MS); + assert.ok(r.skillCycles >= MIN_SKILL_CYCLES); +}); + +test("isWedged: recent need (under threshold) → not wedged", () => { + _resetForTest(); + const t0 = 1_000_000_000_000; + for (let i = 0; i < 12; i++) { + observe({ x: 500, z: 500, now: t0 + i * 10_000, activeNeedId: "food" }); + } + const r = isWedged({ activeNeedId: "food", recentSkillIds: ["a", "b", "a", "b"], now: t0 + 60_000 }); + assert.equal(r.wedged, false); +}); + +test("markRelocationStarted blocks subsequent wedge for 200b", () => { + _resetForTest(); + const t0 = 1_000_000_000_000; + markRelocationStarted({ x: 500, z: 500, heading: { name: "N" } }); + assert.ok(activeRelocation()); + // Stay tight bbox after relocation start + for (let i = 0; i < 12; i++) { + observe({ x: 510, z: 510, now: t0 + i * 60_000, activeNeedId: "food" }); + } + const r = isWedged({ activeNeedId: "food", recentSkillIds: ["a", "b", "a", "b"] }); + assert.equal(r.wedged, false); + assert.equal(r.reason, "relocating"); +}); + +test("relocation clears after travelling ≥200 blocks", () => { + _resetForTest(); + markRelocationStarted({ x: 0, z: 0, heading: { name: "N" } }); + observe({ x: 250, z: 0, activeNeedId: "food" }); + assert.equal(activeRelocation(), null, "relocation cleared after 250b displacement"); +}); diff --git a/runtime/biome-affordances.js b/runtime/biome-affordances.js new file mode 100644 index 0000000..fa8ab1a --- /dev/null +++ b/runtime/biome-affordances.js @@ -0,0 +1,150 @@ +// Static knowledge: what each Minecraft biome reliably affords the bot. +// +// Why: pre-v0.3.1 the bot's "find food" skill scanned a 32-block radius +// for passive mobs and gave up. In a desert/ocean/snowy biome there's +// nothing to scan — the bot looped local searches for hours. This +// table lets skills check the *current* biome and pick a strategy +// before reaching for `pathfinder` blindly. +// +// Coverage is informed by vanilla mob spawn rules +// (https://minecraft.fandom.com/wiki/Spawn) — not exhaustive but +// covers the biomes the bot is realistically going to land in on +// 1.21.4 overworld spawn. +// +// Each entry is conservative: a `true` is "the bot has a real shot at +// finding this here", a `false` is "almost never bother scanning". + +/** + * Affordance shape: + * has_passive_mobs — cows / pigs / chickens / sheep spawn here + * has_trees — oak/birch/spruce/jungle logs grow naturally + * has_water — open surface water that can be fished + * has_crops — natural berries / pumpkins / melons / sweet_berry_bush + * livable — bot can stand on the surface (not in lava, not + * perpetually underwater) + */ +const DEFAULT = Object.freeze({ + has_passive_mobs: true, + has_trees: false, + has_water: false, + has_crops: false, + livable: true, +}); + +const BIOMES = Object.freeze({ + // Forest family — trees + cows/pigs/chickens + forest: { has_passive_mobs: true, has_trees: true, has_water: false, has_crops: false, livable: true }, + birch_forest: { has_passive_mobs: true, has_trees: true, has_water: false, has_crops: false, livable: true }, + dark_forest: { has_passive_mobs: true, has_trees: true, has_water: false, has_crops: true, livable: true }, + old_growth_birch_forest: { has_passive_mobs: true, has_trees: true, has_water: false, has_crops: false, livable: true }, + old_growth_pine_taiga: { has_passive_mobs: true, has_trees: true, has_water: false, has_crops: true, livable: true }, + old_growth_spruce_taiga: { has_passive_mobs: true, has_trees: true, has_water: false, has_crops: true, livable: true }, + taiga: { has_passive_mobs: true, has_trees: true, has_water: false, has_crops: true, livable: true }, + snowy_taiga: { has_passive_mobs: true, has_trees: true, has_water: false, has_crops: false, livable: true }, + flower_forest: { has_passive_mobs: true, has_trees: true, has_water: false, has_crops: false, livable: true }, + pale_garden: { has_passive_mobs: false, has_trees: true, has_water: false, has_crops: false, livable: true }, + + // Plains family — open spawn, lots of mobs, scattered trees + plains: { has_passive_mobs: true, has_trees: true, has_water: false, has_crops: false, livable: true }, + sunflower_plains: { has_passive_mobs: true, has_trees: true, has_water: false, has_crops: false, livable: true }, + meadow: { has_passive_mobs: true, has_trees: false, has_water: false, has_crops: false, livable: true }, + cherry_grove: { has_passive_mobs: true, has_trees: true, has_water: false, has_crops: false, livable: true }, + + // Savanna / jungle — passive mobs + trees + savanna: { has_passive_mobs: true, has_trees: true, has_water: false, has_crops: false, livable: true }, + savanna_plateau: { has_passive_mobs: true, has_trees: true, has_water: false, has_crops: false, livable: true }, + windswept_savanna: { has_passive_mobs: true, has_trees: true, has_water: false, has_crops: false, livable: true }, + jungle: { has_passive_mobs: true, has_trees: true, has_water: false, has_crops: true, livable: true }, + sparse_jungle: { has_passive_mobs: true, has_trees: true, has_water: false, has_crops: false, livable: true }, + bamboo_jungle: { has_passive_mobs: true, has_trees: true, has_water: false, has_crops: false, livable: true }, + + // Swamp / mangrove — has water + berries + swamp: { has_passive_mobs: true, has_trees: true, has_water: true, has_crops: false, livable: true }, + mangrove_swamp: { has_passive_mobs: false, has_trees: true, has_water: true, has_crops: false, livable: true }, + + // Desert / badlands — NO passive mobs, no trees, no surface water. + // Action plan when the bot is here: walk a cardinal until biome + // boundary is detected (sample bot.world.getBiome at radius 64). + desert: { has_passive_mobs: false, has_trees: false, has_water: false, has_crops: false, livable: true }, + badlands: { has_passive_mobs: false, has_trees: false, has_water: false, has_crops: false, livable: true }, + eroded_badlands: { has_passive_mobs: false, has_trees: false, has_water: false, has_crops: false, livable: true }, + wooded_badlands: { has_passive_mobs: false, has_trees: true, has_water: false, has_crops: false, livable: true }, + + // Snowy biomes — no passive mobs (rabbits sometimes), strangled trees + snowy_plains: { has_passive_mobs: true, has_trees: false, has_water: false, has_crops: false, livable: true }, + ice_spikes: { has_passive_mobs: false, has_trees: false, has_water: false, has_crops: false, livable: true }, + frozen_river: { has_passive_mobs: false, has_trees: false, has_water: true, has_crops: false, livable: true }, + frozen_ocean: { has_passive_mobs: false, has_trees: false, has_water: true, has_crops: false, livable: false }, + deep_frozen_ocean: { has_passive_mobs: false, has_trees: false, has_water: true, has_crops: false, livable: false }, + + // Mountains — sparse trees, goats + stony_peaks: { has_passive_mobs: true, has_trees: false, has_water: false, has_crops: false, livable: true }, + jagged_peaks: { has_passive_mobs: true, has_trees: false, has_water: false, has_crops: false, livable: true }, + frozen_peaks: { has_passive_mobs: true, has_trees: false, has_water: false, has_crops: false, livable: true }, + snowy_slopes: { has_passive_mobs: true, has_trees: false, has_water: false, has_crops: false, livable: true }, + grove: { has_passive_mobs: true, has_trees: true, has_water: false, has_crops: false, livable: true }, + windswept_hills: { has_passive_mobs: true, has_trees: true, has_water: false, has_crops: false, livable: true }, + windswept_gravelly_hills: { has_passive_mobs: true, has_trees: false, has_water: false, has_crops: false, livable: true }, + windswept_forest: { has_passive_mobs: true, has_trees: true, has_water: false, has_crops: false, livable: true }, + + // Beaches / ocean — passive mobs scarce, water everywhere + beach: { has_passive_mobs: false, has_trees: false, has_water: true, has_crops: false, livable: true }, + stony_shore: { has_passive_mobs: false, has_trees: false, has_water: true, has_crops: false, livable: true }, + snowy_beach: { has_passive_mobs: false, has_trees: false, has_water: true, has_crops: false, livable: true }, + ocean: { has_passive_mobs: false, has_trees: false, has_water: true, has_crops: false, livable: false }, + cold_ocean: { has_passive_mobs: false, has_trees: false, has_water: true, has_crops: false, livable: false }, + deep_ocean: { has_passive_mobs: false, has_trees: false, has_water: true, has_crops: false, livable: false }, + deep_cold_ocean: { has_passive_mobs: false, has_trees: false, has_water: true, has_crops: false, livable: false }, + lukewarm_ocean: { has_passive_mobs: false, has_trees: false, has_water: true, has_crops: false, livable: false }, + deep_lukewarm_ocean: { has_passive_mobs: false, has_trees: false, has_water: true, has_crops: false, livable: false }, + warm_ocean: { has_passive_mobs: false, has_trees: false, has_water: true, has_crops: false, livable: false }, + river: { has_passive_mobs: false, has_trees: false, has_water: true, has_crops: false, livable: true }, + + // Mushroom — mooshrooms only, no other passive mobs but they ARE food + mushroom_fields: { has_passive_mobs: true, has_trees: false, has_water: false, has_crops: false, livable: true }, + + // Caves / unsupported dimensions — bot should leave + dripstone_caves: { has_passive_mobs: false, has_trees: false, has_water: false, has_crops: false, livable: false }, + lush_caves: { has_passive_mobs: false, has_trees: false, has_water: false, has_crops: true, livable: false }, + deep_dark: { has_passive_mobs: false, has_trees: false, has_water: false, has_crops: false, livable: false }, +}); + +/** + * affordancesFor(biomeName) → affordance object + * + * Unknown / null / undefined names return the optimistic DEFAULT so + * skills don't get crippled when a new 1.x biome shows up; they just + * fall back to the existing local-scan behaviour. + */ +export function affordancesFor(biomeName) { + if (!biomeName || typeof biomeName !== "string") return DEFAULT; + return BIOMES[biomeName] ?? DEFAULT; +} + +export function hasPassiveMobs(biomeName) { + return affordancesFor(biomeName).has_passive_mobs; +} +export function hasTrees(biomeName) { + return affordancesFor(biomeName).has_trees; +} +export function hasWater(biomeName) { + return affordancesFor(biomeName).has_water; +} +export function isLivable(biomeName) { + return affordancesFor(biomeName).livable; +} + +// True if this biome is barren enough that the bot's priority should +// be "leave biome" rather than "search local". +export function isBarren(biomeName) { + const a = affordancesFor(biomeName); + return !a.has_passive_mobs && !a.has_trees && !a.has_crops; +} + +// True if the biome can't be stood on (deep ocean, caves at y=Y). +export function isUnlivable(biomeName) { + return !affordancesFor(biomeName).livable; +} + +// Test exports +export const __testing = { BIOMES, DEFAULT }; diff --git a/runtime/biome-affordances.test.js b/runtime/biome-affordances.test.js new file mode 100644 index 0000000..e6dde51 --- /dev/null +++ b/runtime/biome-affordances.test.js @@ -0,0 +1,66 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { + affordancesFor, + hasPassiveMobs, + hasTrees, + hasWater, + isLivable, + isBarren, + isUnlivable, + __testing, +} from "./biome-affordances.js"; + +test("plains: full affordances (mobs + scattered trees)", () => { + const a = affordancesFor("plains"); + assert.equal(a.has_passive_mobs, true); + assert.equal(a.has_trees, true); + assert.equal(a.livable, true); +}); + +test("desert: barren (no mobs, no trees, no water)", () => { + assert.equal(hasPassiveMobs("desert"), false); + assert.equal(hasTrees("desert"), false); + assert.equal(hasWater("desert"), false); + assert.equal(isBarren("desert"), true); + assert.equal(isLivable("desert"), true, "desert is walkable, just empty"); +}); + +test("ocean / deep_ocean: unlivable + has water", () => { + assert.equal(isUnlivable("ocean"), true); + assert.equal(isUnlivable("deep_ocean"), true); + assert.equal(hasWater("ocean"), true); + assert.equal(hasPassiveMobs("ocean"), false); +}); + +test("mushroom_fields: passive mobs (mooshroom) even though no other animals", () => { + assert.equal(hasPassiveMobs("mushroom_fields"), true); + assert.equal(isBarren("mushroom_fields"), false); +}); + +test("forest variants: trees + mobs", () => { + for (const b of ["forest", "birch_forest", "dark_forest", "taiga", "snowy_taiga", "jungle", "swamp"]) { + assert.equal(hasTrees(b), true, `${b} should have trees`); + assert.equal(hasPassiveMobs(b), true, `${b} should have passive mobs`); + } +}); + +test("badlands variants: no mobs, no trees (except wooded_badlands)", () => { + assert.equal(hasPassiveMobs("badlands"), false); + assert.equal(hasTrees("badlands"), false); + assert.equal(hasTrees("wooded_badlands"), true, "wooded variant has trees"); + assert.equal(isBarren("badlands"), true); +}); + +test("unknown biome: optimistic defaults (don't cripple skills)", () => { + const a = affordancesFor("not_a_real_biome_2026"); + assert.equal(a.has_passive_mobs, true); + assert.equal(a.livable, true); +}); + +test("null / undefined: optimistic defaults", () => { + assert.deepEqual(affordancesFor(null), __testing.DEFAULT); + assert.deepEqual(affordancesFor(undefined), __testing.DEFAULT); + assert.deepEqual(affordancesFor(42), __testing.DEFAULT); +}); diff --git a/runtime/bot.js b/runtime/bot.js index ce06980..a48fee7 100644 --- a/runtime/bot.js +++ b/runtime/bot.js @@ -80,6 +80,7 @@ const ESCALATE_AFTER_NOOPS = 20; const ESCALATION_COOLDOWN_MS = 10 * 60 * 1000; let bot = null; +let botSpawnedAt = 0; let pathWatchdog = null; let awarenessState = null; let reflexPaused = false; @@ -677,6 +678,7 @@ function connect() { reflexCtx.bot = bot; bot.once("spawn", () => { + botSpawnedAt = Date.now(); info("mc", `spawned at ${JSON.stringify(bot.entity.position)}`); appendDiary(`spawned at ${bot.entity.position.x.toFixed(0)},${bot.entity.position.y.toFixed(0)},${bot.entity.position.z.toFixed(0)}`); ipc?.broadcast(EVENT_TYPES.STATUS, buildSnapshot(bot)); @@ -845,6 +847,9 @@ function tick() { } const curriculumEarly = nextCurriculumMilestone(lastSnapshot); lastSnapshot.curriculum = curriculumEarly; + // Time since spawn — used by storyline orient_self to fall through + // when bot is in a barren biome that never produces "saw blocks". + lastSnapshot._sessionMs = botSpawnedAt ? Date.now() - botSpawnedAt : 0; // Storyline current step — surfaced in snapshot so chatter and // other observers can react to step transitions without // re-importing the picker. diff --git a/runtime/coach/advisor-trigger.js b/runtime/coach/advisor-trigger.js index e8caf65..5e1cbed 100644 --- a/runtime/coach/advisor-trigger.js +++ b/runtime/coach/advisor-trigger.js @@ -34,13 +34,27 @@ const PREEMPT_WINDOW_MS = 30_000; const EMERGENCY_HP = 6; const EMERGENCY_HOSTILE_DIST = 8; const EMERGENCY_COOLDOWN_MS = 20_000; +// LLM outage backoff — if 3 consecutive advise() calls return +// http_400 / network_error, suppress further calls for 10 minutes. +const PROVIDER_OUTAGE_FAILS = 3; +const PROVIDER_OUTAGE_BACKOFF_MS = 10 * 60 * 1000; let _lastTriggerAt = 0; let _inFlight = false; +let _consecutiveFails = 0; +let _providerOutageUntil = 0; export function _resetForTest() { _lastTriggerAt = 0; _inFlight = false; + _consecutiveFails = 0; + _providerOutageUntil = 0; +} + +function isProviderError(code) { + return code === "network_error" + || code === "timeout" + || (typeof code === "string" && code.startsWith("http_")); } export function getTriggerState() { @@ -63,6 +77,9 @@ export function tickAdvisor(ctx, { plannedSkillId } = {}) { if (_inFlight) return { fired: false, reason: "in_flight" }; const now = Date.now(); + if (_providerOutageUntil > now) { + return { fired: false, reason: "provider_outage", retryAt: _providerOutageUntil }; + } // Drop a recommendation that's already aged out. if (ctx.advisorRecommendation && now - ctx.advisorRecommendation.at > RECOMMENDATION_TTL_MS) { @@ -142,6 +159,15 @@ export function tickAdvisor(ctx, { plannedSkillId } = {}) { info("advisor-trigger", `recommendation: ${result.action} (${result.latencyMs}ms)`); } else if (!result.ok) { warn("advisor-trigger", `advise failed: ${result.code} (${result.detail})`); + if (isProviderError(result.code)) { + _consecutiveFails += 1; + if (_consecutiveFails >= PROVIDER_OUTAGE_FAILS) { + _providerOutageUntil = Date.now() + PROVIDER_OUTAGE_BACKOFF_MS; + warn("advisor-trigger", `LLM provider outage (${_consecutiveFails} fails in a row); backing off ${Math.round(PROVIDER_OUTAGE_BACKOFF_MS / 60000)}min`); + } + } + } else { + _consecutiveFails = 0; } }) .catch((e) => { diff --git a/runtime/goal/storyline.js b/runtime/goal/storyline.js index ca70a8e..8eaf55a 100644 --- a/runtime/goal/storyline.js +++ b/runtime/goal/storyline.js @@ -113,16 +113,23 @@ export const STORYLINE = Object.freeze([ narration_ru: "Где я? Осмотрюсь и оценю место.", completed(snap) { if (!snap?.connected) return false; - // Considered done once HP is full and we've moved a bit (out of - // spawn confusion) or we know nearby blocks include something tangible. const hp = snap.health ?? 20; - const moved = snap._sessionMs ? snap._sessionMs > 15_000 : true; + // Two completion paths: (a) classic — full HP + saw tangible + // blocks within 16 blocks. (b) timeout — HP=full + session + // >120s. Path (b) exists because in desert/ocean biomes the + // scan radius might never see logs/stone/crops/beds, and we + // were getting stuck on step 1 for hours. + if (hp < 18) return false; const sawBlocks = (snap.nearbyBlocks?.logs ?? 0) + (snap.nearbyBlocks?.stone ?? 0) + (snap.nearbyBlocks?.crops ?? 0) + (snap.nearbyBlocks?.beds ?? 0) > 0; - return hp >= 18 && moved && sawBlocks; + if (sawBlocks) return true; + // Fallback: settled for long enough → call orient done and let + // later steps drive forward into the biome. + const sessionMs = snap._sessionMs ?? 0; + return sessionMs > 120_000; }, suggestSkill(snap) { // Look around — wander a bit to get a snapshot of what's nearby. @@ -141,7 +148,10 @@ export const STORYLINE = Object.freeze([ suggestSkill(snap) { const trees = snap?.nearbyBlocks?.logs ?? 0; if (trees > 0) return { skillId: "gather.logs" }; - // No tree in sight — scout further. + // No tree in sight — scout further. In a biome with no trees + // (desert, ocean) the bot must commit to a long heading; the + // curriculum's wedge detector (v0.3.1+) elevates this to + // village.relocate after a few cycles. return { skillId: "explore.far", args: { searchFor: "logs" } }; }, emergencyPause, @@ -196,7 +206,16 @@ export const STORYLINE = Object.freeze([ return countAny(snap?.inventory, FOOD_ITEMS) >= 2; }, suggestSkill(snap) { - return { skillId: "survive.acquire-food" }; + // Two-tier strategy: + // - If a passive food mob is visible nearby (≤24 blocks in + // snapshot), kill it locally with acquire-food. + // - Otherwise scout-food does long-range biome-aware search. + // It commits to a cardinal for ~200 blocks, rescans, and + // on biome boundary detection heads toward food-capable + // terrain. + const hasPassiveNearby = (snap?.nearbyEntities?.passives?.length ?? 0) > 0; + if (hasPassiveNearby) return { skillId: "survive.acquire-food" }; + return { skillId: "survive.scout-food" }; }, emergencyPause, }, diff --git a/runtime/goal/storyline.test.js b/runtime/goal/storyline.test.js index 24c3c40..b436b23 100644 --- a/runtime/goal/storyline.test.js +++ b/runtime/goal/storyline.test.js @@ -111,6 +111,16 @@ test("pickCurrentStep: fresh spawn → first non-completed step", () => { assert.equal(r.index, 0); }); +test("orient_self: barren biome timeout fallback completes step after 120s", () => { + const n = getStep("orient_self"); + // path (a): full HP + no visible blocks + short session → NOT done (would block). + assert.equal(n.completed(snap({ _sessionMs: 30_000, nearbyBlocks: {} })), false); + // path (b): full HP + no visible blocks + long session → done (timeout fallback). + assert.equal(n.completed(snap({ _sessionMs: 150_000, nearbyBlocks: {} })), true); + // always: low HP → not done + assert.equal(n.completed(snap({ health: 8, _sessionMs: 150_000 })), false); +}); + test("pickCurrentStep: bot with 8+ logs → first_wood done, picks crafting_basics", () => { _resetForTest(); const r = pickCurrentStep(snap({ diff --git a/runtime/reflex.js b/runtime/reflex.js index b8c4c39..bea47f2 100644 --- a/runtime/reflex.js +++ b/runtime/reflex.js @@ -30,6 +30,7 @@ import { tickAdvisor, consumeFreshRecommendation } from "./coach/advisor-trigger import { markRecommendationApplied, markRecommendationOutcome } from "./knowledge/index.js"; import { pickActiveNeed } from "./manifesto/state.js"; import { pickCurrentStep } from "./goal/state.js"; +import { observe as observeWedge, isWedged } from "./awareness/wedge-detector.js"; import { situationHash } from "./scenario-memory.js"; import { tickModes } from "./modes.js"; @@ -459,6 +460,29 @@ function curriculumReflex(ctx) { } const manifestoSkillId = activeNeed?.skillId ?? null; + // v0.3.1 — wedge detector. Feeds position into rolling-bbox tracker. + // When bot has been stuck in a <50-block bbox for 10 minutes with + // the same need cycling its skills, returns wedged=true and we + // short-circuit to village.relocate which walks 300 blocks in a + // fresh cardinal. Tests pass ctx.disableWedge=true to skip. + if (!ctx.disableWedge && s.position) { + observeWedge({ + x: s.position.x, z: s.position.z, + activeNeedId: activeNeed?.need?.id ?? null, + recentSkillIds: ctx.recentSkillIds ?? [], + }); + const wedge = isWedged({ + activeNeedId: activeNeed?.need?.id ?? null, + recentSkillIds: ctx.recentSkillIds ?? [], + }); + if (wedge.wedged) { + ctx.lastCurriculumAt = Date.now(); + info(REFLEX_LOG, `wedged: bbox=${Math.round(wedge.bboxDim)}b need=${activeNeed?.need?.id} for ${Math.round(wedge.needAgeMs / 1000)}s → village.relocate`); + ctx.dispatch(() => runSkill("village.relocate", ctx), "village.relocate", {}); + return { action: "dispatched", kind: "wedge-relocate", label: "village.relocate" }; + } + } + // v0.3.1 — storyline: the canonical Minecraft survival quest. Gives // the bot a concrete, narratable next-action ("collect 8 logs", // "place crafting table"). Storyline yields to manifesto on L0 diff --git a/runtime/reflex.test.js b/runtime/reflex.test.js index 321cc65..2102791 100644 --- a/runtime/reflex.test.js +++ b/runtime/reflex.test.js @@ -52,6 +52,7 @@ function makeCtx({ disableAdvisor = true, // advisor-trigger fires real async LLM calls, // tested directly in advisor-trigger.test.js. disableStoryline = true, // storyline tested in goal/storyline.test.js + disableWedge = true, // wedge tested in awareness/wedge-detector.test.js } = {}) { const dispatches = []; const ctx = { @@ -67,6 +68,7 @@ function makeCtx({ disableManifesto, disableAdvisor, disableStoryline, + disableWedge, dispatch(fn, label, opts = {}) { dispatches.push({ fn, label, opts }); }, diff --git a/runtime/skills/escape-pit-safe.js b/runtime/skills/escape-pit-safe.js new file mode 100644 index 0000000..8426f4d --- /dev/null +++ b/runtime/skills/escape-pit-safe.js @@ -0,0 +1,159 @@ +// recovery.escape-pit-safe — multi-strategy escape from a hole / pit. +// +// Why this exists alongside recovery.tunnel-out and survive.pillar-up: +// +// tunnel-out tries to dig sideways through walls — fails on +// unbreakable terrain or when there's no clear horizontal exit. +// pillar-up places blocks under the bot and jumps — fails when +// there's a ceiling block above the column. +// +// Both fail silently in a deep cave or 2×2 hole with a ceiling. +// escape-pit-safe surveys options first, then commits: +// +// 1. Scan 4 cardinals at head height + foot height. Pick the one +// with the closest open path to surface (defined as: column +// with sky visibility above OR ≥3 air blocks horizontal followed +// by stairs / slope up). +// 2. If a clear horizontal path exists → recovery.tunnel-out toward it. +// 3. If no horizontal path BUT ceiling is open above us → pillar-up. +// 4. If both blocked → return "stuck" and let the LLM-advisor flag a +// genuine improvement (e.g. "need water-bucket-MLG", "need to +// mine through stone"). + +import { info, warn } from "../log.js"; +import { runSkill } from "./index.js"; + +const SCAN_RADIUS = 6; +const HEAD_OFFSET_Y = 1; + +function blockNameAt(bot, x, y, z) { + try { + const b = bot.blockAt?.({ x: Math.floor(x), y: Math.floor(y), z: Math.floor(z) }); + return b?.name ?? null; + } catch { return null; } +} + +function isAir(name) { return name === "air" || name === "cave_air" || name === "void_air"; } +function isWater(name) { return name === "water" || name === "flowing_water"; } +function isLava(name) { return name === "lava" || name === "flowing_lava"; } +function isPassable(name) { return isAir(name) || (name && /carpet|button|torch|grass$/.test(name)); } + +// Look up to 32 blocks straight up from (x,y,z). Return distance to first +// non-air block, or Infinity if open all the way (this is a sky-visible +// column we could pillar-up out of). +function ceilingDistance(bot, x, y, z) { + for (let dy = HEAD_OFFSET_Y + 1; dy <= 32; dy++) { + const n = blockNameAt(bot, x, y + dy, z); + if (!n) return dy; // unloaded chunk = no info; treat conservatively + if (!isAir(n)) return dy; + } + return Infinity; +} + +// Scan SCAN_RADIUS blocks in each cardinal at head height. Return +// summary per direction: open-block count, lava/water hits, first +// non-air block name. +function scanCardinals(bot) { + const pos = bot?.entity?.position; + if (!pos) return []; + const head = pos.offset?.(0, HEAD_OFFSET_Y, 0) ?? { x: pos.x, y: pos.y + HEAD_OFFSET_Y, z: pos.z }; + const dirs = [ + { name: "N", dx: 0, dz: -1 }, + { name: "E", dx: 1, dz: 0 }, + { name: "S", dx: 0, dz: 1 }, + { name: "W", dx: -1, dz: 0 }, + ]; + return dirs.map((d) => { + let openBlocks = 0; + let lavaAt = -1; + let waterAt = -1; + let firstBlock = null; + for (let r = 1; r <= SCAN_RADIUS; r++) { + const x = head.x + d.dx * r; + const z = head.z + d.dz * r; + const n = blockNameAt(bot, x, head.y, z); + if (isLava(n) && lavaAt < 0) lavaAt = r; + if (isWater(n) && waterAt < 0) waterAt = r; + if (isPassable(n)) { + openBlocks++; + if (firstBlock === null) firstBlock = "(air)"; + } else { + if (firstBlock === null) firstBlock = n ?? "(unknown)"; + break; + } + } + return { ...d, openBlocks, lavaAt, waterAt, firstBlock }; + }); +} + +export const skill = Object.freeze({ + id: "recovery.escape-pit-safe", + title: "Escape a pit — survey directions and pick the safest exit", + timeoutMs: 90_000, + preconditions(ctx) { + if (!ctx?.bot?.entity?.position) return { ok: false, code: "no_bot", detail: "bot missing" }; + return { ok: true }; + }, + async execute(ctx) { + const bot = ctx.bot; + const pos = bot.entity.position; + const dirs = scanCardinals(bot); + const ceiling = ceilingDistance(bot, pos.x, pos.y, pos.z); + + info( + "action", + `escape-pit-safe: cardinals=${dirs.map((d) => `${d.name}:${d.openBlocks}/${d.firstBlock}`).join(" ")} ceiling=${ceiling === Infinity ? "open" : ceiling + "b"}`, + ); + + // 1. Choose the direction with most open blocks (≥3) and no lava. + const horizontal = dirs + .filter((d) => d.openBlocks >= 3 && d.lavaAt < 0) + .sort((a, b) => b.openBlocks - a.openBlocks)[0]; + + if (horizontal) { + info("action", `escape-pit-safe: walking out via ${horizontal.name} (${horizontal.openBlocks}b clear)`); + // Delegate to wander step or simple controlled walk. Easier: + // invoke recovery.tunnel-out with a hint of the chosen direction. + const res = await runSkill("recovery.tunnel-out", ctx, { preferredDir: horizontal.name }); + return { + ok: !!res?.ok, + code: res?.code ?? (res?.ok ? "done" : "tunnel_failed"), + detail: { strategy: "horizontal_walk", direction: horizontal.name, tunnelResult: res?.detail }, + worldDelta: res?.worldDelta ?? { strategy: "horizontal_walk", direction: horizontal.name }, + }; + } + + // 2. No horizontal exit — try pillar-up only if ceiling is open + // for the bot's height (≥3 blocks: head + 1 build + 1 ceiling slack). + if (ceiling >= 4) { + info("action", `escape-pit-safe: ceiling clear (${ceiling}b), trying pillar-up`); + const res = await runSkill("survive.pillar-up", ctx); + return { + ok: !!res?.ok, + code: res?.code ?? (res?.ok ? "done" : "pillar_failed"), + detail: { strategy: "pillar_up", ceiling, pillarResult: res?.detail }, + worldDelta: res?.worldDelta ?? { strategy: "pillar_up" }, + }; + } + + // 3. Both blocked — surrender. The LLM-advisor will see this in + // recent dispatches and can suggest village.relocate or flag a + // new skill request. + warn("action", `escape-pit-safe: no viable strategy (horizontal blocked + ceiling ${ceiling}b)`); + return { + ok: false, + code: "no_strategy", + detail: { horizontal: dirs.map((d) => ({ d: d.name, open: d.openBlocks, lava: d.lavaAt })), ceiling }, + worldDelta: null, + }; + }, + recover(ctx, result) { + if (result.code === "no_strategy") { + return { hint: "wander", reason: "no viable pit-escape; let curriculum try a different action" }; + } + return null; + }, +}); + +// Test exports +export const __testing = { scanCardinals, ceilingDistance, isAir, isPassable, SCAN_RADIUS }; diff --git a/runtime/skills/index.js b/runtime/skills/index.js index 5440028..a00a414 100644 --- a/runtime/skills/index.js +++ b/runtime/skills/index.js @@ -30,11 +30,14 @@ 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 escapePitSafe } from "./escape-pit-safe.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"; import { skill as gatherWool } from "./gather-wool.js"; import { skill as acquireFood } from "./acquire-food.js"; +import { skill as scoutFood } from "./scout-food.js"; +import { skill as relocate } from "./relocate.js"; import { skill as chooseBase } from "./choose-base.js"; import { skill as buildShelter } from "./build-shelter.js"; import { skill as placeChest } from "./place-chest.js"; @@ -74,12 +77,15 @@ register(flee); register(sleep); register(tunnelOut); register(pillarUp); +register(escapePitSafe); register(diagPhysics); register(diagScan); register(diagMatch); register(gatherStone); register(gatherWool); register(acquireFood); +register(scoutFood); +register(relocate); register(chooseBase); register(buildShelter); register(placeChest); diff --git a/runtime/skills/relocate.js b/runtime/skills/relocate.js new file mode 100644 index 0000000..e751946 --- /dev/null +++ b/runtime/skills/relocate.js @@ -0,0 +1,139 @@ +// village.relocate — commit-and-walk skill that breaks the bot out of +// "I've been wandering the same 50×50 area for 2 hours" failure mode. +// +// Heuristic: when the wedge-detector says we're stuck, this skill is +// dispatched with no biome preference; it picks the least-recently- +// visited cardinal (or any cardinal if no history) and walks ~300 +// blocks toward it, with a hard time budget. While it runs, the +// reflex's wedge-detector knows a relocation is in flight and won't +// fire another one on top. +// +// The skill ignores the active need entirely for its duration — its +// only job is to displace the bot far enough that the surrounding +// biome is fresh and skills like survive.acquire-food / gather.logs +// have new local context to work with. + +import pathfinderPkg from "mineflayer-pathfinder"; +const { pathfinder, goals, Movements } = pathfinderPkg; + +import { info } from "../log.js"; +import { markRelocationStarted } from "../awareness/wedge-detector.js"; + +const CARDINALS = [ + { name: "N", dx: 0, dz: -1 }, + { name: "E", dx: 1, dz: 0 }, + { name: "S", dx: 0, dz: 1 }, + { name: "W", dx: -1, dz: 0 }, +]; +const DEFAULT_DISTANCE = 300; +const STEP_BLOCKS = 32; // re-path every N blocks for liveness +const STEP_TIMEOUT_MS = 30_000; + +let pluginLoaded = new WeakSet(); +function ensurePathfinder(bot) { + if (pluginLoaded.has(bot)) return; + bot.loadPlugin(pathfinder); + pluginLoaded.add(bot); +} +function setMovementsForTravel(bot) { + const m = new Movements(bot); + m.canDig = false; + m.allow1by1towers = false; + bot.pathfinder.setMovements(m); +} + +function pickCardinal(ctx, args) { + // Explicit override wins + if (args?.heading) { + const found = CARDINALS.find((c) => c.name === args.heading); + if (found) return found; + } + // Otherwise pick a cardinal not recently used. ctx may carry a + // recentRelocations array {name, ts}; default = N. + const recent = new Set((ctx?.recentRelocations ?? []).map((r) => r.name)); + const untried = CARDINALS.filter((c) => !recent.has(c.name)); + return untried[0] ?? CARDINALS[Math.floor(Math.random() * CARDINALS.length)]; +} + +export const skill = Object.freeze({ + id: "village.relocate", + title: "Walk 300 blocks in a fresh cardinal to break a wedge", + timeoutMs: 180_000, + preconditions(ctx) { + if (!ctx?.bot?.entity?.position) { + return { ok: false, code: "no_bot", detail: "bot or entity missing" }; + } + return { ok: true }; + }, + async execute(ctx, args = {}) { + const bot = ctx.bot; + const distance = Math.max(64, Math.min(args?.distance ?? DEFAULT_DISTANCE, 600)); + const cardinal = pickCardinal(ctx, args); + const start = { x: bot.entity.position.x, z: bot.entity.position.z }; + markRelocationStarted({ x: start.x, z: start.z, heading: cardinal }); + ctx.recentRelocations = (ctx.recentRelocations ?? []).slice(-3); + ctx.recentRelocations.push({ name: cardinal.name, ts: Date.now() }); + + ensurePathfinder(bot); + setMovementsForTravel(bot); + info("action", `relocate: heading ${cardinal.name} for ${distance}b from (${Math.round(start.x)}, ${Math.round(start.z)})`); + + let travelled = 0; + const errors = []; + while (travelled < distance) { + if (ctx?.abortSignal?.aborted) { + return { + ok: travelled >= distance / 2, // partial counts if we got at least half + code: travelled >= distance / 2 ? "partial" : "preempted", + detail: { travelled: Math.round(travelled), heading: cardinal.name }, + worldDelta: { moved: Math.round(travelled), heading: cardinal.name }, + }; + } + const stepDist = Math.min(STEP_BLOCKS, distance - travelled); + const targetX = start.x + cardinal.dx * (travelled + stepDist); + const targetZ = start.z + cardinal.dz * (travelled + stepDist); + const targetY = Math.floor(bot.entity.position.y); + try { + await Promise.race([ + bot.pathfinder.goto(new goals.GoalNear(Math.floor(targetX), targetY, Math.floor(targetZ), 4)), + new Promise((_, rej) => setTimeout(() => rej(new Error("step timeout")), STEP_TIMEOUT_MS)), + ]); + } catch (e) { + errors.push(e?.message ?? String(e)); + if (errors.length >= 3) break; + // brief pause then keep trying + await new Promise((r) => setTimeout(r, 500)); + } + // Measure actual progress (pathfinder might have routed around) + const dx = bot.entity.position.x - start.x; + const dz = bot.entity.position.z - start.z; + travelled = Math.hypot(dx, dz); + } + + const endPos = bot.entity.position; + const finalDist = Math.hypot(endPos.x - start.x, endPos.z - start.z); + if (finalDist < 50) { + return { + ok: false, + code: "stuck_in_place", + detail: { travelled: Math.round(finalDist), heading: cardinal.name, errors: errors.slice(0, 3) }, + worldDelta: { moved: Math.round(finalDist) }, + }; + } + return { + ok: true, + code: "done", + detail: { travelled: Math.round(finalDist), heading: cardinal.name }, + worldDelta: { moved: Math.round(finalDist), heading: cardinal.name, mode: "relocate" }, + }; + }, + recover(ctx, result) { + if (result.code === "stuck_in_place") { + return { hint: "wander", reason: "relocate could not gain ground; let wander try a new tactic" }; + } + return null; + }, +}); + +// Test exports +export const __testing = { CARDINALS, DEFAULT_DISTANCE, pickCardinal }; diff --git a/runtime/skills/scout-food.js b/runtime/skills/scout-food.js new file mode 100644 index 0000000..4b27fa6 --- /dev/null +++ b/runtime/skills/scout-food.js @@ -0,0 +1,276 @@ +// survive.scout-food — longer-range, biome-aware food search. +// +// Why this exists alongside survive.acquire-food: +// +// acquire-food is the "I see a cow, hunt it" skill. Its precondition +// requires a passive food mob within ~32 blocks; if there isn't one, +// the skill bails immediately. In v0.3.0 that produced two failure +// modes that kept the bot looping for hours: +// +// 1. Biome with NO passive mobs (desert, ocean, snowy peaks, deep +// caves). acquire-food can never succeed there — the bot just +// kept scanning the same 32-block sphere. +// +// 2. Biome WITH passive mobs but the immediate area is empty. The +// bot wandered 1-8 blocks at a time around the same 50×50 patch +// and never committed to a direction long enough to leave it. +// +// scout-food applies the "commit-to-cardinal" pattern (per the v0.3.1 +// design research): scan(32) → patrol(64, time-budget) → if still +// nothing, walk a chosen cardinal for ~200 blocks, rescanning every +// 16 blocks. On exhaustion of all 4 cardinals it surrenders to the +// curriculum so the operator-driven `village.relocate` or LLM advisor +// can pick up. +// +// Biome-awareness: +// - If current biome's affordance table has has_passive_mobs=false +// AND has_water=false, skip the local scan entirely and pick the +// most plausible "leave biome" heading: sample neighbour biomes at +// radius 64 in 8 directions, pick the first one whose affordances +// say has_passive_mobs=true. +// - In water-bearing barren biomes (ocean shores, frozen rivers) +// fishing isn't implemented yet — operator-facing improvement. + +import pathfinderPkg from "mineflayer-pathfinder"; +const { pathfinder, goals, Movements } = pathfinderPkg; + +import { info, warn } from "../log.js"; +import { foods } from "./groups.js"; +import { affordancesFor, hasPassiveMobs, isBarren } from "../biome-affordances.js"; + +const PASSIVE_FOOD_MOBS = new Set(["cow", "pig", "chicken", "sheep", "rabbit", "mooshroom"]); +const CARDINALS = [ + { name: "N", dx: 0, dz: -1 }, + { name: "E", dx: 1, dz: 0 }, + { name: "S", dx: 0, dz: 1 }, + { name: "W", dx: -1, dz: 0 }, +]; +const PATROL_TICK_DISTANCE = 16; +const DEFAULT_COMMIT_DISTANCE = 200; + +let pluginLoaded = new WeakSet(); +function ensurePathfinder(bot) { + if (pluginLoaded.has(bot)) return; + bot.loadPlugin(pathfinder); + pluginLoaded.add(bot); +} + +function setMovementsForTravel(bot) { + const m = new Movements(bot); + m.canDig = false; + m.allow1by1towers = false; + bot.pathfinder.setMovements(m); +} + +function foodCount(bot) { + const allowed = foods(bot); + return bot.inventory.items().reduce((sum, item) => allowed.has(item.name) ? sum + item.count : sum, 0); +} + +function nearestPassiveFoodMob(bot, maxDistance) { + const here = bot?.entity?.position; + if (!here) return null; + let best = null; + for (const e of Object.values(bot.entities ?? {})) { + if (!e?.position || !PASSIVE_FOOD_MOBS.has(e.name)) continue; + const d = e.position.distanceTo(here); + if (d > maxDistance) continue; + if (!best || d < best.distance) best = { entity: e, distance: d }; + } + return best; +} + +function currentBiomeName(bot) { + try { + const block = bot.blockAt?.(bot.entity?.position); + const b = block?.biome; + if (typeof b === "string") return b; + if (b?.name) return b.name; + return null; + } catch { return null; } +} + +function biomeNameAt(bot, x, y, z) { + try { + const block = bot.blockAt?.({ x: Math.floor(x), y: Math.floor(y), z: Math.floor(z) }); + const b = block?.biome; + if (typeof b === "string") return b; + if (b?.name) return b.name; + return null; + } catch { return null; } +} + +// Sample biomes in 8 compass directions at the given radius; return +// the heading whose biome has passive mobs. +function scanForFoodCapableNeighbourBiome(bot, radius = 64) { + const here = bot?.entity?.position; + if (!here) return null; + const dirs = [ + { name: "N", dx: 0, dz: -1 }, + { name: "NE", dx: 0.71, dz: -0.71 }, + { name: "E", dx: 1, dz: 0 }, + { name: "SE", dx: 0.71, dz: 0.71 }, + { name: "S", dx: 0, dz: 1 }, + { name: "SW", dx: -0.71, dz: 0.71 }, + { name: "W", dx: -1, dz: 0 }, + { name: "NW", dx: -0.71, dz: -0.71 }, + ]; + for (const d of dirs) { + const b = biomeNameAt(bot, here.x + d.dx * radius, here.y, here.z + d.dz * radius); + if (b && hasPassiveMobs(b)) return { heading: d, biome: b }; + } + return null; +} + +async function patrolCardinal(bot, cardinal, distance, ctx) { + ensurePathfinder(bot); + setMovementsForTravel(bot); + const start = bot.entity.position.clone?.() ?? { ...bot.entity.position }; + let travelled = 0; + while (travelled < distance) { + if (ctx?.abortSignal?.aborted) return { aborted: true, travelled }; + const tx = start.x + cardinal.dx * (travelled + PATROL_TICK_DISTANCE); + const tz = start.z + cardinal.dz * (travelled + PATROL_TICK_DISTANCE); + const goal = new goals.GoalNear(Math.floor(tx), Math.floor(bot.entity.position.y), Math.floor(tz), 2); + try { + await Promise.race([ + bot.pathfinder.goto(goal), + new Promise((_, rej) => setTimeout(() => rej(new Error("patrol step timeout")), 30_000)), + ]); + } catch (e) { + return { aborted: false, travelled, error: e?.message ?? String(e) }; + } + travelled += PATROL_TICK_DISTANCE; + // Rescan after every step. + const target = nearestPassiveFoodMob(bot, 32); + if (target) return { aborted: false, travelled, target }; + } + return { aborted: false, travelled }; +} + +export const skill = Object.freeze({ + id: "survive.scout-food", + title: "Scout for food at long range (biome-aware)", + timeoutMs: 240_000, + preconditions(ctx) { + if (!ctx?.bot) return { ok: false, code: "no_bot", detail: "bot missing" }; + if (foodCount(ctx.bot) > 0) { + return { ok: false, code: "already_have", detail: "already carrying edible food" }; + } + return { ok: true }; + }, + async execute(ctx, args = {}) { + const bot = ctx.bot; + const before = foodCount(bot); + const triedCardinals = new Set(args?._triedCardinals ?? []); + + // Step 0: biome check. If barren, head toward a food-capable neighbour. + const biome = currentBiomeName(bot); + const aff = affordancesFor(biome); + info("action", `scout-food: biome=${biome ?? "?"} mobs=${aff.has_passive_mobs} barren=${isBarren(biome)}`); + + if (!aff.has_passive_mobs) { + const next = scanForFoodCapableNeighbourBiome(bot, 64); + if (next) { + info("action", `scout-food: leaving barren biome ${biome} → ${next.biome} via ${next.heading.name}`); + const result = await patrolCardinal(bot, next.heading, DEFAULT_COMMIT_DISTANCE, ctx); + if (result.aborted) return { ok: false, code: "preempted", worldDelta: null }; + if (result.target) { + return await tryHunt(bot, result.target, before); + } + return { + ok: false, + code: "no_target", + detail: `walked ${Math.round(result.travelled)}b ${next.heading.name} toward ${next.biome}, still no food`, + worldDelta: { moved: Math.round(result.travelled), heading: next.heading.name, from_biome: biome, to_biome: next.biome }, + }; + } + // Ringed by barren biomes; pick the first cardinal not yet tried. + } + + // Step 1: scan radius 32 (cheap). + let target = nearestPassiveFoodMob(bot, 32); + if (target) return await tryHunt(bot, target, before); + + // Step 2: scan radius 64 — entities frequently spawn just outside + // our local horizon. + target = nearestPassiveFoodMob(bot, 64); + if (target) return await tryHunt(bot, target, before); + + // Step 3: commit to a cardinal we haven't tried in this incident. + const untried = CARDINALS.filter((c) => !triedCardinals.has(c.name)); + if (untried.length === 0) { + return { + ok: false, + code: "exhausted", + detail: "tried all 4 cardinals without finding a food mob — switch to village.relocate", + worldDelta: null, + }; + } + const cardinal = untried[0]; + info("action", `scout-food: commit cardinal ${cardinal.name} for ${DEFAULT_COMMIT_DISTANCE}b`); + const result = await patrolCardinal(bot, cardinal, DEFAULT_COMMIT_DISTANCE, ctx); + if (result.aborted) return { ok: false, code: "preempted", worldDelta: null }; + if (result.target) return await tryHunt(bot, result.target, before); + return { + ok: false, + code: "no_target", + detail: { tried: cardinal.name, travelled: Math.round(result.travelled), error: result.error ?? null }, + worldDelta: { moved: Math.round(result.travelled), heading: cardinal.name, from_biome: biome }, + }; + }, + recover(ctx, result) { + if (result.code === "exhausted") { + return { hint: "relocate", reason: "scout-food exhausted all 4 cardinals; needs a long jump" }; + } + if (result.code === "no_target") { + return { hint: "wander", reason: "scout completed leg without finding mob; try another cardinal" }; + } + return null; + }, +}); + +async function tryHunt(bot, target, before) { + ensurePathfinder(bot); + setMovementsForTravel(bot); + try { + await Promise.race([ + bot.pathfinder.goto(new goals.GoalFollow(target.entity, 2)), + new Promise((_, rej) => setTimeout(() => rej(new Error("path-to-mob timeout")), 30_000)), + ]); + } catch (e) { + return { ok: false, code: "no_path", detail: e?.message ?? "path failed", worldDelta: null }; + } + info("action", `scout-food: engaging ${target.entity.name}@${target.distance.toFixed(1)}b`); + for (let i = 0; i < 8; i++) { + const current = Object.values(bot.entities ?? {}).find((e) => e.id === target.entity.id); + if (!current) break; + if (current.position.distanceTo(bot.entity.position) > 4) { + try { + await Promise.race([ + bot.pathfinder.goto(new goals.GoalFollow(current, 2)), + new Promise((_, rej) => setTimeout(() => rej(new Error("repath timeout")), 8_000)), + ]); + } catch {} + } + bot.attack(current); + await new Promise((r) => setTimeout(r, 700)); + } + await new Promise((r) => setTimeout(r, 1_000)); + const after = foodCount(bot); + if (after <= before) { + return { ok: false, code: "no_drop", detail: `hunted ${target.entity.name} but no edible drop`, worldDelta: null }; + } + return { + ok: true, + code: "done", + detail: { source: "hunt", mob: target.entity.name, gained: after - before }, + worldDelta: { acquiredFood: after - before, source: "hunt", mob: target.entity.name }, + }; +} + +// Test exports +export const __testing = { + CARDINALS, PATROL_TICK_DISTANCE, DEFAULT_COMMIT_DISTANCE, + nearestPassiveFoodMob, currentBiomeName, scanForFoodCapableNeighbourBiome, +};