Files
pepa-pi-bot/runtime/reflex.js
T
e84148d189 v0.2.0-rc.2: P0 hardening — Pi headless, test state isolation, advice fixes (#21)
P0 (correctness):

1. PEPA_HEADLESS=1 guard in extensions/mineflayer-bridge.ts. When `pi -p`
   spawns a subprocess (banter, coach, planner, reflect, auto-patch), the
   bridge no longer attempts a second MC connect — the hybrid runtime
   already owns the nickname. runtime/pi-bridge.js sets the env var on
   every spawn. Root cause of the "two pepa_bot's racing for the slot"
   bug seen in reply-pi stderr.

2. Test state isolation in runtime/config.js. When running under the node
   test runner (detected via execArgv/argv) — or when PEPA_STATE_DIR is
   set — stateDir redirects to /tmp/pepa-test-state-<pid>/. log.js,
   scenario-memory, world-journal, and knowledge.db all follow.
   `npm test` no longer pollutes live scenarios.jsonl, world-journal.jsonl,
   or daily log files. Verified empirically: post-fix run added 0 test
   rows to the live scenarios file. Cleaned ~550 historical test rows
   from live state in the same change.

3. defendReflex outcome reporting (runtime/reflex.js). Previously a
   creeper-rule override marked the lesson succeeded=false BEFORE the
   flee skill returned. Now dispatchDefendFlee accepts {lessonId} and
   the onComplete fires reportAdviceOutcome with the actual flee result.

4. Mode-name → skill-id translation in runtime/coach/advice.js. Pi-coach
   occasionally returns prefer_skill values that are mode names
   ("night_shelter", "self_preservation", "hunger"). normalisePreferSkill
   maps these to SAFE_OVERRIDES entries before dispatch. Also handles
   "tunnel-out", "survive_flee", "survive flee" shapes.

New behavior:

5. Self-reflection loop (runtime/coach/reflect.js). Every 30 min, the
   bot asks Pi: "Are you making progress, or stuck in a loop? What
   should you do differently?" Pi answers with a verdict
   (progress/loop/recovering/idle/emergency), summary, next-action, and
   0-N new lessons. The reflection is written to
   state/<host>/reflections/<ts>.md and lessons land in the DB with
   source="pi-reflect". Rate-limited to 2 calls/hour. Wired through
   bot.js with the existing askPi + lastSnapshot accessor.

Tests: 246/246 green (+9 new: 6 advice mode-name + 4 reflect).

Co-authored-by: Yuriy Mayatnikov <mayatnikov@me.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 14:10:28 +03:00

568 lines
22 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";
import { consult as consultAdvice, reportOutcome as reportAdviceOutcome } from "./coach/advice.js";
import { situationHash } from "./scenario-memory.js";
import { tickModes } from "./modes.js";
// Each "wander hint" triggered by a skill returning no_target should take
// the bot meaningfully further than 16 blocks — otherwise the curriculum
// re-fires the same skill, gets no_target again, and the bot loops in
// place. We escalate every other wander hint into explore.far (~48
// blocks, quadrant-rotating).
let consecutiveWanderHints = 0;
const REFLEX_LOG = "reflex";
const DEFEND_ATTACK_MAX_SWINGS = 5;
const DEFEND_ATTACK_SETTLE_MS = 650;
const DEFEND_CLEAR_RADIUS = 4.5;
const DEFEND_STUCK_WINDOW_MS = 20_000;
const DEFEND_REPEAT_OK_RETREAT_COUNT = 2;
// 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 delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function nearestHostileDistance(bot, hostileName) {
const here = bot?.entity?.position;
if (!here) return null;
let nearest = null;
for (const e of Object.values(bot.entities ?? {})) {
if (!e?.position) continue;
if (hostileName && e.name !== hostileName) continue;
let dist;
try {
dist = e.position.distanceTo(here);
} catch {
continue;
}
if (!Number.isFinite(dist)) continue;
if (nearest === null || dist < nearest) nearest = dist;
}
return nearest;
}
function distanceDetail(dist) {
return Number.isFinite(dist) ? Number(dist.toFixed(1)) : dist;
}
async function attackNearestUntilClear(bot, hostileName, opts = {}) {
if (!bot?.entity?.position) return { ok: false, code: "no_bot", detail: "bot missing position" };
const maxSwings = Math.max(1, opts.maxSwings ?? DEFEND_ATTACK_MAX_SWINGS);
const settleMs = Math.max(0, opts.settleMs ?? DEFEND_ATTACK_SETTLE_MS);
let lastDetail = null;
for (let swings = 0; swings < maxSwings; swings++) {
const before = nearestHostileDistance(bot, hostileName);
if (before === null || before > DEFEND_CLEAR_RADIUS) {
return { ok: true, code: "done", detail: { target: hostileName, cleared: true, swings } };
}
const res = await attackNearest(bot, hostileName);
lastDetail = res?.detail ?? null;
if (!res?.ok) {
return {
ok: false,
code: res?.code ?? "no_target",
detail: res?.detail ?? "no target in reach",
};
}
if (settleMs > 0) await delay(settleMs);
}
const after = nearestHostileDistance(bot, hostileName);
if (after === null || after > DEFEND_CLEAR_RADIUS) {
return { ok: true, code: "done", detail: { target: hostileName, cleared: true, swings: maxSwings } };
}
return {
ok: false,
code: "hostile_still_near",
detail: {
target: hostileName,
distance: distanceDetail(after),
swings: maxSwings,
last: lastDetail,
},
};
}
function rememberDefendAttack(ctx, hostileName, res) {
const now = Date.now();
if (res?.ok) {
const prev = ctx.defendAttackCleared;
const count = prev?.name === hostileName && now - prev.ts < DEFEND_STUCK_WINDOW_MS
? prev.count + 1
: 1;
ctx.defendAttackCleared = { name: hostileName, count, ts: now };
if (ctx.defendAttackStuck?.name === hostileName) ctx.defendAttackStuck = null;
return;
}
ctx.defendAttackCleared = null;
if (res?.code !== "hostile_still_near") return;
const prev = ctx.defendAttackStuck;
const count = prev?.name === hostileName && now - prev.ts < DEFEND_STUCK_WINDOW_MS
? prev.count + 1
: 1;
ctx.defendAttackStuck = { name: hostileName, count, ts: now };
}
function shouldRetreatFromStuckAttack(ctx, hostileName) {
const stuck = ctx.defendAttackStuck;
if (!stuck || stuck.name !== hostileName) return false;
return stuck.count >= 1 && Date.now() - stuck.ts < DEFEND_STUCK_WINDOW_MS;
}
function shouldRetreatFromRepeatedClear(ctx, hostileName) {
const cleared = ctx.defendAttackCleared;
if (!cleared || cleared.name !== hostileName) return false;
return cleared.count >= DEFEND_REPEAT_OK_RETREAT_COUNT && Date.now() - cleared.ts < DEFEND_STUCK_WINDOW_MS;
}
function matchingHostileEntity(ctx, hostileName, dist) {
return Object.values(ctx.bot?.entities ?? {}).find(
(e) =>
e.name === hostileName &&
e.position &&
Math.abs(e.position.distanceTo(ctx.bot.entity.position) - dist) < 1.5,
);
}
function dispatchDefendFlee(ctx, hostile, dist, opts = {}) {
const lastFlee = ctx.lastFleeAttempt;
if (!opts.ignoreCooldown && lastFlee && lastFlee.name === hostile.name && Date.now() - lastFlee.ts < 60_000) {
return { action: "noop" };
}
ctx.lastFleeAttempt = { name: hostile.name, ts: Date.now() };
const fromEntity = matchingHostileEntity(ctx, hostile.name, dist);
const onComplete = opts.lessonId
? (res) => reportAdviceOutcome({ lessonId: opts.lessonId, succeeded: !!res?.ok })
: undefined;
ctx.dispatch(
() => fleeFrom(ctx.bot, fromEntity, 16),
`flee from ${hostile.name}`,
onComplete ? { onComplete } : {},
);
return { action: "dispatched", kind: "defend-flee", label: hostile.name };
}
function defendReflex(ctx) {
const s = ctx.snapshot;
if (!s.connected) return { action: "noop" };
if (!s.closestHostile) return { action: "noop" };
const hostile = s.closestHostile;
const dist = hostile.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: verified melee attack (do not report success while the
// hostile is still standing in reach)
// - 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) {
if (shouldRetreatFromRepeatedClear(ctx, hostile.name)) {
ctx.defendAttackCleared = null;
return dispatchDefendFlee(ctx, hostile, dist, { ignoreCooldown: true });
}
if (shouldRetreatFromStuckAttack(ctx, hostile.name)) {
ctx.defendAttackStuck = null;
return dispatchDefendFlee(ctx, hostile, dist, { ignoreCooldown: true });
}
// v0.2.0 — consult learned lessons. If knowledge says "do not
// attack <hostile> in this state" (e.g. creeper rule, or no-weapon
// rule learned from post-mortems), flee instead. The lesson outcome
// is reported AFTER the flee skill finishes (via dispatchDefendFlee
// onComplete), not before — flee's success/failure is what proves
// or disproves the lesson, not the act of consulting it. This is
// the closing of the learning loop for emergency combat.
const advice = consultAdvice({ plannedSkillId: `attack ${hostile.name}`, snapshot: s });
if (advice.action === "avoid" || advice.action === "override") {
return dispatchDefendFlee(ctx, hostile, dist, {
ignoreCooldown: true,
lessonId: advice.lessonId,
});
}
ctx.dispatch(
() => attackNearestUntilClear(ctx.bot, hostile.name, {
maxSwings: ctx.defendAttackMaxSwings,
settleMs: ctx.defendAttackSettleMs,
}),
`attack ${hostile.name}`,
{ onComplete: (res) => rememberDefendAttack(ctx, hostile.name, res) },
);
return { action: "dispatched", kind: "defend-attack", label: hostile.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.
return dispatchDefendFlee(ctx, hostile, dist);
}
// ---- 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 -----------------------------------------------------------------
// Inventory check so the sleep reflex doesn't waste a dispatch when we
// have no bed AND no bed nearby — let the curriculum (survive.bed) drive
// bed acquisition instead. The action itself still re-checks, but pre-
// filtering here saves a dispatch + 5-min cooldown on impossible states.
const ANY_BED_NAME_RE = /(?:^|_)bed$/;
function hasAnyBedItem(inv) {
return Object.keys(inv ?? {}).some((n) => ANY_BED_NAME_RE.test(n));
}
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
// Skip dispatch entirely when there is no bed in inventory AND no
// placed bed location we know about. Otherwise every restart at night
// burns a "sleep → no bed" dispatch+5-min cooldown for nothing — saw
// this live 2026-05-26 where the bot would dispatch sleep right after
// every spawn before doing anything productive.
const bedItem = hasAnyBedItem(s.inventory);
const bedLoc = s.locations?.shelter ?? s.locations?.base ?? null;
if (!bedItem && !bedLoc) return { action: "noop" };
// 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;
const METRIC_BACKOFF_MS = 10 * 60_000;
const METRIC_BAD_CODES = new Set(["timeout", "failed", "wedged", "silent_dig_failure", "validation_failed"]);
function isRecentBadMetric(metric, { minFails = 2, maxAgeMs = METRIC_BACKOFF_MS } = {}) {
if (!metric) return false;
if ((metric.fail ?? 0) < minFails) return false;
if (!METRIC_BAD_CODES.has(metric.lastCode)) return false;
if ((metric.ok ?? 0) > 0 && metric.lastCode !== "timeout") return false;
return Date.now() - (metric.lastTs ?? 0) < maxAgeMs;
}
function recentSuccessAfter(metric, ts) {
if (!metric || !ts) return false;
if ((metric.ok ?? 0) <= 0) return false;
return (metric.lastTs ?? 0) > ts && metric.lastCode === "done";
}
function metricRecoverySkill(ctx, plannedSkillId) {
let metrics = null;
try {
metrics = ctx.metrics?.snapshot?.() ?? null;
} catch {
return null;
}
if (!metrics) return null;
const badExplore = isRecentBadMetric(metrics["explore.far"], { minFails: 1 });
const badWander = isRecentBadMetric(metrics.wander, { minFails: 2 });
const lastMovementBadTs = Math.max(
badExplore ? (metrics["explore.far"]?.lastTs ?? 0) : 0,
badWander ? (metrics.wander?.lastTs ?? 0) : 0,
);
if ((badExplore || badWander) && !recentSuccessAfter(metrics["recovery.tunnel-out"], lastMovementBadTs)) {
return { skillId: "recovery.tunnel-out", reason: "recent movement recovery failures" };
}
if (plannedSkillId && isRecentBadMetric(metrics[plannedSkillId], { minFails: 2 })) {
return { skillId: "explore.far", reason: `${plannedSkillId} recently failed repeatedly` };
}
return null;
}
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;
const metricRecovery = metricRecoverySkill(ctx, plan?.skillId);
if (metricRecovery) {
ctx.lastCurriculumAt = Date.now();
ctx.skillBackoff = ctx.skillBackoff ?? {};
if (plan?.skillId) ctx.skillBackoff[plan.skillId] = Date.now() + SKILL_BACKOFF_MS;
const args = metricRecovery.skillId === "recovery.tunnel-out"
? { reason: metricRecovery.reason, maxSteps: 3 }
: {};
ctx.dispatch(() => runSkill(metricRecovery.skillId, ctx, args), metricRecovery.skillId, {});
return {
action: "dispatched",
kind: "curriculum-metric-recovery",
label: metricRecovery.skillId,
};
}
if (s.curriculum?.inventoryFull) {
const depositId = s.locations?.chest || s.nearbyBlocks?.storage ? "village.deposit-surplus" : null;
if (depositId) {
const depositBackoff = ctx.skillBackoff?.[depositId] ?? 0;
if (Date.now() >= depositBackoff) {
ctx.lastCurriculumAt = Date.now();
ctx.dispatch(() => runSkill(depositId, ctx), depositId, {
onComplete: (res) => {
if (!res?.ok) {
ctx.skillBackoff = ctx.skillBackoff ?? {};
ctx.skillBackoff[depositId] = Date.now() + SKILL_BACKOFF_MS;
}
},
});
return { action: "dispatched", kind: "curriculum-deposit", label: depositId };
}
}
}
// No skill plan from curriculum OR a recent skill asked us to wander.
// First hint → small wander (might just be 32-block reach issue).
// Every subsequent hint while still inside the backoff window → use
// explore.far so the bot actually leaves the patch it's stuck in.
if (!plan?.skillId || wantWander) {
ctx.lastCurriculumAt = Date.now();
if (wantWander && consecutiveWanderHints >= 1) {
ctx.dispatch(() => runSkill("explore.far", ctx), "explore.far", {});
return { action: "dispatched", kind: "curriculum-explore-far", label: "explore.far" };
}
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" };
// Scenario memory: this exact (skill, situation) pattern failed N times
// recently? Skip and let the wander/explore hint move us to a different
// situation. The hash includes coarse position + day/night + food + hp
// + inventory keys + nearby hostile — "same kind of place + state".
if (ctx.memory?.shouldSkip && ctx.snapshot) {
const sit = situationHash(ctx.snapshot);
if (ctx.memory.shouldSkip({ skillId, situation: sit })) {
// Pretend a wander hint fired so the next tick will explore.
ctx.skillBackoff = ctx.skillBackoff ?? {};
ctx.skillBackoff["__wander_hint__"] = Date.now() + SKILL_BACKOFF_MS;
return { action: "noop" };
}
}
// v0.2.0 — consult learned lessons. If a high-confidence lesson says
// "avoid <skillId> in this situation", swap to its preferred
// alternative (or back off entirely if no safe alternative is named).
const advice = consultAdvice({ plannedSkillId: skillId, snapshot: ctx.snapshot });
let dispatchSkillId = skillId;
if (advice.action === "avoid") {
ctx.skillBackoff = ctx.skillBackoff ?? {};
ctx.skillBackoff[skillId] = Date.now() + SKILL_BACKOFF_MS;
ctx.skillBackoff["__wander_hint__"] = Date.now() + SKILL_BACKOFF_MS;
return { action: "noop", kind: "curriculum-advice-avoid", label: skillId, lessonId: advice.lessonId };
}
if (advice.action === "override" && advice.overrideSkillId) {
dispatchSkillId = advice.overrideSkillId;
}
ctx.lastCurriculumAt = Date.now();
ctx.dispatch(() => runSkill(dispatchSkillId, ctx), dispatchSkillId, {
onComplete: (res) => {
ctx.skillBackoff = ctx.skillBackoff ?? {};
if (advice.lessonId) reportAdviceOutcome({ lessonId: advice.lessonId, succeeded: !!res?.ok });
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;
consecutiveWanderHints++;
}
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", "no_chest", "no_space", "nothing_to_deposit"]);
if (cooldownCodes.has(res?.code)) {
ctx.skillBackoff[dispatchSkillId] = Date.now() + SKILL_BACKOFF_MS;
}
} else {
// Success clears the wander hint immediately.
ctx.skillBackoff["__wander_hint__"] = 0;
consecutiveWanderHints = 0;
}
},
});
return { action: "dispatched", kind: "curriculum-skill", label: dispatchSkillId };
}
// ---- 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 ?? "(?)" };
}
// Modes (Mindcraft-style priority chain) run BEFORE the legacy reflex
// chain. Any mode with interrupts:["all"] wins outright; ones that only
// interrupt the curriculum just steer us toward a particular skill via
// runSkill. The reflex chain stays as the fallback for things the modes
// don't cover yet.
const modeHit = tickModes(ctx);
if (modeHit?.action?.skillId) {
const fn = () => runSkill(modeHit.action.skillId, ctx, modeHit.action.args ?? {});
ctx.lastReflex = { name: `mode:${modeHit.mode}`, label: modeHit.action.skillId, ts: Date.now() };
ctx.dispatch(fn, modeHit.action.skillId, {});
return {
reflex: `mode:${modeHit.mode}`,
action: "dispatched",
kind: `mode:${modeHit.mode}`,
label: modeHit.action.skillId,
detail: modeHit.detail,
};
}
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 };