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:
Yuriy Mayatnikov
2026-05-25 16:40:36 +03:00
committed by GitHub
co-authored by mayatnikov Claude Opus 4.7
parent 445524b34d
commit f5361ad9a4
4 changed files with 290 additions and 16 deletions
+20
View File
@@ -123,10 +123,30 @@ function StatusBar({ snapshot, paused, connectedToBot }: { snapshot: Snapshot; p
{snapshot.closestHostile ? ` closest=${snapshot.closestHostile.name}@${snapshot.closestHostile.distance}m` : ""}
{snapshot.pendingProposals ? <Text color="magenta" bold>{` [proposals ${snapshot.pendingProposals}, press y]`}</Text> : null}
</Text>
<Text>
{snapshot.busy ? (
<Text color="cyan"> busy: {snapshot.busy.label}</Text>
) : snapshot.lastReflex ? (
<Text dimColor>
last reflex: {snapshot.lastReflex.name}
{snapshot.lastReflex.label ? ` (${snapshot.lastReflex.label})` : ""}{" "}
{snapshot.lastReflex.ts ? formatAge(snapshot.lastReflex.ts) : ""}
</Text>
) : (
<Text dimColor>no reflex action yet</Text>
)}
</Text>
</Box>
);
}
function formatAge(tsMs: number): string {
const ageMs = Date.now() - tsMs;
if (ageMs < 60_000) return `${Math.floor(ageMs / 1000)}s ago`;
if (ageMs < 3600_000) return `${Math.floor(ageMs / 60_000)}m ago`;
return `${Math.floor(ageMs / 3600_000)}h ago`;
}
function ProposalPanel({
proposal,
onClose,