feat(runtime): observability + no-progress detector (Phase 1)
Phase 1 of plans/autonomous-survival-bot-prd.md. The bot must always be able to answer "what am I doing and why am I not doing more?" without parsing the log stream. New modules: - runtime/state.js: pure FSM classifier emitting emergency / working / recovering / planning / social / idle from snapshot + reflex context. - runtime/no-progress.js: sliding-window detector that watches position and inventory; when both are unchanged for 60 s+, emits one stable reason code from REASONS (waiting_for_day, night_hostile_nearby, no_food_source, inventory_full, no_reachable_target, planner_empty, awaiting_action_cooldown). - runtime/viewer.js: optional prismarine-viewer launcher behind VIEWER_PORT. Lazy import so the dep is not required by default. Wiring: - runtime/bot.js: tick() now computes runtimeState + noProgressReason every tick and stamps them on the snapshot along with activeSkill, currentMilestone (read from plan.md, cached 30 s), lastResult, failuresByCode and lastEscalation. - runtime/bot.js: dispatchAction records lastResult and lastFailureAt for the recovering-state classifier. - runtime/planner.js: exports isPlannerBusy(), readNextMilestone() and planExists() so the runtime can show planning state + current milestone without spawning extra Pi calls. - runtime/config.js: adds VIEWER_PORT support. TUI: - tui/tui.tsx: StatusBar gains a state badge, current-skill row, milestone row, no-progress reason warning, last-result line with ok/fail color, failures-by-class summary and last-escalation age. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,146 @@
|
||||
// No-progress detector. Independent from exception-style failures: this
|
||||
// watches whether the bot is producing any *observable* world change
|
||||
// (movement or inventory change) over time, and, if not, emits a single
|
||||
// stable reason code so TUI + diary + future Pi escalation can show one
|
||||
// concrete answer to "why is the bot standing still?".
|
||||
//
|
||||
// Reason codes are deliberately stable strings so they can be diffed
|
||||
// across versions, counted, and used by future skill triggers.
|
||||
|
||||
export const REASONS = Object.freeze({
|
||||
WAITING_FOR_DAY: "waiting_for_day",
|
||||
NIGHT_HOSTILE_NEARBY: "night_hostile_nearby",
|
||||
NO_FOOD_SOURCE: "no_food_source",
|
||||
NO_SAFE_PATH: "no_safe_path",
|
||||
NO_KNOWN_BASE: "no_known_base",
|
||||
INVENTORY_FULL: "inventory_full",
|
||||
PLANNER_EMPTY: "planner_empty",
|
||||
NO_REACHABLE_TARGET: "no_reachable_target",
|
||||
AWAITING_ACTION_COOLDOWN: "awaiting_action_cooldown",
|
||||
});
|
||||
|
||||
const STILL_THRESHOLD_MS = 60_000;
|
||||
const POS_EPSILON = 1.5;
|
||||
const INVENTORY_FULL_SLOTS = 32; // 36 main slots; treat >=32 distinct stacks as full-ish
|
||||
|
||||
// Conservative food list — items the bot can safely consume right now via
|
||||
// the eat reflex / mineflayer auto-eat lineage. Kept short on purpose: if
|
||||
// the bot is "hungry but no food", we want to detect that even when the
|
||||
// inventory has dirt and sticks.
|
||||
const FOOD_NAMES = new Set([
|
||||
"bread",
|
||||
"cooked_beef",
|
||||
"cooked_chicken",
|
||||
"cooked_porkchop",
|
||||
"cooked_mutton",
|
||||
"cooked_rabbit",
|
||||
"cooked_salmon",
|
||||
"cooked_cod",
|
||||
"baked_potato",
|
||||
"apple",
|
||||
"golden_apple",
|
||||
"carrot",
|
||||
"beetroot",
|
||||
"melon_slice",
|
||||
"sweet_berries",
|
||||
"glow_berries",
|
||||
"mushroom_stew",
|
||||
"rabbit_stew",
|
||||
"beetroot_soup",
|
||||
"suspicious_stew",
|
||||
"cooked_chicken",
|
||||
"dried_kelp",
|
||||
"pumpkin_pie",
|
||||
]);
|
||||
|
||||
function hasFood(inventory) {
|
||||
for (const name of Object.keys(inventory ?? {})) {
|
||||
if (FOOD_NAMES.has(name)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function inventoryKey(inventory) {
|
||||
const entries = Object.entries(inventory ?? {});
|
||||
if (entries.length === 0) return "";
|
||||
entries.sort(([a], [b]) => (a < b ? -1 : 1));
|
||||
return entries.map(([k, v]) => `${k}:${v}`).join("|");
|
||||
}
|
||||
|
||||
function distinctStacks(inventory) {
|
||||
return Object.keys(inventory ?? {}).length;
|
||||
}
|
||||
|
||||
function classify({ snapshot, ctx, planExists }) {
|
||||
if (!snapshot.isDay) {
|
||||
if (snapshot.closestHostile && snapshot.closestHostile.distance <= 16) {
|
||||
return REASONS.NIGHT_HOSTILE_NEARBY;
|
||||
}
|
||||
return REASONS.WAITING_FOR_DAY;
|
||||
}
|
||||
|
||||
const food = snapshot.food ?? 20;
|
||||
if (food <= 6 && !hasFood(snapshot.inventory)) {
|
||||
return REASONS.NO_FOOD_SOURCE;
|
||||
}
|
||||
|
||||
if (distinctStacks(snapshot.inventory) >= INVENTORY_FULL_SLOTS) {
|
||||
return REASONS.INVENTORY_FULL;
|
||||
}
|
||||
|
||||
if (ctx?.noTreesUntil && Date.now() < ctx.noTreesUntil) {
|
||||
return REASONS.NO_REACHABLE_TARGET;
|
||||
}
|
||||
|
||||
if (!planExists) return REASONS.PLANNER_EMPTY;
|
||||
|
||||
return REASONS.AWAITING_ACTION_COOLDOWN;
|
||||
}
|
||||
|
||||
export function createNoProgressDetector({ stillThresholdMs = STILL_THRESHOLD_MS } = {}) {
|
||||
let stillSince = null;
|
||||
let lastPos = null;
|
||||
let lastInvKey = "";
|
||||
|
||||
function reset() {
|
||||
stillSince = null;
|
||||
}
|
||||
|
||||
function detect({ snapshot, ctx, planExists = true, now = Date.now() }) {
|
||||
if (!snapshot?.connected) {
|
||||
reset();
|
||||
return null;
|
||||
}
|
||||
if (ctx?.busy) {
|
||||
reset();
|
||||
return null;
|
||||
}
|
||||
|
||||
const pos = snapshot.position;
|
||||
const ik = inventoryKey(snapshot.inventory);
|
||||
const moved =
|
||||
!lastPos || !pos
|
||||
? lastPos !== pos
|
||||
: Math.hypot(pos.x - lastPos.x, pos.z - lastPos.z) > POS_EPSILON ||
|
||||
Math.abs(pos.y - lastPos.y) > POS_EPSILON;
|
||||
const invChanged = ik !== lastInvKey;
|
||||
|
||||
if (moved || invChanged) {
|
||||
lastPos = pos ? { ...pos } : null;
|
||||
lastInvKey = ik;
|
||||
stillSince = now;
|
||||
return null;
|
||||
}
|
||||
|
||||
if (stillSince == null) {
|
||||
stillSince = now;
|
||||
return null;
|
||||
}
|
||||
|
||||
if (now - stillSince < stillThresholdMs) return null;
|
||||
|
||||
return classify({ snapshot, ctx, planExists });
|
||||
}
|
||||
|
||||
return { detect, reset };
|
||||
}
|
||||
Reference in New Issue
Block a user