Files
pepa-pi-bot/runtime/reflex.js
T
f5361ad9a4 feat(runtime): autonomous reflex — chop wood + wander + proactive movement (#8)
Closes the "bot stands on a tree doing nothing" problem reported live
when the operator launched the TUI after PR #6 landed. The reactive
chain (operator > defend > eat > sleep > idle) was passive by design:
day-time, full HP and food, no hostile within 4 m ⇒ every reflex
returned noop. The bot perched in dark-oak canopy and never moved.

Changes

runtime/actions.js:
  - chopNearestTree: find any *_log within 32 blocks, equip best axe
    (falls back to fists), path to the block, dig. Per-bot 5-min
    blacklist of unreachable log positions so we don't grind on the
    same impossible target.
  - wander: pick a random offset 6-16 blocks away and path there.
  - setMovementsForGather / setMovementsForTravel: every action that
    uses pathfinder now sets its own Movements profile (canDig=true)
    instead of inheriting whatever the previous caller left. The old
    behaviour caused chop to inherit flee's canDig=false and get stuck
    in the canopy.
  - fleeFrom now uses canDig=true too — the user observed the bot
    permanently stuck on a leaf block because escape required digging.

runtime/reflex.js:
  - new autonomousReflex between sleep and idle. Cooldown 10s. Picks
    chop when log count < 16, else wander. When chop reports "no
    reachable log within 32 blocks" we switch to wander for 60s so we
    don't re-fire chop against the same impossible position.
  - defendReflex tightened: only flee when closest is ≤8m (or ≤12m
    on low HP). Avoids the "82 distant hostiles ⇒ constant flee
    loop" pathology observed at this spawn.
  - flee cooldown: same mob name within 60s ⇒ noop, so we yield to
    other reflexes if flee keeps timing out.
  - sleepReflex retry cooldown raised 30s → 5min. Sleeping fails
    permanently if no bed is around; the short retry blocked
    autonomous behaviour every tick.
  - ctx.lastReflex now records {name, label, ts} after each
    dispatched/completed reflex so the TUI can show what the bot
    just decided.

runtime/bot.js:
  - per-tick snapshot adds lastReflex and busy fields for the TUI.
  - maybeReplyToPlayer: light canned greetings (yo/hey/hi/привет)
    to non-operators when they address the bot. 30s cooldown so we
    don't spam.

tui/tui.tsx:
  - status bar shows either "▸ busy: <label>" while an action is
    in flight, or "last reflex: <name> (<label>) Ns ago" when idle.
    Gives an at-a-glance answer to "what is the bot doing right now?"

Smoke-tested live (play.xmatic.team, 2026-05-25T13:30-13:39):
  - bot did dispatch chop tree (oak_log at 617,82,95)
  - real bug surfaced and 3-fail rule filed a proposal automatically
  - after the runtime fix, chop returns "no reachable log" gracefully
  - bot switched to wander on the next tick
  - position moved from (623.31, 85, 95.12) to (623.41, 86.02, 96.7)
    — the first observable movement in this dark-oak spawn

Co-authored-by: Yuriy Mayatnikov <mayatnikov@me.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 16:40:36 +03:00

239 lines
8.7 KiB
JavaScript

// Reflex layer: priority-ordered list of pure-script behaviors. Each reflex
// 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, chopNearestTree, wander } from "./actions.js";
const REFLEX_LOG = "reflex";
// A reflex returns one of:
// { action: "noop" } — nothing to do
// { action: "dispatched", kind, label } — dispatched an async action
// { action: "completed", kind, detail } — fully sync, already done
// 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" };
const dist = s.closestHostile.distance;
const lowHp = (s.health ?? 20) <= 8;
// Three regimes — tightened to avoid the "82 distant hostiles → constant
// flee" pathology observed at this spawn:
// - within 4m: melee attack
// - 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),
`attack ${s.closestHostile.name}`,
);
return { action: "dispatched", kind: "defend-attack", 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" };
}
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 -------------------------------------------------------------------
function eatReflex(ctx) {
const s = ctx.snapshot;
if (!s.connected) return { action: "noop" };
if (s.food === undefined || s.food >= 16) return { action: "noop" };
// 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.closestHostile && s.closestHostile.distance < 8) return { action: "noop" }; // not safe
// 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 < 5 * 60_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" };
}
// ---- 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) {
const s = ctx.snapshot;
if (!s.connected) return { action: "noop" };
ctx.idleCounter = (ctx.idleCounter ?? 0) + 1;
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 },
{ name: "autonomous", fn: autonomousReflex },
{ name: "idle", fn: idleReflex },
];
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 {
outcome = reflex.fn(ctx);
} catch (e) {
warn(REFLEX_LOG, `reflex ${reflex.name} threw: ${e?.message ?? e}`);
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;
}