Files
pepa-pi-bot/runtime/reflex.js
T
mayatnikovandClaude Opus 4.7 29542f0559 fix(runtime): unstick scheduler + chop + sleep + bed/shelter/farm skills
Recovers the bot from the live-server symptoms reported 2026-05-26:
1) constant supervisor reconnects, 2) chop "clicks once and stops",
3) sleep does nothing without a bed and so blocks night-skipping for
other players, 4) curriculum reflex always fell through to wander.

Supervisor (#38):
- runtime/watch-filter.js: pure predicate excluding *.test.js + the
  supervisor itself; recursive:true so skills/ + social/ edits also
  restart. Burned a working main once when test files counted toward
  the rollback threshold.
- runtime/supervisor.js: watch-triggered restarts no longer count
  toward the crash-loop rollback path. Watcher is now recursive.

Chop / mine (#39):
- runtime/actions.js + runtime/skills/gather-stone.js: replaced raw
  pathfinder.goto + bot.dig with mineflayer-collectblock's
  bot.collectBlock.collect — handles approach, repositioning, LoS,
  dig and pickup as one primitive. Old version "swung once" because
  GoalGetToBlock often parked the bot in leaves above the log.

Sleep + bed (#40):
- runtime/actions.js: sleepInBed now ALSO places a carried bed on
  solid ground next to the bot and sleeps on it. Critical so the bot
  stops blocking player night-skipping the moment it owns a bed.

Bed pipeline (#41):
- runtime/skills/gather-wool.js: gather.wool skill — mines wool block
  if any nearby, otherwise shears or attacks the nearest sheep.
- runtime/skills/craft.js: craftBedSkill (any colour the bot has ≥3
  wool of, plus 3 planks, plus a table).
- runtime/curriculum.js: new milestone survive.bed sits between
  wood.tools and stone.32 so the bot gets a bed BEFORE everything else.
  Test fixture updated to include a red_bed in post-survive.bed stages.

Village / shelter / wheat (#42, #43):
- runtime/skills/build-shelter.js: village.build-shelter — real 3×3×3
  resumable hut blueprint around the recorded base, places one block
  per loop, idempotent so an interrupted build resumes correctly,
  marks each placed block in the owned-blocks ledger.
- runtime/skills/deposit-surplus.js: village.deposit-surplus opens
  the nearest chest and transfers surplus stacks while keeping a
  reserve of tools/food/bed.
- runtime/skills/farm-wheat.js: farm.wheat does one step per call
  (till adjacent-to-water grass, plant seeds, or harvest ripe wheat).
- runtime/curriculum.js: village.shelter milestone after base-site.

Scheduler glitch (root of "always wander"):
- runtime/bot.js: curriculum + locations are now computed BEFORE
  runTick. Previously they were stamped AFTER, so reflex.js saw
  snapshot.curriculum=undefined every tick and fell through to the
  wander fallback. Verified live: scheduler now dispatches
  gather.logs/gather.stone/craft.* by id via runSkill.

Eat-spam:
- runtime/reflex.js: eatReflex now checks inventory for actual food
  and updates lastEatAt on EVERY dispatch (not only successes), so a
  failed eat respects the 5 s cooldown instead of firing every tick.

npm test 123/123. Validated live on play.xmatic.team (curriculum
dispatched gather.logs via runSkill, recover hint switched to wander
when no log in range).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 11:12:21 +03:00

268 lines
10 KiB
JavaScript

// Reflex layer: priority-ordered behaviours that decide what the bot does
// each tick. The 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).
//
// Chain (top to bottom — first to dispatch wins):
//
// defend event-driven, hostile in melee or low HP + close
// eat event-driven, food bar low
// sleep event-driven, night without hostile in reach
// curriculum the new scheduler — reads snapshot.curriculum and dispatches
// via runtime/skills/runSkill. Replaces the old ad-hoc
// tech-tree + autonomous chop/wander branches.
// idle heartbeat logger
//
// Operator chat does NOT create tasks (Phase 0 pivot). TUI pause/stop is
// the only local override.
import { info, warn } from "./log.js";
import {
attackNearest,
fleeFrom,
eatBestFood,
sleepInBed,
wander,
} from "./actions.js";
import { runSkill, getSkill } from "./skills/index.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.
// ---- 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 -------------------------------------------------------------------
// Lightweight food allowlist — must match what eatBestFood actually tries.
// Kept inline so reflex doesn't have to import the groups module.
const EAT_REFLEX_FOOD = new Set([
"cooked_beef", "cooked_porkchop", "cooked_mutton", "cooked_chicken",
"cooked_rabbit", "cooked_salmon", "cooked_cod", "baked_potato",
"bread", "carrot", "apple", "sweet_berries", "melon_slice",
"beef", "porkchop", "chicken", "mutton",
]);
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 dispatch eat if there's literally no food in inventory — the
// previous version dispatched every tick, got "no food in inventory" and
// burned the whole reflex chain on a hopeless eat-spam. (Observed live
// 2026-05-26.) The curriculum has food.basic as a real milestone now;
// keep eat reflex strictly for "we have food, eat it" cases.
const inv = s.inventory ?? {};
const hasFood = Object.keys(inv).some((n) => EAT_REFLEX_FOOD.has(n));
if (!hasFood) return { action: "noop" };
// Cooldown on attempts (not only successes). Bot.consume has lag on the
// server and re-firing within 5s would just fail. We update lastEatAt
// on EVERY dispatch so a failed attempt also respects the cooldown.
const since = Date.now() - (ctx.lastEatAt ?? 0);
if (since < 5000) return { action: "noop" };
ctx.lastEatAt = Date.now();
ctx.dispatch(() => eatBestFood(ctx.bot), "eat", {});
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" };
}
// ---- curriculum ------------------------------------------------------------
//
// The new scheduler. Reads snapshot.curriculum (produced by runtime/
// curriculum.js in bot.js's tick) and dispatches the suggested skill
// via runSkill. Falls back to wander when:
// * no curriculum result (curriculum says "everything done — late game"),
// * suggested skill is unknown to the registry,
// * recent recover() hint asked us to wander (e.g. no_target from
// gather.logs / gather.stone — same heuristic the old autonomous
// reflex used).
//
// Stone-tier locks: gather.stone needs a pickaxe; the skill's own
// preconditions will reject otherwise. When that happens we record a
// short backoff so we don't dispatch-and-fail every tick.
const CURRICULUM_COOLDOWN_MS = 4_000;
const SKILL_BACKOFF_MS = 60_000;
function curriculumReflex(ctx) {
const s = ctx.snapshot;
if (!s.connected) return { action: "noop" };
if (!ctx.bot) return { action: "noop" };
const since = Date.now() - (ctx.lastCurriculumAt ?? 0);
if (since < CURRICULUM_COOLDOWN_MS) return { action: "noop" };
const plan = s.curriculum?.plan;
const wanderHintUntil = ctx.skillBackoff?.["__wander_hint__"] ?? 0;
const wantWander = wanderHintUntil && Date.now() < wanderHintUntil;
// No skill plan from curriculum OR a recent skill asked us to wander —
// dispatch a wander fallback so we keep moving.
if (!plan?.skillId || wantWander) {
ctx.lastCurriculumAt = Date.now();
ctx.dispatch(() => wander(ctx.bot, 16), "wander", {});
return { action: "dispatched", kind: "curriculum-wander", label: "wander" };
}
const skillId = plan.skillId;
const skill = getSkill(skillId);
if (!skill) {
// Curriculum suggested a skill that isn't registered yet — fall back
// to wander rather than spinning. This is the right behaviour for
// future milestones we haven't wired (e.g. shelter blueprints).
ctx.lastCurriculumAt = Date.now();
ctx.dispatch(() => wander(ctx.bot, 16), "wander", {});
return { action: "dispatched", kind: "curriculum-wander", label: `wander (no skill ${skillId})` };
}
// Per-skill backoff: if this exact skill failed with a non-recoverable
// reason recently (missing_tool, missing_material, no_target) we give it
// breathing room rather than retrying every cooldown.
const backoffUntil = ctx.skillBackoff?.[skillId] ?? 0;
if (Date.now() < backoffUntil) return { action: "noop" };
ctx.lastCurriculumAt = Date.now();
ctx.dispatch(() => runSkill(skillId, ctx), skillId, {
onComplete: (res) => {
ctx.skillBackoff = ctx.skillBackoff ?? {};
if (res?.recovery?.hint === "wander") {
// Same fix the old autonomous reflex applied for "no reachable
// log" — switch to exploration for a minute.
ctx.skillBackoff["__wander_hint__"] = Date.now() + SKILL_BACKOFF_MS;
}
if (!res?.ok) {
// missing_tool / missing_material / no_target shouldn't be
// retried on the very next tick. Hold for SKILL_BACKOFF_MS.
const cooldownCodes = new Set(["missing_tool", "missing_material", "no_target", "no_food_source", "unsupported_version"]);
if (cooldownCodes.has(res?.code)) {
ctx.skillBackoff[skillId] = Date.now() + SKILL_BACKOFF_MS;
}
} else {
// Success clears the wander hint immediately.
ctx.skillBackoff["__wander_hint__"] = 0;
}
},
});
return { action: "dispatched", kind: "curriculum-skill", label: skillId };
}
// ---- 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: "defend", fn: defendReflex },
{ name: "eat", fn: eatReflex },
{ name: "sleep", fn: sleepReflex },
{ name: "curriculum", fn: curriculumReflex },
{ 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;
}
// Exposed for tests.
export const _internal = { curriculumReflex, defendReflex, eatReflex, sleepReflex };