feat(runtime): autonomous reflex — chop wood + wander + proactive movement (#8)
Closes the "bot stands on a tree doing nothing" problem reported live when the operator launched the TUI after PR #6 landed. The reactive chain (operator > defend > eat > sleep > idle) was passive by design: day-time, full HP and food, no hostile within 4 m ⇒ every reflex returned noop. The bot perched in dark-oak canopy and never moved. Changes runtime/actions.js: - chopNearestTree: find any *_log within 32 blocks, equip best axe (falls back to fists), path to the block, dig. Per-bot 5-min blacklist of unreachable log positions so we don't grind on the same impossible target. - wander: pick a random offset 6-16 blocks away and path there. - setMovementsForGather / setMovementsForTravel: every action that uses pathfinder now sets its own Movements profile (canDig=true) instead of inheriting whatever the previous caller left. The old behaviour caused chop to inherit flee's canDig=false and get stuck in the canopy. - fleeFrom now uses canDig=true too — the user observed the bot permanently stuck on a leaf block because escape required digging. runtime/reflex.js: - new autonomousReflex between sleep and idle. Cooldown 10s. Picks chop when log count < 16, else wander. When chop reports "no reachable log within 32 blocks" we switch to wander for 60s so we don't re-fire chop against the same impossible position. - defendReflex tightened: only flee when closest is ≤8m (or ≤12m on low HP). Avoids the "82 distant hostiles ⇒ constant flee loop" pathology observed at this spawn. - flee cooldown: same mob name within 60s ⇒ noop, so we yield to other reflexes if flee keeps timing out. - sleepReflex retry cooldown raised 30s → 5min. Sleeping fails permanently if no bed is around; the short retry blocked autonomous behaviour every tick. - ctx.lastReflex now records {name, label, ts} after each dispatched/completed reflex so the TUI can show what the bot just decided. runtime/bot.js: - per-tick snapshot adds lastReflex and busy fields for the TUI. - maybeReplyToPlayer: light canned greetings (yo/hey/hi/привет) to non-operators when they address the bot. 30s cooldown so we don't spam. tui/tui.tsx: - status bar shows either "▸ busy: <label>" while an action is in flight, or "last reflex: <name> (<label>) Ns ago" when idle. Gives an at-a-glance answer to "what is the bot doing right now?" Smoke-tested live (play.xmatic.team, 2026-05-25T13:30-13:39): - bot did dispatch chop tree (oak_log at 617,82,95) - real bug surfaced and 3-fail rule filed a proposal automatically - after the runtime fix, chop returns "no reachable log" gracefully - bot switched to wander on the next tick - position moved from (623.31, 85, 95.12) to (623.41, 86.02, 96.7) — the first observable movement in this dark-oak spawn Co-authored-by: Yuriy Mayatnikov <mayatnikov@me.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit was merged in pull request #8.
This commit is contained in:
+147
-1
@@ -29,6 +29,24 @@ function ensurePathfinder(bot) {
|
||||
pluginLoaded.add(bot);
|
||||
}
|
||||
|
||||
// Each action that uses pathfinder should set its own Movements profile
|
||||
// before calling goto — otherwise it inherits whatever the previous caller
|
||||
// left set, which has caused live regressions (e.g. flee setting canDig=false,
|
||||
// then a later chop inheriting the same restrictive profile).
|
||||
function setMovementsForGather(bot) {
|
||||
const m = new Movements(bot);
|
||||
m.canDig = true; // chopping is the entire point
|
||||
m.allow1by1towers = false;
|
||||
bot.pathfinder.setMovements(m);
|
||||
}
|
||||
|
||||
function setMovementsForTravel(bot) {
|
||||
const m = new Movements(bot);
|
||||
m.canDig = true; // dig through leaves rather than get stuck
|
||||
m.allow1by1towers = false;
|
||||
bot.pathfinder.setMovements(m);
|
||||
}
|
||||
|
||||
// ---- combat ----------------------------------------------------------------
|
||||
|
||||
const MELEE_WEAPONS = [
|
||||
@@ -96,8 +114,12 @@ export async function fleeFrom(bot, fromEntity, distance = 16) {
|
||||
const ty = Math.round(here.y);
|
||||
info("action", `flee: from=${fromEntity?.name ?? "?"} → ${tx},${ty},${tz}`);
|
||||
|
||||
// canDig:true here is deliberate — without it the bot gets permanently
|
||||
// stuck in dense tree canopy (observed live: bot perched at Y=85 inside
|
||||
// dark-oak leaves, every flee timed out for hours). We accept the risk of
|
||||
// chopping through scenery while panicking; it's how a player would react.
|
||||
const movements = new Movements(bot);
|
||||
movements.canDig = false;
|
||||
movements.canDig = true;
|
||||
movements.allow1by1towers = false;
|
||||
bot.pathfinder.setMovements(movements);
|
||||
|
||||
@@ -194,6 +216,7 @@ export async function sleepInBed(bot) {
|
||||
if (bedBlock) {
|
||||
info("action", `sleep: nearest bed at ${bedBlock.position.x},${bedBlock.position.y},${bedBlock.position.z}`);
|
||||
ensurePathfinder(bot);
|
||||
setMovementsForTravel(bot);
|
||||
try {
|
||||
await withTimeout(
|
||||
bot.pathfinder.goto(new goals.GoalNear(bedBlock.position.x, bedBlock.position.y, bedBlock.position.z, 2)),
|
||||
@@ -214,10 +237,133 @@ export async function sleepInBed(bot) {
|
||||
return { ok: false, detail: "no bed in range and won't place blindly" };
|
||||
}
|
||||
|
||||
// ---- gathering -------------------------------------------------------------
|
||||
|
||||
const LOG_NAMES = [
|
||||
"oak_log",
|
||||
"dark_oak_log",
|
||||
"spruce_log",
|
||||
"birch_log",
|
||||
"jungle_log",
|
||||
"acacia_log",
|
||||
"mangrove_log",
|
||||
"cherry_log",
|
||||
"pale_oak_log",
|
||||
];
|
||||
|
||||
const AXE_NAMES = [
|
||||
"netherite_axe",
|
||||
"diamond_axe",
|
||||
"iron_axe",
|
||||
"stone_axe",
|
||||
"golden_axe",
|
||||
"wooden_axe",
|
||||
];
|
||||
|
||||
async function equipBestAxe(bot) {
|
||||
for (const name of AXE_NAMES) {
|
||||
const item = bot.inventory.items().find((i) => i.name === name);
|
||||
if (item) {
|
||||
try {
|
||||
await bot.equip(item, "hand");
|
||||
return name;
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Per-bot blacklist of (x,y,z) positions that pathfinder failed to reach
|
||||
// recently. Cleared after BLACKLIST_TTL_MS so the bot can retry if the world
|
||||
// has changed (a player chopped a path, the tree fell to natural decay, etc).
|
||||
const chopBlacklist = new WeakMap(); // bot → Map<"x,y,z", expireMs>
|
||||
const BLACKLIST_TTL_MS = 5 * 60_000;
|
||||
|
||||
function getBlacklist(bot) {
|
||||
let m = chopBlacklist.get(bot);
|
||||
if (!m) {
|
||||
m = new Map();
|
||||
chopBlacklist.set(bot, m);
|
||||
}
|
||||
// Sweep expired entries on each lookup — small, cheap.
|
||||
const now = Date.now();
|
||||
for (const [k, exp] of m) if (exp < now) m.delete(k);
|
||||
return m;
|
||||
}
|
||||
|
||||
export async function chopNearestTree(bot) {
|
||||
const blacklist = getBlacklist(bot);
|
||||
// findBlock invokes the matcher for blocks that pass the maxDistance
|
||||
// pre-filter; in dense areas some have a synthetic shape with no
|
||||
// `.position`. Guard or we crash before we even start pathfinding.
|
||||
const log = bot.findBlock({
|
||||
matching: (b) => {
|
||||
if (!b || !b.position || !LOG_NAMES.includes(b.name)) return false;
|
||||
const key = `${b.position.x},${b.position.y},${b.position.z}`;
|
||||
return !blacklist.has(key);
|
||||
},
|
||||
maxDistance: 32,
|
||||
});
|
||||
if (!log) return { ok: false, detail: "no reachable log within 32 blocks" };
|
||||
|
||||
ensurePathfinder(bot);
|
||||
setMovementsForGather(bot);
|
||||
const axe = await equipBestAxe(bot);
|
||||
info(
|
||||
"action",
|
||||
`chop: ${log.name} at ${log.position.x},${log.position.y},${log.position.z} (tool=${axe ?? "fists"})`,
|
||||
);
|
||||
try {
|
||||
await withTimeout(
|
||||
bot.pathfinder.goto(new goals.GoalGetToBlock(log.position.x, log.position.y, log.position.z)),
|
||||
45_000,
|
||||
"pathToLog",
|
||||
);
|
||||
await withTimeout(bot.dig(log), 30_000, "digLog");
|
||||
// Walk over the dropped item briefly (collectblock plugin would do this
|
||||
// for us, but a simple sleep-then-resume is enough for now).
|
||||
await new Promise((r) => setTimeout(r, 1200));
|
||||
return { ok: true, detail: { logType: log.name, at: log.position } };
|
||||
} catch (e) {
|
||||
warn("action", `chop failed: ${e.message}`);
|
||||
const key = `${log.position.x},${log.position.y},${log.position.z}`;
|
||||
blacklist.set(key, Date.now() + BLACKLIST_TTL_MS);
|
||||
return { ok: false, detail: e.message, blacklisted: log.position };
|
||||
}
|
||||
}
|
||||
|
||||
// ---- exploration -----------------------------------------------------------
|
||||
|
||||
export async function wander(bot, radius = 12) {
|
||||
ensurePathfinder(bot);
|
||||
setMovementsForTravel(bot);
|
||||
// Pick a random offset that's at least 6 blocks away — small enough to be
|
||||
// safe, large enough to not be a no-op when we're stuck on the same block.
|
||||
const here = bot.entity.position;
|
||||
const angle = Math.random() * Math.PI * 2;
|
||||
const dist = 6 + Math.random() * (radius - 6);
|
||||
const tx = Math.round(here.x + Math.cos(angle) * dist);
|
||||
const tz = Math.round(here.z + Math.sin(angle) * dist);
|
||||
const ty = Math.round(here.y);
|
||||
info("action", `wander: → ${tx},${ty},${tz} (dist=${dist.toFixed(1)})`);
|
||||
try {
|
||||
await withTimeout(
|
||||
bot.pathfinder.goto(new goals.GoalNear(tx, ty, tz, 2)),
|
||||
30_000,
|
||||
`wander(${tx},${tz})`,
|
||||
);
|
||||
return { ok: true, detail: { to: { x: tx, y: ty, z: tz } } };
|
||||
} catch (e) {
|
||||
warn("action", `wander failed: ${e.message}`);
|
||||
return { ok: false, detail: e.message };
|
||||
}
|
||||
}
|
||||
|
||||
// ---- navigation (for operator come/follow) --------------------------------
|
||||
|
||||
export async function goTo(bot, x, y, z, minDistance = 2) {
|
||||
ensurePathfinder(bot);
|
||||
setMovementsForTravel(bot);
|
||||
info("action", `goTo: ${x},${y},${z} (min ${minDistance})`);
|
||||
try {
|
||||
await withTimeout(
|
||||
|
||||
Reference in New Issue
Block a user