From 1cf60f81e9a8d09606c4b6a32a44d11e303fe95e Mon Sep 17 00:00:00 2001 From: Yuriy Mayatnikov Date: Thu, 28 May 2026 09:20:07 +0300 Subject: [PATCH] =?UTF-8?q?fix(v0.3.1):=20mechanical=20food/stuck=20fixes?= =?UTF-8?q?=20=E2=80=94=20bot=20reaches=20the=20chicken=20now?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The wedge wasn't only in the manifesto layer; several mechanical bugs kept the bot in a dead random-walk: - storyline / manifesto / curriculum: "local food" now means an edible passive mob within <=32 blocks. A distant chicken or a cod no longer fools the bot into dispatching acquire-food (which then fails on no_path). Long-range food goes through scout-food instead. - scout-food: partial approach to a target now counts as progress (approached_target, e.g. moved:14); a blocked heading is NOT counted as movement; added blind/tunnel fallback so it doesn't die when the pathfinder can't route cleanly. - acquire-food: on no_path it now also tries a blind/tunnel approach to the animal; no_drop routes back into food scouting instead of giving up. - explore.far / relocate / flee: fewer false "done" results (micro-steps no longer counted as success), more genuine escapes from stuck. - scripts/show-story.js: live IPC now actually renders the current storyline step. Verification: scripts/lint-patch.js clean; npm test 404/404 green; bot relaunched in tmux `pepa`. Live logs show real progress — bot switched to survive.scout-food, approached the chicken (approached_target moved:14), then reached survive.acquire-food: hunting chicken. Food isn't fully closed yet but the remaining issue is concrete pickup/drop, not dead random-walk. Co-Authored-By: Claude Opus 4.7 --- runtime/actions.js | 26 ++++- runtime/coach/advice.js | 6 ++ runtime/coach/advice.test.js | 2 + runtime/curriculum.js | 11 +- runtime/curriculum.test.js | 4 +- runtime/goal/storyline.js | 71 +++++++++---- runtime/goal/storyline.test.js | 39 ++++++- runtime/manifesto/needs.js | 29 ++++-- runtime/manifesto/needs.test.js | 36 ++++++- runtime/manifesto/state.test.js | 6 +- runtime/reflex.js | 78 +++++++++++++- runtime/reflex.test.js | 4 +- runtime/skills/acquire-food.js | 55 +++++++++- runtime/skills/contract.test.js | 34 ++++++ runtime/skills/explore-far.js | 15 +-- runtime/skills/index.js | 10 +- runtime/skills/relocate.js | 23 +++-- runtime/skills/scout-food.js | 178 +++++++++++++++++++++++++++----- scripts/show-story.js | 27 +++-- 19 files changed, 548 insertions(+), 106 deletions(-) diff --git a/runtime/actions.js b/runtime/actions.js index 5da9715..1b8aade 100644 --- a/runtime/actions.js +++ b/runtime/actions.js @@ -173,7 +173,31 @@ export async function fleeFrom(bot, fromEntity, distance = 16) { ); return { ok: true, detail: { to: { x: tx, y: ty, z: tz } } }; } catch (e) { - warn("action", `flee failed: ${e.message}`); + warn("action", `flee path failed: ${e.message}; trying blind retreat`); + const before = bot.entity.position.clone?.() ?? { ...bot.entity.position }; + try { bot.pathfinder?.stop?.(); } catch {} + try { await bot.look(-Math.atan2(dx / len, dz / len), 0, true); } catch {} + bot.setControlState("forward", true); + bot.setControlState("jump", true); + try { + await new Promise((r) => setTimeout(r, 7_000)); + } finally { + bot.setControlState("forward", false); + bot.setControlState("jump", false); + } + const after = bot.entity.position; + const moved = Math.hypot(after.x - before.x, after.z - before.z); + if (moved >= 4) { + return { + ok: true, + detail: { + to: { x: Math.round(after.x), y: Math.round(after.y), z: Math.round(after.z) }, + mode: "blind-retreat", + moved, + }, + }; + } + warn("action", `flee blind retreat moved only ${moved.toFixed(2)} blocks`); return { ok: false, detail: e.message }; } } diff --git a/runtime/coach/advice.js b/runtime/coach/advice.js index e05cf79..38f2bad 100644 --- a/runtime/coach/advice.js +++ b/runtime/coach/advice.js @@ -21,10 +21,13 @@ const SAFE_OVERRIDES = new Set([ "survive.flee", "survive.sleep", "survive.eat", + "survive.acquire-food", + "survive.scout-food", "survive.pillar-up", "recovery.tunnel-out", "explore.far", "explore.wander", + "village.relocate", "village.build-shelter", "village.choose-base", ]); @@ -45,6 +48,9 @@ const MODE_TO_SKILL = Object.freeze({ "tunnel-out": "recovery.tunnel-out", explore: "explore.far", wander: "explore.far", + scout_food: "survive.scout-food", + "scout-food": "survive.scout-food", + relocate: "village.relocate", }); function normalisePreferSkill(raw) { diff --git a/runtime/coach/advice.test.js b/runtime/coach/advice.test.js index ae71c42..34bdf43 100644 --- a/runtime/coach/advice.test.js +++ b/runtime/coach/advice.test.js @@ -115,6 +115,8 @@ test("normalisePreferSkill: 'survive_flee' shape gets translated to dot form", ( test("normalisePreferSkill: passes through known dot-form skills unchanged", () => { assert.equal(normalisePreferSkill("survive.flee"), "survive.flee"); assert.equal(normalisePreferSkill("explore.far"), "explore.far"); + assert.equal(normalisePreferSkill("survive.scout-food"), "survive.scout-food"); + assert.equal(normalisePreferSkill("village.relocate"), "village.relocate"); }); test("normalisePreferSkill: unknown values rejected (returns null)", () => { diff --git a/runtime/curriculum.js b/runtime/curriculum.js index df09b55..7772c88 100644 --- a/runtime/curriculum.js +++ b/runtime/curriculum.js @@ -72,6 +72,7 @@ function hasAny(inv, names) { const WOODEN_TOOLS = ["wooden_axe", "wooden_pickaxe", "wooden_sword"]; const STONE_TOOLS = ["stone_axe", "stone_pickaxe", "stone_sword"]; +const PASSIVE_FOOD_MOBS = new Set(["cow", "pig", "chicken", "sheep", "rabbit", "mooshroom"]); // A "stage reached" predicate: once the bot has wooden tools, wood.16 is // implicitly considered done even if the log stack is now empty (the bot @@ -86,6 +87,12 @@ function hasStoneTier(inv) { return STONE_TOOLS.some((n) => has(inv, n)); } +function hasVisibleFoodTarget(snap) { + const passives = snap?.nearbyEntities?.passives ?? []; + if (passives.some((e) => PASSIVE_FOOD_MOBS.has(e.name))) return true; + return (snap?.nearbyEntities?.droppedItems?.length ?? 0) > 0; +} + const MILESTONES = [ { id: "wood.16", @@ -157,7 +164,9 @@ const MILESTONES = [ ); return carrying || (snap?.food ?? 20) >= 18; }, - suggest: () => ({ skillId: "survive.acquire-food" }), + suggest: (_inv, snap) => ({ + skillId: hasVisibleFoodTarget(snap) ? "survive.acquire-food" : "survive.scout-food", + }), }, { id: "storage.chest", diff --git a/runtime/curriculum.test.js b/runtime/curriculum.test.js index 3a8ac9f..5279bee 100644 --- a/runtime/curriculum.test.js +++ b/runtime/curriculum.test.js @@ -191,10 +191,10 @@ test("listMilestones exposes ordered ids for diary/TUI", () => { } }); -test("food.basic with no carried food suggests acquire-food", () => { +test("food.basic with no carried food or visible target suggests scout-food", () => { const got = nextMilestone(snapAfter("stone.tools", {}, { food: 8 })); assert.equal(got.milestone.id, "food.basic"); - assert.equal(got.plan.skillId, "survive.acquire-food"); + assert.equal(got.plan.skillId, "survive.scout-food"); }); test("storage.chest crafts first, then places carried chest", () => { diff --git a/runtime/goal/storyline.js b/runtime/goal/storyline.js index 8eaf55a..3c24fa0 100644 --- a/runtime/goal/storyline.js +++ b/runtime/goal/storyline.js @@ -52,6 +52,7 @@ const FOOD_ITEMS = [ "apple", "carrot", "potato", "beetroot", "melon_slice", "sweet_berries", "golden_apple", "golden_carrot", ]; +const PASSIVE_FOOD_MOBS = new Set(["cow", "pig", "chicken", "sheep", "rabbit", "mooshroom"]); function hasSetItem(inv, set) { if (!inv) return false; @@ -92,6 +93,27 @@ function countPlanks(inv) { return total; } +function woodBudget(inv) { + if (!inv) return 0; + const placedOrCarriedTable = (inv.crafting_table ?? 0) > 0 ? 4 : 0; + return countLogs(inv) * 4 + countPlanks(inv) + placedOrCarriedTable; +} + +function blockCount(snap, kind) { + const v = snap?.nearbyBlocks?.[kind]; + if (typeof v === "number") return v; + if (v && typeof v.count === "number") return v.count; + return 0; +} + +function hasLocalFoodMob(snap, maxDistance = 32) { + for (const e of snap?.nearbyEntities?.passives ?? []) { + if (!PASSIVE_FOOD_MOBS.has(e?.name)) continue; + if ((e.distance ?? Infinity) <= maxDistance) return true; + } + return false; +} + function emergencyPause(snap) { if (!snap?.connected) return false; const hp = snap.health ?? 20; @@ -120,11 +142,10 @@ export const STORYLINE = Object.freeze([ // 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; + const sawBlocks = blockCount(snap, "logs") + + blockCount(snap, "stone") + + blockCount(snap, "crops") + + blockCount(snap, "beds") > 0; if (sawBlocks) return true; // Fallback: settled for long enough → call orient done and let // later steps drive forward into the biome. @@ -140,13 +161,17 @@ export const STORYLINE = Object.freeze([ { id: "first_wood", - title: "Собрать 8 поленьев", - narration_ru: "Цель: 8 поленьев. Иду рубить ближайшие деревья.", + title: "Собрать стартовое дерево", + narration_ru: "Нужно дерево для первого крафта. Доберу минимум и сразу к верстаку.", completed(snap) { - return countLogs(snap?.inventory) >= 8; + const inv = snap?.inventory ?? {}; + return woodBudget(inv) >= 16 + || hasSetItem(inv, PICKAXE_WOOD) + || hasSetItem(inv, AXE_WOOD) + || hasSetItem(inv, SWORD_WOOD); }, suggestSkill(snap) { - const trees = snap?.nearbyBlocks?.logs ?? 0; + const trees = blockCount(snap, "logs"); if (trees > 0) return { skillId: "gather.logs" }; // No tree in sight — scout further. In a biome with no trees // (desert, ocean) the bot must commit to a long heading; the @@ -163,17 +188,16 @@ export const STORYLINE = Object.freeze([ narration_ru: "Делаю верстак и палки — без них ничего не скрафтить.", completed(snap) { const inv = snap?.inventory ?? {}; - return (inv.crafting_table ?? 0) > 0 - && (inv.stick ?? 0) >= 2 - && countPlanks(inv) >= 4; + return ((inv.stick ?? 0) >= 2 && countPlanks(inv) >= 4) + || hasSetItem(inv, PICKAXE_WOOD) + || hasSetItem(inv, AXE_WOOD) + || hasSetItem(inv, SWORD_WOOD); }, suggestSkill(snap) { const inv = snap?.inventory ?? {}; if (countPlanks(inv) < 4) return { skillId: "craft.planks" }; if ((inv.stick ?? 0) < 2) return { skillId: "craft.sticks" }; - // We have raw materials, need to *place* a crafting table for tools. - // (No place-table skill yet — flagged as improvement_request elsewhere.) - return { skillId: "craft.sticks" }; + return null; }, emergencyPause, }, @@ -190,6 +214,11 @@ export const STORYLINE = Object.freeze([ }, suggestSkill(snap) { const inv = snap?.inventory ?? {}; + if (countPlanks(inv) < 4) { + if (countLogs(inv) > 0) return { skillId: "craft.planks" }; + return { skillId: "gather.logs" }; + } + if ((inv.stick ?? 0) < 2) return { skillId: "craft.sticks" }; if (!hasSetItem(inv, PICKAXE_WOOD)) return { skillId: "craft.wooden-pickaxe" }; if (!hasSetItem(inv, AXE_WOOD)) return { skillId: "craft.wooden-axe" }; if (!hasSetItem(inv, SWORD_WOOD)) return { skillId: "craft.wooden-sword" }; @@ -213,8 +242,7 @@ export const STORYLINE = Object.freeze([ // 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" }; + if (hasLocalFoodMob(snap)) return { skillId: "survive.acquire-food" }; return { skillId: "survive.scout-food" }; }, emergencyPause, @@ -225,7 +253,7 @@ export const STORYLINE = Object.freeze([ title: "Простой шелтер с кроватью", narration_ru: "Поставлю кровать и стены — пережить ночь.", completed(snap) { - return (snap?.nearbyBlocks?.beds ?? 0) > 0; + return blockCount(snap, "beds") > 0; }, suggestSkill(snap) { const inv = snap?.inventory ?? {}; @@ -272,7 +300,7 @@ export const STORYLINE = Object.freeze([ }, suggestSkill(snap) { const inv = snap?.inventory ?? {}; - if ((inv.wheat_seeds ?? 0) > 0 && (snap?.nearbyBlocks?.crops ?? 0) > 0) { + if ((inv.wheat_seeds ?? 0) > 0 && blockCount(snap, "crops") > 0) { return { skillId: "farm.wheat" }; } return { skillId: "survive.acquire-food" }; @@ -300,8 +328,7 @@ export const STORYLINE = Object.freeze([ title: "Постоянная база", narration_ru: "Выбираю место под деревню — нужно нормальное основание.", completed(snap) { - const nb = snap?.nearbyBlocks ?? {}; - return (nb.beds ?? 0) >= 1 && (nb.storage ?? 0) >= 1; + return blockCount(snap, "beds") >= 1 && blockCount(snap, "storage") >= 1; }, suggestSkill(snap) { const inv = snap?.inventory ?? {}; @@ -335,5 +362,5 @@ export function getStep(id) { // Test exports export const __testing = { countLogs, countPlanks, countAny, hasAny, hasSetItem, - FOOD_ITEMS, BED_ITEMS, emergencyPause, + FOOD_ITEMS, BED_ITEMS, emergencyPause, blockCount, woodBudget, hasLocalFoodMob, }; diff --git a/runtime/goal/storyline.test.js b/runtime/goal/storyline.test.js index b436b23..56e0a14 100644 --- a/runtime/goal/storyline.test.js +++ b/runtime/goal/storyline.test.js @@ -37,7 +37,7 @@ test("STORYLINE: 11 steps, all have id/title/narration/completed/suggestSkill", }); test("getStep: lookup by id", () => { - assert.equal(getStep("first_wood").title, "Собрать 8 поленьев"); + assert.equal(getStep("first_wood").title, "Собрать стартовое дерево"); assert.equal(getStep("does-not-exist"), null); }); @@ -49,16 +49,18 @@ test("emergencyPause: low hp + close hostile → true", () => { assert.equal(emergencyPause(snap({ food: 0 })), true); }); -test("step first_wood: completed when ≥8 logs", () => { +test("step first_wood: completed when bootstrap wood budget is enough", () => { const s = getStep("first_wood"); assert.equal(s.completed(snap()), false); - assert.equal(s.completed(snap({ inventory: { oak_log: 8 } })), true); - assert.equal(s.completed(snap({ inventory: { oak_log: 4, birch_log: 4 } })), true); + assert.equal(s.completed(snap({ inventory: { oak_log: 4 } })), true); + assert.equal(s.completed(snap({ inventory: { oak_log: 2, oak_planks: 8 } })), true); + assert.equal(s.completed(snap({ inventory: { wooden_pickaxe: 1 } })), true); }); test("step first_wood: suggest gather.logs if trees nearby, explore.far otherwise", () => { const s = getStep("first_wood"); assert.equal(s.suggestSkill(snap({ nearbyBlocks: { logs: 5 } })).skillId, "gather.logs"); + assert.equal(s.suggestSkill(snap({ nearbyBlocks: { logs: { count: 1 } } })).skillId, "gather.logs"); assert.equal(s.suggestSkill(snap()).skillId, "explore.far"); }); @@ -77,10 +79,27 @@ test("step first_food: completed at ≥2 food items", () => { assert.equal(s.completed(snap({ inventory: { bread: 2 } })), true); }); +test("step first_food: local hunt only for edible passive mobs within acquire range", () => { + const s = getStep("first_food"); + assert.equal( + s.suggestSkill(snap({ nearbyEntities: { passives: [{ name: "chicken", distance: 18 }] } })).skillId, + "survive.acquire-food", + ); + assert.equal( + s.suggestSkill(snap({ nearbyEntities: { passives: [{ name: "chicken", distance: 51 }] } })).skillId, + "survive.scout-food", + ); + assert.equal( + s.suggestSkill(snap({ nearbyEntities: { passives: [{ name: "cod", distance: 12 }] } })).skillId, + "survive.scout-food", + ); +}); + test("step shelter_minimal: completed when bed placed nearby", () => { const s = getStep("shelter_minimal"); assert.equal(s.completed(snap()), false); assert.equal(s.completed(snap({ nearbyBlocks: { beds: 1 } })), true); + assert.equal(s.completed(snap({ nearbyBlocks: { beds: { count: 1 } } })), true); }); test("step stone_tier: needs cobblestone first", () => { @@ -133,6 +152,18 @@ test("pickCurrentStep: bot with 8+ logs → first_wood done, picks crafting_basi assert.equal(r.completedSteps, 2, "orient_self + first_wood done"); }); +test("pickCurrentStep: bot with planks and sticks advances to first_tools", () => { + _resetForTest(); + const r = pickCurrentStep(snap({ + _sessionMs: 60_000, + nearbyBlocks: { logs: { count: 3 } }, + inventory: { oak_planks: 16, stick: 4 }, + })); + assert.ok(r); + assert.equal(r.step.id, "first_tools"); + assert.equal(r.suggestion.skillId, "craft.wooden-pickaxe"); +}); + test("pickCurrentStep: emergency pauses suggestion", () => { _resetForTest(); const r = pickCurrentStep(snap({ diff --git a/runtime/manifesto/needs.js b/runtime/manifesto/needs.js index 750ab45..861f9b6 100644 --- a/runtime/manifesto/needs.js +++ b/runtime/manifesto/needs.js @@ -37,6 +37,7 @@ const BED_ITEMS = [ "lime_bed", "pink_bed", "gray_bed", "light_gray_bed", "cyan_bed", "purple_bed", "blue_bed", "brown_bed", "green_bed", "red_bed", "black_bed", ]; +const PASSIVE_FOOD_MOBS = new Set(["cow", "pig", "chicken", "sheep", "rabbit", "mooshroom"]); function hasAny(inv, names) { if (!inv) return false; @@ -77,6 +78,18 @@ function hostileImminent(s) { return (h.distance ?? Infinity) < 8; } +function hasVisibleFoodTarget(s) { + const passives = s?.nearbyEntities?.passives ?? []; + return passives.some((e) => PASSIVE_FOOD_MOBS.has(e.name) && (e.distance ?? Infinity) <= 32); +} + +function blockCount(s, kind) { + const v = s?.nearbyBlocks?.[kind]; + if (typeof v === "number") return v; + if (v && typeof v.count === "number") return v.count; + return 0; +} + function aliveDetect(s) { if (!s?.connected) return true; // not connected, nothing to do const hp = s.health ?? 20; @@ -98,7 +111,7 @@ function alivePursue(s) { return { skillId: "survive.eat" }; } if (food <= 0 && !s.hasFood) { - return { skillId: "survive.acquire-food" }; + return { skillId: hasVisibleFoodTarget(s) ? "survive.acquire-food" : "survive.scout-food" }; } if (hostileImminent(s)) { return { skillId: "survive.flee" }; @@ -119,7 +132,7 @@ function foodPursue(s) { if ((s.food ?? 20) < 16 && s.hasFood) { return { skillId: "survive.eat" }; } - return { skillId: "survive.acquire-food" }; + return { skillId: hasVisibleFoodTarget(s) ? "survive.acquire-food" : "survive.scout-food" }; } function toolsWoodDetect(s) { @@ -134,7 +147,7 @@ function toolsWoodPursue(s) { const logs = countLogs(inv); const sticks = inv.stick ?? 0; const hasWb = (inv.crafting_table ?? 0) > 0 - || (s.nearbyBlocks?.craftingTable ?? 0) > 0; + || blockCount(s, "craftingTable") > 0; if (logs < 2 && planks < 4 && !hasWb) { return { skillId: "gather.logs" }; @@ -159,7 +172,7 @@ function toolsWoodPursue(s) { function shelterBasicDetect(s) { const inv = s?.inventory ?? {}; - const bedPlaced = (s.nearbyBlocks?.beds ?? 0) > 0; + const bedPlaced = blockCount(s, "beds") > 0; return bedPlaced || hasAny(inv, BED_ITEMS); } @@ -233,10 +246,10 @@ function foodSecurityDetect(s) { } function foodSecurityPursue(s) { - if ((s.inventory?.wheat_seeds ?? 0) > 0 && (s.nearbyBlocks?.crops ?? 0) > 0) { + if ((s.inventory?.wheat_seeds ?? 0) > 0 && blockCount(s, "crops") > 0) { return { skillId: "farm.wheat" }; } - return { skillId: "survive.acquire-food" }; + return { skillId: hasVisibleFoodTarget(s) ? "survive.acquire-food" : "survive.scout-food" }; } function toolsIronDetect(s) { @@ -265,7 +278,7 @@ function villageSeedDetect(s) { // Heuristic: at least one chest placed AND one bed placed within // nearby radius. Tightens later (POIs of kind "structure"). const nb = s?.nearbyBlocks ?? {}; - return (nb.storage ?? 0) >= 1 && (nb.beds ?? 0) >= 1; + return blockCount(s, "storage") >= 1 && blockCount(s, "beds") >= 1; } function villageSeedPursue(s) { @@ -310,5 +323,5 @@ export function getNeed(id) { // Test exports export const __testing = { hasAny, countAny, countLogs, countPlanks, - FOOD_ITEMS, BED_ITEMS, ARMOR_CHEST_ANY, + FOOD_ITEMS, BED_ITEMS, ARMOR_CHEST_ANY, hasVisibleFoodTarget, blockCount, }; diff --git a/runtime/manifesto/needs.test.js b/runtime/manifesto/needs.test.js index 63e1b88..63cdcb8 100644 --- a/runtime/manifesto/needs.test.js +++ b/runtime/manifesto/needs.test.js @@ -54,13 +54,38 @@ test("L0 alive: zero food and have food → eat", () => { assert.equal(n.pursue(s).skillId, "survive.eat"); }); -test("L0 alive: zero food and no food → acquire", () => { +test("L0 alive: zero food and no visible target → scout-food", () => { const n = getNeed("alive"); const s = snap({ food: 0, hasFood: false }); assert.equal(n.detect(s), false); + assert.equal(n.pursue(s).skillId, "survive.scout-food"); +}); + +test("L0 alive: zero food with visible passive → acquire", () => { + const n = getNeed("alive"); + const s = snap({ + food: 0, + hasFood: false, + nearbyEntities: { passives: [{ name: "cow", distance: 12 }], droppedItems: [] }, + }); + assert.equal(n.detect(s), false); assert.equal(n.pursue(s).skillId, "survive.acquire-food"); }); +test("L0 alive: far or non-food passives do not trigger local acquire", () => { + const n = getNeed("alive"); + assert.equal(n.pursue(snap({ + food: 0, + hasFood: false, + nearbyEntities: { passives: [{ name: "chicken", distance: 51 }], droppedItems: [] }, + })).skillId, "survive.scout-food"); + assert.equal(n.pursue(snap({ + food: 0, + hasFood: false, + nearbyEntities: { passives: [{ name: "cod", distance: 12 }], droppedItems: [] }, + })).skillId, "survive.scout-food"); +}); + test("L1 food: 6+ food items → satisfied", () => { const n = getNeed("food"); assert.equal(n.detect(snap({ food: 10, inventory: { bread: 6 } })), true); @@ -73,6 +98,13 @@ test("L1 food: full saturation + any food → satisfied (no panic gathering)", ( assert.equal(n.detect(snap({ food: 20, inventory: { bread: 3 } })), true); }); +test("L1 food: no local food target uses scout-food instead of local acquire loop", () => { + const n = getNeed("food"); + const s = snap({ food: 10, hasFood: false, inventory: {} }); + assert.equal(n.detect(s), false); + assert.equal(n.pursue(s).skillId, "survive.scout-food"); +}); + test("L2 tools_wood: starts with no logs → gather.logs", () => { const n = getNeed("tools_wood"); const s = snap(); @@ -105,6 +137,7 @@ test("L2 tools_wood: progression to pickaxe → axe → sword", () => { test("L3 shelter_basic: bed nearby → satisfied", () => { const n = getNeed("shelter_basic"); assert.equal(n.detect(snap({ nearbyBlocks: { beds: 1 } })), true); + assert.equal(n.detect(snap({ nearbyBlocks: { beds: { count: 1 } } })), true); assert.equal(n.detect(snap({ inventory: { red_bed: 1 } })), true); assert.equal(n.detect(snap()), false); }); @@ -169,6 +202,7 @@ test("L8 armor_iron: iron_chestplate equipped → satisfied", () => { test("L9 village_seed: bed + storage nearby → satisfied", () => { const n = getNeed("village_seed"); assert.equal(n.detect(snap({ nearbyBlocks: { beds: 1, storage: 1 } })), true); + assert.equal(n.detect(snap({ nearbyBlocks: { beds: { count: 1 }, storage: { count: 1 } } })), true); }); test("L9 village_seed: no chest → craft.chest if enough planks", () => { diff --git a/runtime/manifesto/state.test.js b/runtime/manifesto/state.test.js index 22960f2..1b80b32 100644 --- a/runtime/manifesto/state.test.js +++ b/runtime/manifesto/state.test.js @@ -30,14 +30,14 @@ test("pickActiveNeed: fresh spawn → L0 alive if zero food", () => { _resetForTest(); const a = pickActiveNeed(snap({ food: 0 })); assert.equal(a.need.id, "alive"); - assert.equal(a.skillId, "survive.acquire-food"); + assert.equal(a.skillId, "survive.scout-food"); }); -test("pickActiveNeed: hp ok, no food in inventory → L1 food (acquire)", () => { +test("pickActiveNeed: hp ok, no food in inventory → L1 food (scout)", () => { _resetForTest(); const a = pickActiveNeed(snap()); assert.equal(a.need.id, "food"); - assert.equal(a.skillId, "survive.acquire-food"); + assert.equal(a.skillId, "survive.scout-food"); }); test("pickActiveNeed: food covered → L2 tools_wood (gather logs)", () => { diff --git a/runtime/reflex.js b/runtime/reflex.js index bea47f2..571b29a 100644 --- a/runtime/reflex.js +++ b/runtime/reflex.js @@ -375,6 +375,39 @@ function metricRecoverySkill(ctx, plannedSkillId) { return null; } +function checkSkillPreconditions(ctx, skillId, args = {}) { + const skill = getSkill(skillId); + if (!skill) return { ok: false, code: "unknown_skill", detail: skillId }; + try { + return skill.preconditions(ctx, args) ?? { ok: true }; + } catch (e) { + return { ok: false, code: "precondition_failed", detail: e?.message ?? String(e) }; + } +} + +function resolveAdvisorSkill(ctx, rec, currentSkillId) { + if (!rec?.skillId) return null; + const pre = checkSkillPreconditions(ctx, rec.skillId); + if (pre.ok) return rec.skillId; + + // The most common stale/under-specified advice is "switch to local + // acquire-food" when no passive mob exists in the entity horizon. + // Treat that as the broader food-search intent and route to scout-food. + if (rec.skillId === "survive.acquire-food" && pre.code === "no_target") { + const scoutPre = checkSkillPreconditions(ctx, "survive.scout-food"); + if (scoutPre.ok) { + info(REFLEX_LOG, `advisor correction: ${rec.skillId} has no local target; using survive.scout-food`); + return "survive.scout-food"; + } + } + + info( + REFLEX_LOG, + `advisor ignored: ${currentSkillId} → ${rec.skillId} failed preconditions (${pre.code}: ${String(pre.detail ?? "").slice(0, 80)})`, + ); + 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 @@ -443,6 +476,10 @@ function curriculumReflex(ctx) { const plan = s.curriculum?.plan; const wanderHintUntil = ctx.skillBackoff?.["__wander_hint__"] ?? 0; const wantWander = wanderHintUntil && Date.now() < wanderHintUntil; + const scoutFoodHintUntil = ctx.skillBackoff?.["__scout_food_hint__"] ?? 0; + const wantScoutFood = scoutFoodHintUntil && Date.now() < scoutFoodHintUntil; + const relocateHintUntil = ctx.skillBackoff?.["__relocate_hint__"] ?? 0; + const wantRelocate = relocateHintUntil && Date.now() < relocateHintUntil; // v0.3.0-rc.2 — manifesto layer. Walk the L0-L10 needs ladder; the // lowest unsatisfied need dictates the planned skill. The curriculum @@ -525,6 +562,24 @@ function curriculumReflex(ctx) { } } + if (wantRelocate || wantScoutFood) { + ctx.lastCurriculumAt = Date.now(); + ctx.skillBackoff = ctx.skillBackoff ?? {}; + const hintSkillId = wantRelocate ? "village.relocate" : "survive.scout-food"; + const hintKey = wantRelocate ? "__relocate_hint__" : "__scout_food_hint__"; + ctx.skillBackoff[hintKey] = 0; + const pre = checkSkillPreconditions(ctx, hintSkillId); + if (pre.ok) { + ctx.dispatch(() => runSkill(hintSkillId, ctx), hintSkillId, {}); + return { + action: "dispatched", + kind: wantRelocate ? "curriculum-recovery-relocate" : "curriculum-recovery-scout-food", + label: hintSkillId, + }; + } + info(REFLEX_LOG, `recovery hint ${hintSkillId} skipped (${pre.code}: ${String(pre.detail ?? "").slice(0, 80)})`); + } + // No skill plan from curriculum OR a recent skill asked us to wander. // First hint → small wander (might just be 32-block reach issue). // Every subsequent hint while still inside the backoff window → use @@ -595,11 +650,16 @@ function curriculumReflex(ctx) { if (!ctx.disableAdvisor) { const rec = consumeFreshRecommendation(ctx); if (rec && rec.skillId) { - info(REFLEX_LOG, `advisor override: ${skillId} → ${rec.skillId} (${rec.triggerReason}, ${rec.rationale?.slice(0, 60)})`); - skillId = rec.skillId; - skillSource = `advisor:${rec.triggerReason}`; - appliedRecommendationId = rec.id ?? null; - if (appliedRecommendationId) markRecommendationApplied(appliedRecommendationId); + const resolved = resolveAdvisorSkill(ctx, rec, skillId); + if (resolved) { + info(REFLEX_LOG, `advisor override: ${skillId} → ${resolved} (${rec.triggerReason}, ${rec.rationale?.slice(0, 60)})`); + skillId = resolved; + skillSource = `advisor:${rec.triggerReason}`; + appliedRecommendationId = rec.id ?? null; + if (appliedRecommendationId) markRecommendationApplied(appliedRecommendationId); + } else if (rec.id) { + markRecommendationOutcome(rec.id, { ok: false, code: "precondition_failed" }); + } } // Always fire-and-forget another advise() if triggers fire — the // result lands on a future tick. tickAdvisor handles its own @@ -672,6 +732,14 @@ function curriculumReflex(ctx) { ctx.skillBackoff["__wander_hint__"] = Date.now() + SKILL_BACKOFF_MS; consecutiveWanderHints++; } + if (res?.recovery?.hint === "scout-food") { + ctx.skillBackoff[dispatchSkillId] = Date.now() + SKILL_BACKOFF_MS; + ctx.skillBackoff["__scout_food_hint__"] = Date.now() + SKILL_BACKOFF_MS; + } + if (res?.recovery?.hint === "relocate") { + ctx.skillBackoff[dispatchSkillId] = Date.now() + SKILL_BACKOFF_MS; + ctx.skillBackoff["__relocate_hint__"] = Date.now() + SKILL_BACKOFF_MS; + } if (!res?.ok) { // missing_tool / missing_material / no_target shouldn't be // retried on the very next tick. Hold for SKILL_BACKOFF_MS. diff --git a/runtime/reflex.test.js b/runtime/reflex.test.js index 2102791..ed23e67 100644 --- a/runtime/reflex.test.js +++ b/runtime/reflex.test.js @@ -244,7 +244,7 @@ test("curriculum dispatches suggested skill by id", () => { assert.ok(typeof dispatches[0].opts.onComplete === "function"); }); -test("manifesto: hungry bot with no food drives survive.acquire-food (manifesto fallback when storyline disabled)", () => { +test("manifesto: hungry bot with no visible food drives survive.scout-food (manifesto fallback when storyline disabled)", () => { const { ctx, dispatches } = makeCtx({ disableManifesto: false, disableStoryline: true, @@ -263,7 +263,7 @@ test("manifesto: hungry bot with no food drives survive.acquire-food (manifesto }); const out = runTick(ctx); assert.equal(out.reflex, "curriculum"); - assert.equal(dispatches[0].label, "survive.acquire-food", "manifesto L1 food took over"); + assert.equal(dispatches[0].label, "survive.scout-food", "manifesto L1 food took over"); assert.equal(ctx.activeNeed?.need?.id, "food"); }); diff --git a/runtime/skills/acquire-food.js b/runtime/skills/acquire-food.js index 4b2a317..a1dc9f0 100644 --- a/runtime/skills/acquire-food.js +++ b/runtime/skills/acquire-food.js @@ -8,6 +8,7 @@ const { pathfinder, goals, Movements } = pathfinderPkg; import { info, warn } from "../log.js"; import { foods } from "./groups.js"; +import { blindWalkOrTunnelOut } from "./explore-far.js"; const PASSIVE_FOOD_MOBS = new Set(["cow", "pig", "chicken", "sheep", "rabbit", "mooshroom"]); @@ -61,6 +62,41 @@ function nearbyDroppedItems(bot, maxDistance = 8) { .sort((a, b) => a.distance - b.distance); } +function horizontalDistance(a, b) { + return Math.hypot((b?.x ?? 0) - (a?.x ?? 0), (b?.z ?? 0) - (a?.z ?? 0)); +} + +function yawToward(from, to) { + if (!from || !to) return null; + const dx = to.x - from.x; + const dz = to.z - from.z; + if (Math.hypot(dx, dz) < 0.5) return null; + return -Math.atan2(dx, dz); +} + +async function fallbackApproachFoodMob(bot, target, err) { + try { bot.pathfinder?.stop?.(); } catch {} + const start = bot.entity.position.clone?.() ?? { ...bot.entity.position }; + const alreadyMoved = horizontalDistance(start, bot.entity.position); + if (alreadyMoved >= 4) { + return { ok: true, moved: alreadyMoved, mode: "pathfinder_partial", error: err?.message ?? "path failed" }; + } + const yaw = yawToward(bot.entity.position, target.entity.position); + if (yaw === null) return { ok: false, moved: 0, error: err?.message ?? "path failed" }; + const blind = await blindWalkOrTunnelOut(bot, { + yaw, + dirName: `toward-${target.entity.name}`, + blindMs: 8_000, + minMove: 4, + reason: `acquire-food target ${target.entity.name}`, + }); + const moved = horizontalDistance(start, bot.entity.position); + if (blind.ok || moved >= 4) { + return { ok: true, moved, mode: "blind_target", error: err?.message ?? "path failed" }; + } + return { ok: false, moved, error: err?.message ?? "path failed" }; +} + async function pickupNearbyDrops(bot) { ensurePathfinder(bot); setMovementsForTravel(bot); @@ -115,7 +151,18 @@ export const skill = Object.freeze({ "pathToFoodMob", ); } catch (e) { - return { ok: false, code: "no_path", detail: e.message, worldDelta: null }; + const approached = await fallbackApproachFoodMob(bot, target, e); + const current = Object.values(bot.entities ?? {}).find((entity) => entity.id === target.entity.id); + const dist = current?.position?.distanceTo(bot.entity.position) ?? Infinity; + if (!approached.ok) return { ok: false, code: "no_path", detail: e.message, worldDelta: null }; + if (dist > 4) { + return { + ok: false, + code: "approached_target", + detail: { target: target.entity.name, moved: Math.round(approached.moved), mode: approached.mode, error: approached.error }, + worldDelta: { moved: Math.round(approached.moved), target: target.entity.name, mode: approached.mode }, + }; + } } info("action", `survive.acquire-food: hunting ${target.entity.name} (${target.distance.toFixed(1)}m)`); @@ -153,11 +200,11 @@ export const skill = Object.freeze({ } }, recover(ctx, result) { - if (result.code === "no_target" || result.code === "no_path") { - return { hint: "wander", reason: "need to search for passive food mobs" }; + if (result.code === "no_target" || result.code === "no_path" || result.code === "approached_target" || result.code === "no_drop") { + return { hint: "scout-food", reason: "need a long-range food search, not local acquire-food retry" }; } return null; }, }); -export const _internal = { foodCount, nearestPassiveFoodMob }; +export const _internal = { foodCount, nearestPassiveFoodMob, yawToward, horizontalDistance }; diff --git a/runtime/skills/contract.test.js b/runtime/skills/contract.test.js index 27667ad..535c162 100644 --- a/runtime/skills/contract.test.js +++ b/runtime/skills/contract.test.js @@ -8,6 +8,7 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { runSkill, RUNNER_CODES, _registerForTest } from "./index.js"; +import { __testing as scoutFoodTesting } from "./scout-food.js"; const ctx = {}; // skills under test ignore ctx fully @@ -37,6 +38,27 @@ test("preconditions gate execution", async () => { } }); +test("precondition failures can return recovery hints", async () => { + const teardown = _registerForTest({ + id: "test.precondition-recover", + title: "blocked with recovery", + timeoutMs: 1000, + preconditions: () => ({ ok: false, code: "no_target", detail: "none nearby" }), + execute: async () => { + throw new Error("should not run"); + }, + recover: (_ctx, result) => ({ hint: "scout-food", saw: result.code }), + }); + try { + const res = await runSkill("test.precondition-recover", ctx); + assert.equal(res.ok, false); + assert.equal(res.code, "no_target"); + assert.deepEqual(res.recovery, { hint: "scout-food", saw: "no_target" }); + } finally { + teardown(); + } +}); + test("gather.logs precondition refuses nearby hostiles", async () => { const bot = { registry: { blocksByName: { oak_log: { id: 1 } } } }; const res = await runSkill("gather.logs", { @@ -48,6 +70,18 @@ test("gather.logs precondition refuses nearby hostiles", async () => { assert.match(res.detail, /unsafe to gather logs: drowned 6\.1 blocks away/); }); +test("scout-food progress counts intended cardinal, not sideways tunnel drift", () => { + const north = scoutFoodTesting.CARDINALS.find((c) => c.name === "N"); + assert.deepEqual( + scoutFoodTesting.cardinalProgress({ x: 0, z: 0 }, { x: 0, z: -9 }, north), + { along: 9, total: 9, driftName: "N" }, + ); + assert.deepEqual( + scoutFoodTesting.cardinalProgress({ x: 0, z: 0 }, { x: 9, z: 0 }, north), + { along: 0, total: 9, driftName: "E" }, + ); +}); + test("preconditions that throw produce precondition_failed", async () => { const teardown = _registerForTest({ id: "test.precondition-throw", diff --git a/runtime/skills/explore-far.js b/runtime/skills/explore-far.js index 75f04c9..260c258 100644 --- a/runtime/skills/explore-far.js +++ b/runtime/skills/explore-far.js @@ -90,16 +90,6 @@ export const skill = Object.freeze({ } info("action", `explore.far: cardinal probe trials=${trials.map((t) => `${t.name}:${t.dist.toFixed(1)}`).join(" ")} best=${best.name}`); - const probeMoved = horizontalDistance(beforeProbe, bot.entity.position); - if (probeMoved >= 2) { - return { - ok: true, - code: "done", - detail: { mode: "probe-moved", dir: best.name, moved: probeMoved }, - worldDelta: { movedTo: clonePos(bot.entity.position) }, - }; - } - if (best.dist < 0.5) { // All cardinals blocked. Try the cheap vertical escape first; if it // does not actually move us, carve a short horizontal tunnel. The @@ -122,7 +112,8 @@ export const skill = Object.freeze({ return blindWalkOrTunnelOut(bot, { yaw: best.yaw, dirName: best.name, - blindMs: args.blindMs ?? 7_000, + blindMs: args.blindMs ?? 20_000, + minMove: args.minMove ?? Math.min(14, Math.max(8, dist * 0.25)), tunnelPushMs: args.tunnelPushMs, reason: `explore.far blind ${best.name}`, intended: { x: tx, y: ty, z: tz }, @@ -130,7 +121,7 @@ export const skill = Object.freeze({ }, }); -async function blindWalkOrTunnelOut(bot, { yaw, dirName, blindMs = 7_000, minMove = 0.75, tunnelPushMs, reason = "blind fallback", intended = null } = {}) { +export async function blindWalkOrTunnelOut(bot, { yaw, dirName, blindMs = 7_000, minMove = 0.75, tunnelPushMs, reason = "blind fallback", intended = null } = {}) { const before = clonePos(bot.entity.position); try { await bot.look(yaw, 0, true); } catch {} bot.setControlState("forward", true); diff --git a/runtime/skills/index.js b/runtime/skills/index.js index a00a414..4a19060 100644 --- a/runtime/skills/index.js +++ b/runtime/skills/index.js @@ -204,12 +204,20 @@ export async function runSkill(id, ctx, args = {}) { }; } if (!pre.ok) { - return { + const result = { ok: false, code: pre.code ?? RUNNER_CODES.PRECONDITION_FAILED, detail: pre.detail ?? "preconditions failed", worldDelta: null, }; + if (typeof skill.recover === "function") { + try { + result.recovery = skill.recover(ctx, result) ?? null; + } catch (e) { + warn("skill", `${id}.recover threw: ${e.message}`); + } + } + return result; } const timeoutMs = skill.timeoutMs ?? 30_000; diff --git a/runtime/skills/relocate.js b/runtime/skills/relocate.js index e751946..0964e96 100644 --- a/runtime/skills/relocate.js +++ b/runtime/skills/relocate.js @@ -18,12 +18,13 @@ const { pathfinder, goals, Movements } = pathfinderPkg; import { info } from "../log.js"; import { markRelocationStarted } from "../awareness/wedge-detector.js"; +import { blindWalkOrTunnelOut } from "./explore-far.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 }, + { name: "N", dx: 0, dz: -1, yaw: Math.PI }, + { name: "E", dx: 1, dz: 0, yaw: -Math.PI / 2 }, + { name: "S", dx: 0, dz: 1, yaw: 0 }, + { name: "W", dx: -1, dz: 0, yaw: Math.PI / 2 }, ]; const DEFAULT_DISTANCE = 300; const STEP_BLOCKS = 32; // re-path every N blocks for liveness @@ -37,7 +38,7 @@ function ensurePathfinder(bot) { } function setMovementsForTravel(bot) { const m = new Movements(bot); - m.canDig = false; + m.canDig = true; m.allow1by1towers = false; bot.pathfinder.setMovements(m); } @@ -100,9 +101,15 @@ export const skill = Object.freeze({ ]); } 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)); + info("action", `relocate: path step failed (${e?.message ?? e}); blind fallback ${cardinal.name}`); + const blind = await blindWalkOrTunnelOut(bot, { + yaw: cardinal.yaw, + dirName: cardinal.name, + blindMs: 12_000, + minMove: 8, + reason: `relocate ${cardinal.name}`, + }); + if (!blind.ok && errors.length >= 3) break; } // Measure actual progress (pathfinder might have routed around) const dx = bot.entity.position.x - start.x; diff --git a/runtime/skills/scout-food.js b/runtime/skills/scout-food.js index 4b27fa6..c663a89 100644 --- a/runtime/skills/scout-food.js +++ b/runtime/skills/scout-food.js @@ -37,15 +37,17 @@ const { pathfinder, goals, Movements } = pathfinderPkg; import { info, warn } from "../log.js"; import { foods } from "./groups.js"; import { affordancesFor, hasPassiveMobs, isBarren } from "../biome-affordances.js"; +import { blindWalkOrTunnelOut } from "./explore-far.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 }, + { name: "N", dx: 0, dz: -1, yaw: Math.PI }, + { name: "E", dx: 1, dz: 0, yaw: -Math.PI / 2 }, + { name: "S", dx: 0, dz: 1, yaw: 0 }, + { name: "W", dx: -1, dz: 0, yaw: Math.PI / 2 }, ]; const PATROL_TICK_DISTANCE = 16; +const PATROL_STEP_TIMEOUT_MS = 12_000; const DEFAULT_COMMIT_DISTANCE = 200; let pluginLoaded = new WeakSet(); @@ -57,7 +59,7 @@ function ensurePathfinder(bot) { function setMovementsForTravel(bot) { const m = new Movements(bot); - m.canDig = false; + m.canDig = true; m.allow1by1towers = false; bot.pathfinder.setMovements(m); } @@ -122,6 +124,27 @@ function scanForFoodCapableNeighbourBiome(bot, radius = 64) { return null; } +function scoutState(ctx, bot) { + const here = bot?.entity?.position; + const now = Date.now(); + const prev = ctx.scoutFoodState; + const expired = !prev || now - (prev.ts ?? 0) > 10 * 60_000; + const displaced = prev?.origin && here + ? Math.hypot(here.x - prev.origin.x, here.z - prev.origin.z) > 128 + : false; + if (expired || displaced) { + ctx.scoutFoodState = { + ts: now, + origin: here ? { x: here.x, z: here.z } : null, + tried: new Set(), + }; + return ctx.scoutFoodState; + } + prev.ts = now; + if (!(prev.tried instanceof Set)) prev.tried = new Set(prev.tried ?? []); + return prev; +} + async function patrolCardinal(bot, cardinal, distance, ctx) { ensurePathfinder(bot); setMovementsForTravel(bot); @@ -135,12 +158,39 @@ async function patrolCardinal(bot, cardinal, distance, ctx) { try { await Promise.race([ bot.pathfinder.goto(goal), - new Promise((_, rej) => setTimeout(() => rej(new Error("patrol step timeout")), 30_000)), + new Promise((_, rej) => setTimeout(() => rej(new Error("patrol step timeout")), PATROL_STEP_TIMEOUT_MS)), ]); } catch (e) { - return { aborted: false, travelled, error: e?.message ?? String(e) }; + info("action", `scout-food: path step failed (${e?.message ?? e}); blind fallback ${cardinal.name}`); + try { bot.pathfinder?.stop?.(); } catch {} + const blind = await blindWalkOrTunnelOut(bot, { + yaw: cardinal.yaw ?? -Math.atan2(cardinal.dx, cardinal.dz), + dirName: cardinal.name, + blindMs: 12_000, + minMove: 6, + reason: `scout-food ${cardinal.name}`, + }); + const progress = cardinalProgress(start, bot.entity.position, cardinal); + travelled = progress.along; + const target = nearestPassiveFoodMob(bot, 32); + if (target) return { aborted: false, travelled, target }; + if (progress.total >= 4 && progress.along < 4) { + info("action", `scout-food: ${cardinal.name} blocked; drifted ${progress.driftName ?? "sideways"} ${progress.total.toFixed(1)}b`); + return { + aborted: false, + travelled, + blocked: true, + drifted: progress.driftName, + error: `blocked_${cardinal.name}`, + }; + } + if (!blind.ok && progress.total < 4) { + return { aborted: false, travelled, error: e?.message ?? String(e) }; + } + continue; } - travelled += PATROL_TICK_DISTANCE; + const progress = cardinalProgress(start, bot.entity.position, cardinal); + travelled = Math.max(travelled + PATROL_TICK_DISTANCE, progress.along); // Rescan after every step. const target = nearestPassiveFoodMob(bot, 32); if (target) return { aborted: false, travelled, target }; @@ -148,6 +198,20 @@ async function patrolCardinal(bot, cardinal, distance, ctx) { return { aborted: false, travelled }; } +function cardinalProgress(start, pos, cardinal) { + const dx = (pos?.x ?? 0) - (start?.x ?? 0); + const dz = (pos?.z ?? 0) - (start?.z ?? 0); + const along = Math.max(0, dx * cardinal.dx + dz * cardinal.dz); + const total = Math.hypot(dx, dz); + return { along, total, driftName: dominantCardinal(dx, dz, cardinal.name) }; +} + +function dominantCardinal(dx, dz, fallback = null) { + if (Math.abs(dx) < 0.5 && Math.abs(dz) < 0.5) return fallback; + if (Math.abs(dx) >= Math.abs(dz)) return dx >= 0 ? "E" : "W"; + return dz >= 0 ? "S" : "N"; +} + export const skill = Object.freeze({ id: "survive.scout-food", title: "Scout for food at long range (biome-aware)", @@ -162,7 +226,8 @@ export const skill = Object.freeze({ async execute(ctx, args = {}) { const bot = ctx.bot; const before = foodCount(bot); - const triedCardinals = new Set(args?._triedCardinals ?? []); + const state = scoutState(ctx, bot); + const triedCardinals = new Set([...(args?._triedCardinals ?? []), ...(state.tried ?? [])]); // Step 0: biome check. If barren, head toward a food-capable neighbour. const biome = currentBiomeName(bot); @@ -174,14 +239,23 @@ export const skill = Object.freeze({ 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`, + if (result.aborted) return { ok: false, code: "preempted", worldDelta: null }; + if (result.target) { + return await tryHunt(bot, result.target, before); + } + if (result.blocked) { + state.tried.add(next.heading.name); + return { + ok: false, + code: "blocked_heading", + detail: `blocked ${next.heading.name}, drifted ${result.drifted ?? "sideways"}`, + worldDelta: { moved: Math.round(result.travelled), heading: next.heading.name, drifted: result.drifted ?? null }, + }; + } + 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 }, }; } @@ -208,14 +282,23 @@ export const skill = Object.freeze({ }; } const cardinal = untried[0]; + state.tried.add(cardinal.name); 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); + 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); + if (result.blocked) { return { ok: false, - code: "no_target", - detail: { tried: cardinal.name, travelled: Math.round(result.travelled), error: result.error ?? null }, + code: "blocked_heading", + detail: { tried: cardinal.name, travelled: Math.round(result.travelled), drifted: result.drifted ?? null, error: result.error ?? null }, + worldDelta: { moved: Math.round(result.travelled), heading: cardinal.name, drifted: result.drifted ?? null, from_biome: biome }, + }; + } + 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 }, }; }, @@ -223,6 +306,12 @@ export const skill = Object.freeze({ if (result.code === "exhausted") { return { hint: "relocate", reason: "scout-food exhausted all 4 cardinals; needs a long jump" }; } + if (result.code === "blocked_heading") { + return { hint: "scout-food", reason: "chosen scout heading is blocked; retry another cardinal" }; + } + if (result.code === "approached_target" || result.code === "no_path") { + return { hint: "scout-food", reason: "made or attempted progress toward food target; rescan from current position" }; + } if (result.code === "no_target") { return { hint: "wander", reason: "scout completed leg without finding mob; try another cardinal" }; } @@ -233,12 +322,42 @@ export const skill = Object.freeze({ async function tryHunt(bot, target, before) { ensurePathfinder(bot); setMovementsForTravel(bot); + const start = bot.entity.position.clone?.() ?? { ...bot.entity.position }; 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) { + try { bot.pathfinder?.stop?.(); } catch {} + const moved = horizontalDistance(start, bot.entity.position); + if (moved >= 6) { + return { + ok: false, + code: "approached_target", + detail: { target: target.entity.name, moved: Math.round(moved), mode: "pathfinder_partial", error: e?.message ?? "path failed" }, + worldDelta: { moved: Math.round(moved), target: target.entity.name, mode: "pathfinder_partial" }, + }; + } + const yaw = yawToward(bot.entity.position, target.entity.position); + if (yaw !== null) { + const blind = await blindWalkOrTunnelOut(bot, { + yaw, + dirName: `toward-${target.entity.name}`, + blindMs: 8_000, + minMove: 4, + reason: `scout-food target ${target.entity.name}`, + }); + const afterBlind = horizontalDistance(start, bot.entity.position); + if (blind.ok || afterBlind >= 4) { + return { + ok: false, + code: "approached_target", + detail: { target: target.entity.name, moved: Math.round(afterBlind), mode: "blind_target", error: e?.message ?? "path failed" }, + worldDelta: { moved: Math.round(afterBlind), target: target.entity.name, mode: "blind_target" }, + }; + } + } 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`); @@ -269,8 +388,21 @@ async function tryHunt(bot, target, before) { }; } +function horizontalDistance(a, b) { + return Math.hypot((b?.x ?? 0) - (a?.x ?? 0), (b?.z ?? 0) - (a?.z ?? 0)); +} + +function yawToward(from, to) { + if (!from || !to) return null; + const dx = to.x - from.x; + const dz = to.z - from.z; + if (Math.hypot(dx, dz) < 0.5) return null; + return -Math.atan2(dx, dz); +} + // Test exports export const __testing = { - CARDINALS, PATROL_TICK_DISTANCE, DEFAULT_COMMIT_DISTANCE, + CARDINALS, PATROL_TICK_DISTANCE, DEFAULT_COMMIT_DISTANCE, PATROL_STEP_TIMEOUT_MS, nearestPassiveFoodMob, currentBiomeName, scanForFoodCapableNeighbourBiome, + cardinalProgress, dominantCardinal, horizontalDistance, yawToward, }; diff --git a/scripts/show-story.js b/scripts/show-story.js index 282e223..0a0bb7c 100644 --- a/scripts/show-story.js +++ b/scripts/show-story.js @@ -16,6 +16,7 @@ import net from "node:net"; import { STORYLINE } from "../runtime/goal/storyline.js"; import { pickCurrentStep, progressSummary, _resetForTest } from "../runtime/goal/state.js"; import { socketPath } from "../runtime/config.js"; +import { COMMAND_TYPES, EVENT_TYPES } from "../runtime/ipc-protocol.js"; function plainCatalogue() { console.log("=== Storyline (canonical Minecraft survival arc) ==="); @@ -32,23 +33,31 @@ async function fetchSnapshotViaIpc() { const buf = []; const timer = setTimeout(() => { sock.destroy(); resolve(null); }, 1500); sock.on("connect", () => { - sock.write(JSON.stringify({ kind: "get-status" }) + "\n"); + sock.write(JSON.stringify({ type: COMMAND_TYPES.SNAPSHOT }) + "\n"); }); - sock.on("data", (chunk) => buf.push(chunk)); - sock.on("end", () => { - clearTimeout(timer); + const parse = () => { try { const raw = Buffer.concat(buf).toString("utf8").trim(); const lines = raw.split("\n").filter(Boolean); for (const ln of lines) { const obj = JSON.parse(ln); - if (obj?.kind === "status" && obj?.snapshot) { - resolve(obj.snapshot); - return; + if (obj?.type === EVENT_TYPES.STATUS && obj?.payload) { + clearTimeout(timer); + sock.destroy(); + resolve(obj.payload); + return true; } } - resolve(null); - } catch { resolve(null); } + } catch {} + return false; + }; + sock.on("data", (chunk) => { + buf.push(chunk); + parse(); + }); + sock.on("end", () => { + clearTimeout(timer); + if (!parse()) resolve(null); }); sock.on("error", () => { clearTimeout(timer); resolve(null); }); });