diff --git a/runtime/actions.js b/runtime/actions.js index 8d187c4..adc7c24 100644 --- a/runtime/actions.js +++ b/runtime/actions.js @@ -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( diff --git a/runtime/bot.js b/runtime/bot.js index a293701..33d0f4a 100644 --- a/runtime/bot.js +++ b/runtime/bot.js @@ -233,6 +233,33 @@ function maybeFileProposal(label) { appendDiary(`proposal filed: ${filename} (${summary})`); } +// ---- player chat replies (non-operator) ------------------------------------ + +// Rate-limited light replies to ordinary players. Spam guard is intentional: +// the chat rate limit catches outbound flooding; this one prevents replying +// to every greeting in a busy room. +let lastPlayerReplyAt = 0; +const PLAYER_REPLY_COOLDOWN_MS = 30_000; + +const GREETING_RE = /\b(hi|hello|hey|yo|sup|hola|привет|здаров|здарова|здорова|здравствуй|здравствуйте|салам)\b/i; + +function maybeReplyToPlayer(username, text) { + if (!bot) return; + const lower = text.trim().toLowerCase(); + const botname = bot.username.toLowerCase(); + const addressed = lower.includes(botname); + if (!addressed && !GREETING_RE.test(lower)) return; + const since = Date.now() - lastPlayerReplyAt; + if (since < PLAYER_REPLY_COOLDOWN_MS) return; + lastPlayerReplyAt = Date.now(); + + // Pick a small canned response. Non-operator chat is dialog-only by design + // — we don't act on player verbs, we just acknowledge presence. + const replies = ["yo", "hey", "hi", "привет"]; + const reply = replies[Math.floor(Math.random() * replies.length)]; + botChat(`${username}: ${reply}`); +} + // ---- operator chat commands ------------------------------------------------ function isOperator(username) { @@ -333,6 +360,12 @@ function connect() { } catch (e) { warn("operator", `handler threw: ${e.message}`); } + } else { + try { + maybeReplyToPlayer(username, message); + } catch (e) { + warn("chat", `player reply handler threw: ${e.message}`); + } } }); @@ -416,6 +449,10 @@ function tick() { if (bot && bot.entity) { lastSnapshot = buildSnapshot(bot); lastSnapshot.pendingProposals = listProposals().length; + lastSnapshot.lastReflex = reflexCtx.lastReflex ?? null; + lastSnapshot.busy = reflexCtx.busy + ? { label: reflexCtx.currentActionLabel ?? "?" } + : null; reflexCtx.snapshot = lastSnapshot; if (!reflexPaused) { const result = runTick(reflexCtx); diff --git a/runtime/reflex.js b/runtime/reflex.js index a34c3aa..f2c1e4a 100644 --- a/runtime/reflex.js +++ b/runtime/reflex.js @@ -7,7 +7,7 @@ // escalates to Pi after a threshold (see ESCALATE_AFTER_NOOPS). import { info, warn } from "./log.js"; -import { attackNearest, fleeFrom, eatBestFood, sleepInBed, goTo } from "./actions.js"; +import { attackNearest, fleeFrom, eatBestFood, sleepInBed, goTo, chopNearestTree, wander } from "./actions.js"; const REFLEX_LOG = "reflex"; @@ -54,9 +54,13 @@ function defendReflex(ctx) { const dist = s.closestHostile.distance; const lowHp = (s.health ?? 20) <= 8; - // Two regimes: + // Three regimes — tightened to avoid the "82 distant hostiles → constant + // flee" pathology observed at this spawn: // - within 4m: melee attack - // - 4-12m and either low HP or many hostiles: flee + // - within 8m (and visibly hostile to us): flee + // - low-HP fallback: flee anything within 12m + // Anything beyond 8m with full HP is ignored regardless of how many + // hostiles the perceive snapshot enumerates. if (dist <= 4) { ctx.dispatch( () => attackNearest(ctx.bot, s.closestHostile.name), @@ -64,17 +68,28 @@ function defendReflex(ctx) { ); return { action: "dispatched", kind: "defend-attack", label: s.closestHostile.name }; } - if (dist <= 12 && (lowHp || (s.hostileCount ?? 0) >= 3)) { - const fromEntity = Object.values(ctx.bot.entities).find( - (e) => e.name === s.closestHostile.name && e.position && Math.abs(e.position.distanceTo(ctx.bot.entity.position) - dist) < 1.5, - ); - ctx.dispatch( - () => fleeFrom(ctx.bot, fromEntity, 16), - `flee from ${s.closestHostile.name}`, - ); - return { action: "dispatched", kind: "defend-flee", label: s.closestHostile.name }; + const shouldFlee = (dist <= 8) || (lowHp && dist <= 12); + if (!shouldFlee) return { action: "noop" }; + + // Cooldown — if we just fled from this same mob type and it didn't work + // (timed out), don't immediately re-fire. Let other reflexes run. + const lastFlee = ctx.lastFleeAttempt; + if (lastFlee && lastFlee.name === s.closestHostile.name && Date.now() - lastFlee.ts < 60_000) { + return { action: "noop" }; } - return { action: "noop" }; + ctx.lastFleeAttempt = { name: s.closestHostile.name, ts: Date.now() }; + + const fromEntity = Object.values(ctx.bot.entities).find( + (e) => + e.name === s.closestHostile.name && + e.position && + Math.abs(e.position.distanceTo(ctx.bot.entity.position) - dist) < 1.5, + ); + ctx.dispatch( + () => fleeFrom(ctx.bot, fromEntity, 16), + `flee from ${s.closestHostile.name}`, + ); + return { action: "dispatched", kind: "defend-flee", label: s.closestHostile.name }; } // ---- eat ------------------------------------------------------------------- @@ -107,9 +122,10 @@ function sleepReflex(ctx) { if (!s.connected) return { action: "noop" }; if (s.isDay) return { action: "noop" }; if (s.closestHostile && s.closestHostile.distance < 8) return { action: "noop" }; // not safe - // Cooldown — don't retry sleep more than once per 30s if it failed. + // Longer cooldown after a failure — if there's no bed nearby, retrying + // every 30s blocks autonomous behaviour without ever succeeding. const since = Date.now() - (ctx.lastSleepAttemptAt ?? 0); - if (since < 30_000) return { action: "noop" }; + if (since < 5 * 60_000) return { action: "noop" }; ctx.lastSleepAttemptAt = Date.now(); ctx.dispatch( @@ -125,6 +141,59 @@ function sleepReflex(ctx) { return { action: "dispatched", kind: "sleep", label: "night" }; } +// ---- autonomous "live your best life" -------------------------------------- + +// Triggered when no reactive reflex (operator/defend/eat/sleep) wants to act. +// Picks ONE small proactive action and runs it. Cooldown so we don't fire on +// every 3s tick — actions take 15-45s themselves and we want some breathing +// room between them. +const AUTONOMOUS_COOLDOWN_MS = 10_000; + +function autonomousReflex(ctx) { + const s = ctx.snapshot; + if (!s.connected) return { action: "noop" }; + + // Only suppress autonomous work at night when a hostile is in actual reach + // (within 16m). Distant mobs the perception layer happens to enumerate + // don't count — at this spawn there can be 80+ mobs visible but irrelevant + // to local action. + const nightClose = !s.isDay && s.closestHostile && s.closestHostile.distance <= 16; + if (nightClose) return { action: "noop" }; + + const since = Date.now() - (ctx.lastAutonomousAt ?? 0); + if (since < AUTONOMOUS_COOLDOWN_MS) return { action: "noop" }; + ctx.lastAutonomousAt = Date.now(); + + // What to do: gather wood until we have a small stockpile, then wander a + // bit to find new chunks. If a recent chop attempt reported "no reachable + // log", switch to wander for the next 60s — chopping the same not-found + // position over and over is what the user observed live. + const inv = s.inventory ?? {}; + const logCount = Object.entries(inv) + .filter(([name]) => name.endsWith("_log")) + .reduce((sum, [, n]) => sum + n, 0); + + const noTreesRecently = ctx.noTreesUntil && Date.now() < ctx.noTreesUntil; + const wantChop = logCount < 16 && !noTreesRecently; + if (wantChop) { + ctx.dispatch(() => chopNearestTree(ctx.bot), "chop tree", { + onComplete: (res) => { + if (res.ok) { + info(REFLEX_LOG, `chopped ${res.detail?.logType ?? "log"}`); + ctx.noTreesUntil = 0; // success ⇒ trees exist around us + } else if (typeof res.detail === "string" && res.detail.includes("no reachable")) { + // No log within 32 blocks of the current position. Don't try + // again for 60s — wander first to find a new biome / chunk. + ctx.noTreesUntil = Date.now() + 60_000; + } + }, + }); + return { action: "dispatched", kind: "autonomous-chop", label: "chop tree" }; + } + ctx.dispatch(() => wander(ctx.bot, 16), "wander", {}); + return { action: "dispatched", kind: "autonomous-wander", label: "wander" }; +} + // ---- idle ------------------------------------------------------------------ function idleReflex(ctx) { @@ -144,6 +213,7 @@ const REFLEXES = [ { name: "defend", fn: defendReflex }, { name: "eat", fn: eatReflex }, { name: "sleep", fn: sleepReflex }, + { name: "autonomous", fn: autonomousReflex }, { name: "idle", fn: idleReflex }, ]; @@ -161,6 +231,7 @@ export function runTick(ctx) { continue; } if (!outcome || outcome.action === "noop") continue; + ctx.lastReflex = { name: reflex.name, label: outcome.label ?? outcome.kind, ts: Date.now() }; return { reflex: reflex.name, ...outcome }; } return null; diff --git a/tui/tui.tsx b/tui/tui.tsx index 15fe87d..46ba062 100644 --- a/tui/tui.tsx +++ b/tui/tui.tsx @@ -123,10 +123,30 @@ function StatusBar({ snapshot, paused, connectedToBot }: { snapshot: Snapshot; p {snapshot.closestHostile ? ` closest=${snapshot.closestHostile.name}@${snapshot.closestHostile.distance}m` : ""} {snapshot.pendingProposals ? {` [proposals ${snapshot.pendingProposals}, press y]`} : null} + + {snapshot.busy ? ( + ▸ busy: {snapshot.busy.label} + ) : snapshot.lastReflex ? ( + + last reflex: {snapshot.lastReflex.name} + {snapshot.lastReflex.label ? ` (${snapshot.lastReflex.label})` : ""}{" "} + {snapshot.lastReflex.ts ? formatAge(snapshot.lastReflex.ts) : ""} + + ) : ( + no reflex action yet + )} + ); } +function formatAge(tsMs: number): string { + const ageMs = Date.now() - tsMs; + if (ageMs < 60_000) return `${Math.floor(ageMs / 1000)}s ago`; + if (ageMs < 3600_000) return `${Math.floor(ageMs / 60_000)}m ago`; + return `${Math.floor(ageMs / 3600_000)}h ago`; +} + function ProposalPanel({ proposal, onClose,