feat(runtime): real reflex bodies + auto-escalation + operator chat (#5)

runtime/actions.js — Mineflayer wrappers with hard timeouts and structured
{ok, detail} returns:
  - attackNearest: equip best melee, lookAt, single swing per call
  - fleeFrom: lazy-load pathfinder, walk N blocks away (canDig=false to
    avoid burrowing through walls under panic)
  - eatBestFood: scan inventory by FOOD_PRIORITY, equip + consume
  - sleepInBed: find nearest placed bed within 16 blocks, path to it, sleep
  - goTo: pathfinder.goto for operator come/follow

runtime/reflex.js — bodies now dispatch real actions via ctx.dispatch:
  - operator-goal (highest): satisfy come/follow command
  - defend: ≤4m attack, ≤12m + low HP/many hostiles flee
  - eat: food < 16 + 5s cooldown
  - sleep: night + safe + 30s retry cooldown
  - idle: heartbeat every 20th tick
Reflex returns "skipped" when ctx.busy so we don't count busy ticks as
either productive or noop in the escalation counter.

runtime/bot.js:
  - ctx.dispatch fire-and-forget wrapper with busy gate, onComplete hook
  - consecutiveNoops counter; after ESCALATE_AFTER_NOOPS (=20, ~1 min at
    tick=3s), askPi with the current snapshot. 10-min cooldown.
  - operator chat handler: parses `<botname> <verb>` messages from
    OPERATOR_USERNAMES. Verbs: status, pause, resume, stop, come.
  - Death drops any pending operator goal.

Smoke-tested live against play.xmatic.team:25565: bot connected, logged
in via AuthMe, reflex chain dispatched flee/sleep, hard timeout fired
when pathfinder couldn't reach the flee target (expected — no usable
ground path in dark_forest at this 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 #5.
This commit is contained in:
Yuriy Mayatnikov
2026-05-25 16:11:53 +03:00
committed by GitHub
co-authored by mayatnikov Claude Opus 4.7
parent 1e3b36a9a1
commit ee2b4c26eb
3 changed files with 576 additions and 25 deletions
+262
View File
@@ -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());
}
+203 -5
View File
@@ -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 "<botname>," or "<botname>:" (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;
+111 -20
View File
@@ -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;