From 7f545723b5b3a5d25fb9cafc088ea4ce9312e185 Mon Sep 17 00:00:00 2001 From: Yuriy Mayatnikov Date: Wed, 27 May 2026 20:36:17 +0300 Subject: [PATCH] =?UTF-8?q?feat(v0.3.1):=20storyline=20=E2=80=94=20canonic?= =?UTF-8?q?al=20Minecraft=20survival=20quest?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- package.json | 2 +- runtime/bot.js | 5 + runtime/coach/advisor-trigger.js | 5 +- runtime/coach/fast-advisor.js | 11 +- runtime/coach/reflect.js | 10 +- runtime/goal/state.js | 88 +++++++++ runtime/goal/storyline.js | 320 +++++++++++++++++++++++++++++++ runtime/goal/storyline.test.js | 165 ++++++++++++++++ runtime/persona/chatter.js | 20 +- runtime/reflex.js | 26 ++- runtime/reflex.test.js | 2 + scripts/show-story.js | 94 +++++++++ 12 files changed, 733 insertions(+), 15 deletions(-) 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 scripts/show-story.js diff --git a/package.json b/package.json index b8a23ed..d5e48d1 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "tui": "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/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/bot.js b/runtime/bot.js index cbb9887..ce06980 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"); @@ -844,6 +845,10 @@ function tick() { } const curriculumEarly = nextCurriculumMilestone(lastSnapshot); lastSnapshot.curriculum = curriculumEarly; + // 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); diff --git a/runtime/coach/advisor-trigger.js b/runtime/coach/advisor-trigger.js index 0520ff6..e8caf65 100644 --- a/runtime/coach/advisor-trigger.js +++ b/runtime/coach/advisor-trigger.js @@ -85,10 +85,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 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/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..ca70a8e --- /dev/null +++ b/runtime/goal/storyline.js @@ -0,0 +1,320 @@ +// 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", +]; + +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 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; + // Considered done once HP is full and we've moved a bit (out of + // spawn confusion) or we know nearby blocks include something tangible. + const hp = snap.health ?? 20; + const moved = snap._sessionMs ? snap._sessionMs > 15_000 : true; + const sawBlocks = (snap.nearbyBlocks?.logs ?? 0) + + (snap.nearbyBlocks?.stone ?? 0) + + (snap.nearbyBlocks?.crops ?? 0) + + (snap.nearbyBlocks?.beds ?? 0) + > 0; + return hp >= 18 && moved && sawBlocks; + }, + 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: "Собрать 8 поленьев", + narration_ru: "Цель: 8 поленьев. Иду рубить ближайшие деревья.", + completed(snap) { + return countLogs(snap?.inventory) >= 8; + }, + suggestSkill(snap) { + const trees = snap?.nearbyBlocks?.logs ?? 0; + if (trees > 0) return { skillId: "gather.logs" }; + // No tree in sight — scout further. + return { skillId: "explore.far", args: { searchFor: "logs" } }; + }, + emergencyPause, + }, + + { + id: "crafting_basics", + title: "Сделать верстак и палки", + narration_ru: "Делаю верстак и палки — без них ничего не скрафтить.", + completed(snap) { + const inv = snap?.inventory ?? {}; + return (inv.crafting_table ?? 0) > 0 + && (inv.stick ?? 0) >= 2 + && countPlanks(inv) >= 4; + }, + suggestSkill(snap) { + const inv = snap?.inventory ?? {}; + if (countPlanks(inv) < 4) return { skillId: "craft.planks" }; + if ((inv.stick ?? 0) < 2) return { skillId: "craft.sticks" }; + // We have raw materials, need to *place* a crafting table for tools. + // (No place-table skill yet — flagged as improvement_request elsewhere.) + return { skillId: "craft.sticks" }; + }, + 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 (!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) { + return countAny(snap?.inventory, FOOD_ITEMS) >= 2; + }, + suggestSkill(snap) { + return { skillId: "survive.acquire-food" }; + }, + emergencyPause, + }, + + { + id: "shelter_minimal", + title: "Простой шелтер с кроватью", + narration_ru: "Поставлю кровать и стены — пережить ночь.", + completed(snap) { + return (snap?.nearbyBlocks?.beds ?? 0) > 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 && (snap?.nearbyBlocks?.crops ?? 0) > 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) { + const nb = snap?.nearbyBlocks ?? {}; + return (nb.beds ?? 0) >= 1 && (nb.storage ?? 0) >= 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, +}; diff --git a/runtime/goal/storyline.test.js b/runtime/goal/storyline.test.js new file mode 100644 index 0000000..24c3c40 --- /dev/null +++ b/runtime/goal/storyline.test.js @@ -0,0 +1,165 @@ +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, "Собрать 8 поленьев"); + 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 ≥8 logs", () => { + const s = getStep("first_wood"); + assert.equal(s.completed(snap()), false); + assert.equal(s.completed(snap({ inventory: { oak_log: 8 } })), true); + assert.equal(s.completed(snap({ inventory: { oak_log: 4, birch_log: 4 } })), 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()).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", () => { + const s = getStep("first_food"); + assert.equal(s.completed(snap()), false); + assert.equal(s.completed(snap({ inventory: { bread: 2 } })), true); +}); + +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); +}); + +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("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: 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/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..e386615 100644 --- a/runtime/reflex.js +++ b/runtime/reflex.js @@ -29,6 +29,7 @@ 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 { situationHash } from "./scenario-memory.js"; import { tickModes } from "./modes.js"; @@ -457,6 +458,15 @@ function curriculumReflex(ctx) { ctx.activeNeed = activeNeed; } const manifestoSkillId = activeNeed?.skillId ?? null; + + // 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(); @@ -495,7 +505,7 @@ function curriculumReflex(ctx) { // 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 +540,16 @@ 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: manifesto > storyline > curriculum. + // Manifesto is highest because L0 alive emergencies (lava, low-HP + + // hostile) must override any narrative aspiration. Storyline beats + // curriculum because it expresses a concrete operational subgoal, + // not just "next milestone". + let skillId = manifestoSkillId ?? storySkillId ?? plan.skillId; + let skillSource = manifestoSkillId + ? `manifesto:${activeNeed.need.id}` + : storySkillId ? `storyline:${storyStep.step.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. diff --git a/runtime/reflex.test.js b/runtime/reflex.test.js index 8b255b7..2fb8838 100644 --- a/runtime/reflex.test.js +++ b/runtime/reflex.test.js @@ -51,6 +51,7 @@ 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 } = {}) { const dispatches = []; const ctx = { @@ -65,6 +66,7 @@ function makeCtx({ metrics, disableManifesto, disableAdvisor, + disableStoryline, dispatch(fn, label, opts = {}) { dispatches.push({ fn, label, opts }); }, diff --git a/scripts/show-story.js b/scripts/show-story.js new file mode 100644 index 0000000..282e223 --- /dev/null +++ b/scripts/show-story.js @@ -0,0 +1,94 @@ +#!/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"; + +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({ kind: "get-status" }) + "\n"); + }); + sock.on("data", (chunk) => buf.push(chunk)); + sock.on("end", () => { + clearTimeout(timer); + 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?.kind === "status" && obj?.snapshot) { + resolve(obj.snapshot); + return; + } + } + resolve(null); + } catch { 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); +});