v0.3.1: survival behaviour overhaul — storyline, biome-aware scout, wedge-relocate, food/perf fixes, monitor TUI #28

Merged
halofourteen merged 9 commits from v0.3.1 into main 2026-05-28 09:43:57 +03:00
19 changed files with 548 additions and 106 deletions
Showing only changes of commit 1cf60f81e9 - Show all commits
+25 -1
View File
@@ -173,7 +173,31 @@ export async function fleeFrom(bot, fromEntity, distance = 16) {
); );
return { ok: true, detail: { to: { x: tx, y: ty, z: tz } } }; return { ok: true, detail: { to: { x: tx, y: ty, z: tz } } };
} catch (e) { } 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 }; return { ok: false, detail: e.message };
} }
} }
+6
View File
@@ -21,10 +21,13 @@ const SAFE_OVERRIDES = new Set([
"survive.flee", "survive.flee",
"survive.sleep", "survive.sleep",
"survive.eat", "survive.eat",
"survive.acquire-food",
"survive.scout-food",
"survive.pillar-up", "survive.pillar-up",
"recovery.tunnel-out", "recovery.tunnel-out",
"explore.far", "explore.far",
"explore.wander", "explore.wander",
"village.relocate",
"village.build-shelter", "village.build-shelter",
"village.choose-base", "village.choose-base",
]); ]);
@@ -45,6 +48,9 @@ const MODE_TO_SKILL = Object.freeze({
"tunnel-out": "recovery.tunnel-out", "tunnel-out": "recovery.tunnel-out",
explore: "explore.far", explore: "explore.far",
wander: "explore.far", wander: "explore.far",
scout_food: "survive.scout-food",
"scout-food": "survive.scout-food",
relocate: "village.relocate",
}); });
function normalisePreferSkill(raw) { function normalisePreferSkill(raw) {
+2
View File
@@ -115,6 +115,8 @@ test("normalisePreferSkill: 'survive_flee' shape gets translated to dot form", (
test("normalisePreferSkill: passes through known dot-form skills unchanged", () => { test("normalisePreferSkill: passes through known dot-form skills unchanged", () => {
assert.equal(normalisePreferSkill("survive.flee"), "survive.flee"); assert.equal(normalisePreferSkill("survive.flee"), "survive.flee");
assert.equal(normalisePreferSkill("explore.far"), "explore.far"); 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)", () => { test("normalisePreferSkill: unknown values rejected (returns null)", () => {
+10 -1
View File
@@ -72,6 +72,7 @@ function hasAny(inv, names) {
const WOODEN_TOOLS = ["wooden_axe", "wooden_pickaxe", "wooden_sword"]; const WOODEN_TOOLS = ["wooden_axe", "wooden_pickaxe", "wooden_sword"];
const STONE_TOOLS = ["stone_axe", "stone_pickaxe", "stone_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 // 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 // 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)); 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 = [ const MILESTONES = [
{ {
id: "wood.16", id: "wood.16",
@@ -157,7 +164,9 @@ const MILESTONES = [
); );
return carrying || (snap?.food ?? 20) >= 18; 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", id: "storage.chest",
+2 -2
View File
@@ -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 })); const got = nextMilestone(snapAfter("stone.tools", {}, { food: 8 }));
assert.equal(got.milestone.id, "food.basic"); 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", () => { test("storage.chest crafts first, then places carried chest", () => {
+49 -22
View File
@@ -52,6 +52,7 @@ const FOOD_ITEMS = [
"apple", "carrot", "potato", "beetroot", "melon_slice", "sweet_berries", "apple", "carrot", "potato", "beetroot", "melon_slice", "sweet_berries",
"golden_apple", "golden_carrot", "golden_apple", "golden_carrot",
]; ];
const PASSIVE_FOOD_MOBS = new Set(["cow", "pig", "chicken", "sheep", "rabbit", "mooshroom"]);
function hasSetItem(inv, set) { function hasSetItem(inv, set) {
if (!inv) return false; if (!inv) return false;
@@ -92,6 +93,27 @@ function countPlanks(inv) {
return total; 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) { function emergencyPause(snap) {
if (!snap?.connected) return false; if (!snap?.connected) return false;
const hp = snap.health ?? 20; 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 // scan radius might never see logs/stone/crops/beds, and we
// were getting stuck on step 1 for hours. // were getting stuck on step 1 for hours.
if (hp < 18) return false; if (hp < 18) return false;
const sawBlocks = (snap.nearbyBlocks?.logs ?? 0) const sawBlocks = blockCount(snap, "logs")
+ (snap.nearbyBlocks?.stone ?? 0) + blockCount(snap, "stone")
+ (snap.nearbyBlocks?.crops ?? 0) + blockCount(snap, "crops")
+ (snap.nearbyBlocks?.beds ?? 0) + blockCount(snap, "beds") > 0;
> 0;
if (sawBlocks) return true; if (sawBlocks) return true;
// Fallback: settled for long enough → call orient done and let // Fallback: settled for long enough → call orient done and let
// later steps drive forward into the biome. // later steps drive forward into the biome.
@@ -140,13 +161,17 @@ export const STORYLINE = Object.freeze([
{ {
id: "first_wood", id: "first_wood",
title: "Собрать 8 поленьев", title: "Собрать стартовое дерево",
narration_ru: "Цель: 8 поленьев. Иду рубить ближайшие деревья.", narration_ru: "Нужно дерево для первого крафта. Доберу минимум и сразу к верстаку.",
completed(snap) { 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) { suggestSkill(snap) {
const trees = snap?.nearbyBlocks?.logs ?? 0; const trees = blockCount(snap, "logs");
if (trees > 0) return { skillId: "gather.logs" }; if (trees > 0) return { skillId: "gather.logs" };
// No tree in sight — scout further. In a biome with no trees // No tree in sight — scout further. In a biome with no trees
// (desert, ocean) the bot must commit to a long heading; the // (desert, ocean) the bot must commit to a long heading; the
@@ -163,17 +188,16 @@ export const STORYLINE = Object.freeze([
narration_ru: "Делаю верстак и палки — без них ничего не скрафтить.", narration_ru: "Делаю верстак и палки — без них ничего не скрафтить.",
completed(snap) { completed(snap) {
const inv = snap?.inventory ?? {}; const inv = snap?.inventory ?? {};
return (inv.crafting_table ?? 0) > 0 return ((inv.stick ?? 0) >= 2 && countPlanks(inv) >= 4)
&& (inv.stick ?? 0) >= 2 || hasSetItem(inv, PICKAXE_WOOD)
&& countPlanks(inv) >= 4; || hasSetItem(inv, AXE_WOOD)
|| hasSetItem(inv, SWORD_WOOD);
}, },
suggestSkill(snap) { suggestSkill(snap) {
const inv = snap?.inventory ?? {}; const inv = snap?.inventory ?? {};
if (countPlanks(inv) < 4) return { skillId: "craft.planks" }; if (countPlanks(inv) < 4) return { skillId: "craft.planks" };
if ((inv.stick ?? 0) < 2) return { skillId: "craft.sticks" }; if ((inv.stick ?? 0) < 2) return { skillId: "craft.sticks" };
// We have raw materials, need to *place* a crafting table for tools. return null;
// (No place-table skill yet — flagged as improvement_request elsewhere.)
return { skillId: "craft.sticks" };
}, },
emergencyPause, emergencyPause,
}, },
@@ -190,6 +214,11 @@ export const STORYLINE = Object.freeze([
}, },
suggestSkill(snap) { suggestSkill(snap) {
const inv = snap?.inventory ?? {}; 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, PICKAXE_WOOD)) return { skillId: "craft.wooden-pickaxe" };
if (!hasSetItem(inv, AXE_WOOD)) return { skillId: "craft.wooden-axe" }; if (!hasSetItem(inv, AXE_WOOD)) return { skillId: "craft.wooden-axe" };
if (!hasSetItem(inv, SWORD_WOOD)) return { skillId: "craft.wooden-sword" }; 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 // It commits to a cardinal for ~200 blocks, rescans, and
// on biome boundary detection heads toward food-capable // on biome boundary detection heads toward food-capable
// terrain. // terrain.
const hasPassiveNearby = (snap?.nearbyEntities?.passives?.length ?? 0) > 0; if (hasLocalFoodMob(snap)) return { skillId: "survive.acquire-food" };
if (hasPassiveNearby) return { skillId: "survive.acquire-food" };
return { skillId: "survive.scout-food" }; return { skillId: "survive.scout-food" };
}, },
emergencyPause, emergencyPause,
@@ -225,7 +253,7 @@ export const STORYLINE = Object.freeze([
title: "Простой шелтер с кроватью", title: "Простой шелтер с кроватью",
narration_ru: "Поставлю кровать и стены — пережить ночь.", narration_ru: "Поставлю кровать и стены — пережить ночь.",
completed(snap) { completed(snap) {
return (snap?.nearbyBlocks?.beds ?? 0) > 0; return blockCount(snap, "beds") > 0;
}, },
suggestSkill(snap) { suggestSkill(snap) {
const inv = snap?.inventory ?? {}; const inv = snap?.inventory ?? {};
@@ -272,7 +300,7 @@ export const STORYLINE = Object.freeze([
}, },
suggestSkill(snap) { suggestSkill(snap) {
const inv = snap?.inventory ?? {}; 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: "farm.wheat" };
} }
return { skillId: "survive.acquire-food" }; return { skillId: "survive.acquire-food" };
@@ -300,8 +328,7 @@ export const STORYLINE = Object.freeze([
title: "Постоянная база", title: "Постоянная база",
narration_ru: "Выбираю место под деревню — нужно нормальное основание.", narration_ru: "Выбираю место под деревню — нужно нормальное основание.",
completed(snap) { completed(snap) {
const nb = snap?.nearbyBlocks ?? {}; return blockCount(snap, "beds") >= 1 && blockCount(snap, "storage") >= 1;
return (nb.beds ?? 0) >= 1 && (nb.storage ?? 0) >= 1;
}, },
suggestSkill(snap) { suggestSkill(snap) {
const inv = snap?.inventory ?? {}; const inv = snap?.inventory ?? {};
@@ -335,5 +362,5 @@ export function getStep(id) {
// Test exports // Test exports
export const __testing = { export const __testing = {
countLogs, countPlanks, countAny, hasAny, hasSetItem, countLogs, countPlanks, countAny, hasAny, hasSetItem,
FOOD_ITEMS, BED_ITEMS, emergencyPause, FOOD_ITEMS, BED_ITEMS, emergencyPause, blockCount, woodBudget, hasLocalFoodMob,
}; };
+35 -4
View File
@@ -37,7 +37,7 @@ test("STORYLINE: 11 steps, all have id/title/narration/completed/suggestSkill",
}); });
test("getStep: lookup by id", () => { 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); 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); 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"); const s = getStep("first_wood");
assert.equal(s.completed(snap()), false); 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 } })), true);
assert.equal(s.completed(snap({ inventory: { oak_log: 4, birch_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", () => { test("step first_wood: suggest gather.logs if trees nearby, explore.far otherwise", () => {
const s = getStep("first_wood"); const s = getStep("first_wood");
assert.equal(s.suggestSkill(snap({ nearbyBlocks: { logs: 5 } })).skillId, "gather.logs"); 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"); 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); 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", () => { test("step shelter_minimal: completed when bed placed nearby", () => {
const s = getStep("shelter_minimal"); const s = getStep("shelter_minimal");
assert.equal(s.completed(snap()), false); assert.equal(s.completed(snap()), false);
assert.equal(s.completed(snap({ nearbyBlocks: { beds: 1 } })), true); 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", () => { 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"); 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", () => { test("pickCurrentStep: emergency pauses suggestion", () => {
_resetForTest(); _resetForTest();
const r = pickCurrentStep(snap({ const r = pickCurrentStep(snap({
+21 -8
View File
@@ -37,6 +37,7 @@ const BED_ITEMS = [
"lime_bed", "pink_bed", "gray_bed", "light_gray_bed", "cyan_bed", "lime_bed", "pink_bed", "gray_bed", "light_gray_bed", "cyan_bed",
"purple_bed", "blue_bed", "brown_bed", "green_bed", "red_bed", "black_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) { function hasAny(inv, names) {
if (!inv) return false; if (!inv) return false;
@@ -77,6 +78,18 @@ function hostileImminent(s) {
return (h.distance ?? Infinity) < 8; 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) { function aliveDetect(s) {
if (!s?.connected) return true; // not connected, nothing to do if (!s?.connected) return true; // not connected, nothing to do
const hp = s.health ?? 20; const hp = s.health ?? 20;
@@ -98,7 +111,7 @@ function alivePursue(s) {
return { skillId: "survive.eat" }; return { skillId: "survive.eat" };
} }
if (food <= 0 && !s.hasFood) { if (food <= 0 && !s.hasFood) {
return { skillId: "survive.acquire-food" }; return { skillId: hasVisibleFoodTarget(s) ? "survive.acquire-food" : "survive.scout-food" };
} }
if (hostileImminent(s)) { if (hostileImminent(s)) {
return { skillId: "survive.flee" }; return { skillId: "survive.flee" };
@@ -119,7 +132,7 @@ function foodPursue(s) {
if ((s.food ?? 20) < 16 && s.hasFood) { if ((s.food ?? 20) < 16 && s.hasFood) {
return { skillId: "survive.eat" }; return { skillId: "survive.eat" };
} }
return { skillId: "survive.acquire-food" }; return { skillId: hasVisibleFoodTarget(s) ? "survive.acquire-food" : "survive.scout-food" };
} }
function toolsWoodDetect(s) { function toolsWoodDetect(s) {
@@ -134,7 +147,7 @@ function toolsWoodPursue(s) {
const logs = countLogs(inv); const logs = countLogs(inv);
const sticks = inv.stick ?? 0; const sticks = inv.stick ?? 0;
const hasWb = (inv.crafting_table ?? 0) > 0 const hasWb = (inv.crafting_table ?? 0) > 0
|| (s.nearbyBlocks?.craftingTable ?? 0) > 0; || blockCount(s, "craftingTable") > 0;
if (logs < 2 && planks < 4 && !hasWb) { if (logs < 2 && planks < 4 && !hasWb) {
return { skillId: "gather.logs" }; return { skillId: "gather.logs" };
@@ -159,7 +172,7 @@ function toolsWoodPursue(s) {
function shelterBasicDetect(s) { function shelterBasicDetect(s) {
const inv = s?.inventory ?? {}; const inv = s?.inventory ?? {};
const bedPlaced = (s.nearbyBlocks?.beds ?? 0) > 0; const bedPlaced = blockCount(s, "beds") > 0;
return bedPlaced || hasAny(inv, BED_ITEMS); return bedPlaced || hasAny(inv, BED_ITEMS);
} }
@@ -233,10 +246,10 @@ function foodSecurityDetect(s) {
} }
function foodSecurityPursue(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: "farm.wheat" };
} }
return { skillId: "survive.acquire-food" }; return { skillId: hasVisibleFoodTarget(s) ? "survive.acquire-food" : "survive.scout-food" };
} }
function toolsIronDetect(s) { function toolsIronDetect(s) {
@@ -265,7 +278,7 @@ function villageSeedDetect(s) {
// Heuristic: at least one chest placed AND one bed placed within // Heuristic: at least one chest placed AND one bed placed within
// nearby radius. Tightens later (POIs of kind "structure"). // nearby radius. Tightens later (POIs of kind "structure").
const nb = s?.nearbyBlocks ?? {}; 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) { function villageSeedPursue(s) {
@@ -310,5 +323,5 @@ export function getNeed(id) {
// Test exports // Test exports
export const __testing = { export const __testing = {
hasAny, countAny, countLogs, countPlanks, hasAny, countAny, countLogs, countPlanks,
FOOD_ITEMS, BED_ITEMS, ARMOR_CHEST_ANY, FOOD_ITEMS, BED_ITEMS, ARMOR_CHEST_ANY, hasVisibleFoodTarget, blockCount,
}; };
+35 -1
View File
@@ -54,13 +54,38 @@ test("L0 alive: zero food and have food → eat", () => {
assert.equal(n.pursue(s).skillId, "survive.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 n = getNeed("alive");
const s = snap({ food: 0, hasFood: false }); const s = snap({ food: 0, hasFood: false });
assert.equal(n.detect(s), 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"); 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", () => { test("L1 food: 6+ food items → satisfied", () => {
const n = getNeed("food"); const n = getNeed("food");
assert.equal(n.detect(snap({ food: 10, inventory: { bread: 6 } })), true); 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); 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", () => { test("L2 tools_wood: starts with no logs → gather.logs", () => {
const n = getNeed("tools_wood"); const n = getNeed("tools_wood");
const s = snap(); const s = snap();
@@ -105,6 +137,7 @@ test("L2 tools_wood: progression to pickaxe → axe → sword", () => {
test("L3 shelter_basic: bed nearby → satisfied", () => { test("L3 shelter_basic: bed nearby → satisfied", () => {
const n = getNeed("shelter_basic"); const n = getNeed("shelter_basic");
assert.equal(n.detect(snap({ nearbyBlocks: { beds: 1 } })), true); 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({ inventory: { red_bed: 1 } })), true);
assert.equal(n.detect(snap()), false); 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", () => { test("L9 village_seed: bed + storage nearby → satisfied", () => {
const n = getNeed("village_seed"); const n = getNeed("village_seed");
assert.equal(n.detect(snap({ nearbyBlocks: { beds: 1, storage: 1 } })), true); 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", () => { test("L9 village_seed: no chest → craft.chest if enough planks", () => {
+3 -3
View File
@@ -30,14 +30,14 @@ test("pickActiveNeed: fresh spawn → L0 alive if zero food", () => {
_resetForTest(); _resetForTest();
const a = pickActiveNeed(snap({ food: 0 })); const a = pickActiveNeed(snap({ food: 0 }));
assert.equal(a.need.id, "alive"); 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(); _resetForTest();
const a = pickActiveNeed(snap()); const a = pickActiveNeed(snap());
assert.equal(a.need.id, "food"); 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)", () => { test("pickActiveNeed: food covered → L2 tools_wood (gather logs)", () => {
+70 -2
View File
@@ -375,6 +375,39 @@ function metricRecoverySkill(ctx, plannedSkillId) {
return null; 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 // v0.2.0-rc.3 — wedged-emergency escape. When the bot has not made
// meaningful horizontal progress for ≥ 60s AND there's no immediate // meaningful horizontal progress for ≥ 60s AND there's no immediate
// hostile (defendReflex would have handled it) AND a placeable block // hostile (defendReflex would have handled it) AND a placeable block
@@ -443,6 +476,10 @@ function curriculumReflex(ctx) {
const plan = s.curriculum?.plan; const plan = s.curriculum?.plan;
const wanderHintUntil = ctx.skillBackoff?.["__wander_hint__"] ?? 0; const wanderHintUntil = ctx.skillBackoff?.["__wander_hint__"] ?? 0;
const wantWander = wanderHintUntil && Date.now() < wanderHintUntil; 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 // v0.3.0-rc.2 — manifesto layer. Walk the L0-L10 needs ladder; the
// lowest unsatisfied need dictates the planned skill. The curriculum // 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. // No skill plan from curriculum OR a recent skill asked us to wander.
// First hint → small wander (might just be 32-block reach issue). // First hint → small wander (might just be 32-block reach issue).
// Every subsequent hint while still inside the backoff window → use // Every subsequent hint while still inside the backoff window → use
@@ -595,11 +650,16 @@ function curriculumReflex(ctx) {
if (!ctx.disableAdvisor) { if (!ctx.disableAdvisor) {
const rec = consumeFreshRecommendation(ctx); const rec = consumeFreshRecommendation(ctx);
if (rec && rec.skillId) { if (rec && rec.skillId) {
info(REFLEX_LOG, `advisor override: ${skillId}${rec.skillId} (${rec.triggerReason}, ${rec.rationale?.slice(0, 60)})`); const resolved = resolveAdvisorSkill(ctx, rec, skillId);
skillId = rec.skillId; if (resolved) {
info(REFLEX_LOG, `advisor override: ${skillId}${resolved} (${rec.triggerReason}, ${rec.rationale?.slice(0, 60)})`);
skillId = resolved;
skillSource = `advisor:${rec.triggerReason}`; skillSource = `advisor:${rec.triggerReason}`;
appliedRecommendationId = rec.id ?? null; appliedRecommendationId = rec.id ?? null;
if (appliedRecommendationId) markRecommendationApplied(appliedRecommendationId); 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 // Always fire-and-forget another advise() if triggers fire — the
// result lands on a future tick. tickAdvisor handles its own // 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; ctx.skillBackoff["__wander_hint__"] = Date.now() + SKILL_BACKOFF_MS;
consecutiveWanderHints++; 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) { if (!res?.ok) {
// missing_tool / missing_material / no_target shouldn't be // missing_tool / missing_material / no_target shouldn't be
// retried on the very next tick. Hold for SKILL_BACKOFF_MS. // retried on the very next tick. Hold for SKILL_BACKOFF_MS.
+2 -2
View File
@@ -244,7 +244,7 @@ test("curriculum dispatches suggested skill by id", () => {
assert.ok(typeof dispatches[0].opts.onComplete === "function"); 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({ const { ctx, dispatches } = makeCtx({
disableManifesto: false, disableManifesto: false,
disableStoryline: true, disableStoryline: true,
@@ -263,7 +263,7 @@ test("manifesto: hungry bot with no food drives survive.acquire-food (manifesto
}); });
const out = runTick(ctx); const out = runTick(ctx);
assert.equal(out.reflex, "curriculum"); 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"); assert.equal(ctx.activeNeed?.need?.id, "food");
}); });
+51 -4
View File
@@ -8,6 +8,7 @@ const { pathfinder, goals, Movements } = pathfinderPkg;
import { info, warn } from "../log.js"; import { info, warn } from "../log.js";
import { foods } from "./groups.js"; import { foods } from "./groups.js";
import { blindWalkOrTunnelOut } from "./explore-far.js";
const PASSIVE_FOOD_MOBS = new Set(["cow", "pig", "chicken", "sheep", "rabbit", "mooshroom"]); 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); .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) { async function pickupNearbyDrops(bot) {
ensurePathfinder(bot); ensurePathfinder(bot);
setMovementsForTravel(bot); setMovementsForTravel(bot);
@@ -115,7 +151,18 @@ export const skill = Object.freeze({
"pathToFoodMob", "pathToFoodMob",
); );
} catch (e) { } 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)`); 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) { recover(ctx, result) {
if (result.code === "no_target" || result.code === "no_path") { if (result.code === "no_target" || result.code === "no_path" || result.code === "approached_target" || result.code === "no_drop") {
return { hint: "wander", reason: "need to search for passive food mobs" }; return { hint: "scout-food", reason: "need a long-range food search, not local acquire-food retry" };
} }
return null; return null;
}, },
}); });
export const _internal = { foodCount, nearestPassiveFoodMob }; export const _internal = { foodCount, nearestPassiveFoodMob, yawToward, horizontalDistance };
+34
View File
@@ -8,6 +8,7 @@ import { test } from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { runSkill, RUNNER_CODES, _registerForTest } from "./index.js"; import { runSkill, RUNNER_CODES, _registerForTest } from "./index.js";
import { __testing as scoutFoodTesting } from "./scout-food.js";
const ctx = {}; // skills under test ignore ctx fully 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 () => { test("gather.logs precondition refuses nearby hostiles", async () => {
const bot = { registry: { blocksByName: { oak_log: { id: 1 } } } }; const bot = { registry: { blocksByName: { oak_log: { id: 1 } } } };
const res = await runSkill("gather.logs", { 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/); 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 () => { test("preconditions that throw produce precondition_failed", async () => {
const teardown = _registerForTest({ const teardown = _registerForTest({
id: "test.precondition-throw", id: "test.precondition-throw",
+3 -12
View File
@@ -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}`); 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) { if (best.dist < 0.5) {
// All cardinals blocked. Try the cheap vertical escape first; if it // All cardinals blocked. Try the cheap vertical escape first; if it
// does not actually move us, carve a short horizontal tunnel. The // does not actually move us, carve a short horizontal tunnel. The
@@ -122,7 +112,8 @@ export const skill = Object.freeze({
return blindWalkOrTunnelOut(bot, { return blindWalkOrTunnelOut(bot, {
yaw: best.yaw, yaw: best.yaw,
dirName: best.name, 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, tunnelPushMs: args.tunnelPushMs,
reason: `explore.far blind ${best.name}`, reason: `explore.far blind ${best.name}`,
intended: { x: tx, y: ty, z: tz }, 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); const before = clonePos(bot.entity.position);
try { await bot.look(yaw, 0, true); } catch {} try { await bot.look(yaw, 0, true); } catch {}
bot.setControlState("forward", true); bot.setControlState("forward", true);
+9 -1
View File
@@ -204,12 +204,20 @@ export async function runSkill(id, ctx, args = {}) {
}; };
} }
if (!pre.ok) { if (!pre.ok) {
return { const result = {
ok: false, ok: false,
code: pre.code ?? RUNNER_CODES.PRECONDITION_FAILED, code: pre.code ?? RUNNER_CODES.PRECONDITION_FAILED,
detail: pre.detail ?? "preconditions failed", detail: pre.detail ?? "preconditions failed",
worldDelta: null, 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; const timeoutMs = skill.timeoutMs ?? 30_000;
+15 -8
View File
@@ -18,12 +18,13 @@ const { pathfinder, goals, Movements } = pathfinderPkg;
import { info } from "../log.js"; import { info } from "../log.js";
import { markRelocationStarted } from "../awareness/wedge-detector.js"; import { markRelocationStarted } from "../awareness/wedge-detector.js";
import { blindWalkOrTunnelOut } from "./explore-far.js";
const CARDINALS = [ const CARDINALS = [
{ name: "N", dx: 0, dz: -1 }, { name: "N", dx: 0, dz: -1, yaw: Math.PI },
{ name: "E", dx: 1, dz: 0 }, { name: "E", dx: 1, dz: 0, yaw: -Math.PI / 2 },
{ name: "S", dx: 0, dz: 1 }, { name: "S", dx: 0, dz: 1, yaw: 0 },
{ name: "W", dx: -1, dz: 0 }, { name: "W", dx: -1, dz: 0, yaw: Math.PI / 2 },
]; ];
const DEFAULT_DISTANCE = 300; const DEFAULT_DISTANCE = 300;
const STEP_BLOCKS = 32; // re-path every N blocks for liveness const STEP_BLOCKS = 32; // re-path every N blocks for liveness
@@ -37,7 +38,7 @@ function ensurePathfinder(bot) {
} }
function setMovementsForTravel(bot) { function setMovementsForTravel(bot) {
const m = new Movements(bot); const m = new Movements(bot);
m.canDig = false; m.canDig = true;
m.allow1by1towers = false; m.allow1by1towers = false;
bot.pathfinder.setMovements(m); bot.pathfinder.setMovements(m);
} }
@@ -100,9 +101,15 @@ export const skill = Object.freeze({
]); ]);
} catch (e) { } catch (e) {
errors.push(e?.message ?? String(e)); errors.push(e?.message ?? String(e));
if (errors.length >= 3) break; info("action", `relocate: path step failed (${e?.message ?? e}); blind fallback ${cardinal.name}`);
// brief pause then keep trying const blind = await blindWalkOrTunnelOut(bot, {
await new Promise((r) => setTimeout(r, 500)); 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) // Measure actual progress (pathfinder might have routed around)
const dx = bot.entity.position.x - start.x; const dx = bot.entity.position.x - start.x;
+141 -9
View File
@@ -37,15 +37,17 @@ const { pathfinder, goals, Movements } = pathfinderPkg;
import { info, warn } from "../log.js"; import { info, warn } from "../log.js";
import { foods } from "./groups.js"; import { foods } from "./groups.js";
import { affordancesFor, hasPassiveMobs, isBarren } from "../biome-affordances.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 PASSIVE_FOOD_MOBS = new Set(["cow", "pig", "chicken", "sheep", "rabbit", "mooshroom"]);
const CARDINALS = [ const CARDINALS = [
{ name: "N", dx: 0, dz: -1 }, { name: "N", dx: 0, dz: -1, yaw: Math.PI },
{ name: "E", dx: 1, dz: 0 }, { name: "E", dx: 1, dz: 0, yaw: -Math.PI / 2 },
{ name: "S", dx: 0, dz: 1 }, { name: "S", dx: 0, dz: 1, yaw: 0 },
{ name: "W", dx: -1, dz: 0 }, { name: "W", dx: -1, dz: 0, yaw: Math.PI / 2 },
]; ];
const PATROL_TICK_DISTANCE = 16; const PATROL_TICK_DISTANCE = 16;
const PATROL_STEP_TIMEOUT_MS = 12_000;
const DEFAULT_COMMIT_DISTANCE = 200; const DEFAULT_COMMIT_DISTANCE = 200;
let pluginLoaded = new WeakSet(); let pluginLoaded = new WeakSet();
@@ -57,7 +59,7 @@ function ensurePathfinder(bot) {
function setMovementsForTravel(bot) { function setMovementsForTravel(bot) {
const m = new Movements(bot); const m = new Movements(bot);
m.canDig = false; m.canDig = true;
m.allow1by1towers = false; m.allow1by1towers = false;
bot.pathfinder.setMovements(m); bot.pathfinder.setMovements(m);
} }
@@ -122,6 +124,27 @@ function scanForFoodCapableNeighbourBiome(bot, radius = 64) {
return null; 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) { async function patrolCardinal(bot, cardinal, distance, ctx) {
ensurePathfinder(bot); ensurePathfinder(bot);
setMovementsForTravel(bot); setMovementsForTravel(bot);
@@ -135,12 +158,39 @@ async function patrolCardinal(bot, cardinal, distance, ctx) {
try { try {
await Promise.race([ await Promise.race([
bot.pathfinder.goto(goal), 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) { } catch (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) }; return { aborted: false, travelled, error: e?.message ?? String(e) };
} }
travelled += PATROL_TICK_DISTANCE; continue;
}
const progress = cardinalProgress(start, bot.entity.position, cardinal);
travelled = Math.max(travelled + PATROL_TICK_DISTANCE, progress.along);
// Rescan after every step. // Rescan after every step.
const target = nearestPassiveFoodMob(bot, 32); const target = nearestPassiveFoodMob(bot, 32);
if (target) return { aborted: false, travelled, target }; if (target) return { aborted: false, travelled, target };
@@ -148,6 +198,20 @@ async function patrolCardinal(bot, cardinal, distance, ctx) {
return { aborted: false, travelled }; 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({ export const skill = Object.freeze({
id: "survive.scout-food", id: "survive.scout-food",
title: "Scout for food at long range (biome-aware)", title: "Scout for food at long range (biome-aware)",
@@ -162,7 +226,8 @@ export const skill = Object.freeze({
async execute(ctx, args = {}) { async execute(ctx, args = {}) {
const bot = ctx.bot; const bot = ctx.bot;
const before = foodCount(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. // Step 0: biome check. If barren, head toward a food-capable neighbour.
const biome = currentBiomeName(bot); const biome = currentBiomeName(bot);
@@ -178,6 +243,15 @@ export const skill = Object.freeze({
if (result.target) { if (result.target) {
return await tryHunt(bot, result.target, before); 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 { return {
ok: false, ok: false,
code: "no_target", code: "no_target",
@@ -208,10 +282,19 @@ export const skill = Object.freeze({
}; };
} }
const cardinal = untried[0]; const cardinal = untried[0];
state.tried.add(cardinal.name);
info("action", `scout-food: commit cardinal ${cardinal.name} for ${DEFAULT_COMMIT_DISTANCE}b`); info("action", `scout-food: commit cardinal ${cardinal.name} for ${DEFAULT_COMMIT_DISTANCE}b`);
const result = await patrolCardinal(bot, cardinal, DEFAULT_COMMIT_DISTANCE, ctx); const result = await patrolCardinal(bot, cardinal, DEFAULT_COMMIT_DISTANCE, ctx);
if (result.aborted) return { ok: false, code: "preempted", worldDelta: null }; if (result.aborted) return { ok: false, code: "preempted", worldDelta: null };
if (result.target) return await tryHunt(bot, result.target, before); if (result.target) return await tryHunt(bot, result.target, before);
if (result.blocked) {
return {
ok: false,
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 { return {
ok: false, ok: false,
code: "no_target", code: "no_target",
@@ -223,6 +306,12 @@ export const skill = Object.freeze({
if (result.code === "exhausted") { if (result.code === "exhausted") {
return { hint: "relocate", reason: "scout-food exhausted all 4 cardinals; needs a long jump" }; 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") { if (result.code === "no_target") {
return { hint: "wander", reason: "scout completed leg without finding mob; try another cardinal" }; 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) { async function tryHunt(bot, target, before) {
ensurePathfinder(bot); ensurePathfinder(bot);
setMovementsForTravel(bot); setMovementsForTravel(bot);
const start = bot.entity.position.clone?.() ?? { ...bot.entity.position };
try { try {
await Promise.race([ await Promise.race([
bot.pathfinder.goto(new goals.GoalFollow(target.entity, 2)), bot.pathfinder.goto(new goals.GoalFollow(target.entity, 2)),
new Promise((_, rej) => setTimeout(() => rej(new Error("path-to-mob timeout")), 30_000)), new Promise((_, rej) => setTimeout(() => rej(new Error("path-to-mob timeout")), 30_000)),
]); ]);
} catch (e) { } 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 }; 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`); 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 // Test exports
export const __testing = { export const __testing = {
CARDINALS, PATROL_TICK_DISTANCE, DEFAULT_COMMIT_DISTANCE, CARDINALS, PATROL_TICK_DISTANCE, DEFAULT_COMMIT_DISTANCE, PATROL_STEP_TIMEOUT_MS,
nearestPassiveFoodMob, currentBiomeName, scanForFoodCapableNeighbourBiome, nearestPassiveFoodMob, currentBiomeName, scanForFoodCapableNeighbourBiome,
cardinalProgress, dominantCardinal, horizontalDistance, yawToward,
}; };
+18 -9
View File
@@ -16,6 +16,7 @@ import net from "node:net";
import { STORYLINE } from "../runtime/goal/storyline.js"; import { STORYLINE } from "../runtime/goal/storyline.js";
import { pickCurrentStep, progressSummary, _resetForTest } from "../runtime/goal/state.js"; import { pickCurrentStep, progressSummary, _resetForTest } from "../runtime/goal/state.js";
import { socketPath } from "../runtime/config.js"; import { socketPath } from "../runtime/config.js";
import { COMMAND_TYPES, EVENT_TYPES } from "../runtime/ipc-protocol.js";
function plainCatalogue() { function plainCatalogue() {
console.log("=== Storyline (canonical Minecraft survival arc) ==="); console.log("=== Storyline (canonical Minecraft survival arc) ===");
@@ -32,23 +33,31 @@ async function fetchSnapshotViaIpc() {
const buf = []; const buf = [];
const timer = setTimeout(() => { sock.destroy(); resolve(null); }, 1500); const timer = setTimeout(() => { sock.destroy(); resolve(null); }, 1500);
sock.on("connect", () => { 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)); const parse = () => {
sock.on("end", () => {
clearTimeout(timer);
try { try {
const raw = Buffer.concat(buf).toString("utf8").trim(); const raw = Buffer.concat(buf).toString("utf8").trim();
const lines = raw.split("\n").filter(Boolean); const lines = raw.split("\n").filter(Boolean);
for (const ln of lines) { for (const ln of lines) {
const obj = JSON.parse(ln); const obj = JSON.parse(ln);
if (obj?.kind === "status" && obj?.snapshot) { if (obj?.type === EVENT_TYPES.STATUS && obj?.payload) {
resolve(obj.snapshot); clearTimeout(timer);
return; sock.destroy();
resolve(obj.payload);
return true;
} }
} }
resolve(null); } catch {}
} catch { resolve(null); } 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); }); sock.on("error", () => { clearTimeout(timer); resolve(null); });
}); });