From 15b6c1100249cfe4151830c6abeb38d0f2366cb9 Mon Sep 17 00:00:00 2001 From: Yuriy Mayatnikov Date: Thu, 28 May 2026 09:43:57 +0300 Subject: [PATCH] =?UTF-8?q?v0.3.1:=20survival=20behaviour=20overhaul=20?= =?UTF-8?q?=E2=80=94=20storyline,=20biome-aware=20scout,=20wedge-relocate,?= =?UTF-8?q?=20food/perf=20fixes,=20monitor=20TUI=20(#28)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 --------- Co-authored-by: Yuriy Mayatnikov Co-authored-by: Claude Opus 4.7 --- dev/v0.3.1/PRD.md | 192 +++++++++ package.json | 5 +- runtime/actions.js | 26 +- runtime/awareness/wedge-detector.js | 135 +++++++ runtime/awareness/wedge-detector.test.js | 104 +++++ runtime/biome-affordances.js | 150 +++++++ runtime/biome-affordances.test.js | 66 +++ runtime/bot.js | 29 ++ runtime/coach/advice.js | 6 + runtime/coach/advice.test.js | 2 + runtime/coach/advisor-trigger.js | 31 +- runtime/coach/fast-advisor.js | 11 +- runtime/coach/reflect.js | 10 +- runtime/coach/trigger-tuner.js | 10 +- runtime/coach/trigger-tuner.test.js | 23 +- runtime/curriculum.js | 11 +- runtime/curriculum.test.js | 4 +- runtime/goal/state.js | 88 ++++ runtime/goal/storyline.js | 367 +++++++++++++++++ runtime/goal/storyline.test.js | 210 ++++++++++ runtime/knowledge/index.js | 73 +++- runtime/knowledge/knowledge.test.js | 36 ++ runtime/manifesto/needs.js | 40 +- runtime/manifesto/needs.test.js | 36 +- runtime/manifesto/state.test.js | 16 +- runtime/persona/chatter.js | 20 +- runtime/reflex.js | 141 ++++++- runtime/reflex.test.js | 65 ++- runtime/skills/acquire-food.js | 55 ++- runtime/skills/contract.test.js | 34 ++ runtime/skills/escape-pit-safe.js | 159 ++++++++ runtime/skills/explore-far.js | 15 +- runtime/skills/index.js | 16 +- runtime/skills/relocate.js | 146 +++++++ runtime/skills/scout-food.js | 408 +++++++++++++++++++ scripts/show-story.js | 103 +++++ tui/monitor.tsx | 487 +++++++++++++++++++++++ 37 files changed, 3259 insertions(+), 71 deletions(-) create mode 100644 dev/v0.3.1/PRD.md create mode 100644 runtime/awareness/wedge-detector.js create mode 100644 runtime/awareness/wedge-detector.test.js create mode 100644 runtime/biome-affordances.js create mode 100644 runtime/biome-affordances.test.js create mode 100644 runtime/goal/state.js create mode 100644 runtime/goal/storyline.js create mode 100644 runtime/goal/storyline.test.js create mode 100644 runtime/skills/escape-pit-safe.js create mode 100644 runtime/skills/relocate.js create mode 100644 runtime/skills/scout-food.js create mode 100644 scripts/show-story.js create mode 100644 tui/monitor.tsx diff --git a/dev/v0.3.1/PRD.md b/dev/v0.3.1/PRD.md new file mode 100644 index 0000000..fc9c294 --- /dev/null +++ b/dev/v0.3.1/PRD.md @@ -0,0 +1,192 @@ +# pepa v0.3.1 — PRD: LLM prompt cost optimization + +**Status**: Design draft. No code in this version yet — this PRD is the +spec future commits implement against. Owner: operator. +**Trigger**: TimeWeb admin panel after first day of v0.3.0 live: +~34K tokens used in a half-day session (mostly bot + some smoke). +At the 6-calls/hour cap that projects to **~480 ₽/month** (101 ₽/M in, +608 ₽/M out for gpt-5.4-mini). Manageable but worth shrinking — most +of the per-call cost is repeated infrastructure tokens, not the +situational signal the model actually uses. + +## Goals + +1. Cut per-advise() input tokens from ~800 → ≤300 (target 250). +2. Preserve correctness: the LLM must still see enough context to + produce a valid `skill_id` from the registry and a useful rationale. +3. Keep all changes transparent to the rest of the runtime — the + public `advise()` / `complete()` surface area doesn't change. + +Non-goals: +- Switching providers. TimeWeb stays. +- Caching the LLM's *responses* (cache key would be situational, too + many misses to be worth the bookkeeping). +- Touching the analytical loops (postmortem / reflect). They're called + less often and need fuller context; cost there is acceptable. + +## Cost breakdown — what we're optimizing + +Measured on live advise() calls (TimeWeb gpt-5.4-mini, single advisor +trigger): + +| Block | tokens (avg) | % of call | +|--------------------------------------|--------------|-----------| +| `skillRegistryPrompt({limit:1800})` | ~450 | 56% | +| System instructions (rules + JSON) | ~200 | 25% | +| User snapshot + threats + need + recent | ~150 | 19% | +| **Total input** | **~800** | **100%** | +| Output (JSON answer) | ~40-50 | — | + +The registry block dominates. It currently lists all 30+ registered +skills with their human titles. The model rarely needs the full list — +most decisions are within 5-8 plausible skills per trigger. + +## Proposed changes + +### 1. Compact registry format (P1, biggest win) + +Drop human titles and the per-skill descriptions; switch to +namespace-grouped, comma-separated id lists. + +**Before** (~450 tokens): +``` +Valid skill ids (USE ONLY THESE for avoid_skill / prefer_skill): + craft: + - craft.bed — Craft bed + - craft.chest — Craft chest + - craft.furnace — Craft furnace + ... + survive: + - survive.acquire-food — Acquire food + - survive.eat — Eat + ... +``` + +**After** (~100 tokens): +``` +Valid skill ids (USE EXACTLY one of these or null): + craft: bed, chest, furnace, planks, sticks, torch, wooden-axe, + wooden-pickaxe, wooden-sword, stone-axe, stone-pickaxe, stone-sword + survive: acquire-food, eat, flee, pillar-up, sleep + gather: logs, stone, wool + recovery: tunnel-out + explore: far, wander + village: build-shelter, choose-base, deposit-surplus, place-chest + farm: wheat + diag: physics, scan, match +``` + +Saving: **~350 tokens/call**. + +Implementation: add `skillRegistryPrompt({ mode: "compact" })` mode in +`runtime/skill-registry.js`. Default mode stays for slow analytical +loops (postmortem / reflect) which can afford the verbose form. + +### 2. Need-scoped registry (P2, additional ~50 token saving) + +When `activeNeed` is set, filter the registry to skills plausibly +relevant to that level + always-available safety skills. + +Relevance table (manually curated, lives in `runtime/manifesto/needs.js`): + +| Need | Relevant skills (in addition to ALWAYS set) | +|-------------------|------------------------------------------------------------| +| alive | survive.flee, survive.eat, recovery.tunnel-out | +| food | survive.acquire-food, survive.eat, farm.wheat | +| tools_wood | gather.logs, craft.planks, craft.sticks, craft.wooden-* | +| shelter_basic | gather.wool, craft.bed, village.build-shelter, village.choose-base | +| tools_stone | gather.stone, craft.sticks, craft.stone-* | +| armor_basic | gather.wool (placeholder) | +| food_security | farm.wheat, survive.acquire-food | +| tools_iron | gather.stone | +| armor_iron | (none — no skill yet) | +| village_seed | craft.chest, village.deposit-surplus, village.build-shelter | +| village_full | (full registry) | +| ALWAYS | survive.flee, survive.pillar-up, recovery.tunnel-out, | +| | explore.far, explore.wander | + +Compact + scoped = **~50 tokens** for the registry block (down from 450). + +Add a `prompt-builder.test.js` checking that: +- `survive.flee` is always present (emergency safety) +- The recommended skill from the previous call would still be in the + scoped registry (regression protection) + +### 3. Snapshot pruning (P3, ~50 tokens) + +The user-prompt snapshot includes fields the LLM rarely consults: +`weather`, `experience`, `dimension`, `biome`, `players[]`. Drop them +from the advise() user-prompt builder. Keep `position`, `hp`, `food`, +`isDay`, `closestHostile`, `activeNeed`, `recent dispatches`, +`hazards.footBlock` (lava detection), top inventory keys. + +### 4. Prompt caching — investigation (P4) + +OpenAI and Anthropic both support implicit prompt caching: when ≥1024 +prefix tokens are identical across consecutive requests, the prefix +is billed once. TimeWeb's docs are silent on this. + +Task: probe whether TimeWeb passes through OpenAI's `prompt_tokens_details.cached_tokens` +field. If yes, *increase* the system prefix length (keep verbose registry) +because cached input is ~10x cheaper than fresh. If no, full optimization +1+2+3 still wins. + +Add a one-off check in `scripts/check-timeweb.js`: print +`payload?.usage?.prompt_tokens_details?.cached_tokens` if present. + +### 5. Telemetry — per-trigger token attribution (P5) + +Today `advisor_recommendations` records `tokens_in` per row but the +operator has no easy view of *which trigger types* are most expensive. + +Extend `scripts/list-improvements.js --stats` to also print per-trigger: +``` +trigger_reason total applied ok fail avg_in avg_out cost_₽ share% +wedged_* 20 18 3 15 280 45 2.1 45% +emergency_* 3 3 2 1 240 50 0.3 6% +repeat_* 8 7 0 7 260 42 0.8 17% +preempt_retry_* 14 12 2 10 290 44 1.5 32% +``` + +`cost_₽` = avg_in × calls × IN_PRICE + avg_out × calls × OUT_PRICE, +with prices read from env (`TIMEWEB_PRICE_IN_RUB_PER_M`, +`TIMEWEB_PRICE_OUT_RUB_PER_M`). + +## Acceptance + +After v0.3.1 lands: +- Re-run `node scripts/check-timeweb.js` probe 3 (`advise()`): + expect `tokens_in` ≤ 300 (was ~800). +- Re-run probe 4 (auto-trigger flow): rationale still references the + registered skill correctly. +- Run live for 1 hour, check `node scripts/list-improvements.js --stats`: + per-trigger `avg_in` ≤ 300. +- Existing 360 tests still green; new prompt-builder tests cover the + scoped registry behaviour. + +## Out of scope (later versions) + +- Tool/function-calling instead of free-form JSON (TimeWeb support unclear). +- Custom model selection per trigger (gpt-5.4-nano for routine repeats, + gpt-5.4-mini for emergencies). Defer until cost/quality data points + exist. +- Embedding-based prior-recommendation similarity check ("we already + told the bot to tunnel-out at this exact wedge 10 minutes ago, skip"). + +## Implementation order + +When this version is greenlit, work in this order on a single branch +`v0.3.1` (one PR per session, per recently-updated workflow memory): + +1. P1 compact registry mode + prompt-builder test +2. P2 need-scoped registry (extend `runtime/manifesto/needs.js` with + `relevantSkills`) +3. P3 snapshot pruning in `fast-advisor.js#buildUserPrompt` +4. P4 caching probe (one-off) +5. P5 cost telemetry in CLI viewer +6. STATUS.md + smoke retest + PR + +All changes are additive; no behaviour regression expected. If +real-world after v0.3.1 shows the LLM giving worse advice with the +compact registry, fall back to default mode by flipping a single +constant in `fast-advisor.js`. diff --git a/package.json b/package.json index b8a23ed..16626d5 100644 --- a/package.json +++ b/package.json @@ -13,10 +13,11 @@ "agent:resume": "pi -c", "bot": "node runtime/supervisor.js", "bot:bare": "node runtime/bot.js", - "tui": "tsx tui/tui.tsx", + "tui": "tsx tui/monitor.tsx", + "tui:legacy": "tsx tui/tui.tsx", "propose:apply": "node scripts/propose-apply.js", "stop": "bash scripts/stop.sh", - "test": "node --test runtime/skills/contract.test.js runtime/skills/groups.test.js runtime/skills/compat.test.js runtime/skills/recovery-tunnel-out.test.js runtime/skills/pillar-up.test.js runtime/curriculum.test.js runtime/social/social.test.js runtime/social/conversation.test.js runtime/social/chat-history.test.js runtime/social/reply-pi.test.js runtime/stuck-incident.test.js runtime/compat.test.js runtime/reflex.test.js runtime/base-site.test.js runtime/locations.test.js runtime/watch-filter.test.js runtime/world-journal.test.js runtime/scenario-memory.test.js runtime/critic.test.js runtime/skill-library.test.js runtime/skill-registry.test.js runtime/modes.test.js runtime/pathfinder-watchdog.test.js runtime/manifesto/needs.test.js runtime/manifesto/state.test.js runtime/awareness/events.test.js runtime/knowledge/knowledge.test.js runtime/llm/provider.test.js runtime/coach/postmortem.test.js runtime/coach/advice.test.js runtime/coach/reflect.test.js runtime/coach/fast-advisor.test.js runtime/coach/advisor-trigger.test.js runtime/coach/trigger-tuner.test.js runtime/persona/chatter.test.js scripts/edit-scope.test.js scripts/lint-patch.test.js" + "test": "node --test runtime/skills/contract.test.js runtime/skills/groups.test.js runtime/skills/compat.test.js runtime/skills/recovery-tunnel-out.test.js runtime/skills/pillar-up.test.js runtime/curriculum.test.js runtime/social/social.test.js runtime/social/conversation.test.js runtime/social/chat-history.test.js runtime/social/reply-pi.test.js runtime/stuck-incident.test.js runtime/compat.test.js runtime/reflex.test.js runtime/base-site.test.js runtime/locations.test.js runtime/watch-filter.test.js runtime/world-journal.test.js runtime/scenario-memory.test.js runtime/critic.test.js runtime/skill-library.test.js runtime/skill-registry.test.js runtime/modes.test.js runtime/pathfinder-watchdog.test.js runtime/manifesto/needs.test.js runtime/manifesto/state.test.js runtime/goal/storyline.test.js runtime/awareness/events.test.js runtime/awareness/wedge-detector.test.js runtime/biome-affordances.test.js runtime/knowledge/knowledge.test.js runtime/llm/provider.test.js runtime/coach/postmortem.test.js runtime/coach/advice.test.js runtime/coach/reflect.test.js runtime/coach/fast-advisor.test.js runtime/coach/advisor-trigger.test.js runtime/coach/trigger-tuner.test.js runtime/persona/chatter.test.js scripts/edit-scope.test.js scripts/lint-patch.test.js" }, "dependencies": { "better-sqlite3": "^11.10.0", diff --git a/runtime/actions.js b/runtime/actions.js index 5da9715..1b8aade 100644 --- a/runtime/actions.js +++ b/runtime/actions.js @@ -173,7 +173,31 @@ export async function fleeFrom(bot, fromEntity, distance = 16) { ); return { ok: true, detail: { to: { x: tx, y: ty, z: tz } } }; } catch (e) { - warn("action", `flee failed: ${e.message}`); + warn("action", `flee path failed: ${e.message}; trying blind retreat`); + const before = bot.entity.position.clone?.() ?? { ...bot.entity.position }; + try { bot.pathfinder?.stop?.(); } catch {} + try { await bot.look(-Math.atan2(dx / len, dz / len), 0, true); } catch {} + bot.setControlState("forward", true); + bot.setControlState("jump", true); + try { + await new Promise((r) => setTimeout(r, 7_000)); + } finally { + bot.setControlState("forward", false); + bot.setControlState("jump", false); + } + const after = bot.entity.position; + const moved = Math.hypot(after.x - before.x, after.z - before.z); + if (moved >= 4) { + return { + ok: true, + detail: { + to: { x: Math.round(after.x), y: Math.round(after.y), z: Math.round(after.z) }, + mode: "blind-retreat", + moved, + }, + }; + } + warn("action", `flee blind retreat moved only ${moved.toFixed(2)} blocks`); return { ok: false, detail: e.message }; } } diff --git a/runtime/awareness/wedge-detector.js b/runtime/awareness/wedge-detector.js new file mode 100644 index 0000000..efdd1fb --- /dev/null +++ b/runtime/awareness/wedge-detector.js @@ -0,0 +1,135 @@ +// Wedge detector — rolling-10min position bounding box. +// +// Sits above the skill layer (per the v0.3.1 design research). The +// reflex calls observe() with the latest position once per tick; on +// every call we evict older entries and recompute the bbox over the +// trailing window. If the bbox stays small for long enough AND a need +// has been unmet for long enough AND the same skill cycled enough +// times, isWedged() returns true and the reflex injects a `relocate` +// task that supersedes the current need until the bot has displaced +// ≥200 blocks from the wedge centre. +// +// This is intentionally NOT inside any single skill — every skill +// resets its own counters when re-entered, so per-skill stuck checks +// can't break a multi-skill cycle. + +const WINDOW_MS = 10 * 60 * 1000; // 10 minutes +const MIN_BBOX_FOR_WEDGE = 50; // <50 blocks max-dim → wedge +const MIN_UNMET_NEED_MS = 5 * 60 * 1000; // need unsatisfied for 5+ min +const MIN_SKILL_CYCLES = 3; // same need's skill restarted ≥3x + +let _samples = []; // { t, x, z } +let _activeRelocation = null; // { startedAt, fromCenter:{x,z}, headingName } +let _needStartedAt = new Map(); // needId → t when first detected +let _lastNeedId = null; + +export function _resetForTest() { + _samples = []; + _activeRelocation = null; + _needStartedAt = new Map(); + _lastNeedId = null; +} + +/** + * observe({ x, z, now, activeNeedId, recentSkillIds }) + * + * Lightweight: called every reflex tick (~1-2s). Updates sliding + * window and need-duration accounting. + */ +export function observe({ x, z, now = Date.now(), activeNeedId = null, recentSkillIds = [] } = {}) { + if (typeof x !== "number" || typeof z !== "number") return; + _samples.push({ t: now, x, z }); + // evict + const cutoff = now - WINDOW_MS; + while (_samples.length && _samples[0].t < cutoff) _samples.shift(); + + // Track need duration + if (activeNeedId !== _lastNeedId) { + _lastNeedId = activeNeedId; + if (activeNeedId && !_needStartedAt.has(activeNeedId)) { + _needStartedAt.set(activeNeedId, now); + } + } + if (activeNeedId && !_needStartedAt.has(activeNeedId)) { + _needStartedAt.set(activeNeedId, now); + } + + // If a relocation is in progress, check whether we've travelled + // far enough to clear it. + if (_activeRelocation && typeof _activeRelocation.fromCenter?.x === "number") { + const dx = x - _activeRelocation.fromCenter.x; + const dz = z - _activeRelocation.fromCenter.z; + if (Math.hypot(dx, dz) >= 200) { + _activeRelocation = null; + // Reset all need-duration timers so the post-relocation env + // gets a fair shot at being labelled satisfied / unsatisfied. + _needStartedAt = new Map(); + } + } +} + +/** + * isWedged({ activeNeedId, recentSkillIds, now }) + * → { wedged: true, bboxDim, needAgeMs, skillCycles, centerX, centerZ } | { wedged: false } + */ +export function isWedged({ activeNeedId, recentSkillIds = [], now = Date.now() } = {}) { + if (_activeRelocation) return { wedged: false, reason: "relocating" }; + if (_samples.length < 8) return { wedged: false, reason: "insufficient_samples" }; + + let xs = Infinity, xb = -Infinity, zs = Infinity, zb = -Infinity, cx = 0, cz = 0; + for (const s of _samples) { + if (s.x < xs) xs = s.x; + if (s.x > xb) xb = s.x; + if (s.z < zs) zs = s.z; + if (s.z > zb) zb = s.z; + cx += s.x; cz += s.z; + } + cx /= _samples.length; cz /= _samples.length; + const bboxDim = Math.max(xb - xs, zb - zs); + if (bboxDim >= MIN_BBOX_FOR_WEDGE) return { wedged: false, reason: "bbox_ok", bboxDim }; + + const needAgeMs = (activeNeedId && _needStartedAt.has(activeNeedId)) + ? now - _needStartedAt.get(activeNeedId) + : 0; + if (needAgeMs < MIN_UNMET_NEED_MS) return { wedged: false, reason: "need_recent", needAgeMs, bboxDim }; + + // Skill cycle count: how many distinct dispatches of the same skill + // appear in the recent rolling window. + const skillCycles = countCycles(recentSkillIds); + if (skillCycles < MIN_SKILL_CYCLES) return { wedged: false, reason: "few_cycles", skillCycles, bboxDim }; + + return { wedged: true, bboxDim, needAgeMs, skillCycles, centerX: cx, centerZ: cz }; +} + +/** + * markRelocationStarted({ x, z, heading }) + * The reflex calls this when it dispatches village.relocate. While + * a relocation is in flight, isWedged() returns false (relocating) + * so we don't fire a SECOND relocation on top. + */ +export function markRelocationStarted({ x, z, heading } = {}) { + _activeRelocation = { + startedAt: Date.now(), + fromCenter: { x, z }, + headingName: heading?.name ?? "?", + }; +} + +export function activeRelocation() { return _activeRelocation; } + +function countCycles(ids) { + if (!Array.isArray(ids) || ids.length === 0) return 0; + // A "cycle" = a transition like A → B → A. Count those. + let cycles = 0; + for (let i = 2; i < ids.length; i++) { + if (ids[i] === ids[i - 2] && ids[i] !== ids[i - 1]) cycles++; + if (ids[i] === ids[i - 1] && ids[i - 1] === ids[i - 2]) cycles++; + } + return cycles; +} + +// Test exports +export const __testing = { + WINDOW_MS, MIN_BBOX_FOR_WEDGE, MIN_UNMET_NEED_MS, MIN_SKILL_CYCLES, + countCycles, +}; diff --git a/runtime/awareness/wedge-detector.test.js b/runtime/awareness/wedge-detector.test.js new file mode 100644 index 0000000..c601f94 --- /dev/null +++ b/runtime/awareness/wedge-detector.test.js @@ -0,0 +1,104 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { + observe, + isWedged, + markRelocationStarted, + activeRelocation, + _resetForTest, + __testing, +} from "./wedge-detector.js"; + +const { WINDOW_MS, MIN_BBOX_FOR_WEDGE, MIN_UNMET_NEED_MS, MIN_SKILL_CYCLES, countCycles } = __testing; + +test("countCycles: empty / short → 0", () => { + assert.equal(countCycles([]), 0); + assert.equal(countCycles(["a"]), 0); + assert.equal(countCycles(["a", "b"]), 0); +}); + +test("countCycles: A→B→A counts as cycle", () => { + assert.equal(countCycles(["a", "b", "a"]), 1); + assert.equal(countCycles(["a", "b", "a", "b", "a"]), 3); +}); + +test("countCycles: same skill 3+ times in a row also counts", () => { + assert.equal(countCycles(["a", "a", "a"]), 1); + assert.equal(countCycles(["a", "a", "a", "a"]), 2); +}); + +test("isWedged: insufficient samples → not wedged", () => { + _resetForTest(); + const r = isWedged({ activeNeedId: "food" }); + assert.equal(r.wedged, false); + assert.equal(r.reason, "insufficient_samples"); +}); + +test("isWedged: large bbox → not wedged", () => { + _resetForTest(); + const t0 = 1_000_000_000_000; + // scatter across 200 blocks + for (let i = 0; i < 12; i++) { + observe({ x: i * 30, z: i * 25, now: t0 + i * 1000, activeNeedId: "food", recentSkillIds: [] }); + } + const r = isWedged({ activeNeedId: "food", recentSkillIds: ["a", "b", "a", "b"], now: t0 + 13_000 }); + assert.equal(r.wedged, false); + assert.equal(r.reason, "bbox_ok"); +}); + +test("isWedged: tight bbox + old need + cycles → WEDGED", () => { + _resetForTest(); + const t0 = 1_000_000_000_000; + // 12 samples within a 30-block bbox, spread across ~9 min so they + // stay inside the 10-min sliding window. + for (let i = 0; i < 12; i++) { + observe({ + x: 500 + (i % 4) * 8, + z: 500 + Math.floor(i / 4) * 8, + now: t0 + i * 45_000, + activeNeedId: "food", + recentSkillIds: ["acquire-food", "explore.far", "acquire-food", "explore.far", "acquire-food"], + }); + } + const r = isWedged({ + activeNeedId: "food", + recentSkillIds: ["acquire-food", "explore.far", "acquire-food", "explore.far", "acquire-food", "explore.far"], + now: t0 + 12 * 45_000, + }); + assert.equal(r.wedged, true, `expected wedged, got ${JSON.stringify(r)}`); + assert.ok(r.bboxDim < MIN_BBOX_FOR_WEDGE); + assert.ok(r.needAgeMs >= MIN_UNMET_NEED_MS); + assert.ok(r.skillCycles >= MIN_SKILL_CYCLES); +}); + +test("isWedged: recent need (under threshold) → not wedged", () => { + _resetForTest(); + const t0 = 1_000_000_000_000; + for (let i = 0; i < 12; i++) { + observe({ x: 500, z: 500, now: t0 + i * 10_000, activeNeedId: "food" }); + } + const r = isWedged({ activeNeedId: "food", recentSkillIds: ["a", "b", "a", "b"], now: t0 + 60_000 }); + assert.equal(r.wedged, false); +}); + +test("markRelocationStarted blocks subsequent wedge for 200b", () => { + _resetForTest(); + const t0 = 1_000_000_000_000; + markRelocationStarted({ x: 500, z: 500, heading: { name: "N" } }); + assert.ok(activeRelocation()); + // Stay tight bbox after relocation start + for (let i = 0; i < 12; i++) { + observe({ x: 510, z: 510, now: t0 + i * 60_000, activeNeedId: "food" }); + } + const r = isWedged({ activeNeedId: "food", recentSkillIds: ["a", "b", "a", "b"] }); + assert.equal(r.wedged, false); + assert.equal(r.reason, "relocating"); +}); + +test("relocation clears after travelling ≥200 blocks", () => { + _resetForTest(); + markRelocationStarted({ x: 0, z: 0, heading: { name: "N" } }); + observe({ x: 250, z: 0, activeNeedId: "food" }); + assert.equal(activeRelocation(), null, "relocation cleared after 250b displacement"); +}); diff --git a/runtime/biome-affordances.js b/runtime/biome-affordances.js new file mode 100644 index 0000000..fa8ab1a --- /dev/null +++ b/runtime/biome-affordances.js @@ -0,0 +1,150 @@ +// Static knowledge: what each Minecraft biome reliably affords the bot. +// +// Why: pre-v0.3.1 the bot's "find food" skill scanned a 32-block radius +// for passive mobs and gave up. In a desert/ocean/snowy biome there's +// nothing to scan — the bot looped local searches for hours. This +// table lets skills check the *current* biome and pick a strategy +// before reaching for `pathfinder` blindly. +// +// Coverage is informed by vanilla mob spawn rules +// (https://minecraft.fandom.com/wiki/Spawn) — not exhaustive but +// covers the biomes the bot is realistically going to land in on +// 1.21.4 overworld spawn. +// +// Each entry is conservative: a `true` is "the bot has a real shot at +// finding this here", a `false` is "almost never bother scanning". + +/** + * Affordance shape: + * has_passive_mobs — cows / pigs / chickens / sheep spawn here + * has_trees — oak/birch/spruce/jungle logs grow naturally + * has_water — open surface water that can be fished + * has_crops — natural berries / pumpkins / melons / sweet_berry_bush + * livable — bot can stand on the surface (not in lava, not + * perpetually underwater) + */ +const DEFAULT = Object.freeze({ + has_passive_mobs: true, + has_trees: false, + has_water: false, + has_crops: false, + livable: true, +}); + +const BIOMES = Object.freeze({ + // Forest family — trees + cows/pigs/chickens + forest: { has_passive_mobs: true, has_trees: true, has_water: false, has_crops: false, livable: true }, + birch_forest: { has_passive_mobs: true, has_trees: true, has_water: false, has_crops: false, livable: true }, + dark_forest: { has_passive_mobs: true, has_trees: true, has_water: false, has_crops: true, livable: true }, + old_growth_birch_forest: { has_passive_mobs: true, has_trees: true, has_water: false, has_crops: false, livable: true }, + old_growth_pine_taiga: { has_passive_mobs: true, has_trees: true, has_water: false, has_crops: true, livable: true }, + old_growth_spruce_taiga: { has_passive_mobs: true, has_trees: true, has_water: false, has_crops: true, livable: true }, + taiga: { has_passive_mobs: true, has_trees: true, has_water: false, has_crops: true, livable: true }, + snowy_taiga: { has_passive_mobs: true, has_trees: true, has_water: false, has_crops: false, livable: true }, + flower_forest: { has_passive_mobs: true, has_trees: true, has_water: false, has_crops: false, livable: true }, + pale_garden: { has_passive_mobs: false, has_trees: true, has_water: false, has_crops: false, livable: true }, + + // Plains family — open spawn, lots of mobs, scattered trees + plains: { has_passive_mobs: true, has_trees: true, has_water: false, has_crops: false, livable: true }, + sunflower_plains: { has_passive_mobs: true, has_trees: true, has_water: false, has_crops: false, livable: true }, + meadow: { has_passive_mobs: true, has_trees: false, has_water: false, has_crops: false, livable: true }, + cherry_grove: { has_passive_mobs: true, has_trees: true, has_water: false, has_crops: false, livable: true }, + + // Savanna / jungle — passive mobs + trees + savanna: { has_passive_mobs: true, has_trees: true, has_water: false, has_crops: false, livable: true }, + savanna_plateau: { has_passive_mobs: true, has_trees: true, has_water: false, has_crops: false, livable: true }, + windswept_savanna: { has_passive_mobs: true, has_trees: true, has_water: false, has_crops: false, livable: true }, + jungle: { has_passive_mobs: true, has_trees: true, has_water: false, has_crops: true, livable: true }, + sparse_jungle: { has_passive_mobs: true, has_trees: true, has_water: false, has_crops: false, livable: true }, + bamboo_jungle: { has_passive_mobs: true, has_trees: true, has_water: false, has_crops: false, livable: true }, + + // Swamp / mangrove — has water + berries + swamp: { has_passive_mobs: true, has_trees: true, has_water: true, has_crops: false, livable: true }, + mangrove_swamp: { has_passive_mobs: false, has_trees: true, has_water: true, has_crops: false, livable: true }, + + // Desert / badlands — NO passive mobs, no trees, no surface water. + // Action plan when the bot is here: walk a cardinal until biome + // boundary is detected (sample bot.world.getBiome at radius 64). + desert: { has_passive_mobs: false, has_trees: false, has_water: false, has_crops: false, livable: true }, + badlands: { has_passive_mobs: false, has_trees: false, has_water: false, has_crops: false, livable: true }, + eroded_badlands: { has_passive_mobs: false, has_trees: false, has_water: false, has_crops: false, livable: true }, + wooded_badlands: { has_passive_mobs: false, has_trees: true, has_water: false, has_crops: false, livable: true }, + + // Snowy biomes — no passive mobs (rabbits sometimes), strangled trees + snowy_plains: { has_passive_mobs: true, has_trees: false, has_water: false, has_crops: false, livable: true }, + ice_spikes: { has_passive_mobs: false, has_trees: false, has_water: false, has_crops: false, livable: true }, + frozen_river: { has_passive_mobs: false, has_trees: false, has_water: true, has_crops: false, livable: true }, + frozen_ocean: { has_passive_mobs: false, has_trees: false, has_water: true, has_crops: false, livable: false }, + deep_frozen_ocean: { has_passive_mobs: false, has_trees: false, has_water: true, has_crops: false, livable: false }, + + // Mountains — sparse trees, goats + stony_peaks: { has_passive_mobs: true, has_trees: false, has_water: false, has_crops: false, livable: true }, + jagged_peaks: { has_passive_mobs: true, has_trees: false, has_water: false, has_crops: false, livable: true }, + frozen_peaks: { has_passive_mobs: true, has_trees: false, has_water: false, has_crops: false, livable: true }, + snowy_slopes: { has_passive_mobs: true, has_trees: false, has_water: false, has_crops: false, livable: true }, + grove: { has_passive_mobs: true, has_trees: true, has_water: false, has_crops: false, livable: true }, + windswept_hills: { has_passive_mobs: true, has_trees: true, has_water: false, has_crops: false, livable: true }, + windswept_gravelly_hills: { has_passive_mobs: true, has_trees: false, has_water: false, has_crops: false, livable: true }, + windswept_forest: { has_passive_mobs: true, has_trees: true, has_water: false, has_crops: false, livable: true }, + + // Beaches / ocean — passive mobs scarce, water everywhere + beach: { has_passive_mobs: false, has_trees: false, has_water: true, has_crops: false, livable: true }, + stony_shore: { has_passive_mobs: false, has_trees: false, has_water: true, has_crops: false, livable: true }, + snowy_beach: { has_passive_mobs: false, has_trees: false, has_water: true, has_crops: false, livable: true }, + ocean: { has_passive_mobs: false, has_trees: false, has_water: true, has_crops: false, livable: false }, + cold_ocean: { has_passive_mobs: false, has_trees: false, has_water: true, has_crops: false, livable: false }, + deep_ocean: { has_passive_mobs: false, has_trees: false, has_water: true, has_crops: false, livable: false }, + deep_cold_ocean: { has_passive_mobs: false, has_trees: false, has_water: true, has_crops: false, livable: false }, + lukewarm_ocean: { has_passive_mobs: false, has_trees: false, has_water: true, has_crops: false, livable: false }, + deep_lukewarm_ocean: { has_passive_mobs: false, has_trees: false, has_water: true, has_crops: false, livable: false }, + warm_ocean: { has_passive_mobs: false, has_trees: false, has_water: true, has_crops: false, livable: false }, + river: { has_passive_mobs: false, has_trees: false, has_water: true, has_crops: false, livable: true }, + + // Mushroom — mooshrooms only, no other passive mobs but they ARE food + mushroom_fields: { has_passive_mobs: true, has_trees: false, has_water: false, has_crops: false, livable: true }, + + // Caves / unsupported dimensions — bot should leave + dripstone_caves: { has_passive_mobs: false, has_trees: false, has_water: false, has_crops: false, livable: false }, + lush_caves: { has_passive_mobs: false, has_trees: false, has_water: false, has_crops: true, livable: false }, + deep_dark: { has_passive_mobs: false, has_trees: false, has_water: false, has_crops: false, livable: false }, +}); + +/** + * affordancesFor(biomeName) → affordance object + * + * Unknown / null / undefined names return the optimistic DEFAULT so + * skills don't get crippled when a new 1.x biome shows up; they just + * fall back to the existing local-scan behaviour. + */ +export function affordancesFor(biomeName) { + if (!biomeName || typeof biomeName !== "string") return DEFAULT; + return BIOMES[biomeName] ?? DEFAULT; +} + +export function hasPassiveMobs(biomeName) { + return affordancesFor(biomeName).has_passive_mobs; +} +export function hasTrees(biomeName) { + return affordancesFor(biomeName).has_trees; +} +export function hasWater(biomeName) { + return affordancesFor(biomeName).has_water; +} +export function isLivable(biomeName) { + return affordancesFor(biomeName).livable; +} + +// True if this biome is barren enough that the bot's priority should +// be "leave biome" rather than "search local". +export function isBarren(biomeName) { + const a = affordancesFor(biomeName); + return !a.has_passive_mobs && !a.has_trees && !a.has_crops; +} + +// True if the biome can't be stood on (deep ocean, caves at y=Y). +export function isUnlivable(biomeName) { + return !affordancesFor(biomeName).livable; +} + +// Test exports +export const __testing = { BIOMES, DEFAULT }; diff --git a/runtime/biome-affordances.test.js b/runtime/biome-affordances.test.js new file mode 100644 index 0000000..e6dde51 --- /dev/null +++ b/runtime/biome-affordances.test.js @@ -0,0 +1,66 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { + affordancesFor, + hasPassiveMobs, + hasTrees, + hasWater, + isLivable, + isBarren, + isUnlivable, + __testing, +} from "./biome-affordances.js"; + +test("plains: full affordances (mobs + scattered trees)", () => { + const a = affordancesFor("plains"); + assert.equal(a.has_passive_mobs, true); + assert.equal(a.has_trees, true); + assert.equal(a.livable, true); +}); + +test("desert: barren (no mobs, no trees, no water)", () => { + assert.equal(hasPassiveMobs("desert"), false); + assert.equal(hasTrees("desert"), false); + assert.equal(hasWater("desert"), false); + assert.equal(isBarren("desert"), true); + assert.equal(isLivable("desert"), true, "desert is walkable, just empty"); +}); + +test("ocean / deep_ocean: unlivable + has water", () => { + assert.equal(isUnlivable("ocean"), true); + assert.equal(isUnlivable("deep_ocean"), true); + assert.equal(hasWater("ocean"), true); + assert.equal(hasPassiveMobs("ocean"), false); +}); + +test("mushroom_fields: passive mobs (mooshroom) even though no other animals", () => { + assert.equal(hasPassiveMobs("mushroom_fields"), true); + assert.equal(isBarren("mushroom_fields"), false); +}); + +test("forest variants: trees + mobs", () => { + for (const b of ["forest", "birch_forest", "dark_forest", "taiga", "snowy_taiga", "jungle", "swamp"]) { + assert.equal(hasTrees(b), true, `${b} should have trees`); + assert.equal(hasPassiveMobs(b), true, `${b} should have passive mobs`); + } +}); + +test("badlands variants: no mobs, no trees (except wooded_badlands)", () => { + assert.equal(hasPassiveMobs("badlands"), false); + assert.equal(hasTrees("badlands"), false); + assert.equal(hasTrees("wooded_badlands"), true, "wooded variant has trees"); + assert.equal(isBarren("badlands"), true); +}); + +test("unknown biome: optimistic defaults (don't cripple skills)", () => { + const a = affordancesFor("not_a_real_biome_2026"); + assert.equal(a.has_passive_mobs, true); + assert.equal(a.livable, true); +}); + +test("null / undefined: optimistic defaults", () => { + assert.deepEqual(affordancesFor(null), __testing.DEFAULT); + assert.deepEqual(affordancesFor(undefined), __testing.DEFAULT); + assert.deepEqual(affordancesFor(42), __testing.DEFAULT); +}); diff --git a/runtime/bot.js b/runtime/bot.js index cbb9887..351c358 100644 --- a/runtime/bot.js +++ b/runtime/bot.js @@ -63,6 +63,7 @@ import { attach as attachReflect } from "./coach/reflect.js"; import { attach as attachTuner } from "./coach/trigger-tuner.js"; import { attach as attachChatter } from "./persona/chatter.js"; import { attachAwareness } from "./awareness/events.js"; +import { pickCurrentStep } from "./goal/state.js"; fs.mkdirSync(stateDir, { recursive: true }); const JOINED_FLAG = path.join(stateDir, "joined-before.flag"); @@ -79,6 +80,7 @@ const ESCALATE_AFTER_NOOPS = 20; const ESCALATION_COOLDOWN_MS = 10 * 60 * 1000; let bot = null; +let botSpawnedAt = 0; let pathWatchdog = null; let awarenessState = null; let reflexPaused = false; @@ -676,6 +678,7 @@ function connect() { reflexCtx.bot = bot; bot.once("spawn", () => { + botSpawnedAt = Date.now(); info("mc", `spawned at ${JSON.stringify(bot.entity.position)}`); appendDiary(`spawned at ${bot.entity.position.x.toFixed(0)},${bot.entity.position.y.toFixed(0)},${bot.entity.position.z.toFixed(0)}`); ipc?.broadcast(EVENT_TYPES.STATUS, buildSnapshot(bot)); @@ -844,6 +847,13 @@ function tick() { } const curriculumEarly = nextCurriculumMilestone(lastSnapshot); lastSnapshot.curriculum = curriculumEarly; + // Time since spawn — used by storyline orient_self to fall through + // when bot is in a barren biome that never produces "saw blocks". + lastSnapshot._sessionMs = botSpawnedAt ? Date.now() - botSpawnedAt : 0; + // Storyline current step — surfaced in snapshot so chatter and + // other observers can react to step transitions without + // re-importing the picker. + try { lastSnapshot.storyStep = pickCurrentStep(lastSnapshot); } catch {} reflexCtx.snapshot = lastSnapshot; if (!reflexPaused) { const result = runTick(reflexCtx); @@ -936,6 +946,25 @@ function tick() { function startTickLoop() { if (tickTimer) clearInterval(tickTimer); tickTimer = setInterval(tick, config.tickIntervalMs); + startPerfBufferReaper(); +} + +// mineflayer + mineflayer-pathfinder emit performance.mark/measure +// entries that accumulate in the global perf_hooks buffer with no +// upper bound. Over a multi-hour run this grew past 1,000,000 entries +// ("MaxPerformanceEntryBufferExceededWarning") and is a prime suspect +// for the overnight OOM. We don't consume those entries, so clear the +// buffer on a slow interval. +let perfReaperTimer = null; +function startPerfBufferReaper() { + if (perfReaperTimer) return; + perfReaperTimer = setInterval(() => { + try { + performance.clearMeasures?.(); + performance.clearMarks?.(); + } catch {} + }, 60_000); + perfReaperTimer.unref?.(); } // ---- IPC commands ---------------------------------------------------------- diff --git a/runtime/coach/advice.js b/runtime/coach/advice.js index e05cf79..38f2bad 100644 --- a/runtime/coach/advice.js +++ b/runtime/coach/advice.js @@ -21,10 +21,13 @@ const SAFE_OVERRIDES = new Set([ "survive.flee", "survive.sleep", "survive.eat", + "survive.acquire-food", + "survive.scout-food", "survive.pillar-up", "recovery.tunnel-out", "explore.far", "explore.wander", + "village.relocate", "village.build-shelter", "village.choose-base", ]); @@ -45,6 +48,9 @@ const MODE_TO_SKILL = Object.freeze({ "tunnel-out": "recovery.tunnel-out", explore: "explore.far", wander: "explore.far", + scout_food: "survive.scout-food", + "scout-food": "survive.scout-food", + relocate: "village.relocate", }); function normalisePreferSkill(raw) { diff --git a/runtime/coach/advice.test.js b/runtime/coach/advice.test.js index ae71c42..34bdf43 100644 --- a/runtime/coach/advice.test.js +++ b/runtime/coach/advice.test.js @@ -115,6 +115,8 @@ test("normalisePreferSkill: 'survive_flee' shape gets translated to dot form", ( test("normalisePreferSkill: passes through known dot-form skills unchanged", () => { assert.equal(normalisePreferSkill("survive.flee"), "survive.flee"); assert.equal(normalisePreferSkill("explore.far"), "explore.far"); + assert.equal(normalisePreferSkill("survive.scout-food"), "survive.scout-food"); + assert.equal(normalisePreferSkill("village.relocate"), "village.relocate"); }); test("normalisePreferSkill: unknown values rejected (returns null)", () => { diff --git a/runtime/coach/advisor-trigger.js b/runtime/coach/advisor-trigger.js index 0520ff6..5e1cbed 100644 --- a/runtime/coach/advisor-trigger.js +++ b/runtime/coach/advisor-trigger.js @@ -34,13 +34,27 @@ const PREEMPT_WINDOW_MS = 30_000; const EMERGENCY_HP = 6; const EMERGENCY_HOSTILE_DIST = 8; const EMERGENCY_COOLDOWN_MS = 20_000; +// LLM outage backoff — if 3 consecutive advise() calls return +// http_400 / network_error, suppress further calls for 10 minutes. +const PROVIDER_OUTAGE_FAILS = 3; +const PROVIDER_OUTAGE_BACKOFF_MS = 10 * 60 * 1000; let _lastTriggerAt = 0; let _inFlight = false; +let _consecutiveFails = 0; +let _providerOutageUntil = 0; export function _resetForTest() { _lastTriggerAt = 0; _inFlight = false; + _consecutiveFails = 0; + _providerOutageUntil = 0; +} + +function isProviderError(code) { + return code === "network_error" + || code === "timeout" + || (typeof code === "string" && code.startsWith("http_")); } export function getTriggerState() { @@ -63,6 +77,9 @@ export function tickAdvisor(ctx, { plannedSkillId } = {}) { if (_inFlight) return { fired: false, reason: "in_flight" }; const now = Date.now(); + if (_providerOutageUntil > now) { + return { fired: false, reason: "provider_outage", retryAt: _providerOutageUntil }; + } // Drop a recommendation that's already aged out. if (ctx.advisorRecommendation && now - ctx.advisorRecommendation.at > RECOMMENDATION_TTL_MS) { @@ -85,10 +102,11 @@ export function tickAdvisor(ctx, { plannedSkillId } = {}) { const snapshot = ctx.snapshot ?? null; const recentSkillIds = (ctx.recentSkillIds ?? []).slice(-8); const activeNeed = ctx.activeNeed ?? null; + const storyStep = ctx.storyStep ?? null; - info("advisor-trigger", `firing because ${reason} (planned=${plannedSkillId ?? "?"}, need=${activeNeed?.need?.id ?? "?"})`); + info("advisor-trigger", `firing because ${reason} (planned=${plannedSkillId ?? "?"}, need=${activeNeed?.need?.id ?? "?"}, step=${storyStep?.step?.id ?? "?"})`); // Fire-and-forget. The promise's resolution writes ctx.advisorRecommendation. - advise({ snapshot, reason, recentSkillIds, lessonsTail: ctx.recentLessons ?? [], activeNeed, force: true }) + advise({ snapshot, reason, recentSkillIds, lessonsTail: ctx.recentLessons ?? [], activeNeed, storyStep, force: true }) .then((result) => { _inFlight = false; const needLabel = activeNeed @@ -141,6 +159,15 @@ export function tickAdvisor(ctx, { plannedSkillId } = {}) { info("advisor-trigger", `recommendation: ${result.action} (${result.latencyMs}ms)`); } else if (!result.ok) { warn("advisor-trigger", `advise failed: ${result.code} (${result.detail})`); + if (isProviderError(result.code)) { + _consecutiveFails += 1; + if (_consecutiveFails >= PROVIDER_OUTAGE_FAILS) { + _providerOutageUntil = Date.now() + PROVIDER_OUTAGE_BACKOFF_MS; + warn("advisor-trigger", `LLM provider outage (${_consecutiveFails} fails in a row); backing off ${Math.round(PROVIDER_OUTAGE_BACKOFF_MS / 60000)}min`); + } + } + } else { + _consecutiveFails = 0; } }) .catch((e) => { diff --git a/runtime/coach/fast-advisor.js b/runtime/coach/fast-advisor.js index 7724230..17ca658 100644 --- a/runtime/coach/fast-advisor.js +++ b/runtime/coach/fast-advisor.js @@ -67,6 +67,7 @@ export async function advise({ recentSkillIds = [], lessonsTail = [], activeNeed = null, + storyStep = null, force = false, } = {}) { if (!isAvailable()) { @@ -83,7 +84,7 @@ export async function advise({ } const system = buildSystemPrompt(); - const user = buildUserPrompt({ snapshot, reason, recentSkillIds, lessonsTail, activeNeed }); + const user = buildUserPrompt({ snapshot, reason, recentSkillIds, lessonsTail, activeNeed, storyStep }); _callTimes.push(now); _lastCallAt = now; @@ -164,7 +165,7 @@ function buildSystemPrompt() { ].join("\n"); } -function buildUserPrompt({ snapshot, reason, recentSkillIds, lessonsTail, activeNeed }) { +function buildUserPrompt({ snapshot, reason, recentSkillIds, lessonsTail, activeNeed, storyStep }) { const pos = snapshot?.position; const inv = snapshot?.inventory ? Object.keys(snapshot.inventory).slice(0, 10).join(", ") : "(empty)"; const recent = (recentSkillIds ?? []).slice(-8).join(" → ") || "(none)"; @@ -172,6 +173,9 @@ function buildUserPrompt({ snapshot, reason, recentSkillIds, lessonsTail, active const needLine = activeNeed ? `L${activeNeed.need.level} ${activeNeed.need.id} (${activeNeed.need.title}) — manifesto wants ${activeNeed.skillId}` : "(no active need)"; + const storyLine = storyStep + ? `step ${storyStep.index + 1} '${storyStep.step.id}' — ${storyStep.step.title}${storyStep.suggestion?.skillId ? ` (storyline wants ${storyStep.suggestion.skillId})` : ""}${storyStep.emergency ? " [EMERGENCY PAUSE]" : ""}` + : "(no current step)"; const hostile = snapshot?.closestHostile ? `${snapshot.closestHostile.name}@${snapshot.closestHostile.distance}b` : "(none)"; @@ -180,6 +184,7 @@ function buildUserPrompt({ snapshot, reason, recentSkillIds, lessonsTail, active `Trigger: ${reason}`, `Position: ${pos ? `(${Math.round(pos.x)}, ${Math.round(pos.y)}, ${Math.round(pos.z)})` : "?"}`, `HP: ${snapshot?.health ?? "?"} food: ${snapshot?.food ?? "?"} day: ${snapshot?.isDay ? "yes" : "no"}`, + `Storyline progress: ${storyLine}`, `Active need (Maslow ladder): ${needLine}`, `Closest hostile: ${hostile}`, `Active skill: ${snapshot?.activeSkill ?? "(idle)"}`, @@ -190,7 +195,7 @@ function buildUserPrompt({ snapshot, reason, recentSkillIds, lessonsTail, active "", lessons ? `Relevant lessons:\n${lessons}\n` : "", "What should the bot do RIGHT NOW? Return the JSON decision.", - "Prefer a skill that helps satisfy the active need unless an emergency forces another action.", + "Prefer a skill that advances the current storyline step. If a manifesto emergency fires, that wins over both. Don't repeat a skill that has been failing in the recent dispatches list.", ].filter(Boolean).join("\n"); } diff --git a/runtime/coach/reflect.js b/runtime/coach/reflect.js index e147dd0..b9db295 100644 --- a/runtime/coach/reflect.js +++ b/runtime/coach/reflect.js @@ -18,6 +18,7 @@ import { resolve } from "node:path"; import { isAvailable as knowledgeAvailable, record as recordLesson, createImprovementRequest } from "../knowledge/index.js"; import { isRegistered, skillRegistryPrompt } from "../skill-registry.js"; import { pickActiveNeed } from "../manifesto/state.js"; +import { pickCurrentStep } from "../goal/state.js"; import { isAvailable as llmAvailable } from "../llm/provider.js"; import { askAnalytical } from "./llm-call.js"; import { info, warn } from "../log.js"; @@ -81,8 +82,9 @@ export async function runOnce({ stateDir, getSnapshot, force = false, askAnalyti const diary = readDiaryTail(stateDir); const plan = readPlan(stateDir); const activeNeed = pickActiveNeed(snap); + const storyStep = pickCurrentStep(snap); - const { system, user } = buildPrompt({ snap, journal, scenarios, diary, plan, activeNeed }); + const { system, user } = buildPrompt({ snap, journal, scenarios, diary, plan, activeNeed, storyStep }); _llmCallTimes.push(now); const parsed = await askAnalyticalFn({ system, user, json: true }); @@ -172,13 +174,16 @@ function readPlan(stateDir) { try { return readFileSync(f, "utf8"); } catch { return ""; } } -function buildPrompt({ snap, journal, scenarios, diary, plan, activeNeed }) { +function buildPrompt({ snap, journal, scenarios, diary, plan, activeNeed, storyStep }) { const pos = snap?.position; const inv = snap?.inventory ? Object.keys(snap.inventory).slice(0, 12).join(", ") : "(empty)"; const lastResult = snap?.lastResult ? JSON.stringify(snap.lastResult).slice(0, 200) : "(none)"; const needLine = activeNeed ? `L${activeNeed.need.level} ${activeNeed.need.id} → ${activeNeed.skillId} (${activeNeed.need.title})` : "(satisfied through L10 / no active need)"; + const storyLine = storyStep + ? `step ${storyStep.index + 1}/11 '${storyStep.step.id}' — ${storyStep.step.title}${storyStep.suggestion?.skillId ? ` (wants ${storyStep.suggestion.skillId})` : ""}${storyStep.emergency ? " [EMERGENCY PAUSE]" : ""}` + : "(no current step)"; const system = [ "You are pepa, an autonomous Minecraft survival bot, reflecting on your own progress.", @@ -216,6 +221,7 @@ function buildPrompt({ snap, journal, scenarios, diary, plan, activeNeed }) { `- hp: ${snap?.health ?? "?"} food: ${snap?.food ?? "?"} day: ${snap?.isDay ? "yes" : "no"}`, `- runtimeState: ${snap?.runtimeState ?? "?"}`, `- activeSkill: ${snap?.activeSkill ?? "(idle)"}`, + `- storylineStep: ${storyLine}`, `- activeNeed (Maslow ladder L0-L10): ${needLine}`, `- currentMilestone: ${snap?.currentMilestone ?? "?"}`, `- noProgressReason: ${snap?.noProgressReason ?? "(none)"}`, diff --git a/runtime/coach/trigger-tuner.js b/runtime/coach/trigger-tuner.js index d6d69de..a99d370 100644 --- a/runtime/coach/trigger-tuner.js +++ b/runtime/coach/trigger-tuner.js @@ -39,7 +39,15 @@ export function attach({ intervalMs = TUNE_INTERVAL_MS } = {}) { return; } _timer = setInterval(() => { - runOnce().catch((e) => warn("tuner", `tick err: ${e?.message ?? e}`)); + // runOnce is synchronous (pure SQL, no await) — must NOT call + // .catch on its plain-object return. A try/catch here is the + // correct guard. (This exact bug crashed the live bot after + // ~1h uptime when the first tuner tick fired.) + try { + runOnce(); + } catch (e) { + warn("tuner", `tick err: ${e?.message ?? e}`); + } }, intervalMs); _timer.unref?.(); info("tuner", `attached; tune every ${Math.round(intervalMs / 60000)} min`); diff --git a/runtime/coach/trigger-tuner.test.js b/runtime/coach/trigger-tuner.test.js index 7021727..7cdd124 100644 --- a/runtime/coach/trigger-tuner.test.js +++ b/runtime/coach/trigger-tuner.test.js @@ -6,10 +6,31 @@ import { join } from "node:path"; import { initKnowledge, isAvailable, listImprovements } from "../knowledge/index.js"; import { closeStore, __resetForTests } from "../knowledge/store.js"; -import { runOnce, __testing } from "./trigger-tuner.js"; +import { runOnce, attach, detach, __testing } from "./trigger-tuner.js"; const { MIN_SAMPLE } = __testing; +test("attach: timer tick does not crash (runOnce is sync, regression for .catch bug)", async () => { + // Reproduces the crash that killed the live bot after ~1h: the + // setInterval body called runOnce().catch(...) but runOnce returns + // a plain object, not a Promise. attach must guard with try/catch. + detach(); + let threw = false; + const origSetInterval = globalThis.setInterval; + let captured = null; + // capture the interval callback without actually waiting + globalThis.setInterval = (fn) => { captured = fn; return { unref() {} }; }; + try { + attach({ intervalMs: 999999 }); + // invoke the captured tick synchronously — must not throw + try { captured?.(); } catch { threw = true; } + } finally { + globalThis.setInterval = origSetInterval; + detach(); + } + assert.equal(threw, false, "tuner timer tick must not throw"); +}); + async function bootstrap() { const tmp = mkdtempSync(join(tmpdir(), "pepa-tuner-test-")); __resetForTests(); diff --git a/runtime/curriculum.js b/runtime/curriculum.js index df09b55..7772c88 100644 --- a/runtime/curriculum.js +++ b/runtime/curriculum.js @@ -72,6 +72,7 @@ function hasAny(inv, names) { const WOODEN_TOOLS = ["wooden_axe", "wooden_pickaxe", "wooden_sword"]; const STONE_TOOLS = ["stone_axe", "stone_pickaxe", "stone_sword"]; +const PASSIVE_FOOD_MOBS = new Set(["cow", "pig", "chicken", "sheep", "rabbit", "mooshroom"]); // A "stage reached" predicate: once the bot has wooden tools, wood.16 is // implicitly considered done even if the log stack is now empty (the bot @@ -86,6 +87,12 @@ function hasStoneTier(inv) { return STONE_TOOLS.some((n) => has(inv, n)); } +function hasVisibleFoodTarget(snap) { + const passives = snap?.nearbyEntities?.passives ?? []; + if (passives.some((e) => PASSIVE_FOOD_MOBS.has(e.name))) return true; + return (snap?.nearbyEntities?.droppedItems?.length ?? 0) > 0; +} + const MILESTONES = [ { id: "wood.16", @@ -157,7 +164,9 @@ const MILESTONES = [ ); return carrying || (snap?.food ?? 20) >= 18; }, - suggest: () => ({ skillId: "survive.acquire-food" }), + suggest: (_inv, snap) => ({ + skillId: hasVisibleFoodTarget(snap) ? "survive.acquire-food" : "survive.scout-food", + }), }, { id: "storage.chest", diff --git a/runtime/curriculum.test.js b/runtime/curriculum.test.js index 3a8ac9f..5279bee 100644 --- a/runtime/curriculum.test.js +++ b/runtime/curriculum.test.js @@ -191,10 +191,10 @@ test("listMilestones exposes ordered ids for diary/TUI", () => { } }); -test("food.basic with no carried food suggests acquire-food", () => { +test("food.basic with no carried food or visible target suggests scout-food", () => { const got = nextMilestone(snapAfter("stone.tools", {}, { food: 8 })); assert.equal(got.milestone.id, "food.basic"); - assert.equal(got.plan.skillId, "survive.acquire-food"); + assert.equal(got.plan.skillId, "survive.scout-food"); }); test("storage.chest crafts first, then places carried chest", () => { diff --git a/runtime/goal/state.js b/runtime/goal/state.js new file mode 100644 index 0000000..812eaeb --- /dev/null +++ b/runtime/goal/state.js @@ -0,0 +1,88 @@ +// Storyline state: which step the bot is currently on. +// +// pickCurrentStep(snapshot) walks STORYLINE from the top and returns +// the first non-completed step. If an emergency condition fires +// (low HP near hostile, lava under foot, etc.) the step is paused and +// callers should defer to manifesto's L0 alive emergency dispatch +// instead of step.suggestSkill. + +import { STORYLINE, getStep } from "./storyline.js"; +import { isRegistered } from "../skill-registry.js"; +import { info } from "../log.js"; + +const CACHE_TTL_MS = 3_000; + +let _cache = null; +let _lastStepId = null; + +export function _resetForTest() { + _cache = null; + _lastStepId = null; +} + +/** + * pickCurrentStep(snapshot) → + * { + * step: { id, title, narration_ru }, + * index: number, // 0-based position + * suggestion: { skillId, args } | null, + * emergency: boolean, // true if emergencyPause fires + * completedSteps: number // how many done so far + * } | null + */ +export function pickCurrentStep(snapshot) { + if (!snapshot?.connected) return null; + const now = Date.now(); + if (_cache && _cache.snapshot === snapshot && now - _cache.ts < CACHE_TTL_MS) { + return _cache.result; + } + let completedSteps = 0; + let chosen = null; + for (let i = 0; i < STORYLINE.length; i++) { + const step = STORYLINE[i]; + let done; + try { done = !!step.completed(snapshot); } catch { done = false; } + if (done) { + completedSteps += 1; + continue; + } + let emergency = false; + try { emergency = !!step.emergencyPause?.(snapshot); } catch {} + let suggestion = null; + if (!emergency) { + try { suggestion = step.suggestSkill(snapshot) ?? null; } catch { suggestion = null; } + if (suggestion?.skillId && !isRegistered(suggestion.skillId)) { + info("storyline", `step ${step.id}: suggested unknown skill ${suggestion.skillId}; dropping`); + suggestion = null; + } + } + chosen = { + step: { id: step.id, title: step.title, narration_ru: step.narration_ru }, + index: i, + suggestion, + emergency, + completedSteps, + }; + break; + } + if (chosen && _lastStepId !== chosen.step.id) { + info("storyline", `step ${chosen.index + 1}/${STORYLINE.length}: ${chosen.step.id} — ${chosen.step.title} → ${chosen.suggestion?.skillId ?? "(no concrete skill)"}`); + _lastStepId = chosen.step.id; + } + _cache = { snapshot, ts: now, result: chosen }; + return chosen; +} + +/** + * progressSummary(snapshot) → string, e.g. + * "step 3/11 'first_tools' — Деревянные орудия → craft.wooden-pickaxe" + */ +export function progressSummary(snapshot) { + const cur = pickCurrentStep(snapshot); + if (!cur) return "(no storyline progress — disconnected)"; + const tail = cur.suggestion?.skillId ? ` → ${cur.suggestion.skillId}` : ""; + const emer = cur.emergency ? " [EMERGENCY PAUSE]" : ""; + return `step ${cur.index + 1}/${STORYLINE.length} '${cur.step.id}' — ${cur.step.title}${tail}${emer}`; +} + +export { STORYLINE, getStep }; diff --git a/runtime/goal/storyline.js b/runtime/goal/storyline.js new file mode 100644 index 0000000..20641b6 --- /dev/null +++ b/runtime/goal/storyline.js @@ -0,0 +1,367 @@ +// Storyline — canonical Minecraft survival quest the bot lives inside. +// +// Why this exists (rationale, 2026-05-27 evening): +// +// After v0.3.0 went live the bot got stuck in a loop: +// acquire-food (fail: no nearby food) → explore.far → pillar-up (fail) → repeat +// +// Manifesto + LLM advisor both correctly say "you need food" but +// neither expresses *what concretely to do next*: scout 64 blocks N +// for cows; chop oak nearby; place a crafting table. The bot has no +// narrative arc, just a priority ranking of unsatisfied needs. +// +// Storyline fixes this by laying down the classic vanilla Minecraft +// survival path as an ordered list of *concrete* steps. Each step +// owns: +// - id, title, narration_ru (chat-friendly Russian one-liner) +// - completed(snapshot) → bool — detects if this step's goal has +// been achieved purely from snapshot +// - suggestSkill(snapshot) → { skillId, args? } | null — the +// concrete next dispatch for the step's pursuit +// - emergencyPause(snapshot) → bool — true if a higher-priority +// condition (low HP near hostile, lava under foot, etc.) means we +// should drop story progression for a tick +// +// The runtime/goal/state.js picker walks the list and returns the +// first non-completed step, with its suggestSkill. That feeds into: +// - reflex.js dispatch picking (storyline overrides curriculum, but +// manifesto L0 alive emergencies still win) +// - persona/chatter.js — narrates step start in MC chat +// - coach/fast-advisor.js — user prompt includes current step so +// the LLM advice is anchored in the actual narrative +// - postmortem / reflect — the LLM can flag missing skills using +// the current step as concrete context +// +// Storyline order mirrors manifesto levels but is more *operational*: +// where manifesto says "L2 tools_wood satisfied if you have a wood +// pickaxe", storyline says "step first_tools: place crafting table, +// craft wooden pickaxe + axe + sword, with these specific subgoals." + +const PICKAXE_WOOD = new Set(["wooden_pickaxe", "stone_pickaxe", "iron_pickaxe", "diamond_pickaxe", "netherite_pickaxe"]); +const AXE_WOOD = new Set(["wooden_axe", "stone_axe", "iron_axe", "diamond_axe", "netherite_axe"]); +const SWORD_WOOD = new Set(["wooden_sword", "stone_sword", "iron_sword", "diamond_sword", "netherite_sword"]); +const PICKAXE_STONE = new Set(["stone_pickaxe", "iron_pickaxe", "diamond_pickaxe", "netherite_pickaxe"]); +const BED_ITEMS = [ + "white_bed", "orange_bed", "magenta_bed", "light_blue_bed", "yellow_bed", + "lime_bed", "pink_bed", "gray_bed", "light_gray_bed", "cyan_bed", + "purple_bed", "blue_bed", "brown_bed", "green_bed", "red_bed", "black_bed", +]; +const FOOD_ITEMS = [ + "bread", "cooked_beef", "cooked_porkchop", "cooked_chicken", "cooked_mutton", + "cooked_rabbit", "cooked_cod", "cooked_salmon", "baked_potato", + "apple", "carrot", "potato", "beetroot", "melon_slice", "sweet_berries", + "golden_apple", "golden_carrot", +]; +const PASSIVE_FOOD_MOBS = new Set(["cow", "pig", "chicken", "sheep", "rabbit", "mooshroom"]); + +function hasSetItem(inv, set) { + if (!inv) return false; + for (const name of Object.keys(inv)) { + if (set.has(name) && inv[name] > 0) return true; + } + return false; +} + +function hasAny(inv, names) { + if (!inv) return false; + for (const n of names) if ((inv[n] ?? 0) > 0) return true; + return false; +} + +function countAny(inv, names) { + if (!inv) return 0; + let total = 0; + for (const n of names) total += inv[n] ?? 0; + return total; +} + +function countLogs(inv) { + if (!inv) return 0; + let total = 0; + for (const [name, count] of Object.entries(inv)) { + if (name.endsWith("_log")) total += count; + } + return total; +} + +function countPlanks(inv) { + if (!inv) return 0; + let total = 0; + for (const [name, count] of Object.entries(inv)) { + if (name.endsWith("_planks")) total += count; + } + return total; +} + +function woodBudget(inv) { + if (!inv) return 0; + const placedOrCarriedTable = (inv.crafting_table ?? 0) > 0 ? 4 : 0; + return countLogs(inv) * 4 + countPlanks(inv) + placedOrCarriedTable; +} + +function blockCount(snap, kind) { + const v = snap?.nearbyBlocks?.[kind]; + if (typeof v === "number") return v; + if (v && typeof v.count === "number") return v.count; + return 0; +} + +function hasLocalFoodMob(snap, maxDistance = 32) { + for (const e of snap?.nearbyEntities?.passives ?? []) { + if (!PASSIVE_FOOD_MOBS.has(e?.name)) continue; + if ((e.distance ?? Infinity) <= maxDistance) return true; + } + return false; +} + +function emergencyPause(snap) { + if (!snap?.connected) return false; + const hp = snap.health ?? 20; + const food = snap.food ?? 20; + const hostile = snap.closestHostile; + if (hp <= 5) return true; + if (food <= 0) return true; + if (hostile && (hostile.distance ?? Infinity) <= 5 && hp <= 12) return true; + if (snap.hazards?.footBlock === "lava") return true; + return false; +} + +// --------------------------------------------------------------------------- + +export const STORYLINE = Object.freeze([ + { + id: "orient_self", + title: "Понять где я", + narration_ru: "Где я? Осмотрюсь и оценю место.", + completed(snap) { + if (!snap?.connected) return false; + const hp = snap.health ?? 20; + // Two completion paths: (a) classic — full HP + saw tangible + // blocks within 16 blocks. (b) timeout — HP=full + session + // >120s. Path (b) exists because in desert/ocean biomes the + // scan radius might never see logs/stone/crops/beds, and we + // were getting stuck on step 1 for hours. + if (hp < 18) return false; + const sawBlocks = blockCount(snap, "logs") + + blockCount(snap, "stone") + + blockCount(snap, "crops") + + blockCount(snap, "beds") > 0; + if (sawBlocks) return true; + // Fallback: settled for long enough → call orient done and let + // later steps drive forward into the biome. + const sessionMs = snap._sessionMs ?? 0; + return sessionMs > 120_000; + }, + suggestSkill(snap) { + // Look around — wander a bit to get a snapshot of what's nearby. + return { skillId: "explore.wander", args: { radius: 12 } }; + }, + emergencyPause, + }, + + { + id: "first_wood", + title: "Собрать стартовое дерево", + narration_ru: "Нужно дерево для первого крафта. Доберу минимум и сразу к верстаку.", + completed(snap) { + const inv = snap?.inventory ?? {}; + return woodBudget(inv) >= 16 + || hasSetItem(inv, PICKAXE_WOOD) + || hasSetItem(inv, AXE_WOOD) + || hasSetItem(inv, SWORD_WOOD); + }, + suggestSkill(snap) { + const trees = blockCount(snap, "logs"); + if (trees > 0) return { skillId: "gather.logs" }; + // No tree in sight — scout further. In a biome with no trees + // (desert, ocean) the bot must commit to a long heading; the + // curriculum's wedge detector (v0.3.1+) elevates this to + // village.relocate after a few cycles. + return { skillId: "explore.far", args: { searchFor: "logs" } }; + }, + emergencyPause, + }, + + { + id: "crafting_basics", + title: "Сделать верстак и палки", + narration_ru: "Делаю верстак и палки — без них ничего не скрафтить.", + completed(snap) { + const inv = snap?.inventory ?? {}; + return ((inv.stick ?? 0) >= 2 && countPlanks(inv) >= 4) + || hasSetItem(inv, PICKAXE_WOOD) + || hasSetItem(inv, AXE_WOOD) + || hasSetItem(inv, SWORD_WOOD); + }, + suggestSkill(snap) { + const inv = snap?.inventory ?? {}; + if (countPlanks(inv) < 4) return { skillId: "craft.planks" }; + if ((inv.stick ?? 0) < 2) return { skillId: "craft.sticks" }; + return null; + }, + emergencyPause, + }, + + { + id: "first_tools", + title: "Деревянные орудия", + narration_ru: "Крафчу деревянный пикакс, топор и меч.", + completed(snap) { + const inv = snap?.inventory ?? {}; + return hasSetItem(inv, PICKAXE_WOOD) + && hasSetItem(inv, AXE_WOOD) + && hasSetItem(inv, SWORD_WOOD); + }, + suggestSkill(snap) { + const inv = snap?.inventory ?? {}; + if (countPlanks(inv) < 4) { + if (countLogs(inv) > 0) return { skillId: "craft.planks" }; + return { skillId: "gather.logs" }; + } + if ((inv.stick ?? 0) < 2) return { skillId: "craft.sticks" }; + if (!hasSetItem(inv, PICKAXE_WOOD)) return { skillId: "craft.wooden-pickaxe" }; + if (!hasSetItem(inv, AXE_WOOD)) return { skillId: "craft.wooden-axe" }; + if (!hasSetItem(inv, SWORD_WOOD)) return { skillId: "craft.wooden-sword" }; + return null; + }, + emergencyPause, + }, + + { + id: "first_food", + title: "Найти первую еду", + narration_ru: "Нужна еда — ищу корову, курицу или ягоды.", + completed(snap) { + // Done if we have a stock (≥2 food items) OR the hunger bar is + // comfortable (≥14). A sated bot should be chopping wood, not + // chasing a chicken it doesn't need — it'll grab food + // opportunistically when one wanders close. + if (countAny(snap?.inventory, FOOD_ITEMS) >= 2) return true; + if ((snap?.food ?? 20) >= 14) return true; + return false; + }, + suggestSkill(snap) { + // Only reached when the bot is actually hungry (food < 14) and + // has no stock. Local mob → acquire-food; else long-range scout. + if (hasLocalFoodMob(snap)) return { skillId: "survive.acquire-food" }; + return { skillId: "survive.scout-food" }; + }, + emergencyPause, + }, + + { + id: "shelter_minimal", + title: "Простой шелтер с кроватью", + narration_ru: "Поставлю кровать и стены — пережить ночь.", + completed(snap) { + return blockCount(snap, "beds") > 0; + }, + suggestSkill(snap) { + const inv = snap?.inventory ?? {}; + if (!hasAny(inv, BED_ITEMS)) { + const wool = countAny(inv, [ + "white_wool", "orange_wool", "magenta_wool", "light_blue_wool", + "yellow_wool", "lime_wool", "pink_wool", "gray_wool", + "light_gray_wool", "cyan_wool", "purple_wool", "blue_wool", + "brown_wool", "green_wool", "red_wool", "black_wool", + ]); + if (wool >= 3 && countPlanks(inv) >= 3) return { skillId: "craft.bed" }; + if (wool < 3) return { skillId: "gather.wool" }; + } + return { skillId: "village.build-shelter" }; + }, + emergencyPause, + }, + + { + id: "stone_tier", + title: "Каменные орудия", + narration_ru: "Шахта по камню — нужен каменный сет.", + completed(snap) { + return hasSetItem(snap?.inventory, PICKAXE_STONE); + }, + suggestSkill(snap) { + const inv = snap?.inventory ?? {}; + const cobble = inv.cobblestone ?? 0; + if (cobble < 4) return { skillId: "gather.stone" }; + if ((inv.stick ?? 0) < 2) return { skillId: "craft.sticks" }; + if (!hasSetItem(inv, PICKAXE_STONE)) return { skillId: "craft.stone-pickaxe" }; + if (!hasAny(inv, ["stone_axe"])) return { skillId: "craft.stone-axe" }; + return { skillId: "craft.stone-sword" }; + }, + emergencyPause, + }, + + { + id: "food_security", + title: "Запас еды на 16+", + narration_ru: "Делаю ферму или загон — еды должно быть с запасом.", + completed(snap) { + return countAny(snap?.inventory, FOOD_ITEMS) >= 16; + }, + suggestSkill(snap) { + const inv = snap?.inventory ?? {}; + if ((inv.wheat_seeds ?? 0) > 0 && blockCount(snap, "crops") > 0) { + return { skillId: "farm.wheat" }; + } + return { skillId: "survive.acquire-food" }; + }, + emergencyPause, + }, + + { + id: "iron_age", + title: "Железо и печь", + narration_ru: "Иду за железом — пора в шахту глубже.", + completed(snap) { + const inv = snap?.inventory ?? {}; + return (inv.iron_ingot ?? 0) >= 3 || (inv.iron_pickaxe ?? 0) > 0; + }, + suggestSkill(snap) { + // No iron-specific gather skill yet — operator-facing improvement. + return { skillId: "gather.stone" }; + }, + emergencyPause, + }, + + { + id: "settle_base", + title: "Постоянная база", + narration_ru: "Выбираю место под деревню — нужно нормальное основание.", + completed(snap) { + return blockCount(snap, "beds") >= 1 && blockCount(snap, "storage") >= 1; + }, + suggestSkill(snap) { + const inv = snap?.inventory ?? {}; + if ((inv.chest ?? 0) === 0 && countPlanks(inv) >= 8) return { skillId: "craft.chest" }; + if ((inv.chest ?? 0) > 0) return { skillId: "village.place-chest" }; + return { skillId: "village.choose-base" }; + }, + emergencyPause, + }, + + { + id: "village_grow", + title: "Развивать деревню", + narration_ru: "Стою на ногах — теперь строю по плану деревни.", + completed() { return false; }, // ongoing — never auto-completes + suggestSkill(snap) { + const inv = snap?.inventory ?? {}; + if ((inv.chest ?? 0) > 0 && countAny(inv, FOOD_ITEMS) > 0) { + return { skillId: "village.deposit-surplus" }; + } + return { skillId: "village.build-shelter" }; + }, + emergencyPause, + }, +]); + +export function getStep(id) { + return STORYLINE.find((s) => s.id === id) ?? null; +} + +// Test exports +export const __testing = { + countLogs, countPlanks, countAny, hasAny, hasSetItem, + FOOD_ITEMS, BED_ITEMS, emergencyPause, blockCount, woodBudget, hasLocalFoodMob, +}; diff --git a/runtime/goal/storyline.test.js b/runtime/goal/storyline.test.js new file mode 100644 index 0000000..bbc5040 --- /dev/null +++ b/runtime/goal/storyline.test.js @@ -0,0 +1,210 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { STORYLINE, getStep, __testing } from "./storyline.js"; +import { pickCurrentStep, progressSummary, _resetForTest } from "./state.js"; + +function snap(overrides = {}) { + return { + connected: true, + health: 20, + food: 20, + hasFood: false, + inventory: {}, + equipment: { hand: null, head: null, torso: null, legs: null, feet: null }, + nearbyBlocks: {}, + hazards: { footBlock: "grass_block", belowBlock: "dirt", headBlock: "air" }, + isDay: true, + hostileCount: 0, + closestHostile: null, + _sessionMs: 60_000, + ...overrides, + }; +} + +test("STORYLINE: 11 steps, all have id/title/narration/completed/suggestSkill", () => { + assert.equal(STORYLINE.length, 11); + for (const s of STORYLINE) { + assert.ok(s.id, `step missing id`); + assert.ok(s.title); + assert.ok(s.narration_ru); + assert.equal(typeof s.completed, "function"); + assert.equal(typeof s.suggestSkill, "function"); + } + // Ids unique + const ids = STORYLINE.map((s) => s.id); + assert.equal(new Set(ids).size, ids.length); +}); + +test("getStep: lookup by id", () => { + assert.equal(getStep("first_wood").title, "Собрать стартовое дерево"); + assert.equal(getStep("does-not-exist"), null); +}); + +test("emergencyPause: low hp + close hostile → true", () => { + const { emergencyPause } = __testing; + assert.equal(emergencyPause(snap({ health: 4, closestHostile: { name: "zombie", distance: 3 } })), true); + assert.equal(emergencyPause(snap()), false); + assert.equal(emergencyPause(snap({ hazards: { footBlock: "lava" } })), true); + assert.equal(emergencyPause(snap({ food: 0 })), true); +}); + +test("step first_wood: completed when bootstrap wood budget is enough", () => { + const s = getStep("first_wood"); + assert.equal(s.completed(snap()), false); + assert.equal(s.completed(snap({ inventory: { oak_log: 4 } })), true); + assert.equal(s.completed(snap({ inventory: { oak_log: 2, oak_planks: 8 } })), true); + assert.equal(s.completed(snap({ inventory: { wooden_pickaxe: 1 } })), true); +}); + +test("step first_wood: suggest gather.logs if trees nearby, explore.far otherwise", () => { + const s = getStep("first_wood"); + assert.equal(s.suggestSkill(snap({ nearbyBlocks: { logs: 5 } })).skillId, "gather.logs"); + assert.equal(s.suggestSkill(snap({ nearbyBlocks: { logs: { count: 1 } } })).skillId, "gather.logs"); + assert.equal(s.suggestSkill(snap()).skillId, "explore.far"); +}); + +test("step first_tools: requires all three wood tools", () => { + const s = getStep("first_tools"); + assert.equal(s.completed(snap({ inventory: { wooden_pickaxe: 1 } })), false); + assert.equal(s.completed(snap({ inventory: { wooden_pickaxe: 1, wooden_axe: 1 } })), false); + assert.equal(s.completed(snap({ inventory: { wooden_pickaxe: 1, wooden_axe: 1, wooden_sword: 1 } })), true); + // Higher tier also counts + assert.equal(s.completed(snap({ inventory: { stone_pickaxe: 1, stone_axe: 1, stone_sword: 1 } })), true); +}); + +test("step first_food: completed at ≥2 food items, OR when sated (food≥14)", () => { + const s = getStep("first_food"); + // hungry + no food → not done + assert.equal(s.completed(snap({ food: 8 })), false); + // hungry + 2 food items → done (have a stock) + assert.equal(s.completed(snap({ food: 8, inventory: { bread: 2 } })), true); + // sated (food≥14) + no food → done (don't chase food while full) + assert.equal(s.completed(snap({ food: 17 })), true); +}); + +test("step first_food: local hunt only for edible passive mobs within acquire range", () => { + const s = getStep("first_food"); + assert.equal( + s.suggestSkill(snap({ nearbyEntities: { passives: [{ name: "chicken", distance: 18 }] } })).skillId, + "survive.acquire-food", + ); + assert.equal( + s.suggestSkill(snap({ nearbyEntities: { passives: [{ name: "chicken", distance: 51 }] } })).skillId, + "survive.scout-food", + ); + assert.equal( + s.suggestSkill(snap({ nearbyEntities: { passives: [{ name: "cod", distance: 12 }] } })).skillId, + "survive.scout-food", + ); +}); + +test("step shelter_minimal: completed when bed placed nearby", () => { + const s = getStep("shelter_minimal"); + assert.equal(s.completed(snap()), false); + assert.equal(s.completed(snap({ nearbyBlocks: { beds: 1 } })), true); + assert.equal(s.completed(snap({ nearbyBlocks: { beds: { count: 1 } } })), true); +}); + +test("step stone_tier: needs cobblestone first", () => { + const s = getStep("stone_tier"); + assert.equal(s.completed(snap()), false); + assert.equal(s.suggestSkill(snap()).skillId, "gather.stone"); + assert.equal(s.suggestSkill(snap({ inventory: { cobblestone: 6, stick: 4 } })).skillId, "craft.stone-pickaxe"); +}); + +test("village_grow: never auto-completes (ongoing)", () => { + const s = getStep("village_grow"); + assert.equal(s.completed(snap({ inventory: { iron_pickaxe: 1, diamond_pickaxe: 1 } })), false); +}); + +test("pickCurrentStep: disconnected → null", () => { + _resetForTest(); + assert.equal(pickCurrentStep({ connected: false }), null); +}); + +test("pickCurrentStep: fresh spawn → first non-completed step", () => { + _resetForTest(); + const s = snap({ _sessionMs: 5_000, nearbyBlocks: {} }); + const r = pickCurrentStep(s); + assert.ok(r); + // orient_self is the first; with no nearby blocks and short session, + // completed() returns false → picked. + assert.equal(r.step.id, "orient_self"); + assert.equal(r.index, 0); +}); + +test("orient_self: barren biome timeout fallback completes step after 120s", () => { + const n = getStep("orient_self"); + // path (a): full HP + no visible blocks + short session → NOT done (would block). + assert.equal(n.completed(snap({ _sessionMs: 30_000, nearbyBlocks: {} })), false); + // path (b): full HP + no visible blocks + long session → done (timeout fallback). + assert.equal(n.completed(snap({ _sessionMs: 150_000, nearbyBlocks: {} })), true); + // always: low HP → not done + assert.equal(n.completed(snap({ health: 8, _sessionMs: 150_000 })), false); +}); + +test("pickCurrentStep: bot with 8+ logs → first_wood done, picks crafting_basics", () => { + _resetForTest(); + const r = pickCurrentStep(snap({ + _sessionMs: 60_000, + nearbyBlocks: { logs: 3 }, + inventory: { oak_log: 10 }, + })); + assert.ok(r); + assert.equal(r.step.id, "crafting_basics"); + assert.equal(r.completedSteps, 2, "orient_self + first_wood done"); +}); + +test("pickCurrentStep: bot with planks and sticks advances to first_tools", () => { + _resetForTest(); + const r = pickCurrentStep(snap({ + _sessionMs: 60_000, + nearbyBlocks: { logs: { count: 3 } }, + inventory: { oak_planks: 16, stick: 4 }, + })); + assert.ok(r); + assert.equal(r.step.id, "first_tools"); + assert.equal(r.suggestion.skillId, "craft.wooden-pickaxe"); +}); + +test("pickCurrentStep: emergency pauses suggestion", () => { + _resetForTest(); + const r = pickCurrentStep(snap({ + health: 4, + closestHostile: { name: "zombie", distance: 3 }, + nearbyBlocks: { logs: 2 }, + })); + assert.ok(r); + assert.equal(r.emergency, true); + assert.equal(r.suggestion, null, "no concrete suggestion while emergency holds"); +}); + +test("pickCurrentStep: rejects unknown skill ids from suggestSkill", () => { + _resetForTest(); + // Inject a synthetic step with bogus skill — but STORYLINE is frozen, + // so we just verify that real ids are valid (sanity check). + const r = pickCurrentStep(snap({ + _sessionMs: 60_000, + nearbyBlocks: { logs: 2 }, + })); + if (r?.suggestion?.skillId) { + // All real STORYLINE skill ids should be registered. + // (skill-registry imports a frozen list of skills/index.js.) + assert.ok(r.suggestion.skillId.includes("."), "skill id is namespaced"); + } +}); + +test("progressSummary: formats step n/N + skill + emergency tag", () => { + _resetForTest(); + const s1 = progressSummary(snap({ _sessionMs: 5_000 })); + assert.match(s1, /step 1\/11/); + assert.match(s1, /orient_self/); + + _resetForTest(); + const s2 = progressSummary(snap({ + health: 3, + closestHostile: { name: "creeper", distance: 2 }, + })); + assert.match(s2, /EMERGENCY/); +}); diff --git a/runtime/knowledge/index.js b/runtime/knowledge/index.js index cb5938f..10ce71b 100644 --- a/runtime/knowledge/index.js +++ b/runtime/knowledge/index.js @@ -334,16 +334,28 @@ export function createImprovementRequest({ } = {}) { if (!_isAvailable() || !title) return null; try { - // Dedup: if an open request with same title (case-insensitive) exists, - // bump its votes instead of inserting a new row. - const dup = _getStore().prepare(` - SELECT id, votes FROM improvement_requests - WHERE LOWER(title) = LOWER(?) AND status = 'open' - ORDER BY ts DESC LIMIT 1 - `).get(title); - if (dup) { - _getStore().prepare(`UPDATE improvement_requests SET votes = votes + 1 WHERE id = ?`).run(dup.id); - return dup.id; + // Fuzzy dedup: the LLM re-files the same gap with reworded titles + // ("целевого поиска еды" vs "целевого дальнего поиска еды по биому"). + // Exact-title match misses these, so we compare normalized token + // sets against ALL recent requests (any status — a closed/ + // implemented one shouldn't reappear as a fresh open row). On a + // strong overlap we bump votes (if still open) and return, instead + // of inserting a near-duplicate. + const incoming = tokenize(title); + const candidates = _getStore().prepare(` + SELECT id, title, status, votes FROM improvement_requests + ORDER BY ts DESC LIMIT 60 + `).all(); + for (const c of candidates) { + if (isDuplicateTitle(incoming, tokenize(c.title))) { + // Re-flagged gap. If it's still open, count the vote. If it + // was implemented/rejected, do NOT resurrect it — just + // return its id so the caller treats it as "already known". + if (c.status === "open") { + _getStore().prepare(`UPDATE improvement_requests SET votes = votes + 1 WHERE id = ?`).run(c.id); + } + return c.id; + } } const res = _getStore().prepare(` INSERT INTO improvement_requests @@ -412,7 +424,48 @@ export function markImprovementStatus(id, { status, notes } = {}) { function clamp(n, lo, hi) { return Math.max(lo, Math.min(hi, Number(n) || lo)); } +// Normalize a title into a set of meaningful tokens for fuzzy dedup. +// Lowercase, strip punctuation, drop short / stop words (RU + EN) that +// carry no signal ("нет", "навыка", "для", "the", "a", …). +const _STOP = new Set([ + "нет", "навык", "навыка", "для", "из", "по", "в", "на", "и", "с", "к", + "когда", "нужно", "это", "что", "the", "a", "an", "to", "of", "for", + "no", "skill", "has", "is", "low", "rate", +]); +function tokenize(s) { + if (!s || typeof s !== "string") return new Set(); + const words = s.toLowerCase() + .replace(/["'`(),.:;!?\/\\-]+/g, " ") + .split(/\s+/) + .filter((w) => w.length >= 3 && !_STOP.has(w)); + return new Set(words); +} +function jaccard(a, b) { + if (a.size === 0 || b.size === 0) return 0; + let inter = 0; + for (const w of a) if (b.has(w)) inter++; + return inter / (a.size + b.size - inter); +} +function intersectionSize(a, b) { + let n = 0; + for (const w of a) if (b.has(w)) n++; + return n; +} +// Two titles are "the same gap" when they either overlap very strongly +// (jaccard ≥ 0.75) OR share at least 3 meaningful tokens with moderate +// overlap (≥ 0.5). The 3-token floor stops short titles with one or two +// generic words in common ("low-prio thing" vs "high-prio thing") from +// false-matching, while still catching reworded long titles. +function isDuplicateTitle(a, b) { + const j = jaccard(a, b); + if (j >= 0.75) return true; + return intersectionSize(a, b) >= 3 && j >= 0.5; +} + function safeParse(s) { if (!s) return null; try { return JSON.parse(s); } catch { return null; } } + +// Test exports +export const __testing = { tokenize, jaccard, isDuplicateTitle }; diff --git a/runtime/knowledge/knowledge.test.js b/runtime/knowledge/knowledge.test.js index 5d24908..df07573 100644 --- a/runtime/knowledge/knowledge.test.js +++ b/runtime/knowledge/knowledge.test.js @@ -320,6 +320,42 @@ test("improvement requests: create, dedup-by-title bumps votes, list filters", a assert.ok(!stillOpen.some((r) => r.id === id1)); }); +test("improvement requests: fuzzy dedup catches reworded titles + won't resurrect closed ones", async () => { + await bootstrap(); + if (!isAvailable()) return; + const a = createImprovementRequest({ + source: "postmortem", category: "skill", + title: "Нет навыка целевого поиска еды по биому", priority: 1, + }); + assert.ok(a); + // Reworded near-duplicate — should map to the SAME row, bump votes. + const b = createImprovementRequest({ + source: "reflect", category: "skill", + title: "Нет навыка целевого дальнего поиска еды по биому", priority: 1, + }); + assert.equal(b, a, "reworded title deduped to original"); + + // Mark it implemented, then the LLM re-files the same gap reworded + // again — must NOT create a fresh open row (no resurrection). + markImprovementStatus(a, { status: "implemented", notes: "scout-food" }); + const c = createImprovementRequest({ + source: "reflect", category: "skill", + title: "Нужен навык дальнего поиска еды по биому", priority: 1, + }); + assert.equal(c, a, "re-filed closed gap returns existing id, no new row"); + const open = listImprovements({ status: "open" }); + assert.ok(!open.some((r) => r.id === a), "closed gap stays closed"); +}); + +test("tokenize / jaccard: similarity helpers", async () => { + const { tokenize, jaccard } = (await import("./index.js")).__testing; + const t1 = tokenize("Нет навыка целевого поиска еды по биому"); + const t2 = tokenize("Нет навыка целевого дальнего поиска еды по биому"); + assert.ok(jaccard(t1, t2) >= 0.6, `expected ≥0.6, got ${jaccard(t1, t2)}`); + const t3 = tokenize("Trigger wedged_96s has low success rate"); + assert.ok(jaccard(t1, t3) < 0.3, "unrelated titles score low"); +}); + test("improvement requests: priority and status ordering", async () => { await bootstrap(); if (!isAvailable()) return; diff --git a/runtime/manifesto/needs.js b/runtime/manifesto/needs.js index 750ab45..a2e823d 100644 --- a/runtime/manifesto/needs.js +++ b/runtime/manifesto/needs.js @@ -37,6 +37,7 @@ const BED_ITEMS = [ "lime_bed", "pink_bed", "gray_bed", "light_gray_bed", "cyan_bed", "purple_bed", "blue_bed", "brown_bed", "green_bed", "red_bed", "black_bed", ]; +const PASSIVE_FOOD_MOBS = new Set(["cow", "pig", "chicken", "sheep", "rabbit", "mooshroom"]); function hasAny(inv, names) { if (!inv) return false; @@ -77,6 +78,18 @@ function hostileImminent(s) { return (h.distance ?? Infinity) < 8; } +function hasVisibleFoodTarget(s) { + const passives = s?.nearbyEntities?.passives ?? []; + return passives.some((e) => PASSIVE_FOOD_MOBS.has(e.name) && (e.distance ?? Infinity) <= 32); +} + +function blockCount(s, kind) { + const v = s?.nearbyBlocks?.[kind]; + if (typeof v === "number") return v; + if (v && typeof v.count === "number") return v.count; + return 0; +} + function aliveDetect(s) { if (!s?.connected) return true; // not connected, nothing to do const hp = s.health ?? 20; @@ -98,7 +111,7 @@ function alivePursue(s) { return { skillId: "survive.eat" }; } if (food <= 0 && !s.hasFood) { - return { skillId: "survive.acquire-food" }; + return { skillId: hasVisibleFoodTarget(s) ? "survive.acquire-food" : "survive.scout-food" }; } if (hostileImminent(s)) { return { skillId: "survive.flee" }; @@ -109,9 +122,18 @@ function alivePursue(s) { return null; } +// SATED_FOOD — above this hunger level the bot is NOT hungry; chasing +// food (scout/acquire) is wasted motion. The bot should keep working +// toward wood/tools/shelter and pick up food opportunistically. It +// only becomes a hard need again when the bar drops below this. +const SATED_FOOD = 14; + function foodDetect(s) { if (!s?.connected) return true; - if ((s.food ?? 20) >= 18 && countAny(s.inventory, FOOD_ITEMS) >= 1) return true; + // Comfortable hunger bar → treat L1 as satisfied even with an empty + // food inventory. (Was: required 6+ food items, which made a sated + // bot loop scout/acquire-food for hours doing nothing else.) + if ((s.food ?? 20) >= SATED_FOOD) return true; return countAny(s.inventory, FOOD_ITEMS) >= 6; } @@ -119,7 +141,7 @@ function foodPursue(s) { if ((s.food ?? 20) < 16 && s.hasFood) { return { skillId: "survive.eat" }; } - return { skillId: "survive.acquire-food" }; + return { skillId: hasVisibleFoodTarget(s) ? "survive.acquire-food" : "survive.scout-food" }; } function toolsWoodDetect(s) { @@ -134,7 +156,7 @@ function toolsWoodPursue(s) { const logs = countLogs(inv); const sticks = inv.stick ?? 0; const hasWb = (inv.crafting_table ?? 0) > 0 - || (s.nearbyBlocks?.craftingTable ?? 0) > 0; + || blockCount(s, "craftingTable") > 0; if (logs < 2 && planks < 4 && !hasWb) { return { skillId: "gather.logs" }; @@ -159,7 +181,7 @@ function toolsWoodPursue(s) { function shelterBasicDetect(s) { const inv = s?.inventory ?? {}; - const bedPlaced = (s.nearbyBlocks?.beds ?? 0) > 0; + const bedPlaced = blockCount(s, "beds") > 0; return bedPlaced || hasAny(inv, BED_ITEMS); } @@ -233,10 +255,10 @@ function foodSecurityDetect(s) { } function foodSecurityPursue(s) { - if ((s.inventory?.wheat_seeds ?? 0) > 0 && (s.nearbyBlocks?.crops ?? 0) > 0) { + if ((s.inventory?.wheat_seeds ?? 0) > 0 && blockCount(s, "crops") > 0) { return { skillId: "farm.wheat" }; } - return { skillId: "survive.acquire-food" }; + return { skillId: hasVisibleFoodTarget(s) ? "survive.acquire-food" : "survive.scout-food" }; } function toolsIronDetect(s) { @@ -265,7 +287,7 @@ function villageSeedDetect(s) { // Heuristic: at least one chest placed AND one bed placed within // nearby radius. Tightens later (POIs of kind "structure"). const nb = s?.nearbyBlocks ?? {}; - return (nb.storage ?? 0) >= 1 && (nb.beds ?? 0) >= 1; + return blockCount(s, "storage") >= 1 && blockCount(s, "beds") >= 1; } function villageSeedPursue(s) { @@ -310,5 +332,5 @@ export function getNeed(id) { // Test exports export const __testing = { hasAny, countAny, countLogs, countPlanks, - FOOD_ITEMS, BED_ITEMS, ARMOR_CHEST_ANY, + FOOD_ITEMS, BED_ITEMS, ARMOR_CHEST_ANY, hasVisibleFoodTarget, blockCount, }; diff --git a/runtime/manifesto/needs.test.js b/runtime/manifesto/needs.test.js index 63e1b88..63cdcb8 100644 --- a/runtime/manifesto/needs.test.js +++ b/runtime/manifesto/needs.test.js @@ -54,13 +54,38 @@ test("L0 alive: zero food and have food → eat", () => { assert.equal(n.pursue(s).skillId, "survive.eat"); }); -test("L0 alive: zero food and no food → acquire", () => { +test("L0 alive: zero food and no visible target → scout-food", () => { const n = getNeed("alive"); const s = snap({ food: 0, hasFood: false }); assert.equal(n.detect(s), false); + assert.equal(n.pursue(s).skillId, "survive.scout-food"); +}); + +test("L0 alive: zero food with visible passive → acquire", () => { + const n = getNeed("alive"); + const s = snap({ + food: 0, + hasFood: false, + nearbyEntities: { passives: [{ name: "cow", distance: 12 }], droppedItems: [] }, + }); + assert.equal(n.detect(s), false); assert.equal(n.pursue(s).skillId, "survive.acquire-food"); }); +test("L0 alive: far or non-food passives do not trigger local acquire", () => { + const n = getNeed("alive"); + assert.equal(n.pursue(snap({ + food: 0, + hasFood: false, + nearbyEntities: { passives: [{ name: "chicken", distance: 51 }], droppedItems: [] }, + })).skillId, "survive.scout-food"); + assert.equal(n.pursue(snap({ + food: 0, + hasFood: false, + nearbyEntities: { passives: [{ name: "cod", distance: 12 }], droppedItems: [] }, + })).skillId, "survive.scout-food"); +}); + test("L1 food: 6+ food items → satisfied", () => { const n = getNeed("food"); assert.equal(n.detect(snap({ food: 10, inventory: { bread: 6 } })), true); @@ -73,6 +98,13 @@ test("L1 food: full saturation + any food → satisfied (no panic gathering)", ( assert.equal(n.detect(snap({ food: 20, inventory: { bread: 3 } })), true); }); +test("L1 food: no local food target uses scout-food instead of local acquire loop", () => { + const n = getNeed("food"); + const s = snap({ food: 10, hasFood: false, inventory: {} }); + assert.equal(n.detect(s), false); + assert.equal(n.pursue(s).skillId, "survive.scout-food"); +}); + test("L2 tools_wood: starts with no logs → gather.logs", () => { const n = getNeed("tools_wood"); const s = snap(); @@ -105,6 +137,7 @@ test("L2 tools_wood: progression to pickaxe → axe → sword", () => { test("L3 shelter_basic: bed nearby → satisfied", () => { const n = getNeed("shelter_basic"); assert.equal(n.detect(snap({ nearbyBlocks: { beds: 1 } })), true); + assert.equal(n.detect(snap({ nearbyBlocks: { beds: { count: 1 } } })), true); assert.equal(n.detect(snap({ inventory: { red_bed: 1 } })), true); assert.equal(n.detect(snap()), false); }); @@ -169,6 +202,7 @@ test("L8 armor_iron: iron_chestplate equipped → satisfied", () => { test("L9 village_seed: bed + storage nearby → satisfied", () => { const n = getNeed("village_seed"); assert.equal(n.detect(snap({ nearbyBlocks: { beds: 1, storage: 1 } })), true); + assert.equal(n.detect(snap({ nearbyBlocks: { beds: { count: 1 }, storage: { count: 1 } } })), true); }); test("L9 village_seed: no chest → craft.chest if enough planks", () => { diff --git a/runtime/manifesto/state.test.js b/runtime/manifesto/state.test.js index 22960f2..9031579 100644 --- a/runtime/manifesto/state.test.js +++ b/runtime/manifesto/state.test.js @@ -30,14 +30,20 @@ test("pickActiveNeed: fresh spawn → L0 alive if zero food", () => { _resetForTest(); const a = pickActiveNeed(snap({ food: 0 })); assert.equal(a.need.id, "alive"); - assert.equal(a.skillId, "survive.acquire-food"); + assert.equal(a.skillId, "survive.scout-food"); }); -test("pickActiveNeed: hp ok, no food in inventory → L1 food (acquire)", () => { +test("pickActiveNeed: hungry (food<14), no food in inventory → L1 food (scout)", () => { + _resetForTest(); + const a = pickActiveNeed(snap({ food: 8 })); + assert.equal(a.need.id, "food"); + assert.equal(a.skillId, "survive.scout-food"); +}); + +test("pickActiveNeed: sated (food=20) + empty inventory → skips L1, goes to L2 tools_wood", () => { _resetForTest(); const a = pickActiveNeed(snap()); - assert.equal(a.need.id, "food"); - assert.equal(a.skillId, "survive.acquire-food"); + assert.equal(a.need.id, "tools_wood", "sated bot works toward tools, not chasing food"); }); test("pickActiveNeed: food covered → L2 tools_wood (gather logs)", () => { @@ -103,7 +109,7 @@ test("pickActiveNeed: hostile imminent + low HP → L0 takes over", () => { test("describeActiveNeed: returns 'L '", () => { _resetForTest(); - const s = describeActiveNeed(snap()); + const s = describeActiveNeed(snap({ food: 8 })); assert.match(s, /^L1 food → /); }); diff --git a/runtime/persona/chatter.js b/runtime/persona/chatter.js index 9e02f2a..7fb0e75 100644 --- a/runtime/persona/chatter.js +++ b/runtime/persona/chatter.js @@ -94,6 +94,7 @@ let _last = { threatHostile: null, dayPart: null, noProgressReason: null, + storyStepId: null, }; let _lastNarrationAt = 0; let _narrationTimes = []; @@ -164,6 +165,15 @@ function tick() { _last.noProgressReason = snap.noProgressReason; } + // 4b. Storyline step transition — narrate the *narration_ru* line + // straight from runtime/goal/storyline.js when the step changes. + // This is the bot speaking about its current quest concretely. + const story = snap.storyStep ?? null; + if (story?.step?.id && story.step.id !== _last.storyStepId && story.step.narration_ru) { + maybeNarrateRaw(story.step.narration_ru); + _last.storyStepId = story.step.id; + } + // 5. Milestone done — fires when activeSkill flips to noop and lastResult.ok const last = snap.lastResult; if (last?.ok && last?.code === "done") { @@ -200,13 +210,19 @@ function inferDayPart(snap) { } function maybeNarrate(key) { + const line = pickLine(key); + if (!line) return; + maybeNarrateRaw(line); +} + +function maybeNarrateRaw(line) { + if (!line) return; const now = Date.now(); const hourAgo = now - 3600_000; _narrationTimes = _narrationTimes.filter((t) => t > hourAgo); if (now - _lastNarrationAt < MIN_GAP_MS) return; if (_narrationTimes.length >= MAX_PER_HOUR) return; - const line = pickLine(key); - if (!line) return; + if (line === _lastTemplate) return; const ok = sendChat(line); if (ok) { _lastNarrationAt = now; diff --git a/runtime/reflex.js b/runtime/reflex.js index a8eace4..571b29a 100644 --- a/runtime/reflex.js +++ b/runtime/reflex.js @@ -29,6 +29,8 @@ import { consult as consultAdvice, reportOutcome as reportAdviceOutcome } from " 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"; @@ -373,6 +375,39 @@ function metricRecoverySkill(ctx, plannedSkillId) { 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 @@ -441,6 +476,10 @@ function curriculumReflex(ctx) { 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 @@ -457,6 +496,38 @@ function curriculumReflex(ctx) { 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(); @@ -491,11 +562,29 @@ function curriculumReflex(ctx) { } } + 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) || wantWander) { + 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 @@ -530,10 +619,29 @@ function curriculumReflex(ctx) { return { action: "dispatched", kind: "curriculum-wander", label: "wander" }; } - // Pick what to dispatch: manifesto wins over curriculum plan because - // it expresses concrete needs rather than abstract "next milestone". - let skillId = manifestoSkillId ?? plan.skillId; - let skillSource = manifestoSkillId ? `manifesto:${activeNeed.need.id}` : "curriculum"; + // 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. @@ -542,11 +650,16 @@ function curriculumReflex(ctx) { if (!ctx.disableAdvisor) { const rec = consumeFreshRecommendation(ctx); if (rec && rec.skillId) { - info(REFLEX_LOG, `advisor override: ${skillId} → ${rec.skillId} (${rec.triggerReason}, ${rec.rationale?.slice(0, 60)})`); - skillId = rec.skillId; - skillSource = `advisor:${rec.triggerReason}`; - appliedRecommendationId = rec.id ?? null; - if (appliedRecommendationId) markRecommendationApplied(appliedRecommendationId); + 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 @@ -619,6 +732,14 @@ function curriculumReflex(ctx) { 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. diff --git a/runtime/reflex.test.js b/runtime/reflex.test.js index 8b255b7..ed23e67 100644 --- a/runtime/reflex.test.js +++ b/runtime/reflex.test.js @@ -51,6 +51,8 @@ function makeCtx({ // runtime/manifesto/state.test.js separately. disableAdvisor = true, // advisor-trigger fires real async LLM calls, // tested directly in advisor-trigger.test.js. + disableStoryline = true, // storyline tested in goal/storyline.test.js + disableWedge = true, // wedge tested in awareness/wedge-detector.test.js } = {}) { const dispatches = []; const ctx = { @@ -65,6 +67,8 @@ function makeCtx({ metrics, disableManifesto, disableAdvisor, + disableStoryline, + disableWedge, dispatch(fn, label, opts = {}) { dispatches.push({ fn, label, opts }); }, @@ -240,9 +244,10 @@ test("curriculum dispatches suggested skill by id", () => { assert.ok(typeof dispatches[0].opts.onComplete === "function"); }); -test("manifesto: hungry bot with no food drives survive.acquire-food (overrides curriculum plan)", () => { +test("manifesto: hungry bot with no visible food drives survive.scout-food (manifesto fallback when storyline disabled)", () => { const { ctx, dispatches } = makeCtx({ disableManifesto: false, + disableStoryline: true, snapshot: { connected: true, health: 20, @@ -258,10 +263,66 @@ test("manifesto: hungry bot with no food drives survive.acquire-food (overrides }); const out = runTick(ctx); assert.equal(out.reflex, "curriculum"); - assert.equal(dispatches[0].label, "survive.acquire-food", "manifesto L1 food took over"); + assert.equal(dispatches[0].label, "survive.scout-food", "manifesto L1 food took over"); assert.equal(ctx.activeNeed?.need?.id, "food"); }); +test("L0 manifesto emergency: defend/modes layer catches the threat BEFORE curriculum", () => { + // HP=4 + creeper@3m fires `modes` (self_preservation) or defendReflex + // before curriculum even runs — which is the right outcome: alive + // emergencies don't reach the storyline/manifesto branch at all. + const { ctx, dispatches } = makeCtx({ + disableManifesto: false, + disableStoryline: false, + bot: { entities: { z1: { name: "creeper", height: 1.7, position: { x: 3, y: 64, z: 0, distanceTo: () => 3 } } }, entity: { position: { x: 0, y: 64, z: 0 } } }, + snapshot: { + connected: true, + health: 4, + food: 12, + hasFood: false, + inventory: { oak_log: 10 }, + equipment: {}, + nearbyBlocks: { logs: 4 }, + hazards: { footBlock: "grass_block", belowBlock: "dirt", headBlock: "air" }, + closestHostile: { name: "creeper", distance: 3 }, + threats: [{ name: "creeper", distance: 3, position: { x: 3, y: 64, z: 0 } }], + isDay: true, + curriculum: { plan: { skillId: "gather.logs" } }, + }, + }); + const out = runTick(ctx); + assert.notEqual(out?.kind, "curriculum-skill", "an upstream reflex caught the emergency before curriculum"); +}); + +test("storyline beats manifesto L1+ when both have suggestions", () => { + // Snapshot: well-fed (food=20 + 8 bread → manifesto L1 satisfied, L2 + // tools_wood unmet) AND storyline orient_self complete (HP=20, blocks + // visible). Storyline should drive a wood-tier crafting step, not + // manifesto's gather.logs (which would also be valid but less concrete). + const { ctx, dispatches } = makeCtx({ + disableManifesto: false, + disableStoryline: false, + snapshot: { + connected: true, + health: 20, + food: 20, + hasFood: true, + inventory: { bread: 8, oak_log: 10 }, // logs done, no planks + equipment: {}, + nearbyBlocks: { logs: 3 }, + hazards: { footBlock: "grass_block", belowBlock: "dirt", headBlock: "air" }, + isDay: true, + _sessionMs: 60_000, + curriculum: { plan: { skillId: "explore.far" } }, + }, + }); + const out = runTick(ctx); + assert.equal(out.reflex, "curriculum"); + // Storyline step crafting_basics suggests craft.planks (10 logs, no planks yet). + assert.equal(dispatches[0].label, "craft.planks"); + assert.equal(ctx.storyStep?.step?.id, "crafting_basics"); +}); + test("manifesto: well-fed bot with all wood tools defers to curriculum plan", () => { const { ctx, dispatches } = makeCtx({ disableManifesto: false, diff --git a/runtime/skills/acquire-food.js b/runtime/skills/acquire-food.js index 4b2a317..a1dc9f0 100644 --- a/runtime/skills/acquire-food.js +++ b/runtime/skills/acquire-food.js @@ -8,6 +8,7 @@ const { pathfinder, goals, Movements } = pathfinderPkg; import { info, warn } from "../log.js"; import { foods } from "./groups.js"; +import { blindWalkOrTunnelOut } from "./explore-far.js"; const PASSIVE_FOOD_MOBS = new Set(["cow", "pig", "chicken", "sheep", "rabbit", "mooshroom"]); @@ -61,6 +62,41 @@ function nearbyDroppedItems(bot, maxDistance = 8) { .sort((a, b) => a.distance - b.distance); } +function horizontalDistance(a, b) { + return Math.hypot((b?.x ?? 0) - (a?.x ?? 0), (b?.z ?? 0) - (a?.z ?? 0)); +} + +function yawToward(from, to) { + if (!from || !to) return null; + const dx = to.x - from.x; + const dz = to.z - from.z; + if (Math.hypot(dx, dz) < 0.5) return null; + return -Math.atan2(dx, dz); +} + +async function fallbackApproachFoodMob(bot, target, err) { + try { bot.pathfinder?.stop?.(); } catch {} + const start = bot.entity.position.clone?.() ?? { ...bot.entity.position }; + const alreadyMoved = horizontalDistance(start, bot.entity.position); + if (alreadyMoved >= 4) { + return { ok: true, moved: alreadyMoved, mode: "pathfinder_partial", error: err?.message ?? "path failed" }; + } + const yaw = yawToward(bot.entity.position, target.entity.position); + if (yaw === null) return { ok: false, moved: 0, error: err?.message ?? "path failed" }; + const blind = await blindWalkOrTunnelOut(bot, { + yaw, + dirName: `toward-${target.entity.name}`, + blindMs: 8_000, + minMove: 4, + reason: `acquire-food target ${target.entity.name}`, + }); + const moved = horizontalDistance(start, bot.entity.position); + if (blind.ok || moved >= 4) { + return { ok: true, moved, mode: "blind_target", error: err?.message ?? "path failed" }; + } + return { ok: false, moved, error: err?.message ?? "path failed" }; +} + async function pickupNearbyDrops(bot) { ensurePathfinder(bot); setMovementsForTravel(bot); @@ -115,7 +151,18 @@ export const skill = Object.freeze({ "pathToFoodMob", ); } catch (e) { - return { ok: false, code: "no_path", detail: e.message, worldDelta: null }; + const approached = await fallbackApproachFoodMob(bot, target, e); + const current = Object.values(bot.entities ?? {}).find((entity) => entity.id === target.entity.id); + const dist = current?.position?.distanceTo(bot.entity.position) ?? Infinity; + if (!approached.ok) return { ok: false, code: "no_path", detail: e.message, worldDelta: null }; + if (dist > 4) { + return { + ok: false, + code: "approached_target", + detail: { target: target.entity.name, moved: Math.round(approached.moved), mode: approached.mode, error: approached.error }, + worldDelta: { moved: Math.round(approached.moved), target: target.entity.name, mode: approached.mode }, + }; + } } info("action", `survive.acquire-food: hunting ${target.entity.name} (${target.distance.toFixed(1)}m)`); @@ -153,11 +200,11 @@ export const skill = Object.freeze({ } }, recover(ctx, result) { - if (result.code === "no_target" || result.code === "no_path") { - return { hint: "wander", reason: "need to search for passive food mobs" }; + if (result.code === "no_target" || result.code === "no_path" || result.code === "approached_target" || result.code === "no_drop") { + return { hint: "scout-food", reason: "need a long-range food search, not local acquire-food retry" }; } return null; }, }); -export const _internal = { foodCount, nearestPassiveFoodMob }; +export const _internal = { foodCount, nearestPassiveFoodMob, yawToward, horizontalDistance }; diff --git a/runtime/skills/contract.test.js b/runtime/skills/contract.test.js index 27667ad..535c162 100644 --- a/runtime/skills/contract.test.js +++ b/runtime/skills/contract.test.js @@ -8,6 +8,7 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { runSkill, RUNNER_CODES, _registerForTest } from "./index.js"; +import { __testing as scoutFoodTesting } from "./scout-food.js"; const ctx = {}; // skills under test ignore ctx fully @@ -37,6 +38,27 @@ test("preconditions gate execution", async () => { } }); +test("precondition failures can return recovery hints", async () => { + const teardown = _registerForTest({ + id: "test.precondition-recover", + title: "blocked with recovery", + timeoutMs: 1000, + preconditions: () => ({ ok: false, code: "no_target", detail: "none nearby" }), + execute: async () => { + throw new Error("should not run"); + }, + recover: (_ctx, result) => ({ hint: "scout-food", saw: result.code }), + }); + try { + const res = await runSkill("test.precondition-recover", ctx); + assert.equal(res.ok, false); + assert.equal(res.code, "no_target"); + assert.deepEqual(res.recovery, { hint: "scout-food", saw: "no_target" }); + } finally { + teardown(); + } +}); + test("gather.logs precondition refuses nearby hostiles", async () => { const bot = { registry: { blocksByName: { oak_log: { id: 1 } } } }; const res = await runSkill("gather.logs", { @@ -48,6 +70,18 @@ test("gather.logs precondition refuses nearby hostiles", async () => { assert.match(res.detail, /unsafe to gather logs: drowned 6\.1 blocks away/); }); +test("scout-food progress counts intended cardinal, not sideways tunnel drift", () => { + const north = scoutFoodTesting.CARDINALS.find((c) => c.name === "N"); + assert.deepEqual( + scoutFoodTesting.cardinalProgress({ x: 0, z: 0 }, { x: 0, z: -9 }, north), + { along: 9, total: 9, driftName: "N" }, + ); + assert.deepEqual( + scoutFoodTesting.cardinalProgress({ x: 0, z: 0 }, { x: 9, z: 0 }, north), + { along: 0, total: 9, driftName: "E" }, + ); +}); + test("preconditions that throw produce precondition_failed", async () => { const teardown = _registerForTest({ id: "test.precondition-throw", diff --git a/runtime/skills/escape-pit-safe.js b/runtime/skills/escape-pit-safe.js new file mode 100644 index 0000000..8426f4d --- /dev/null +++ b/runtime/skills/escape-pit-safe.js @@ -0,0 +1,159 @@ +// recovery.escape-pit-safe — multi-strategy escape from a hole / pit. +// +// Why this exists alongside recovery.tunnel-out and survive.pillar-up: +// +// tunnel-out tries to dig sideways through walls — fails on +// unbreakable terrain or when there's no clear horizontal exit. +// pillar-up places blocks under the bot and jumps — fails when +// there's a ceiling block above the column. +// +// Both fail silently in a deep cave or 2×2 hole with a ceiling. +// escape-pit-safe surveys options first, then commits: +// +// 1. Scan 4 cardinals at head height + foot height. Pick the one +// with the closest open path to surface (defined as: column +// with sky visibility above OR ≥3 air blocks horizontal followed +// by stairs / slope up). +// 2. If a clear horizontal path exists → recovery.tunnel-out toward it. +// 3. If no horizontal path BUT ceiling is open above us → pillar-up. +// 4. If both blocked → return "stuck" and let the LLM-advisor flag a +// genuine improvement (e.g. "need water-bucket-MLG", "need to +// mine through stone"). + +import { info, warn } from "../log.js"; +import { runSkill } from "./index.js"; + +const SCAN_RADIUS = 6; +const HEAD_OFFSET_Y = 1; + +function blockNameAt(bot, x, y, z) { + try { + const b = bot.blockAt?.({ x: Math.floor(x), y: Math.floor(y), z: Math.floor(z) }); + return b?.name ?? null; + } catch { return null; } +} + +function isAir(name) { return name === "air" || name === "cave_air" || name === "void_air"; } +function isWater(name) { return name === "water" || name === "flowing_water"; } +function isLava(name) { return name === "lava" || name === "flowing_lava"; } +function isPassable(name) { return isAir(name) || (name && /carpet|button|torch|grass$/.test(name)); } + +// Look up to 32 blocks straight up from (x,y,z). Return distance to first +// non-air block, or Infinity if open all the way (this is a sky-visible +// column we could pillar-up out of). +function ceilingDistance(bot, x, y, z) { + for (let dy = HEAD_OFFSET_Y + 1; dy <= 32; dy++) { + const n = blockNameAt(bot, x, y + dy, z); + if (!n) return dy; // unloaded chunk = no info; treat conservatively + if (!isAir(n)) return dy; + } + return Infinity; +} + +// Scan SCAN_RADIUS blocks in each cardinal at head height. Return +// summary per direction: open-block count, lava/water hits, first +// non-air block name. +function scanCardinals(bot) { + const pos = bot?.entity?.position; + if (!pos) return []; + const head = pos.offset?.(0, HEAD_OFFSET_Y, 0) ?? { x: pos.x, y: pos.y + HEAD_OFFSET_Y, z: pos.z }; + const dirs = [ + { name: "N", dx: 0, dz: -1 }, + { name: "E", dx: 1, dz: 0 }, + { name: "S", dx: 0, dz: 1 }, + { name: "W", dx: -1, dz: 0 }, + ]; + return dirs.map((d) => { + let openBlocks = 0; + let lavaAt = -1; + let waterAt = -1; + let firstBlock = null; + for (let r = 1; r <= SCAN_RADIUS; r++) { + const x = head.x + d.dx * r; + const z = head.z + d.dz * r; + const n = blockNameAt(bot, x, head.y, z); + if (isLava(n) && lavaAt < 0) lavaAt = r; + if (isWater(n) && waterAt < 0) waterAt = r; + if (isPassable(n)) { + openBlocks++; + if (firstBlock === null) firstBlock = "(air)"; + } else { + if (firstBlock === null) firstBlock = n ?? "(unknown)"; + break; + } + } + return { ...d, openBlocks, lavaAt, waterAt, firstBlock }; + }); +} + +export const skill = Object.freeze({ + id: "recovery.escape-pit-safe", + title: "Escape a pit — survey directions and pick the safest exit", + timeoutMs: 90_000, + preconditions(ctx) { + if (!ctx?.bot?.entity?.position) return { ok: false, code: "no_bot", detail: "bot missing" }; + return { ok: true }; + }, + async execute(ctx) { + const bot = ctx.bot; + const pos = bot.entity.position; + const dirs = scanCardinals(bot); + const ceiling = ceilingDistance(bot, pos.x, pos.y, pos.z); + + info( + "action", + `escape-pit-safe: cardinals=${dirs.map((d) => `${d.name}:${d.openBlocks}/${d.firstBlock}`).join(" ")} ceiling=${ceiling === Infinity ? "open" : ceiling + "b"}`, + ); + + // 1. Choose the direction with most open blocks (≥3) and no lava. + const horizontal = dirs + .filter((d) => d.openBlocks >= 3 && d.lavaAt < 0) + .sort((a, b) => b.openBlocks - a.openBlocks)[0]; + + if (horizontal) { + info("action", `escape-pit-safe: walking out via ${horizontal.name} (${horizontal.openBlocks}b clear)`); + // Delegate to wander step or simple controlled walk. Easier: + // invoke recovery.tunnel-out with a hint of the chosen direction. + const res = await runSkill("recovery.tunnel-out", ctx, { preferredDir: horizontal.name }); + return { + ok: !!res?.ok, + code: res?.code ?? (res?.ok ? "done" : "tunnel_failed"), + detail: { strategy: "horizontal_walk", direction: horizontal.name, tunnelResult: res?.detail }, + worldDelta: res?.worldDelta ?? { strategy: "horizontal_walk", direction: horizontal.name }, + }; + } + + // 2. No horizontal exit — try pillar-up only if ceiling is open + // for the bot's height (≥3 blocks: head + 1 build + 1 ceiling slack). + if (ceiling >= 4) { + info("action", `escape-pit-safe: ceiling clear (${ceiling}b), trying pillar-up`); + const res = await runSkill("survive.pillar-up", ctx); + return { + ok: !!res?.ok, + code: res?.code ?? (res?.ok ? "done" : "pillar_failed"), + detail: { strategy: "pillar_up", ceiling, pillarResult: res?.detail }, + worldDelta: res?.worldDelta ?? { strategy: "pillar_up" }, + }; + } + + // 3. Both blocked — surrender. The LLM-advisor will see this in + // recent dispatches and can suggest village.relocate or flag a + // new skill request. + warn("action", `escape-pit-safe: no viable strategy (horizontal blocked + ceiling ${ceiling}b)`); + return { + ok: false, + code: "no_strategy", + detail: { horizontal: dirs.map((d) => ({ d: d.name, open: d.openBlocks, lava: d.lavaAt })), ceiling }, + worldDelta: null, + }; + }, + recover(ctx, result) { + if (result.code === "no_strategy") { + return { hint: "wander", reason: "no viable pit-escape; let curriculum try a different action" }; + } + return null; + }, +}); + +// Test exports +export const __testing = { scanCardinals, ceilingDistance, isAir, isPassable, SCAN_RADIUS }; diff --git a/runtime/skills/explore-far.js b/runtime/skills/explore-far.js index 75f04c9..260c258 100644 --- a/runtime/skills/explore-far.js +++ b/runtime/skills/explore-far.js @@ -90,16 +90,6 @@ export const skill = Object.freeze({ } info("action", `explore.far: cardinal probe trials=${trials.map((t) => `${t.name}:${t.dist.toFixed(1)}`).join(" ")} best=${best.name}`); - const probeMoved = horizontalDistance(beforeProbe, bot.entity.position); - if (probeMoved >= 2) { - return { - ok: true, - code: "done", - detail: { mode: "probe-moved", dir: best.name, moved: probeMoved }, - worldDelta: { movedTo: clonePos(bot.entity.position) }, - }; - } - if (best.dist < 0.5) { // All cardinals blocked. Try the cheap vertical escape first; if it // does not actually move us, carve a short horizontal tunnel. The @@ -122,7 +112,8 @@ export const skill = Object.freeze({ return blindWalkOrTunnelOut(bot, { yaw: best.yaw, dirName: best.name, - blindMs: args.blindMs ?? 7_000, + blindMs: args.blindMs ?? 20_000, + minMove: args.minMove ?? Math.min(14, Math.max(8, dist * 0.25)), tunnelPushMs: args.tunnelPushMs, reason: `explore.far blind ${best.name}`, intended: { x: tx, y: ty, z: tz }, @@ -130,7 +121,7 @@ export const skill = Object.freeze({ }, }); -async function blindWalkOrTunnelOut(bot, { yaw, dirName, blindMs = 7_000, minMove = 0.75, tunnelPushMs, reason = "blind fallback", intended = null } = {}) { +export async function blindWalkOrTunnelOut(bot, { yaw, dirName, blindMs = 7_000, minMove = 0.75, tunnelPushMs, reason = "blind fallback", intended = null } = {}) { const before = clonePos(bot.entity.position); try { await bot.look(yaw, 0, true); } catch {} bot.setControlState("forward", true); diff --git a/runtime/skills/index.js b/runtime/skills/index.js index 5440028..4a19060 100644 --- a/runtime/skills/index.js +++ b/runtime/skills/index.js @@ -30,11 +30,14 @@ import { skill as flee } from "./flee.js"; import { skill as sleep } from "./sleep.js"; import { skill as tunnelOut } from "./recovery-tunnel-out.js"; import { skill as pillarUp } from "./pillar-up.js"; +import { skill as escapePitSafe } from "./escape-pit-safe.js"; import { skill as diagPhysics } from "./diagnose-physics.js"; import { skill as diagScan, matchSkill as diagMatch } from "./diagnose-scan.js"; import { skill as gatherStone } from "./gather-stone.js"; import { skill as gatherWool } from "./gather-wool.js"; import { skill as acquireFood } from "./acquire-food.js"; +import { skill as scoutFood } from "./scout-food.js"; +import { skill as relocate } from "./relocate.js"; import { skill as chooseBase } from "./choose-base.js"; import { skill as buildShelter } from "./build-shelter.js"; import { skill as placeChest } from "./place-chest.js"; @@ -74,12 +77,15 @@ register(flee); register(sleep); register(tunnelOut); register(pillarUp); +register(escapePitSafe); register(diagPhysics); register(diagScan); register(diagMatch); register(gatherStone); register(gatherWool); register(acquireFood); +register(scoutFood); +register(relocate); register(chooseBase); register(buildShelter); register(placeChest); @@ -198,12 +204,20 @@ export async function runSkill(id, ctx, args = {}) { }; } if (!pre.ok) { - return { + const result = { ok: false, code: pre.code ?? RUNNER_CODES.PRECONDITION_FAILED, detail: pre.detail ?? "preconditions failed", worldDelta: null, }; + if (typeof skill.recover === "function") { + try { + result.recovery = skill.recover(ctx, result) ?? null; + } catch (e) { + warn("skill", `${id}.recover threw: ${e.message}`); + } + } + return result; } const timeoutMs = skill.timeoutMs ?? 30_000; diff --git a/runtime/skills/relocate.js b/runtime/skills/relocate.js new file mode 100644 index 0000000..0964e96 --- /dev/null +++ b/runtime/skills/relocate.js @@ -0,0 +1,146 @@ +// village.relocate — commit-and-walk skill that breaks the bot out of +// "I've been wandering the same 50×50 area for 2 hours" failure mode. +// +// Heuristic: when the wedge-detector says we're stuck, this skill is +// dispatched with no biome preference; it picks the least-recently- +// visited cardinal (or any cardinal if no history) and walks ~300 +// blocks toward it, with a hard time budget. While it runs, the +// reflex's wedge-detector knows a relocation is in flight and won't +// fire another one on top. +// +// The skill ignores the active need entirely for its duration — its +// only job is to displace the bot far enough that the surrounding +// biome is fresh and skills like survive.acquire-food / gather.logs +// have new local context to work with. + +import pathfinderPkg from "mineflayer-pathfinder"; +const { pathfinder, goals, Movements } = pathfinderPkg; + +import { info } from "../log.js"; +import { markRelocationStarted } from "../awareness/wedge-detector.js"; +import { blindWalkOrTunnelOut } from "./explore-far.js"; + +const CARDINALS = [ + { name: "N", dx: 0, dz: -1, yaw: Math.PI }, + { name: "E", dx: 1, dz: 0, yaw: -Math.PI / 2 }, + { name: "S", dx: 0, dz: 1, yaw: 0 }, + { name: "W", dx: -1, dz: 0, yaw: Math.PI / 2 }, +]; +const DEFAULT_DISTANCE = 300; +const STEP_BLOCKS = 32; // re-path every N blocks for liveness +const STEP_TIMEOUT_MS = 30_000; + +let pluginLoaded = new WeakSet(); +function ensurePathfinder(bot) { + if (pluginLoaded.has(bot)) return; + bot.loadPlugin(pathfinder); + pluginLoaded.add(bot); +} +function setMovementsForTravel(bot) { + const m = new Movements(bot); + m.canDig = true; + m.allow1by1towers = false; + bot.pathfinder.setMovements(m); +} + +function pickCardinal(ctx, args) { + // Explicit override wins + if (args?.heading) { + const found = CARDINALS.find((c) => c.name === args.heading); + if (found) return found; + } + // Otherwise pick a cardinal not recently used. ctx may carry a + // recentRelocations array {name, ts}; default = N. + const recent = new Set((ctx?.recentRelocations ?? []).map((r) => r.name)); + const untried = CARDINALS.filter((c) => !recent.has(c.name)); + return untried[0] ?? CARDINALS[Math.floor(Math.random() * CARDINALS.length)]; +} + +export const skill = Object.freeze({ + id: "village.relocate", + title: "Walk 300 blocks in a fresh cardinal to break a wedge", + timeoutMs: 180_000, + preconditions(ctx) { + if (!ctx?.bot?.entity?.position) { + return { ok: false, code: "no_bot", detail: "bot or entity missing" }; + } + return { ok: true }; + }, + async execute(ctx, args = {}) { + const bot = ctx.bot; + const distance = Math.max(64, Math.min(args?.distance ?? DEFAULT_DISTANCE, 600)); + const cardinal = pickCardinal(ctx, args); + const start = { x: bot.entity.position.x, z: bot.entity.position.z }; + markRelocationStarted({ x: start.x, z: start.z, heading: cardinal }); + ctx.recentRelocations = (ctx.recentRelocations ?? []).slice(-3); + ctx.recentRelocations.push({ name: cardinal.name, ts: Date.now() }); + + ensurePathfinder(bot); + setMovementsForTravel(bot); + info("action", `relocate: heading ${cardinal.name} for ${distance}b from (${Math.round(start.x)}, ${Math.round(start.z)})`); + + let travelled = 0; + const errors = []; + while (travelled < distance) { + if (ctx?.abortSignal?.aborted) { + return { + ok: travelled >= distance / 2, // partial counts if we got at least half + code: travelled >= distance / 2 ? "partial" : "preempted", + detail: { travelled: Math.round(travelled), heading: cardinal.name }, + worldDelta: { moved: Math.round(travelled), heading: cardinal.name }, + }; + } + const stepDist = Math.min(STEP_BLOCKS, distance - travelled); + const targetX = start.x + cardinal.dx * (travelled + stepDist); + const targetZ = start.z + cardinal.dz * (travelled + stepDist); + const targetY = Math.floor(bot.entity.position.y); + try { + await Promise.race([ + bot.pathfinder.goto(new goals.GoalNear(Math.floor(targetX), targetY, Math.floor(targetZ), 4)), + new Promise((_, rej) => setTimeout(() => rej(new Error("step timeout")), STEP_TIMEOUT_MS)), + ]); + } catch (e) { + errors.push(e?.message ?? String(e)); + info("action", `relocate: path step failed (${e?.message ?? e}); blind fallback ${cardinal.name}`); + const blind = await blindWalkOrTunnelOut(bot, { + yaw: cardinal.yaw, + dirName: cardinal.name, + blindMs: 12_000, + minMove: 8, + reason: `relocate ${cardinal.name}`, + }); + if (!blind.ok && errors.length >= 3) break; + } + // Measure actual progress (pathfinder might have routed around) + const dx = bot.entity.position.x - start.x; + const dz = bot.entity.position.z - start.z; + travelled = Math.hypot(dx, dz); + } + + const endPos = bot.entity.position; + const finalDist = Math.hypot(endPos.x - start.x, endPos.z - start.z); + if (finalDist < 50) { + return { + ok: false, + code: "stuck_in_place", + detail: { travelled: Math.round(finalDist), heading: cardinal.name, errors: errors.slice(0, 3) }, + worldDelta: { moved: Math.round(finalDist) }, + }; + } + return { + ok: true, + code: "done", + detail: { travelled: Math.round(finalDist), heading: cardinal.name }, + worldDelta: { moved: Math.round(finalDist), heading: cardinal.name, mode: "relocate" }, + }; + }, + recover(ctx, result) { + if (result.code === "stuck_in_place") { + return { hint: "wander", reason: "relocate could not gain ground; let wander try a new tactic" }; + } + return null; + }, +}); + +// Test exports +export const __testing = { CARDINALS, DEFAULT_DISTANCE, pickCardinal }; diff --git a/runtime/skills/scout-food.js b/runtime/skills/scout-food.js new file mode 100644 index 0000000..c663a89 --- /dev/null +++ b/runtime/skills/scout-food.js @@ -0,0 +1,408 @@ +// survive.scout-food — longer-range, biome-aware food search. +// +// Why this exists alongside survive.acquire-food: +// +// acquire-food is the "I see a cow, hunt it" skill. Its precondition +// requires a passive food mob within ~32 blocks; if there isn't one, +// the skill bails immediately. In v0.3.0 that produced two failure +// modes that kept the bot looping for hours: +// +// 1. Biome with NO passive mobs (desert, ocean, snowy peaks, deep +// caves). acquire-food can never succeed there — the bot just +// kept scanning the same 32-block sphere. +// +// 2. Biome WITH passive mobs but the immediate area is empty. The +// bot wandered 1-8 blocks at a time around the same 50×50 patch +// and never committed to a direction long enough to leave it. +// +// scout-food applies the "commit-to-cardinal" pattern (per the v0.3.1 +// design research): scan(32) → patrol(64, time-budget) → if still +// nothing, walk a chosen cardinal for ~200 blocks, rescanning every +// 16 blocks. On exhaustion of all 4 cardinals it surrenders to the +// curriculum so the operator-driven `village.relocate` or LLM advisor +// can pick up. +// +// Biome-awareness: +// - If current biome's affordance table has has_passive_mobs=false +// AND has_water=false, skip the local scan entirely and pick the +// most plausible "leave biome" heading: sample neighbour biomes at +// radius 64 in 8 directions, pick the first one whose affordances +// say has_passive_mobs=true. +// - In water-bearing barren biomes (ocean shores, frozen rivers) +// fishing isn't implemented yet — operator-facing improvement. + +import pathfinderPkg from "mineflayer-pathfinder"; +const { pathfinder, goals, Movements } = pathfinderPkg; + +import { info, warn } from "../log.js"; +import { foods } from "./groups.js"; +import { affordancesFor, hasPassiveMobs, isBarren } from "../biome-affordances.js"; +import { blindWalkOrTunnelOut } from "./explore-far.js"; + +const PASSIVE_FOOD_MOBS = new Set(["cow", "pig", "chicken", "sheep", "rabbit", "mooshroom"]); +const CARDINALS = [ + { name: "N", dx: 0, dz: -1, yaw: Math.PI }, + { name: "E", dx: 1, dz: 0, yaw: -Math.PI / 2 }, + { name: "S", dx: 0, dz: 1, yaw: 0 }, + { name: "W", dx: -1, dz: 0, yaw: Math.PI / 2 }, +]; +const PATROL_TICK_DISTANCE = 16; +const PATROL_STEP_TIMEOUT_MS = 12_000; +const DEFAULT_COMMIT_DISTANCE = 200; + +let pluginLoaded = new WeakSet(); +function ensurePathfinder(bot) { + if (pluginLoaded.has(bot)) return; + bot.loadPlugin(pathfinder); + pluginLoaded.add(bot); +} + +function setMovementsForTravel(bot) { + const m = new Movements(bot); + m.canDig = true; + m.allow1by1towers = false; + bot.pathfinder.setMovements(m); +} + +function foodCount(bot) { + const allowed = foods(bot); + return bot.inventory.items().reduce((sum, item) => allowed.has(item.name) ? sum + item.count : sum, 0); +} + +function nearestPassiveFoodMob(bot, maxDistance) { + const here = bot?.entity?.position; + if (!here) return null; + let best = null; + for (const e of Object.values(bot.entities ?? {})) { + if (!e?.position || !PASSIVE_FOOD_MOBS.has(e.name)) continue; + const d = e.position.distanceTo(here); + if (d > maxDistance) continue; + if (!best || d < best.distance) best = { entity: e, distance: d }; + } + return best; +} + +function currentBiomeName(bot) { + try { + const block = bot.blockAt?.(bot.entity?.position); + const b = block?.biome; + if (typeof b === "string") return b; + if (b?.name) return b.name; + return null; + } catch { return null; } +} + +function biomeNameAt(bot, x, y, z) { + try { + const block = bot.blockAt?.({ x: Math.floor(x), y: Math.floor(y), z: Math.floor(z) }); + const b = block?.biome; + if (typeof b === "string") return b; + if (b?.name) return b.name; + return null; + } catch { return null; } +} + +// Sample biomes in 8 compass directions at the given radius; return +// the heading whose biome has passive mobs. +function scanForFoodCapableNeighbourBiome(bot, radius = 64) { + const here = bot?.entity?.position; + if (!here) return null; + const dirs = [ + { name: "N", dx: 0, dz: -1 }, + { name: "NE", dx: 0.71, dz: -0.71 }, + { name: "E", dx: 1, dz: 0 }, + { name: "SE", dx: 0.71, dz: 0.71 }, + { name: "S", dx: 0, dz: 1 }, + { name: "SW", dx: -0.71, dz: 0.71 }, + { name: "W", dx: -1, dz: 0 }, + { name: "NW", dx: -0.71, dz: -0.71 }, + ]; + for (const d of dirs) { + const b = biomeNameAt(bot, here.x + d.dx * radius, here.y, here.z + d.dz * radius); + if (b && hasPassiveMobs(b)) return { heading: d, biome: b }; + } + return null; +} + +function scoutState(ctx, bot) { + const here = bot?.entity?.position; + const now = Date.now(); + const prev = ctx.scoutFoodState; + const expired = !prev || now - (prev.ts ?? 0) > 10 * 60_000; + const displaced = prev?.origin && here + ? Math.hypot(here.x - prev.origin.x, here.z - prev.origin.z) > 128 + : false; + if (expired || displaced) { + ctx.scoutFoodState = { + ts: now, + origin: here ? { x: here.x, z: here.z } : null, + tried: new Set(), + }; + return ctx.scoutFoodState; + } + prev.ts = now; + if (!(prev.tried instanceof Set)) prev.tried = new Set(prev.tried ?? []); + return prev; +} + +async function patrolCardinal(bot, cardinal, distance, ctx) { + ensurePathfinder(bot); + setMovementsForTravel(bot); + const start = bot.entity.position.clone?.() ?? { ...bot.entity.position }; + let travelled = 0; + while (travelled < distance) { + if (ctx?.abortSignal?.aborted) return { aborted: true, travelled }; + const tx = start.x + cardinal.dx * (travelled + PATROL_TICK_DISTANCE); + const tz = start.z + cardinal.dz * (travelled + PATROL_TICK_DISTANCE); + const goal = new goals.GoalNear(Math.floor(tx), Math.floor(bot.entity.position.y), Math.floor(tz), 2); + try { + await Promise.race([ + bot.pathfinder.goto(goal), + new Promise((_, rej) => setTimeout(() => rej(new Error("patrol step timeout")), PATROL_STEP_TIMEOUT_MS)), + ]); + } catch (e) { + info("action", `scout-food: path step failed (${e?.message ?? e}); blind fallback ${cardinal.name}`); + try { bot.pathfinder?.stop?.(); } catch {} + const blind = await blindWalkOrTunnelOut(bot, { + yaw: cardinal.yaw ?? -Math.atan2(cardinal.dx, cardinal.dz), + dirName: cardinal.name, + blindMs: 12_000, + minMove: 6, + reason: `scout-food ${cardinal.name}`, + }); + const progress = cardinalProgress(start, bot.entity.position, cardinal); + travelled = progress.along; + const target = nearestPassiveFoodMob(bot, 32); + if (target) return { aborted: false, travelled, target }; + if (progress.total >= 4 && progress.along < 4) { + info("action", `scout-food: ${cardinal.name} blocked; drifted ${progress.driftName ?? "sideways"} ${progress.total.toFixed(1)}b`); + return { + aborted: false, + travelled, + blocked: true, + drifted: progress.driftName, + error: `blocked_${cardinal.name}`, + }; + } + if (!blind.ok && progress.total < 4) { + return { aborted: false, travelled, error: e?.message ?? String(e) }; + } + continue; + } + const progress = cardinalProgress(start, bot.entity.position, cardinal); + travelled = Math.max(travelled + PATROL_TICK_DISTANCE, progress.along); + // Rescan after every step. + const target = nearestPassiveFoodMob(bot, 32); + if (target) return { aborted: false, travelled, target }; + } + return { aborted: false, travelled }; +} + +function cardinalProgress(start, pos, cardinal) { + const dx = (pos?.x ?? 0) - (start?.x ?? 0); + const dz = (pos?.z ?? 0) - (start?.z ?? 0); + const along = Math.max(0, dx * cardinal.dx + dz * cardinal.dz); + const total = Math.hypot(dx, dz); + return { along, total, driftName: dominantCardinal(dx, dz, cardinal.name) }; +} + +function dominantCardinal(dx, dz, fallback = null) { + if (Math.abs(dx) < 0.5 && Math.abs(dz) < 0.5) return fallback; + if (Math.abs(dx) >= Math.abs(dz)) return dx >= 0 ? "E" : "W"; + return dz >= 0 ? "S" : "N"; +} + +export const skill = Object.freeze({ + id: "survive.scout-food", + title: "Scout for food at long range (biome-aware)", + timeoutMs: 240_000, + preconditions(ctx) { + if (!ctx?.bot) return { ok: false, code: "no_bot", detail: "bot missing" }; + if (foodCount(ctx.bot) > 0) { + return { ok: false, code: "already_have", detail: "already carrying edible food" }; + } + return { ok: true }; + }, + async execute(ctx, args = {}) { + const bot = ctx.bot; + const before = foodCount(bot); + const state = scoutState(ctx, bot); + const triedCardinals = new Set([...(args?._triedCardinals ?? []), ...(state.tried ?? [])]); + + // Step 0: biome check. If barren, head toward a food-capable neighbour. + const biome = currentBiomeName(bot); + const aff = affordancesFor(biome); + info("action", `scout-food: biome=${biome ?? "?"} mobs=${aff.has_passive_mobs} barren=${isBarren(biome)}`); + + if (!aff.has_passive_mobs) { + const next = scanForFoodCapableNeighbourBiome(bot, 64); + if (next) { + info("action", `scout-food: leaving barren biome ${biome} → ${next.biome} via ${next.heading.name}`); + const result = await patrolCardinal(bot, next.heading, DEFAULT_COMMIT_DISTANCE, ctx); + if (result.aborted) return { ok: false, code: "preempted", worldDelta: null }; + if (result.target) { + return await tryHunt(bot, result.target, before); + } + if (result.blocked) { + state.tried.add(next.heading.name); + return { + ok: false, + code: "blocked_heading", + detail: `blocked ${next.heading.name}, drifted ${result.drifted ?? "sideways"}`, + worldDelta: { moved: Math.round(result.travelled), heading: next.heading.name, drifted: result.drifted ?? null }, + }; + } + return { + ok: false, + code: "no_target", + detail: `walked ${Math.round(result.travelled)}b ${next.heading.name} toward ${next.biome}, still no food`, + worldDelta: { moved: Math.round(result.travelled), heading: next.heading.name, from_biome: biome, to_biome: next.biome }, + }; + } + // Ringed by barren biomes; pick the first cardinal not yet tried. + } + + // Step 1: scan radius 32 (cheap). + let target = nearestPassiveFoodMob(bot, 32); + if (target) return await tryHunt(bot, target, before); + + // Step 2: scan radius 64 — entities frequently spawn just outside + // our local horizon. + target = nearestPassiveFoodMob(bot, 64); + if (target) return await tryHunt(bot, target, before); + + // Step 3: commit to a cardinal we haven't tried in this incident. + const untried = CARDINALS.filter((c) => !triedCardinals.has(c.name)); + if (untried.length === 0) { + return { + ok: false, + code: "exhausted", + detail: "tried all 4 cardinals without finding a food mob — switch to village.relocate", + worldDelta: null, + }; + } + const cardinal = untried[0]; + state.tried.add(cardinal.name); + info("action", `scout-food: commit cardinal ${cardinal.name} for ${DEFAULT_COMMIT_DISTANCE}b`); + const result = await patrolCardinal(bot, cardinal, DEFAULT_COMMIT_DISTANCE, ctx); + if (result.aborted) return { ok: false, code: "preempted", worldDelta: null }; + if (result.target) return await tryHunt(bot, result.target, before); + if (result.blocked) { + return { + ok: false, + code: "blocked_heading", + detail: { tried: cardinal.name, travelled: Math.round(result.travelled), drifted: result.drifted ?? null, error: result.error ?? null }, + worldDelta: { moved: Math.round(result.travelled), heading: cardinal.name, drifted: result.drifted ?? null, from_biome: biome }, + }; + } + return { + ok: false, + code: "no_target", + detail: { tried: cardinal.name, travelled: Math.round(result.travelled), error: result.error ?? null }, + worldDelta: { moved: Math.round(result.travelled), heading: cardinal.name, from_biome: biome }, + }; + }, + recover(ctx, result) { + if (result.code === "exhausted") { + return { hint: "relocate", reason: "scout-food exhausted all 4 cardinals; needs a long jump" }; + } + if (result.code === "blocked_heading") { + return { hint: "scout-food", reason: "chosen scout heading is blocked; retry another cardinal" }; + } + if (result.code === "approached_target" || result.code === "no_path") { + return { hint: "scout-food", reason: "made or attempted progress toward food target; rescan from current position" }; + } + if (result.code === "no_target") { + return { hint: "wander", reason: "scout completed leg without finding mob; try another cardinal" }; + } + return null; + }, +}); + +async function tryHunt(bot, target, before) { + ensurePathfinder(bot); + setMovementsForTravel(bot); + const start = bot.entity.position.clone?.() ?? { ...bot.entity.position }; + try { + await Promise.race([ + bot.pathfinder.goto(new goals.GoalFollow(target.entity, 2)), + new Promise((_, rej) => setTimeout(() => rej(new Error("path-to-mob timeout")), 30_000)), + ]); + } catch (e) { + try { bot.pathfinder?.stop?.(); } catch {} + const moved = horizontalDistance(start, bot.entity.position); + if (moved >= 6) { + return { + ok: false, + code: "approached_target", + detail: { target: target.entity.name, moved: Math.round(moved), mode: "pathfinder_partial", error: e?.message ?? "path failed" }, + worldDelta: { moved: Math.round(moved), target: target.entity.name, mode: "pathfinder_partial" }, + }; + } + const yaw = yawToward(bot.entity.position, target.entity.position); + if (yaw !== null) { + const blind = await blindWalkOrTunnelOut(bot, { + yaw, + dirName: `toward-${target.entity.name}`, + blindMs: 8_000, + minMove: 4, + reason: `scout-food target ${target.entity.name}`, + }); + const afterBlind = horizontalDistance(start, bot.entity.position); + if (blind.ok || afterBlind >= 4) { + return { + ok: false, + code: "approached_target", + detail: { target: target.entity.name, moved: Math.round(afterBlind), mode: "blind_target", error: e?.message ?? "path failed" }, + worldDelta: { moved: Math.round(afterBlind), target: target.entity.name, mode: "blind_target" }, + }; + } + } + return { ok: false, code: "no_path", detail: e?.message ?? "path failed", worldDelta: null }; + } + info("action", `scout-food: engaging ${target.entity.name}@${target.distance.toFixed(1)}b`); + for (let i = 0; i < 8; i++) { + const current = Object.values(bot.entities ?? {}).find((e) => e.id === target.entity.id); + if (!current) break; + if (current.position.distanceTo(bot.entity.position) > 4) { + try { + await Promise.race([ + bot.pathfinder.goto(new goals.GoalFollow(current, 2)), + new Promise((_, rej) => setTimeout(() => rej(new Error("repath timeout")), 8_000)), + ]); + } catch {} + } + bot.attack(current); + await new Promise((r) => setTimeout(r, 700)); + } + await new Promise((r) => setTimeout(r, 1_000)); + const after = foodCount(bot); + if (after <= before) { + return { ok: false, code: "no_drop", detail: `hunted ${target.entity.name} but no edible drop`, worldDelta: null }; + } + return { + ok: true, + code: "done", + detail: { source: "hunt", mob: target.entity.name, gained: after - before }, + worldDelta: { acquiredFood: after - before, source: "hunt", mob: target.entity.name }, + }; +} + +function horizontalDistance(a, b) { + return Math.hypot((b?.x ?? 0) - (a?.x ?? 0), (b?.z ?? 0) - (a?.z ?? 0)); +} + +function yawToward(from, to) { + if (!from || !to) return null; + const dx = to.x - from.x; + const dz = to.z - from.z; + if (Math.hypot(dx, dz) < 0.5) return null; + return -Math.atan2(dx, dz); +} + +// Test exports +export const __testing = { + CARDINALS, PATROL_TICK_DISTANCE, DEFAULT_COMMIT_DISTANCE, PATROL_STEP_TIMEOUT_MS, + nearestPassiveFoodMob, currentBiomeName, scanForFoodCapableNeighbourBiome, + cardinalProgress, dominantCardinal, horizontalDistance, yawToward, +}; diff --git a/scripts/show-story.js b/scripts/show-story.js new file mode 100644 index 0000000..0a0bb7c --- /dev/null +++ b/scripts/show-story.js @@ -0,0 +1,103 @@ +#!/usr/bin/env node +// Operator-facing view of the bot's storyline progress. +// +// Reads the bot's IPC sock if available (live snapshot), else falls +// back to "what would the picker say given an empty inventory". The +// useful form is the live one. +// +// Usage: +// node scripts/show-story.js # live snapshot via IPC +// node scripts/show-story.js --plain # show static catalogue + +import { config as loadDotenv } from "dotenv"; +loadDotenv(); + +import net from "node:net"; +import { STORYLINE } from "../runtime/goal/storyline.js"; +import { pickCurrentStep, progressSummary, _resetForTest } from "../runtime/goal/state.js"; +import { socketPath } from "../runtime/config.js"; +import { COMMAND_TYPES, EVENT_TYPES } from "../runtime/ipc-protocol.js"; + +function plainCatalogue() { + console.log("=== Storyline (canonical Minecraft survival arc) ==="); + for (let i = 0; i < STORYLINE.length; i++) { + const s = STORYLINE[i]; + console.log(` ${(i + 1).toString().padStart(2)}. ${s.id.padEnd(20)} ${s.title}`); + console.log(` → ${s.narration_ru}`); + } +} + +async function fetchSnapshotViaIpc() { + return new Promise((resolve) => { + const sock = net.connect(socketPath); + const buf = []; + const timer = setTimeout(() => { sock.destroy(); resolve(null); }, 1500); + sock.on("connect", () => { + sock.write(JSON.stringify({ type: COMMAND_TYPES.SNAPSHOT }) + "\n"); + }); + const parse = () => { + try { + const raw = Buffer.concat(buf).toString("utf8").trim(); + const lines = raw.split("\n").filter(Boolean); + for (const ln of lines) { + const obj = JSON.parse(ln); + if (obj?.type === EVENT_TYPES.STATUS && obj?.payload) { + clearTimeout(timer); + sock.destroy(); + resolve(obj.payload); + return true; + } + } + } catch {} + return false; + }; + sock.on("data", (chunk) => { + buf.push(chunk); + parse(); + }); + sock.on("end", () => { + clearTimeout(timer); + if (!parse()) resolve(null); + }); + sock.on("error", () => { clearTimeout(timer); resolve(null); }); + }); +} + +async function main() { + if (process.argv.includes("--plain")) { + plainCatalogue(); + return; + } + + const snap = await fetchSnapshotViaIpc(); + if (!snap) { + console.log("(bot IPC not reachable — showing static catalogue)"); + console.log(""); + plainCatalogue(); + return; + } + + _resetForTest(); + const cur = pickCurrentStep(snap); + console.log(`=== Storyline progress (live snapshot) ===`); + console.log(progressSummary(snap)); + console.log(""); + if (!cur) { + console.log("(disconnected)"); + return; + } + for (let i = 0; i < STORYLINE.length; i++) { + const s = STORYLINE[i]; + const mark = i < cur.index ? "✓" : (i === cur.index ? "→" : " "); + const tag = i === cur.index ? ` (${cur.suggestion?.skillId ?? "-"})${cur.emergency ? " [PAUSED]" : ""}` : ""; + console.log(` ${mark} ${(i + 1).toString().padStart(2)}. ${s.id.padEnd(20)} ${s.title}${tag}`); + } + console.log(""); + console.log(`Inventory keys: ${snap.inventory ? Object.keys(snap.inventory).slice(0, 12).join(", ") : "(empty)"}`); + console.log(`HP ${snap.health ?? "?"} / food ${snap.food ?? "?"} / day=${snap.isDay ? "yes" : "no"}`); +} + +main().catch((e) => { + console.error("ERROR:", e?.message ?? e); + process.exit(1); +}); diff --git a/tui/monitor.tsx b/tui/monitor.tsx new file mode 100644 index 0000000..546c57a --- /dev/null +++ b/tui/monitor.tsx @@ -0,0 +1,487 @@ +/** + * pepa monitor — fullscreen, read-only TUI. + * + * Replaces the old action-heavy tui/tui.tsx with a pure observability + * dashboard inspired by opencode's full-screen layout. No hotkeys to + * dispatch skills / send chat / approve proposals — operator does that + * via scripts/* or by writing to the IPC sock directly. This screen + * just shows what the bot is doing, in colour. + * + * Panels (top → bottom): + * 1. Header — connection / position / hp / food / time + * 2. Storyline — current step + the 11-step quest map + * 3. Activity — last N dispatches (colour-coded by outcome) + * + MC chat — last N lines + * 4. Advisor — last 6 LLM recommendations (trigger / outcome / tokens) + * + Improvements — open queue from improvement_requests + * 5. Footer — token usage today, advisor stats, q to quit + * + * Live data sources: + * - IPC sock (snapshot frames, log frames, chat frames) + * - SQLite (knowledge.db) polled every 5s for advisor + improvements + */ + +import React, { useEffect, useReducer, useState } from "react"; +import { render, Box, Text, useApp, useInput, useStdout } from "ink"; +import { createIpcClient } from "./ipc-client.js"; +import { EVENT_TYPES } from "../runtime/ipc-protocol.js"; +import { stateDir } from "../runtime/config.js"; +import { initKnowledge, isAvailable as knowledgeReady, recentRecommendations, listImprovements, recommendationStats } from "../runtime/knowledge/index.js"; + +// --- types ------------------------------------------------------------------ + +type LogEntry = { ts: string; level: string; source: string; text: string }; +type ChatEntry = { uid: number; ts: string; from: string; text: string; kind: string }; +type Snapshot = Record; +type Dispatch = { uid: number; ts: number; kind: "start" | "end"; label: string; ok?: boolean; code?: string; detail?: string }; + +let _uidSeq = 0; +function nextUid() { _uidSeq += 1; return _uidSeq; } + +type State = { + connectedIpc: boolean; + snapshot: Snapshot; + logs: LogEntry[]; + chat: ChatEntry[]; + dispatches: Dispatch[]; + startedAt: number; +}; + +type Action = + | { type: "ipc-connected" } + | { type: "ipc-disconnected" } + | { type: "snapshot"; payload: Snapshot } + | { type: "log"; payload: LogEntry } + | { type: "chat"; payload: { from: string; text: string; kind: string }; ts: string }; + +const MAX_LOGS = 200; +const MAX_CHAT = 50; +const MAX_DISPATCHES = 30; + +// --- reducer ---------------------------------------------------------------- + +function reducer(state: State, action: Action): State { + switch (action.type) { + case "ipc-connected": + return { ...state, connectedIpc: true }; + case "ipc-disconnected": + return { ...state, connectedIpc: false }; + case "snapshot": + return { ...state, snapshot: action.payload }; + case "log": { + const logs = [...state.logs, action.payload].slice(-MAX_LOGS); + // also derive a dispatches view: lines like "→ label" or "← label ok/fail (...)" + const dispatches = extractDispatch(state.dispatches, action.payload); + return { ...state, logs, dispatches }; + } + case "chat": { + const chat = [...state.chat, { uid: nextUid(), ts: action.ts, ...action.payload }].slice(-MAX_CHAT); + return { ...state, chat }; + } + default: + return state; + } +} + +function extractDispatch(prev: Dispatch[], log: LogEntry): Dispatch[] { + if (log.source !== "dispatch") return prev; + const ts = Date.parse(log.ts) || Date.now(); + // "→ label" — skill starting + const startMatch = /^→\s+(\S+)/.exec(log.text); + if (startMatch) { + return [...prev, { uid: nextUid(), ts, kind: "start", label: startMatch[1] }].slice(-MAX_DISPATCHES); + } + // "← label ok/fail (...)" — skill ending + const endMatch = /^←\s+(\S+)\s+(ok|fail)(?:\s+\((.*)\))?/.exec(log.text); + if (endMatch) { + const [, label, outcome, detail] = endMatch; + return [...prev, { uid: nextUid(), ts, kind: "end", label, ok: outcome === "ok", detail }].slice(-MAX_DISPATCHES); + } + return prev; +} + +// --- helpers ---------------------------------------------------------------- + +function formatAge(ts: number) { + const dt = Math.max(0, Date.now() - ts); + if (dt < 60_000) return `${Math.floor(dt / 1000)}s`; + if (dt < 3600_000) return `${Math.floor(dt / 60_000)}m`; + const h = Math.floor(dt / 3600_000); + const m = Math.floor((dt % 3600_000) / 60_000); + return `${h}h${m}m`; +} + +function formatDuration(ms: number) { + const s = Math.floor(ms / 1000) % 60; + const m = Math.floor(ms / 60_000) % 60; + const h = Math.floor(ms / 3600_000); + if (h > 0) return `${h}h${m.toString().padStart(2, "0")}m`; + if (m > 0) return `${m}m${s.toString().padStart(2, "0")}s`; + return `${s}s`; +} + +function shortTime(ts: number) { + const d = new Date(ts); + return `${d.getHours().toString().padStart(2, "0")}:${d.getMinutes().toString().padStart(2, "0")}:${d.getSeconds().toString().padStart(2, "0")}`; +} + +function hpColor(hp: number | undefined) { + if (hp == null) return "gray"; + if (hp <= 5) return "red"; + if (hp <= 12) return "yellow"; + return "green"; +} +function foodColor(food: number | undefined) { + if (food == null) return "gray"; + if (food <= 4) return "red"; + if (food <= 10) return "yellow"; + return "green"; +} + +// --- components ------------------------------------------------------------- + +function StatusHeader({ snapshot, connectedIpc, width, startedAt }: { snapshot: Snapshot; connectedIpc: boolean; width: number; startedAt: number }) { + const pos = snapshot.position + ? `(${Math.round(snapshot.position.x)},${Math.round(snapshot.position.y)},${Math.round(snapshot.position.z)})` + : "?"; + const hp = snapshot.health; + const food = snapshot.food; + const day = snapshot.isDay ? "☀" : "🌙"; + const session = formatDuration(Date.now() - startedAt); + const mcOnline = snapshot.connected; + const story = snapshot.storyStep; + const idx = story?.index ?? 0; + const cur = story?.step; + const want = story?.suggestion?.skillId; + // 11-step progress bar in 11 cells + const bar = Array.from({ length: 11 }, (_, i) => { + if (i < idx) return "▓"; + if (i === idx) return "▒"; + return "░"; + }).join(""); + return ( + + + pepa + · + {mcOnline ? "●MC" : "○MC"} + · + {connectedIpc ? "●IPC" : "○IPC"} + · + {session} + · + {snapshot.username ?? "?"} + · + {pos} + · + HP {hp ?? "?"} + · + food {food ?? "?"} + · + {day} + {(snapshot.hostileCount ?? 0) > 0 ? ( + <> + · + ⚔{snapshot.hostileCount} + {snapshot.closestHostile ? ({snapshot.closestHostile.name}@{Math.round(snapshot.closestHostile.distance)}b) : null} + + ) : null} + + + story + {bar} + + {idx + 1}/11 + + {cur ? ( + <> + {cur.id} + · + {cur.title} + + ) : (no story)} + {want ? ( + <> + + {want} + + ) : null} + {story?.emergency ? [EMERGENCY] : null} + + + ); +} + +function ActivityPanel({ dispatches, width, height }: { dispatches: Dispatch[]; width: number; height: number }) { + const visible = dispatches.slice(-height); + return ( + + Activity (last {visible.length}) + {visible.map((d) => { + if (d.kind === "start") { + return ( + + {shortTime(d.ts)} → {d.label} + + ); + } + const color = d.ok ? "green" : "red"; + const tail = d.detail ? ` (${String(d.detail).slice(0, 30)})` : ""; + return ( + + {shortTime(d.ts)} + ← {d.label} {d.ok ? "ok" : "fail"} + {tail} + + ); + })} + {visible.length === 0 ? (waiting for activity…) : null} + + ); +} + +function ChatPanel({ chat, width, height }: { chat: ChatEntry[]; width: number; height: number }) { + const visible = chat.slice(-height); + return ( + + MC Chat (last {visible.length}) + {visible.map((c) => ( + + {c.ts?.slice(11, 16) ?? ""} + + {c.from}: + + {c.text.slice(0, width - 12)} + + ))} + {visible.length === 0 ? (no chat yet…) : null} + + ); +} + +function AdvisorPanel({ recs, width, height }: { recs: any[]; width: number; height: number }) { + const visible = recs.slice(0, height); + return ( + + Advisor (last {visible.length}) + {visible.map((r) => { + const ok = r.outcome_ok; + const outcomeMark = ok == null ? "·" : ok ? "✓" : "✗"; + const outcomeColor = ok == null ? "gray" : ok ? "green" : "red"; + const target = r.recommended_skill ?? r.action; + const trigger = String(r.trigger_reason ?? "?"); + // One line per recommendation: " ✓ wedged_60s → survive.flee 802t 1900ms" + const lhs = `${outcomeMark} ${trigger} → ${target}`; + const rhs = `${r.tokens_in ?? "?"}t ${r.latency_ms ?? "?"}ms`; + const free = Math.max(20, width - rhs.length - 6); + return ( + + {outcomeMark} + {trigger} + + {String(target).slice(0, Math.max(8, free - trigger.length - 6))} + {rhs} + + ); + })} + {visible.length === 0 ? (no advisor calls yet) : null} + + ); +} + +function ImprovementsPanel({ items, width, height }: { items: any[]; width: number; height: number }) { + const visible = items.slice(0, height); + return ( + + Improvements (open {items.length}) + {visible.map((r) => ( + + #{r.id} + P{r.priority} + ×{r.votes} + {String(r.title).slice(0, Math.max(20, width - 14))} + + ))} + {visible.length === 0 ? (no improvement requests yet) : null} + + ); +} + +function Footer({ stats, width }: { stats: any[]; width: number }) { + const total = stats.reduce( + (acc, s) => ({ + calls: acc.calls + (s.total ?? 0), + succ: acc.succ + (s.succeeded ?? 0), + fail: acc.fail + (s.failed ?? 0), + in: acc.in + (s.avg_in ?? 0) * (s.total ?? 0), + out: acc.out + (s.avg_out ?? 0) * (s.total ?? 0), + }), + { calls: 0, succ: 0, fail: 0, in: 0, out: 0 }, + ); + const priceInRub = Number(process.env.TIMEWEB_PRICE_IN_RUB_PER_M) || 101; + const priceOutRub = Number(process.env.TIMEWEB_PRICE_OUT_RUB_PER_M) || 608; + const costRub = (total.in * priceInRub + total.out * priceOutRub) / 1_000_000; + return ( + + + Last 24h: + {total.calls} + advisor calls ( + {total.succ} ok + / + {total.fail} fail + ) tokens + {Math.round(total.in / 1000)}K + in / + {Math.round(total.out / 1000)}K + out ≈ + {costRub.toFixed(2)} ₽ + + + q — quit (bot keeps running) + + + ); +} + +// --- main app --------------------------------------------------------------- + +function App() { + const { exit } = useApp(); + const { stdout } = useStdout(); + // Sample stdout dimensions periodically. Subscribing directly to + // stdout.on('resize') from a React hook conflicts with ink's own + // listener and produces "MaxListenersExceededWarning" / reconciler + // errors. A 1s poll is cheap and good enough — terminals don't + // resize often. + const [cols, setCols] = useState((stdout as any)?.columns ?? 120); + const [rows, setRows] = useState((stdout as any)?.rows ?? 30); + useEffect(() => { + const t = setInterval(() => { + setCols((stdout as any)?.columns ?? 120); + setRows((stdout as any)?.rows ?? 30); + }, 1000); + return () => clearInterval(t); + }, [stdout]); + + const [state, dispatch] = useReducer(reducer, { + connectedIpc: false, + snapshot: {}, + logs: [], + chat: [], + dispatches: [], + startedAt: Date.now(), + }); + + const [client] = useState(() => createIpcClient()); + const [knowledgeOk, setKnowledgeOk] = useState(false); + const [recs, setRecs] = useState([]); + const [improvements, setImprovements] = useState([]); + const [stats, setStats] = useState([]); + + // IPC + useEffect(() => { + const onConnected = () => dispatch({ type: "ipc-connected" }); + const onDisconnected = () => dispatch({ type: "ipc-disconnected" }); + const onFrame = (frame: any) => { + switch (frame.type) { + case EVENT_TYPES.STATUS: dispatch({ type: "snapshot", payload: frame.payload }); break; + case EVENT_TYPES.LOG: dispatch({ type: "log", payload: frame.payload }); break; + case EVENT_TYPES.CHAT: dispatch({ type: "chat", payload: frame.payload, ts: frame.ts }); break; + case EVENT_TYPES.HELLO: + if (frame.payload?.snapshot) dispatch({ type: "snapshot", payload: frame.payload.snapshot }); + if (frame.payload?.recentLogs) { + for (const lg of frame.payload.recentLogs) dispatch({ type: "log", payload: lg }); + } + break; + } + }; + (client as any).on("connected", onConnected); + (client as any).on("disconnected", onDisconnected); + (client as any).on("frame", onFrame); + return () => { + (client as any).off("connected", onConnected); + (client as any).off("disconnected", onDisconnected); + (client as any).off("frame", onFrame); + client.close(); + }; + }, [client]); + + // Knowledge DB init + polling + useEffect(() => { + let mounted = true; + (async () => { + try { + await initKnowledge({ stateDir }); + if (!mounted) return; + setKnowledgeOk(knowledgeReady()); + } catch {} + })(); + const poll = () => { + if (!knowledgeReady()) return; + try { + setRecs(recentRecommendations({ limit: 12 })); + setImprovements(listImprovements({ status: "open", limit: 12 })); + setStats(recommendationStats({ sinceHours: 24 })); + } catch {} + }; + poll(); + const t = setInterval(poll, 5000); + return () => { mounted = false; clearInterval(t); }; + }, [knowledgeOk]); + + // useInput requires TTY raw mode; skip it when stdin isn't a TTY + // (e.g. when piped during a smoke test). Real `npm run tui` is always TTY. + const isTty = !!process.stdin.isTTY; + if (isTty) { + // eslint-disable-next-line react-hooks/rules-of-hooks + useInput((input, key) => { + if (input === "q" || (key.ctrl && input === "c") || key.escape) { + client.close(); + exit(); + } + }); + } + + // Layout math: compact 4-section vertical stack. + // row budget: + // header (story + status) ≈ 4 rows + // middle activity/chat ≈ floor((rows - 4 - 4 - 4) / 2) + // bottom advisor/improvements ≈ same + // footer ≈ 4 rows + const totalWidth = Math.max(80, cols); + const halfWidth = Math.floor(totalWidth / 2); + const totalRows = Math.max(20, rows); + const middleRows = Math.max(5, Math.floor((totalRows - 4 - 4 - 4) / 2)); + + return ( + + + + + + + + + + +