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: Yuriy Mayatnikov <mayatnikov@me.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
147 lines
3.8 KiB
JavaScript
147 lines
3.8 KiB
JavaScript
// 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 };
|
|
}
|