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>
This commit was merged in pull request #8.
This commit is contained in:
@@ -233,6 +233,33 @@ function maybeFileProposal(label) {
|
||||
appendDiary(`proposal filed: ${filename} (${summary})`);
|
||||
}
|
||||
|
||||
// ---- player chat replies (non-operator) ------------------------------------
|
||||
|
||||
// Rate-limited light replies to ordinary players. Spam guard is intentional:
|
||||
// the chat rate limit catches outbound flooding; this one prevents replying
|
||||
// to every greeting in a busy room.
|
||||
let lastPlayerReplyAt = 0;
|
||||
const PLAYER_REPLY_COOLDOWN_MS = 30_000;
|
||||
|
||||
const GREETING_RE = /\b(hi|hello|hey|yo|sup|hola|привет|здаров|здарова|здорова|здравствуй|здравствуйте|салам)\b/i;
|
||||
|
||||
function maybeReplyToPlayer(username, text) {
|
||||
if (!bot) return;
|
||||
const lower = text.trim().toLowerCase();
|
||||
const botname = bot.username.toLowerCase();
|
||||
const addressed = lower.includes(botname);
|
||||
if (!addressed && !GREETING_RE.test(lower)) return;
|
||||
const since = Date.now() - lastPlayerReplyAt;
|
||||
if (since < PLAYER_REPLY_COOLDOWN_MS) return;
|
||||
lastPlayerReplyAt = Date.now();
|
||||
|
||||
// Pick a small canned response. Non-operator chat is dialog-only by design
|
||||
// — we don't act on player verbs, we just acknowledge presence.
|
||||
const replies = ["yo", "hey", "hi", "привет"];
|
||||
const reply = replies[Math.floor(Math.random() * replies.length)];
|
||||
botChat(`${username}: ${reply}`);
|
||||
}
|
||||
|
||||
// ---- operator chat commands ------------------------------------------------
|
||||
|
||||
function isOperator(username) {
|
||||
@@ -333,6 +360,12 @@ function connect() {
|
||||
} catch (e) {
|
||||
warn("operator", `handler threw: ${e.message}`);
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
maybeReplyToPlayer(username, message);
|
||||
} catch (e) {
|
||||
warn("chat", `player reply handler threw: ${e.message}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -416,6 +449,10 @@ function tick() {
|
||||
if (bot && bot.entity) {
|
||||
lastSnapshot = buildSnapshot(bot);
|
||||
lastSnapshot.pendingProposals = listProposals().length;
|
||||
lastSnapshot.lastReflex = reflexCtx.lastReflex ?? null;
|
||||
lastSnapshot.busy = reflexCtx.busy
|
||||
? { label: reflexCtx.currentActionLabel ?? "?" }
|
||||
: null;
|
||||
reflexCtx.snapshot = lastSnapshot;
|
||||
if (!reflexPaused) {
|
||||
const result = runTick(reflexCtx);
|
||||
|
||||
Reference in New Issue
Block a user