feat(runtime): real reflex bodies + auto-escalation + operator chat
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: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+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