diff --git a/runtime/actions.js b/runtime/actions.js new file mode 100644 index 0000000..8d187c4 --- /dev/null +++ b/runtime/actions.js @@ -0,0 +1,262 @@ +// Mineflayer action primitives. Each function is async, has a hard timeout, +// catches its own errors, and returns { ok: boolean, detail?: any }. +// Actions are dispatched from the reflex layer via ctx.dispatch — never +// awaited inline in a tick, because they may take seconds. + +import pathfinderPkg from "mineflayer-pathfinder"; +const { pathfinder, goals, Movements } = pathfinderPkg; + +import { info, warn } from "./log.js"; + +// Hard timeout wrapper. Mineflayer goals (pathfinder, pvp targeting) can hang +// when the goal is unreachable; without a ceiling the whole reflex chain stops. +function withTimeout(promise, ms, label) { + return Promise.race([ + Promise.resolve(promise), + new Promise((_, reject) => + setTimeout(() => reject(new Error(`${label} timed out after ${ms / 1000}s`)), ms), + ), + ]); +} + +// Mineflayer ships with the pathfinder plugin externally — but we need to +// ensure it's loaded exactly once per bot. The reflex bot doesn't auto-load +// it the way mineflayer-bridge.ts did, so we lazy-load here. +let pluginLoaded = new WeakSet(); +function ensurePathfinder(bot) { + if (pluginLoaded.has(bot)) return; + bot.loadPlugin(pathfinder); + pluginLoaded.add(bot); +} + +// ---- combat ---------------------------------------------------------------- + +const MELEE_WEAPONS = [ + "netherite_sword", + "diamond_sword", + "iron_sword", + "stone_sword", + "golden_sword", + "wooden_sword", + "netherite_axe", + "diamond_axe", + "iron_axe", + "stone_axe", + "golden_axe", + "wooden_axe", +]; + +async function equipBestMelee(bot) { + for (const name of MELEE_WEAPONS) { + const item = bot.inventory.items().find((i) => i.name === name); + if (item) { + try { + await bot.equip(item, "hand"); + return name; + } catch { + // keep trying + } + } + } + return null; +} + +export async function attackNearest(bot, hostileType) { + const target = Object.values(bot.entities).find( + (e) => (hostileType ? e.name === hostileType : isHostile(e)) && e.position.distanceTo(bot.entity.position) <= 4, + ); + if (!target) return { ok: false, detail: "no target in reach" }; + + const weapon = await equipBestMelee(bot); + info("action", `attack: target=${target.name} dist=${target.position.distanceTo(bot.entity.position).toFixed(1)} weapon=${weapon ?? "fists"}`); + try { + // Look at target then swing. Single hit per call — reflex tick re-fires + // until the target is dead or out of range. + await withTimeout(bot.lookAt(target.position.offset(0, target.height ?? 1, 0)), 2000, "lookAt"); + bot.attack(target); + return { ok: true, detail: { target: target.name, weapon } }; + } catch (e) { + warn("action", `attack failed: ${e.message}`); + return { ok: false, detail: e.message }; + } +} + +// ---- flee ------------------------------------------------------------------ + +export async function fleeFrom(bot, fromEntity, distance = 16) { + ensurePathfinder(bot); + const from = fromEntity?.position ?? bot.entity.position; + const here = bot.entity.position; + // vector away + const dx = here.x - from.x; + const dz = here.z - from.z; + const len = Math.hypot(dx, dz) || 1; + const tx = Math.round(here.x + (dx / len) * distance); + const tz = Math.round(here.z + (dz / len) * distance); + const ty = Math.round(here.y); + info("action", `flee: from=${fromEntity?.name ?? "?"} → ${tx},${ty},${tz}`); + + const movements = new Movements(bot); + movements.canDig = false; + movements.allow1by1towers = false; + bot.pathfinder.setMovements(movements); + + try { + await withTimeout( + bot.pathfinder.goto(new goals.GoalNear(tx, ty, tz, 1)), + 30_000, + `fleeFrom(${fromEntity?.name})`, + ); + return { ok: true, detail: { to: { x: tx, y: ty, z: tz } } }; + } catch (e) { + warn("action", `flee failed: ${e.message}`); + return { ok: false, detail: e.message }; + } +} + +// ---- food ------------------------------------------------------------------ + +const FOOD_PRIORITY = [ + "cooked_beef", + "cooked_porkchop", + "cooked_mutton", + "cooked_chicken", + "cooked_rabbit", + "cooked_salmon", + "cooked_cod", + "baked_potato", + "bread", + "carrot", + "apple", + "sweet_berries", + "melon_slice", + "beef", + "porkchop", + "chicken", + "mutton", +]; + +function findFood(bot) { + for (const name of FOOD_PRIORITY) { + const item = bot.inventory.items().find((i) => i.name === name); + if (item) return item; + } + // fallback: anything with food value via mc-data is too brittle; we accept + // only the priority list to avoid accidentally eating poisonous spider eyes. + return null; +} + +export async function eatBestFood(bot) { + const item = findFood(bot); + if (!item) return { ok: false, detail: "no food in inventory" }; + info("action", `eat: ${item.name}`); + try { + await withTimeout(bot.equip(item, "hand"), 3000, "equip food"); + await withTimeout(bot.consume(), 15_000, "consume"); + return { ok: true, detail: { ate: item.name } }; + } catch (e) { + warn("action", `eat failed: ${e.message}`); + return { ok: false, detail: e.message }; + } +} + +// ---- sleep ----------------------------------------------------------------- + +const BED_NAMES = [ + "red_bed", + "white_bed", + "orange_bed", + "yellow_bed", + "lime_bed", + "green_bed", + "cyan_bed", + "light_blue_bed", + "blue_bed", + "purple_bed", + "magenta_bed", + "pink_bed", + "brown_bed", + "gray_bed", + "light_gray_bed", + "black_bed", +]; + +export async function sleepInBed(bot) { + // Already in a bed? + if (bot.isSleeping) return { ok: true, detail: "already sleeping" }; + + // Find a nearby placed bed first. + const bedBlock = bot.findBlock({ + matching: (b) => BED_NAMES.includes(b?.name), + maxDistance: 16, + }); + + if (bedBlock) { + info("action", `sleep: nearest bed at ${bedBlock.position.x},${bedBlock.position.y},${bedBlock.position.z}`); + ensurePathfinder(bot); + try { + await withTimeout( + bot.pathfinder.goto(new goals.GoalNear(bedBlock.position.x, bedBlock.position.y, bedBlock.position.z, 2)), + 15_000, + "goto bed", + ); + await withTimeout(bot.sleep(bedBlock), 10_000, "bot.sleep"); + return { ok: true, detail: { bedAt: bedBlock.position } }; + } catch (e) { + warn("action", `sleep failed: ${e.message}`); + return { ok: false, detail: e.message }; + } + } + + // No placed bed — try placing one if we carry one. Skip — we don't want to + // invent a base location accidentally. Future: only place when at our base + // per locations.json. + return { ok: false, detail: "no bed in range and won't place blindly" }; +} + +// ---- navigation (for operator come/follow) -------------------------------- + +export async function goTo(bot, x, y, z, minDistance = 2) { + ensurePathfinder(bot); + info("action", `goTo: ${x},${y},${z} (min ${minDistance})`); + try { + await withTimeout( + bot.pathfinder.goto(new goals.GoalNear(x, y, z, minDistance)), + 60_000, + `goTo(${x},${y},${z})`, + ); + return { ok: true, detail: { x, y, z } }; + } catch (e) { + warn("action", `goTo failed: ${e.message}`); + return { ok: false, detail: e.message }; + } +} + +// ---- helpers --------------------------------------------------------------- + +const HOSTILE_NAMES = new Set([ + "zombie", + "skeleton", + "creeper", + "spider", + "witch", + "pillager", + "vindicator", + "husk", + "stray", + "drowned", + "phantom", + "enderman", + "slime", + "magma_cube", + "hoglin", + "piglin_brute", + "ravager", + "warden", + "breeze", + "bogged", +]); + +export function isHostile(entity) { + return HOSTILE_NAMES.has((entity?.name || "").toLowerCase()); +} diff --git a/runtime/bot.js b/runtime/bot.js index e9ffd34..e0a328a 100644 --- a/runtime/bot.js +++ b/runtime/bot.js @@ -2,7 +2,8 @@ // - the MC TCP connection + reconnect policy // - the reflex tick loop (no LLM in hot path) // - the IPC server for TUI clients -// - on-demand Pi-headless escalation +// - on-demand Pi-headless escalation (manual or automatic) +// - simple operator-chat commands from OPERATOR_USERNAMES // // Lifecycle: started by `npm run bot`. Connects to MC, spawns IPC server, // ticks every TICK_INTERVAL_SECONDS, broadcasts STATUS to clients each tick. @@ -23,6 +24,12 @@ import { COMMAND_TYPES, EVENT_TYPES } from "./ipc-protocol.js"; fs.mkdirSync(stateDir, { recursive: true }); const JOINED_FLAG = path.join(stateDir, "joined-before.flag"); +// Auto-escalation tunables. With tick=3s, 20 noops ≈ 1 minute idle before we +// even consider asking Pi. Cooldown prevents spamming the LLM when the bot +// is permanently stuck on the same situation. +const ESCALATE_AFTER_NOOPS = 20; +const ESCALATION_COOLDOWN_MS = 10 * 60 * 1000; + let bot = null; let reflexPaused = false; let tickTimer = null; @@ -30,13 +37,32 @@ let reconnectTimer = null; let shuttingDown = false; let lastSnapshot = { connected: false }; -const reflexCtx = { snapshot: lastSnapshot, idleCounter: 0 }; +let consecutiveNoops = 0; +let lastEscalationAt = 0; + +// Reflex context — passed into reflex.js every tick. Mutable across ticks. +const reflexCtx = { + bot: null, + snapshot: lastSnapshot, + busy: false, + currentActionLabel: null, + operatorGoal: null, + idleCounter: 0, + lastEatAt: 0, + lastSleepAttemptAt: 0, + dispatch: dispatchAction, + clearOperatorGoal: () => { + reflexCtx.operatorGoal = null; + }, +}; let chatTimestamps = []; const CHAT_WINDOW_MS = 60_000; let ipc; +// ---- chat rate limit ------------------------------------------------------- + function chatRateAllowed() { const now = Date.now(); chatTimestamps = chatTimestamps.filter((t) => now - t < CHAT_WINDOW_MS); @@ -45,6 +71,17 @@ function chatRateAllowed() { return true; } +function botChat(text) { + if (!bot) return; + if (!chatRateAllowed()) { + warn("chat", `dropped chat (rate-limited): ${text.slice(0, 60)}`); + return; + } + bot.chat(text); +} + +// ---- auth ------------------------------------------------------------------ + function hasJoinedBefore() { return fs.existsSync(JOINED_FLAG); } @@ -75,6 +112,107 @@ function maybeHandleAuthPrompt(text) { markJoinedBefore(); } +// ---- action dispatch ------------------------------------------------------- + +// Reflexes call this to fire an async action without blocking the tick. +// Sets busy=true, runs fn, clears busy when done; optional onComplete callback +// receives the action's { ok, detail } result. +function dispatchAction(fn, label, opts = {}) { + if (reflexCtx.busy) { + warn("dispatch", `tried to dispatch ${label} while busy with ${reflexCtx.currentActionLabel}`); + return; + } + reflexCtx.busy = true; + reflexCtx.currentActionLabel = label; + info("dispatch", `→ ${label}`); + Promise.resolve() + .then(() => fn()) + .then((res) => { + info("dispatch", `← ${label} ${res?.ok ? "ok" : "fail"}${res?.detail ? ` (${JSON.stringify(res.detail).slice(0, 80)})` : ""}`); + if (opts.onComplete) { + try { + opts.onComplete(res ?? { ok: false, detail: "no result" }); + } catch (e) { + warn("dispatch", `onComplete threw for ${label}: ${e.message}`); + } + } + }) + .catch((e) => { + warn("dispatch", `${label} threw: ${e?.message ?? e}`); + }) + .finally(() => { + reflexCtx.busy = false; + reflexCtx.currentActionLabel = null; + }); +} + +// ---- operator chat commands ------------------------------------------------ + +function isOperator(username) { + if (!username) return false; + return config.operators.includes(username.toLowerCase()); +} + +function handleOperatorChat(username, text) { + // Two address formats accepted: prefix "," or ":" (case-insensitive), + // or full chat starting with the bot's own name. We're permissive here. + const lower = text.trim().toLowerCase(); + const botname = bot.username.toLowerCase(); + const prefixed = lower.startsWith(botname + " ") || lower.startsWith(botname + ",") || lower.startsWith(botname + ":"); + const stripped = prefixed ? text.trim().slice(botname.length).replace(/^[,:\s]+/, "") : text.trim(); + const cmd = stripped.toLowerCase(); + + // Unaddressed chat is fine — just don't treat it as a command. + if (!prefixed) return; + + info("operator", `${username} → "${cmd}"`); + + if (cmd === "status" || cmd === "how are you?") { + const s = lastSnapshot; + botChat( + `hp=${s.health}/20 food=${s.food}/20 pos=${s.position?.x},${s.position?.y},${s.position?.z}${ + s.hostileCount ? ` hostiles=${s.hostileCount}` : "" + }${reflexCtx.busy ? ` busy=${reflexCtx.currentActionLabel}` : ""}`, + ); + return; + } + if (cmd === "pause") { + reflexPaused = true; + botChat(`reflex paused, awaiting your call.`); + return; + } + if (cmd === "resume") { + reflexPaused = false; + botChat(`reflex resumed.`); + return; + } + if (cmd === "stop") { + botChat(`bye.`); + setTimeout(() => gracefulExit(0), 500); + return; + } + if (cmd === "come" || cmd === "come here") { + const op = Object.values(bot.entities).find((e) => e.username === username); + if (!op) { + botChat(`${username}: can't see you nearby.`); + return; + } + reflexCtx.operatorGoal = { + kind: "come", + from: username, + x: Math.round(op.position.x), + y: Math.round(op.position.y), + z: Math.round(op.position.z), + }; + botChat(`on my way to ${reflexCtx.operatorGoal.x},${reflexCtx.operatorGoal.y},${reflexCtx.operatorGoal.z}`); + return; + } + + botChat(`${username}: didn't recognize "${cmd}". I know: status, come, pause, resume, stop.`); +} + +// ---- connect --------------------------------------------------------------- + function connect() { if (bot) return; info("mc", `connecting as ${config.username} → ${config.host}:${config.port} (v${config.version})`); @@ -86,13 +224,14 @@ function connect() { version: config.version, hideErrors: false, }); + reflexCtx.bot = bot; bot.once("spawn", () => { info("mc", `spawned at ${JSON.stringify(bot.entity.position)}`); ipc?.broadcast(EVENT_TYPES.STATUS, buildSnapshot(bot)); }); - bot.on("messagestr", (text, _position, _jsonMsg) => { + bot.on("messagestr", (text) => { ipc?.broadcast(EVENT_TYPES.CHAT, { from: "server", text, kind: "system" }); maybeHandleAuthPrompt(text); }); @@ -100,12 +239,21 @@ function connect() { bot.on("chat", (username, message) => { if (username === bot.username) return; ipc?.broadcast(EVENT_TYPES.CHAT, { from: username, text: message, kind: "player" }); + if (isOperator(username)) { + try { + handleOperatorChat(username, message); + } catch (e) { + warn("operator", `handler threw: ${e.message}`); + } + } }); bot.on("death", () => { const pos = bot.entity?.position; warn("mc", `died at ${JSON.stringify(pos)}`); ipc?.broadcast(EVENT_TYPES.DEATH, { reason: "unknown", position: pos }); + // On death, drop any operator goal — they need to ask again. + reflexCtx.operatorGoal = null; }); bot.on("kicked", (reason) => { @@ -119,6 +267,7 @@ function connect() { bot.on("end", (reason) => { warn("mc", `connection ended: ${reason}`); bot = null; + reflexCtx.bot = null; lastSnapshot = { connected: false }; if (!shuttingDown) scheduleReconnect(); }); @@ -134,13 +283,59 @@ function scheduleReconnect() { }, delay); } +// ---- escalation ------------------------------------------------------------ + +function maybeAutoEscalate() { + if (reflexCtx.busy) return; + if (consecutiveNoops < ESCALATE_AFTER_NOOPS) return; + const since = Date.now() - lastEscalationAt; + if (since < ESCALATION_COOLDOWN_MS) return; + lastEscalationAt = Date.now(); + consecutiveNoops = 0; + + const promptCtx = JSON.stringify(lastSnapshot, null, 2); + const prompt = [ + `You are the escalation cortex for an autonomous Minecraft bot. The bot's`, + `script-driven reflex loop has produced no useful action for ${ESCALATE_AFTER_NOOPS} consecutive ticks`, + `(~${Math.round((ESCALATE_AFTER_NOOPS * config.tickIntervalMs) / 1000)}s). The reflex chain is:`, + ` operator-goal > defend > eat > sleep > idle`, + `Snapshot:`, + "```json", + promptCtx, + "```", + `Decide ONE next thing the bot should attempt. Output as plain text — what it should do and why, in 3 lines max.`, + `If "do nothing" is the right answer, say so.`, + `Do NOT propose code changes. Do NOT propose anything beyond what mineflayer + the existing actions (attack, flee, eat, sleep, goTo) can do.`, + ].join("\n"); + + info("escalation", `firing askPi after ${ESCALATE_AFTER_NOOPS} noops`); + askPi({ + prompt, + onChunk: (chunk) => ipc?.broadcast(EVENT_TYPES.ASK_PI_CHUNK, chunk), + onDone: (result) => { + info("escalation", `pi done code=${result.code} dur=${result.durationMs}ms`); + ipc?.broadcast(EVENT_TYPES.ASK_PI_DONE, result); + }, + }); +} + +// ---- tick ------------------------------------------------------------------ + function tick() { if (shuttingDown) return; if (bot && bot.entity) { lastSnapshot = buildSnapshot(bot); reflexCtx.snapshot = lastSnapshot; if (!reflexPaused) { - runTick(reflexCtx); + const result = runTick(reflexCtx); + if (!result || result.action === "noop") { + consecutiveNoops++; + maybeAutoEscalate(); + } else if (result.action === "skipped") { + // busy — neither productive nor stuck; don't increment noops + } else { + consecutiveNoops = 0; + } } ipc?.broadcast(EVENT_TYPES.STATUS, lastSnapshot); } else { @@ -154,12 +349,13 @@ function startTickLoop() { tickTimer = setInterval(tick, config.tickIntervalMs); } +// ---- IPC commands ---------------------------------------------------------- + function handleCommand(msg, send) { switch (msg.type) { case COMMAND_TYPES.PAUSE: reflexPaused = true; info("ipc", "reflex paused by client"); - send(EVENT_TYPES.LOG, { ts: new Date().toISOString(), level: "info", source: "ipc", text: "reflex paused" }); break; case COMMAND_TYPES.RESUME: reflexPaused = false; @@ -197,6 +393,8 @@ function handleCommand(msg, send) { } } +// ---- shutdown -------------------------------------------------------------- + function gracefulExit(code) { if (shuttingDown) return; shuttingDown = true; diff --git a/runtime/reflex.js b/runtime/reflex.js index 02235be..a34c3aa 100644 --- a/runtime/reflex.js +++ b/runtime/reflex.js @@ -1,58 +1,146 @@ // Reflex layer: priority-ordered list of pure-script behaviors. Each reflex -// inspects the latest snapshot and either returns a no-op or starts an action. -// LLM is NOT called here. If every reflex declines, the tick yields and we try -// again next interval. Escalation to Pi happens elsewhere, only when the bot -// has been idle/stuck for an extended window. +// inspects the latest snapshot and either returns a no-op, dispatches an +// async action via ctx.dispatch, or completes synchronously. +// +// LLM is NOT called here. If every reflex declines, the tick yields and we +// try again next interval. The bot.js layer tracks consecutive noops and +// 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"; const REFLEX_LOG = "reflex"; // A reflex returns one of: // { action: "noop" } — nothing to do -// { action: "starting", kind, detail } — kicked off async work +// { action: "dispatched", kind, label } — dispatched an async action // { action: "completed", kind, detail } — fully sync, already done -// Reflexes must NEVER throw — they should log and return noop on failure. +// Reflexes must NEVER throw — they log and return noop on failure. + +// ---- operator goal reflex -------------------------------------------------- + +// Highest priority: if the operator gave a "come here" / "follow me" command, +// satisfy that before anything else (except defending if HP is critical). +function operatorGoalReflex(ctx) { + const goal = ctx.operatorGoal; + if (!goal) return { action: "noop" }; + if (goal.kind === "come") { + ctx.dispatch( + () => goTo(ctx.bot, goal.x, goal.y, goal.z, 2), + `operator-come(${goal.from})`, + { + onComplete: (res) => { + if (res.ok) { + ctx.bot.chat(`${goal.from}: arrived.`); + } else { + ctx.bot.chat(`${goal.from}: couldn't reach you (${res.detail}).`); + } + ctx.clearOperatorGoal(); + }, + }, + ); + return { action: "dispatched", kind: "operator-come", label: `come ${goal.x},${goal.y},${goal.z}` }; + } + return { action: "noop" }; +} + +// ---- defend ---------------------------------------------------------------- function defendReflex(ctx) { const s = ctx.snapshot; if (!s.connected) return { action: "noop" }; if (!s.closestHostile) return { action: "noop" }; - if (s.closestHostile.distance > 6) return { action: "noop" }; - // Stub: just log. Real attack/flee logic comes in a follow-up commit. - info(REFLEX_LOG, `defend: hostile ${s.closestHostile.name} at ${s.closestHostile.distance}m (stub, no action yet)`); - return { action: "completed", kind: "defend-stub", detail: s.closestHostile }; + + const dist = s.closestHostile.distance; + const lowHp = (s.health ?? 20) <= 8; + + // Two regimes: + // - within 4m: melee attack + // - 4-12m and either low HP or many hostiles: flee + if (dist <= 4) { + ctx.dispatch( + () => attackNearest(ctx.bot, s.closestHostile.name), + `attack ${s.closestHostile.name}`, + ); + 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 }; + } + return { action: "noop" }; } +// ---- eat ------------------------------------------------------------------- + function eatReflex(ctx) { const s = ctx.snapshot; if (!s.connected) return { action: "noop" }; if (s.food === undefined || s.food >= 16) return { action: "noop" }; - // Stub: log only. consume() integration arrives with the actions module. - info(REFLEX_LOG, `eat: hunger ${s.food}/20 (stub, no action yet)`); - return { action: "completed", kind: "eat-stub", detail: { food: s.food } }; + // Don't eat if we just ate (the food bar refresh on the server has a + // small lag — re-firing within 5s would just fail bot.consume). + const since = Date.now() - (ctx.lastEatAt ?? 0); + if (since < 5000) return { action: "noop" }; + + ctx.dispatch( + () => eatBestFood(ctx.bot), + "eat", + { + onComplete: (res) => { + if (res.ok) ctx.lastEatAt = Date.now(); + }, + }, + ); + return { action: "dispatched", kind: "eat", label: `food=${s.food}` }; } +// ---- sleep ----------------------------------------------------------------- + function sleepReflex(ctx) { const s = ctx.snapshot; if (!s.connected) return { action: "noop" }; if (s.isDay) return { action: "noop" }; - if (!s.inventory?.["red_bed"] && !s.inventory?.["white_bed"]) return { action: "noop" }; - info(REFLEX_LOG, `sleep: night detected, bed in inventory (stub)`); - return { action: "completed", kind: "sleep-stub" }; + 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. + const since = Date.now() - (ctx.lastSleepAttemptAt ?? 0); + if (since < 30_000) return { action: "noop" }; + + ctx.lastSleepAttemptAt = Date.now(); + ctx.dispatch( + () => sleepInBed(ctx.bot), + "sleep", + { + onComplete: (res) => { + if (res.ok) info(REFLEX_LOG, `sleep: success (${JSON.stringify(res.detail)})`); + else info(REFLEX_LOG, `sleep: declined (${res.detail})`); + }, + }, + ); + return { action: "dispatched", kind: "sleep", label: "night" }; } +// ---- idle ------------------------------------------------------------------ + function idleReflex(ctx) { const s = ctx.snapshot; if (!s.connected) return { action: "noop" }; - // Once every ~10 ticks, log a heartbeat with HP/food/pos. Tunable later. ctx.idleCounter = (ctx.idleCounter ?? 0) + 1; - if (ctx.idleCounter % 10 !== 0) return { action: "noop" }; - info(REFLEX_LOG, `idle: hp=${s.health} food=${s.food} pos=${s.position?.x},${s.position?.y},${s.position?.z}`); + if (ctx.idleCounter % 20 !== 0) return { action: "noop" }; + info( + REFLEX_LOG, + `idle: hp=${s.health} food=${s.food} pos=${s.position?.x},${s.position?.y},${s.position?.z} time=${s.time}`, + ); return { action: "completed", kind: "idle-heartbeat" }; } const REFLEXES = [ + { name: "operator-goal", fn: operatorGoalReflex }, { name: "defend", fn: defendReflex }, { name: "eat", fn: eatReflex }, { name: "sleep", fn: sleepReflex }, @@ -60,6 +148,10 @@ const REFLEXES = [ ]; export function runTick(ctx) { + // Bot is in the middle of an async action — don't dispatch another. + if (ctx.busy) { + return { reflex: "busy", action: "skipped", label: ctx.currentActionLabel ?? "(?)" }; + } for (const reflex of REFLEXES) { let outcome; try { @@ -69,7 +161,6 @@ export function runTick(ctx) { continue; } if (!outcome || outcome.action === "noop") continue; - // First non-noop wins — stop the chain so we don't double-act per tick. return { reflex: reflex.name, ...outcome }; } return null;