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:
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user