Files
pepa-pi-bot/runtime/reflex.js
T
15b6c11002 v0.3.1: survival behaviour overhaul — storyline, biome-aware scout, wedge-relocate, food/perf fixes, monitor TUI (#28)
* docs(v0.3.1): PRD — LLM prompt cost optimization

Design-only commit; no runtime changes. Spec for the next patch iteration.

Goal: cut per-advise() input tokens from ~800 to ≤300, preserving the
LLM's ability to produce valid registered skill ids and useful rationale.

Five proposed changes ranked by impact:

  P1  Compact registry format (saves ~350t/call) — group by namespace,
      comma-list ids, drop human titles. Default mode for advisor;
      verbose mode kept for postmortem/reflect.
  P2  Need-scoped registry (~50t additional) — show LLM only skills
      relevant to the active Maslow need + always-available safety
      skills (survive.flee, pillar-up, recovery.tunnel-out, explore.*).
  P3  Snapshot pruning (~50t) — drop weather/experience/dimension/biome/
      players from the user prompt; the LLM doesn't consult them.
  P4  Prompt caching probe — check if TimeWeb passes through
      prompt_tokens_details.cached_tokens. If yes, restructure prefix
      to maximize cache hits (cached input is ~10x cheaper at OpenAI).
  P5  Per-trigger cost telemetry in scripts/list-improvements.js --stats:
      avg_in / avg_out / cost_₽ / share% per trigger_reason, using
      TIMEWEB_PRICE_IN_RUB_PER_M and TIMEWEB_PRICE_OUT_RUB_PER_M env.

Trigger: TimeWeb admin panel after first day of v0.3.0 live showed
34K tokens / day at low activity. At cap budget that projects to
~480₽/month (101₽/M in, 608₽/M out for gpt-5.4-mini). Manageable
but the savings are mostly free — repeated infra tokens, not signal.

All changes are additive; runtime behaviour stays the same. If the
LLM produces worse advice with the compact registry, flip back via a
single constant in fast-advisor.js.

Acceptance: re-run scripts/check-timeweb.js probe 3 — expect
tokens_in ≤ 300 (was ~800). Live for 1h, check --stats: avg_in ≤ 300
per trigger group. Existing 360 tests still green.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(v0.3.1): storyline — canonical Minecraft survival quest

The bot has been stuck in a loop for two days:
  acquire-food (fail: no nearby food) → explore.far → pillar-up (fail) → repeat

Diagnosis: manifesto + LLM advisor both correctly identify "you need
food" but neither expresses *what concretely to do next*. Manifesto is
a priority ladder (need-detection), not a narrative arc.

This commit adds the missing narrative layer — an ordered list of
operational steps that mirror the vanilla Minecraft survival path:

  1. orient_self      — Понять где я
  2. first_wood       — Собрать 8 поленьев
  3. crafting_basics  — Сделать верстак и палки
  4. first_tools      — Деревянные орудия
  5. first_food       — Найти первую еду
  6. shelter_minimal  — Простой шелтер с кроватью
  7. stone_tier       — Каменные орудия
  8. food_security    — Запас еды на 16+
  9. iron_age         — Железо и печь
  10. settle_base     — Постоянная база
  11. village_grow    — Развивать деревню (ongoing)

Each step has:
  - completed(snapshot) → bool — detects achievement from snapshot
  - suggestSkill(snapshot) → { skillId, args? } — concrete next dispatch
  - emergencyPause(snapshot) → bool — defers to manifesto L0 alive
    emergencies (low HP near hostile, lava under foot, food = 0)
  - narration_ru — chat-friendly Russian one-liner spoken on entry

Components:

- runtime/goal/storyline.js — 11-step canonical quest catalogue
- runtime/goal/state.js — pickCurrentStep(snapshot) walks the list,
  returns first non-completed step + its suggestion. 3s cache.
  Validates suggestSkill's skillId against the live registry.
- runtime/reflex.js — curriculumReflex dispatch priority is now:
    1. manifesto (L0 alive emergencies always win)
    2. storyline (concrete operational subgoal)
    3. curriculum plan (legacy fallback)
  Tests pass ctx.disableStoryline=true for isolation.
- runtime/bot.js — snapshot.storyStep populated each tick so
  chatter/advisor/reflect observers see the same view.
- runtime/coach/fast-advisor.js — buildUserPrompt now embeds the
  current step + its suggested skill, so LLM advice is anchored
  ("step 5 first_food, storyline wants survive.acquire-food, but
  recent dispatches show it's failing — try explore.far + scout").
- runtime/coach/advisor-trigger.js — forwards ctx.storyStep into
  advise() and logs step id at trigger time.
- runtime/coach/reflect.js — reflection prompt includes storyline
  progress so 30-min self-assessment is anchored.
- runtime/persona/chatter.js — narrates step.narration_ru on
  transition. Rate-limited via existing maybeNarrateRaw().

New operator CLI:

- scripts/show-story.js — fetches the live snapshot via IPC sock and
  prints step progress with ✓/→/ markers, current skill, inventory.
  Falls back to --plain catalogue view when bot offline.

Token cost impact: ~+30 input tokens per advise() call (one extra
line in user prompt). Trivial vs the value of grounding LLM advice
in a concrete narrative.

Operator usage:

  node scripts/show-story.js          # live progress + which step + why
  node scripts/show-story.js --plain  # static catalogue of all 11 steps

Tests: 376 green (was 360, +16 storyline tests).

Also in this branch (already committed): dev/v0.3.1/PRD.md —
LLM prompt cost optimization design doc.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(v0.3.1): storyline beats manifesto L1+ (only L0 alive emergencies override)

Found in live logs after the previous commit deployed:
  storyline: step 1/11: orient_self → explore.wander
  advisor-trigger: firing because wedged (planned=survive.acquire-food, ...)

Manifesto was still picking survive.acquire-food (L1 food) over the
storyline's orient_self → explore.wander. That's the wrong precedence —
storyline expresses a *concrete operational subgoal* and L1+ manifesto
needs are just "you'd benefit from food" priorities, not emergencies.

New dispatch precedence in curriculumReflex:
  1. manifesto L0 (alive emergencies: lava, low-HP+hostile, food=0)
  2. storyline (concrete narrative subgoal — beats L1+ manifesto)
  3. manifesto L1+ (fallback when storyline has no concrete suggestion)
  4. curriculum plan (legacy fallback)

This way the bot starts following the narrative arc even while
manifesto's L1 food is technically unsatisfied — orient_self runs to
completion before pursuing food explicitly. Storyline already handles
food as step 5 (first_food), so we're not skipping it.

Tests: 378 green (+2 priority-ordering tests):
- L0 manifesto emergency: upstream reflex (defend/modes) catches before
  curriculum dispatch
- storyline beats manifesto when both have suggestions: well-fed bot
  with logs → craft.planks (storyline crafting_basics), not gather.logs
  (manifesto L2)
- updated "manifesto fallback" test to require disableStoryline=true

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(tui): fullscreen monitor-only TUI (opencode-style)

Replaces the old tui/tui.tsx hotkey-heavy dashboard with a read-only
observability screen. Operator actions live in scripts/* now —
TUI is for watching, not driving.

Layout (top to bottom, all auto-resizing to terminal):
  1. Header     — MC/IPC status, pos, HP, food, day/night, hostiles
  2. Storyline  — current step + 11-step quest map (✓/→/○)
  3. Activity   — last N skill dispatches (colour by outcome)
  4. MC Chat    — last N chat lines (cyan for bot, yellow for players)
  5. Advisor    — last N LLM recommendations (trigger + outcome + tokens)
  6. Improvements — open requests from knowledge.improvement_requests
  7. Footer     — 24h token usage + cost in ₽ + q-to-quit

Data sources:
  - IPC sock: snapshot frames, log frames, chat frames (push)
  - SQLite knowledge.db: advisor_recommendations + improvement_requests
    polled every 5s (pull)

Token cost displayed live using TIMEWEB_PRICE_IN_RUB_PER_M /
TIMEWEB_PRICE_OUT_RUB_PER_M env vars (defaults: 101 / 608 for
gpt-5.4-mini).

Switches:
  - npm run tui          → new monitor (this file)
  - npm run tui:legacy   → old action-driven tui/tui.tsx (kept for now)

Implementation notes:
  - Uses ink + alternate-screen-buffer ANSI for proper "opencode-feel"
    fullscreen behaviour; restores prior terminal contents on quit.
  - Skips alt-screen and useInput when stdin/stdout isn't a TTY
    (smoke tests, piped output) — both gracefully degrade.
  - Stable React keys via per-event uid counter, avoids reconciler
    duplicate-key warnings as logs/chat/dispatches stream in.
  - Resize handled via 1s stdout-dimension poll, NOT direct
    'resize' listener (which conflicts with ink's own listener and
    triggers MaxListenersExceededWarning).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* ui(tui): compact 4-section monitor (was 6) — fits 1080p without zoom

Operator reported the TUI overflowed the screen unless terminal was
zoomed way out. The 11-step storyline list alone was eating ~13
rows, and each advisor/improvement entry took 2-3 rows. Now:

- Header + storyline collapsed into one panel (2 lines):
    line 1: pepa · ●MC ●IPC · 1m50s · pepa_bot · (697,61,702) · HP 20 · food 5 · ☀ · ⚔60(creeper@58b)
    line 2: story ▓▒░░░░░░░░░ 1/11 orient_self · Понять где я → explore.wander
  The 11-step ladder is now a unicode progress bar (▓ done, ▒ current,
  ░ pending) — same info, fits in one row.

- Advisor entries: one line each instead of two.
    ✓ wedged_60s → survive.flee  802t 1900ms
    (outcome mark / trigger / target skill / tokens / latency)

- Improvements entries: one line each instead of two.
    #1 P2 ×3  Add craft.iron-pickaxe skill
  Description dropped from the row — use `node scripts/list-improvements.js`
  for full text.

- Sections: 4 (was 6).
    [header+story] · [activity | chat] · [advisor | improvements] · [footer]

Tested on a typical 1080p terminal — fits comfortably without zoom.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(v0.3.1): real survival patterns — biome-aware scout, wedge-relocate, escape-pit-safe

Operator reported the bot wandered the same 50×50 patch for 2 hours
without making any progress toward food. Diagnosis showed three root
causes; this commit addresses all five open improvement_requests
the LLM (postmortem + tuner) flagged automatically.

Research basis (`Voyager`, `Plan4MC`, `GITM`, `Mindcraft`):
  - Coverage / commit-to-cardinal exploration when local scan fails
  - Biome-aware strategy switching using a static affordance table
  - Wedge detector above the skill layer that triggers RELOCATE not
    RETRY (per-skill stuck checks reset on re-entry — useless)
  - Time-in-region bbox heuristic + need-duration AND skill-cycle gate

Concrete changes:

1. `runtime/goal/storyline.js`
   - orient_self.completed: added timeout fallback (HP=full + session
     >120s → done) so barren biomes don't block the bot on step 1.
     Closes improvement #2 'Нет навыка оценки когда сменить район'.
   - first_food.suggestSkill: now picks survive.scout-food (new) when
     no passive mob is nearby; falls back to survive.acquire-food only
     when something is in immediate range.

2. `runtime/biome-affordances.js` (new)
   - Static table: 40+ biomes → {has_passive_mobs, has_trees,
     has_water, has_crops, livable}.
   - Unknown biomes return optimistic defaults to avoid regressions.
   - Closes improvement #1 'Нет навыка целевого поиска еды по биому'.

3. `runtime/skills/scout-food.js` (new — survive.scout-food)
   - Tiered strategy: biome check → scan 32 → scan 64 → commit a
     cardinal for 200 blocks rescanning every 16. On cardinal
     exhaustion, returns code:"exhausted" so the curriculum can
     escalate to village.relocate.
   - In barren biomes (desert/ocean/snowy_plains) the scan is
     SKIPPED — bot walks straight toward the nearest neighbour
     biome that affords passive mobs (8-direction biome probe at
     radius 64).

4. `runtime/awareness/wedge-detector.js` (new)
   - Rolling 10-min position bbox tracker. observe() called every
     tick; isWedged() returns true when bbox<50 AND active need
     unmet >5min AND skill cycles ≥3.
   - markRelocationStarted() suppresses further wedge firings
     until the bot has displaced ≥200b — prevents stack overflow
     of relocate calls.
   - Lives ABOVE the skill layer (in runtime/reflex.js), because
     any per-skill stuck check resets on re-entry.

5. `runtime/skills/relocate.js` (new — village.relocate)
   - 300-block walk in least-recently-used cardinal (per-incident
     memory in ctx.recentRelocations).
   - Re-paths every 32 blocks, soft-tolerates pathfinder failures
     (3 consecutive throws → exit with code:"stuck_in_place").
   - Closes improvement #2 + #4 ('low success rate trigger').

6. `runtime/skills/escape-pit-safe.js` (new — recovery.escape-pit-safe)
   - Surveys 4 cardinals AND ceiling height before committing.
     Picks the direction with most open blocks (≥3, no lava).
     Falls through to pillar-up only if ceiling clear ≥4b. Returns
     code:"no_strategy" if both blocked so curriculum can escalate
     to relocate.
   - Closes improvement #3 'Нет навыка для безопасного выхода'.

7. `runtime/reflex.js`
   - Wedge detector wired before manifesto/storyline. If wedge.wedged
     is true, dispatches village.relocate directly and returns —
     bypasses every other branch.
   - ctx.disableWedge flag for tests.

8. `runtime/coach/advisor-trigger.js`
   - LLM provider outage backoff: 3 consecutive http_400 / timeout /
     network_error → suppress advisor for 10 min. Today's TimeWeb
     gpt-5.4-mini was 400'ing for an hour straight; we were spending
     trigger budget on dead calls. Closes improvement implicit gap
     in #5.

9. `runtime/bot.js`
   - Tracks botSpawnedAt; snapshot._sessionMs exposed for storyline
     orient_self timeout fallback.

Tests: 396 green (was 378, +18):
  - runtime/biome-affordances.test.js — 8 tests
  - runtime/awareness/wedge-detector.test.js — 9 tests
  - runtime/goal/storyline.test.js — 1 new test (orient_self timeout)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(coach/trigger-tuner): crash after ~1h — runOnce is sync, not a Promise

The live bot died overnight with:
  TypeError: runOnce(...).catch is not a function
  at trigger-tuner.js:42  →  [supervisor] child exited code=1

attach() wrapped the timer body as `runOnce().catch(...)` but
runOnce() returns a plain {ok, flagged, ...} object (pure SQL, no
await). The first tuner tick (60min after spawn) threw → killed the
whole bot process. Never surfaced before because the bot rarely ran
uninterrupted for a full hour during development.

Fix: guard the synchronous call with try/catch, matching how
persona/chatter.js already does its sync tick. (postmortem.drainOnce
and reflect.runOnce ARE async, so their .catch is correct — audited.)

Regression test added: captures the setInterval callback and invokes
it synchronously, asserting it does not throw.

Tests: 397 green.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(v0.3.1): mechanical food/stuck fixes — bot reaches the chicken now

The wedge wasn't only in the manifesto layer; several mechanical bugs
kept the bot in a dead random-walk:

- storyline / manifesto / curriculum: "local food" now means an edible
  passive mob within <=32 blocks. A distant chicken or a cod no longer
  fools the bot into dispatching acquire-food (which then fails on
  no_path). Long-range food goes through scout-food instead.

- scout-food: partial approach to a target now counts as progress
  (approached_target, e.g. moved:14); a blocked heading is NOT counted
  as movement; added blind/tunnel fallback so it doesn't die when the
  pathfinder can't route cleanly.

- acquire-food: on no_path it now also tries a blind/tunnel approach to
  the animal; no_drop routes back into food scouting instead of giving
  up.

- explore.far / relocate / flee: fewer false "done" results (micro-steps
  no longer counted as success), more genuine escapes from stuck.

- scripts/show-story.js: live IPC now actually renders the current
  storyline step.

Verification: scripts/lint-patch.js clean; npm test 404/404 green; bot
relaunched in tmux `pepa`. Live logs show real progress — bot switched
to survive.scout-food, approached the chicken (approached_target
moved:14), then reached survive.acquire-food: hunting chicken. Food
isn't fully closed yet but the remaining issue is concrete pickup/drop,
not dead random-walk.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(v0.3.1): sated bot stops chasing food + perf-leak + fuzzy improvement dedup

Third day of "bot just walks back and forth burning tokens". Root
causes were mechanical, not the manifesto:

1. SATED BOT CHASING FOOD (the big one)
   Bot had food=17 (nearly full) but storyline first_food + manifesto
   L1 required 2+ food ITEMS in inventory, so it looped scout-food /
   acquire-food for hours instead of working. Now both treat a hunger
   bar >= 14 (SATED_FOOD) as satisfied even with empty food inventory —
   a full bot chops wood / makes tools and grabs food opportunistically,
   only hard-pursuing food when actually hungry (< 14).
   manifesto/needs.js foodDetect + goal/storyline.js first_food.completed.

2. perf_hooks MEMORY LEAK (overnight OOM suspect)
   "MaxPerformanceEntryBufferExceededWarning: 1,000,001 measure entries".
   mineflayer/pathfinder emit perf marks we never consume. Added a
   60s reaper in bot.js (performance.clearMeasures/clearMarks). unref'd.

3. IMPROVEMENT QUEUE SELF-DUPLICATING
   The LLM re-filed closed gaps with reworded titles (#5/#8/#9 were
   dupes of implemented #1/#2/#3). Exact-title dedup missed them.
   Replaced with token-set fuzzy match (isDuplicateTitle): jaccard>=0.75
   OR >=3 shared meaningful tokens with jaccard>=0.5. Also: a re-filed
   gap that's already implemented/rejected is NOT resurrected as a new
   open row. Cleared all 5 open requests (now genuinely implemented).

Also confirmed (no change needed):
- canDig=true is a DELIBERATE codebase-wide choice ("without it the bot
  gets permanently stuck", actions.js). The stale memory recommending
  canDig=false is updated. ViaBackwards dig works partially (dug:1
  moved:1.8 observed); false would trap the bot in every pit.
- scout-food already has blind/tunnel fallback + 12s step timeout
  (operator's earlier edits) so trapped-pathfinder degrades instead of
  hanging 30s.

Tests: 407 green (was 404). Updated needs/state/storyline tests for the
SATED_FOOD threshold; added fuzzy-dedup + tokenize/jaccard tests.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

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

822 lines
34 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 { tickAdvisor, consumeFreshRecommendation } from "./coach/advisor-trigger.js";
import { markRecommendationApplied, markRecommendationOutcome } from "./knowledge/index.js";
import { pickActiveNeed } from "./manifesto/state.js";
import { pickCurrentStep } from "./goal/state.js";
import { observe as observeWedge, isWedged } from "./awareness/wedge-detector.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 checkSkillPreconditions(ctx, skillId, args = {}) {
const skill = getSkill(skillId);
if (!skill) return { ok: false, code: "unknown_skill", detail: skillId };
try {
return skill.preconditions(ctx, args) ?? { ok: true };
} catch (e) {
return { ok: false, code: "precondition_failed", detail: e?.message ?? String(e) };
}
}
function resolveAdvisorSkill(ctx, rec, currentSkillId) {
if (!rec?.skillId) return null;
const pre = checkSkillPreconditions(ctx, rec.skillId);
if (pre.ok) return rec.skillId;
// The most common stale/under-specified advice is "switch to local
// acquire-food" when no passive mob exists in the entity horizon.
// Treat that as the broader food-search intent and route to scout-food.
if (rec.skillId === "survive.acquire-food" && pre.code === "no_target") {
const scoutPre = checkSkillPreconditions(ctx, "survive.scout-food");
if (scoutPre.ok) {
info(REFLEX_LOG, `advisor correction: ${rec.skillId} has no local target; using survive.scout-food`);
return "survive.scout-food";
}
}
info(
REFLEX_LOG,
`advisor ignored: ${currentSkillId}${rec.skillId} failed preconditions (${pre.code}: ${String(pre.detail ?? "").slice(0, 80)})`,
);
return null;
}
// v0.2.0-rc.3 — wedged-emergency escape. When the bot has not made
// meaningful horizontal progress for ≥ 60s AND there's no immediate
// hostile (defendReflex would have handled it) AND a placeable block
// is in inventory, dispatch survive.pillar-up to climb vertically out
// of pit terrain. Breaks the tunnel-out-fail-fall-back-to-flee loop
// observed live in the rc.2 deploy. noProgressReason is a hint, not
// required — pillar-up only writes blocks underneath, so even if the
// real cause is something else, the worst case is +1 dirt placed.
const WEDGED_MIN_MS = 60_000;
function wedgedEscapeSkill(ctx) {
const s = ctx.snapshot;
if (!s) return null;
if (s.closestHostile && (s.closestHostile.distance ?? Infinity) < 6) return null;
const lastMove = ctx.lastSignificantMoveAt ?? 0;
if (!lastMove) return null; // need at least one tick of position tracking
if (Date.now() - lastMove < WEDGED_MIN_MS) return null;
// Last attempted escape was recent? give it room.
if (ctx.skillBackoff?.["survive.pillar-up"] && Date.now() < ctx.skillBackoff["survive.pillar-up"]) {
return null;
}
const pillar = getSkill("survive.pillar-up");
if (!pillar) return null;
const pre = pillar.preconditions(ctx);
if (!pre.ok) return null;
return "survive.pillar-up";
}
function trackSignificantMovement(ctx) {
const s = ctx.snapshot;
const pos = s?.position;
if (!pos) return;
const last = ctx.lastSignificantPos;
if (!last) {
ctx.lastSignificantPos = { x: pos.x, z: pos.z };
ctx.lastSignificantMoveAt = Date.now();
return;
}
const dx = pos.x - last.x;
const dz = pos.z - last.z;
if (dx * dx + dz * dz >= 16) {
ctx.lastSignificantPos = { x: pos.x, z: pos.z };
ctx.lastSignificantMoveAt = Date.now();
}
}
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" };
trackSignificantMovement(ctx);
const wedged = wedgedEscapeSkill(ctx);
if (wedged) {
ctx.lastCurriculumAt = Date.now();
ctx.skillBackoff = ctx.skillBackoff ?? {};
// Don't pillar-up every tick — cool off for 2 min after each attempt.
ctx.skillBackoff["survive.pillar-up"] = Date.now() + 2 * 60_000;
ctx.dispatch(() => runSkill(wedged, ctx), wedged, {});
return { action: "dispatched", kind: "curriculum-wedged-escape", label: wedged };
}
const plan = s.curriculum?.plan;
const wanderHintUntil = ctx.skillBackoff?.["__wander_hint__"] ?? 0;
const wantWander = wanderHintUntil && Date.now() < wanderHintUntil;
const scoutFoodHintUntil = ctx.skillBackoff?.["__scout_food_hint__"] ?? 0;
const wantScoutFood = scoutFoodHintUntil && Date.now() < scoutFoodHintUntil;
const relocateHintUntil = ctx.skillBackoff?.["__relocate_hint__"] ?? 0;
const wantRelocate = relocateHintUntil && Date.now() < relocateHintUntil;
// v0.3.0-rc.2 — manifesto layer. Walk the L0-L10 needs ladder; the
// lowest unsatisfied need dictates the planned skill. The curriculum
// plan is used as a fallback when the manifesto has nothing concrete
// (e.g. armour pursue=null, or village_full with no specific next
// step). This is what makes the bot pursue tangible intermediate
// goals (tools_wood → shelter → tools_stone → ...) instead of
// wandering in the same quadrant.
//
// Tests can pass ctx.disableManifesto=true to exercise the curriculum
// branch in isolation without having to construct a full snapshot.
const activeNeed = ctx.disableManifesto ? null : pickActiveNeed(s);
if (activeNeed) {
ctx.activeNeed = activeNeed;
}
const manifestoSkillId = activeNeed?.skillId ?? null;
// v0.3.1 — wedge detector. Feeds position into rolling-bbox tracker.
// When bot has been stuck in a <50-block bbox for 10 minutes with
// the same need cycling its skills, returns wedged=true and we
// short-circuit to village.relocate which walks 300 blocks in a
// fresh cardinal. Tests pass ctx.disableWedge=true to skip.
if (!ctx.disableWedge && s.position) {
observeWedge({
x: s.position.x, z: s.position.z,
activeNeedId: activeNeed?.need?.id ?? null,
recentSkillIds: ctx.recentSkillIds ?? [],
});
const wedge = isWedged({
activeNeedId: activeNeed?.need?.id ?? null,
recentSkillIds: ctx.recentSkillIds ?? [],
});
if (wedge.wedged) {
ctx.lastCurriculumAt = Date.now();
info(REFLEX_LOG, `wedged: bbox=${Math.round(wedge.bboxDim)}b need=${activeNeed?.need?.id} for ${Math.round(wedge.needAgeMs / 1000)}s → village.relocate`);
ctx.dispatch(() => runSkill("village.relocate", ctx), "village.relocate", {});
return { action: "dispatched", kind: "wedge-relocate", label: "village.relocate" };
}
}
// v0.3.1 — storyline: the canonical Minecraft survival quest. Gives
// the bot a concrete, narratable next-action ("collect 8 logs",
// "place crafting table"). Storyline yields to manifesto on L0
// alive emergencies but otherwise its suggestion is preferred over
// the curriculum plan when it picks a registered skill.
const storyStep = ctx.disableStoryline ? null : pickCurrentStep(s);
if (storyStep) ctx.storyStep = storyStep;
const storySkillId = (storyStep && !storyStep.emergency && storyStep.suggestion?.skillId) ? storyStep.suggestion.skillId : null;
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 };
}
}
}
if (wantRelocate || wantScoutFood) {
ctx.lastCurriculumAt = Date.now();
ctx.skillBackoff = ctx.skillBackoff ?? {};
const hintSkillId = wantRelocate ? "village.relocate" : "survive.scout-food";
const hintKey = wantRelocate ? "__relocate_hint__" : "__scout_food_hint__";
ctx.skillBackoff[hintKey] = 0;
const pre = checkSkillPreconditions(ctx, hintSkillId);
if (pre.ok) {
ctx.dispatch(() => runSkill(hintSkillId, ctx), hintSkillId, {});
return {
action: "dispatched",
kind: wantRelocate ? "curriculum-recovery-relocate" : "curriculum-recovery-scout-food",
label: hintSkillId,
};
}
info(REFLEX_LOG, `recovery hint ${hintSkillId} skipped (${pre.code}: ${String(pre.detail ?? "").slice(0, 80)})`);
}
// 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 && !manifestoSkillId && !storySkillId) || wantWander) {
ctx.lastCurriculumAt = Date.now();
const fallbackId = wantWander && consecutiveWanderHints >= 1 ? "explore.far" : "wander";
// v0.2.0-rc.3 — consult advice on the FALLBACK dispatch too. Without
// this, Pi-coach lessons like "do not explore.far after a zombie
// sighting" never fire (the bot keeps falling into the fallback path
// after each curriculum skill bails on no_target / wander_hint).
const fbAdvice = consultAdvice({ plannedSkillId: fallbackId === "wander" ? "explore.far" : fallbackId, snapshot: ctx.snapshot });
if (fbAdvice.action === "override" && fbAdvice.overrideSkillId) {
ctx.dispatch(() => runSkill(fbAdvice.overrideSkillId, ctx), fbAdvice.overrideSkillId, {
onComplete: (res) => reportAdviceOutcome({ lessonId: fbAdvice.lessonId, succeeded: !!res?.ok }),
});
return { action: "dispatched", kind: "curriculum-fallback-advice-override", label: fbAdvice.overrideSkillId, lessonId: fbAdvice.lessonId };
}
if (fbAdvice.action === "avoid") {
// Lesson says don't do the fallback either. Try pillar-up as a
// constructive last resort if we have a placeable block — otherwise
// just idle for a tick so the next loop can re-evaluate.
const pillarSkill = getSkill("survive.pillar-up");
if (pillarSkill && pillarSkill.preconditions(ctx).ok) {
ctx.dispatch(() => runSkill("survive.pillar-up", ctx), "survive.pillar-up", {
onComplete: (res) => reportAdviceOutcome({ lessonId: fbAdvice.lessonId, succeeded: !!res?.ok }),
});
return { action: "dispatched", kind: "curriculum-fallback-pillar-up", label: "survive.pillar-up", lessonId: fbAdvice.lessonId };
}
return { action: "noop", kind: "curriculum-fallback-advice-avoid", label: fallbackId, lessonId: fbAdvice.lessonId };
}
if (fallbackId === "explore.far") {
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" };
}
// Pick what to dispatch. Order:
// 1. manifesto L0 (alive emergencies: low HP near hostile, lava
// under foot, food=0) — absolute priority; do NOT let
// storyline overrule a "you're dying" signal.
// 2. storyline — concrete narrative subgoal ("collect 8 logs",
// "craft wooden pickaxe"). Beats manifesto L1+ because the
// ladder needs operational direction, not just "you need food
// → dispatch acquire-food forever".
// 3. manifesto L1+ — fallback when storyline has no concrete
// pursue (e.g. armor levels with pursue=null).
// 4. curriculum plan — legacy fallback.
const manifestoEmergency = activeNeed?.need?.level === 0;
let skillId, skillSource;
if (manifestoEmergency) {
skillId = manifestoSkillId ?? storySkillId ?? plan.skillId;
skillSource = `manifesto:${activeNeed.need.id}`;
} else if (storySkillId) {
skillId = storySkillId;
skillSource = `storyline:${storyStep.step.id}`;
} else {
skillId = manifestoSkillId ?? plan.skillId;
skillSource = manifestoSkillId ? `manifesto:${activeNeed.need.id}` : "curriculum";
}
// v0.3.0 fast-advisor: if a fresh recommendation is sitting on ctx
// (the result of a previous tick's async advise() call), use it.
// This is the closing of the awareness → LLM → action loop.
let appliedRecommendationId = null;
if (!ctx.disableAdvisor) {
const rec = consumeFreshRecommendation(ctx);
if (rec && rec.skillId) {
const resolved = resolveAdvisorSkill(ctx, rec, skillId);
if (resolved) {
info(REFLEX_LOG, `advisor override: ${skillId}${resolved} (${rec.triggerReason}, ${rec.rationale?.slice(0, 60)})`);
skillId = resolved;
skillSource = `advisor:${rec.triggerReason}`;
appliedRecommendationId = rec.id ?? null;
if (appliedRecommendationId) markRecommendationApplied(appliedRecommendationId);
} else if (rec.id) {
markRecommendationOutcome(rec.id, { ok: false, code: "precondition_failed" });
}
}
// Always fire-and-forget another advise() if triggers fire — the
// result lands on a future tick. tickAdvisor handles its own
// cooldown / in-flight checks so this is safe to call every tick.
tickAdvisor(ctx, { plannedSkillId: skillId });
}
const skill = getSkill(skillId);
if (!skill) {
// 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}; source ${skillSource})` };
}
// 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();
const dispatchArgs = (manifestoSkillId && manifestoSkillId === dispatchSkillId)
? (activeNeed.args ?? {})
: {};
ctx.dispatch(() => runSkill(dispatchSkillId, ctx, dispatchArgs), dispatchSkillId, {
onComplete: (res) => {
ctx.skillBackoff = ctx.skillBackoff ?? {};
if (advice.lessonId) reportAdviceOutcome({ lessonId: advice.lessonId, succeeded: !!res?.ok });
if (appliedRecommendationId) {
markRecommendationOutcome(appliedRecommendationId, {
ok: !!res?.ok,
code: res?.code ?? null,
});
}
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?.recovery?.hint === "scout-food") {
ctx.skillBackoff[dispatchSkillId] = Date.now() + SKILL_BACKOFF_MS;
ctx.skillBackoff["__scout_food_hint__"] = Date.now() + SKILL_BACKOFF_MS;
}
if (res?.recovery?.hint === "relocate") {
ctx.skillBackoff[dispatchSkillId] = Date.now() + SKILL_BACKOFF_MS;
ctx.skillBackoff["__relocate_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", "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, source: skillSource };
}
// ---- 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 };