fix(v0.3.1): mechanical food/stuck fixes — bot reaches the chicken now
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 <noreply@anthropic.com>
This commit is contained in:
+25
-1
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)", () => {
|
||||
|
||||
+10
-1
@@ -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",
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
+49
-22
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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)", () => {
|
||||
|
||||
+73
-5
@@ -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.
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
+155
-23
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user