feat(v0.3.1): storyline — canonical Minecraft survival quest
The bot has been stuck in a loop for two days:
acquire-food (fail: no nearby food) → explore.far → pillar-up (fail) → repeat
Diagnosis: manifesto + LLM advisor both correctly identify "you need
food" but neither expresses *what concretely to do next*. Manifesto is
a priority ladder (need-detection), not a narrative arc.
This commit adds the missing narrative layer — an ordered list of
operational steps that mirror the vanilla Minecraft survival path:
1. orient_self — Понять где я
2. first_wood — Собрать 8 поленьев
3. crafting_basics — Сделать верстак и палки
4. first_tools — Деревянные орудия
5. first_food — Найти первую еду
6. shelter_minimal — Простой шелтер с кроватью
7. stone_tier — Каменные орудия
8. food_security — Запас еды на 16+
9. iron_age — Железо и печь
10. settle_base — Постоянная база
11. village_grow — Развивать деревню (ongoing)
Each step has:
- completed(snapshot) → bool — detects achievement from snapshot
- suggestSkill(snapshot) → { skillId, args? } — concrete next dispatch
- emergencyPause(snapshot) → bool — defers to manifesto L0 alive
emergencies (low HP near hostile, lava under foot, food = 0)
- narration_ru — chat-friendly Russian one-liner spoken on entry
Components:
- runtime/goal/storyline.js — 11-step canonical quest catalogue
- runtime/goal/state.js — pickCurrentStep(snapshot) walks the list,
returns first non-completed step + its suggestion. 3s cache.
Validates suggestSkill's skillId against the live registry.
- runtime/reflex.js — curriculumReflex dispatch priority is now:
1. manifesto (L0 alive emergencies always win)
2. storyline (concrete operational subgoal)
3. curriculum plan (legacy fallback)
Tests pass ctx.disableStoryline=true for isolation.
- runtime/bot.js — snapshot.storyStep populated each tick so
chatter/advisor/reflect observers see the same view.
- runtime/coach/fast-advisor.js — buildUserPrompt now embeds the
current step + its suggested skill, so LLM advice is anchored
("step 5 first_food, storyline wants survive.acquire-food, but
recent dispatches show it's failing — try explore.far + scout").
- runtime/coach/advisor-trigger.js — forwards ctx.storyStep into
advise() and logs step id at trigger time.
- runtime/coach/reflect.js — reflection prompt includes storyline
progress so 30-min self-assessment is anchored.
- runtime/persona/chatter.js — narrates step.narration_ru on
transition. Rate-limited via existing maybeNarrateRaw().
New operator CLI:
- scripts/show-story.js — fetches the live snapshot via IPC sock and
prints step progress with ✓/→/ markers, current skill, inventory.
Falls back to --plain catalogue view when bot offline.
Token cost impact: ~+30 input tokens per advise() call (one extra
line in user prompt). Trivial vs the value of grounding LLM advice
in a concrete narrative.
Operator usage:
node scripts/show-story.js # live progress + which step + why
node scripts/show-story.js --plain # static catalogue of all 11 steps
Tests: 376 green (was 360, +16 storyline tests).
Also in this branch (already committed): dev/v0.3.1/PRD.md —
LLM prompt cost optimization design doc.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
+1
-1
@@ -16,7 +16,7 @@
|
|||||||
"tui": "tsx tui/tui.tsx",
|
"tui": "tsx tui/tui.tsx",
|
||||||
"propose:apply": "node scripts/propose-apply.js",
|
"propose:apply": "node scripts/propose-apply.js",
|
||||||
"stop": "bash scripts/stop.sh",
|
"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": {
|
"dependencies": {
|
||||||
"better-sqlite3": "^11.10.0",
|
"better-sqlite3": "^11.10.0",
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ import { attach as attachReflect } from "./coach/reflect.js";
|
|||||||
import { attach as attachTuner } from "./coach/trigger-tuner.js";
|
import { attach as attachTuner } from "./coach/trigger-tuner.js";
|
||||||
import { attach as attachChatter } from "./persona/chatter.js";
|
import { attach as attachChatter } from "./persona/chatter.js";
|
||||||
import { attachAwareness } from "./awareness/events.js";
|
import { attachAwareness } from "./awareness/events.js";
|
||||||
|
import { pickCurrentStep } from "./goal/state.js";
|
||||||
|
|
||||||
fs.mkdirSync(stateDir, { recursive: true });
|
fs.mkdirSync(stateDir, { recursive: true });
|
||||||
const JOINED_FLAG = path.join(stateDir, "joined-before.flag");
|
const JOINED_FLAG = path.join(stateDir, "joined-before.flag");
|
||||||
@@ -844,6 +845,10 @@ function tick() {
|
|||||||
}
|
}
|
||||||
const curriculumEarly = nextCurriculumMilestone(lastSnapshot);
|
const curriculumEarly = nextCurriculumMilestone(lastSnapshot);
|
||||||
lastSnapshot.curriculum = curriculumEarly;
|
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;
|
reflexCtx.snapshot = lastSnapshot;
|
||||||
if (!reflexPaused) {
|
if (!reflexPaused) {
|
||||||
const result = runTick(reflexCtx);
|
const result = runTick(reflexCtx);
|
||||||
|
|||||||
@@ -85,10 +85,11 @@ export function tickAdvisor(ctx, { plannedSkillId } = {}) {
|
|||||||
const snapshot = ctx.snapshot ?? null;
|
const snapshot = ctx.snapshot ?? null;
|
||||||
const recentSkillIds = (ctx.recentSkillIds ?? []).slice(-8);
|
const recentSkillIds = (ctx.recentSkillIds ?? []).slice(-8);
|
||||||
const activeNeed = ctx.activeNeed ?? null;
|
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.
|
// 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) => {
|
.then((result) => {
|
||||||
_inFlight = false;
|
_inFlight = false;
|
||||||
const needLabel = activeNeed
|
const needLabel = activeNeed
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ export async function advise({
|
|||||||
recentSkillIds = [],
|
recentSkillIds = [],
|
||||||
lessonsTail = [],
|
lessonsTail = [],
|
||||||
activeNeed = null,
|
activeNeed = null,
|
||||||
|
storyStep = null,
|
||||||
force = false,
|
force = false,
|
||||||
} = {}) {
|
} = {}) {
|
||||||
if (!isAvailable()) {
|
if (!isAvailable()) {
|
||||||
@@ -83,7 +84,7 @@ export async function advise({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const system = buildSystemPrompt();
|
const system = buildSystemPrompt();
|
||||||
const user = buildUserPrompt({ snapshot, reason, recentSkillIds, lessonsTail, activeNeed });
|
const user = buildUserPrompt({ snapshot, reason, recentSkillIds, lessonsTail, activeNeed, storyStep });
|
||||||
|
|
||||||
_callTimes.push(now);
|
_callTimes.push(now);
|
||||||
_lastCallAt = now;
|
_lastCallAt = now;
|
||||||
@@ -164,7 +165,7 @@ function buildSystemPrompt() {
|
|||||||
].join("\n");
|
].join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildUserPrompt({ snapshot, reason, recentSkillIds, lessonsTail, activeNeed }) {
|
function buildUserPrompt({ snapshot, reason, recentSkillIds, lessonsTail, activeNeed, storyStep }) {
|
||||||
const pos = snapshot?.position;
|
const pos = snapshot?.position;
|
||||||
const inv = snapshot?.inventory ? Object.keys(snapshot.inventory).slice(0, 10).join(", ") : "(empty)";
|
const inv = snapshot?.inventory ? Object.keys(snapshot.inventory).slice(0, 10).join(", ") : "(empty)";
|
||||||
const recent = (recentSkillIds ?? []).slice(-8).join(" → ") || "(none)";
|
const recent = (recentSkillIds ?? []).slice(-8).join(" → ") || "(none)";
|
||||||
@@ -172,6 +173,9 @@ function buildUserPrompt({ snapshot, reason, recentSkillIds, lessonsTail, active
|
|||||||
const needLine = activeNeed
|
const needLine = activeNeed
|
||||||
? `L${activeNeed.need.level} ${activeNeed.need.id} (${activeNeed.need.title}) — manifesto wants ${activeNeed.skillId}`
|
? `L${activeNeed.need.level} ${activeNeed.need.id} (${activeNeed.need.title}) — manifesto wants ${activeNeed.skillId}`
|
||||||
: "(no active need)";
|
: "(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
|
const hostile = snapshot?.closestHostile
|
||||||
? `${snapshot.closestHostile.name}@${snapshot.closestHostile.distance}b`
|
? `${snapshot.closestHostile.name}@${snapshot.closestHostile.distance}b`
|
||||||
: "(none)";
|
: "(none)";
|
||||||
@@ -180,6 +184,7 @@ function buildUserPrompt({ snapshot, reason, recentSkillIds, lessonsTail, active
|
|||||||
`Trigger: ${reason}`,
|
`Trigger: ${reason}`,
|
||||||
`Position: ${pos ? `(${Math.round(pos.x)}, ${Math.round(pos.y)}, ${Math.round(pos.z)})` : "?"}`,
|
`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"}`,
|
`HP: ${snapshot?.health ?? "?"} food: ${snapshot?.food ?? "?"} day: ${snapshot?.isDay ? "yes" : "no"}`,
|
||||||
|
`Storyline progress: ${storyLine}`,
|
||||||
`Active need (Maslow ladder): ${needLine}`,
|
`Active need (Maslow ladder): ${needLine}`,
|
||||||
`Closest hostile: ${hostile}`,
|
`Closest hostile: ${hostile}`,
|
||||||
`Active skill: ${snapshot?.activeSkill ?? "(idle)"}`,
|
`Active skill: ${snapshot?.activeSkill ?? "(idle)"}`,
|
||||||
@@ -190,7 +195,7 @@ function buildUserPrompt({ snapshot, reason, recentSkillIds, lessonsTail, active
|
|||||||
"",
|
"",
|
||||||
lessons ? `Relevant lessons:\n${lessons}\n` : "",
|
lessons ? `Relevant lessons:\n${lessons}\n` : "",
|
||||||
"What should the bot do RIGHT NOW? Return the JSON decision.",
|
"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");
|
].filter(Boolean).join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { resolve } from "node:path";
|
|||||||
import { isAvailable as knowledgeAvailable, record as recordLesson, createImprovementRequest } from "../knowledge/index.js";
|
import { isAvailable as knowledgeAvailable, record as recordLesson, createImprovementRequest } from "../knowledge/index.js";
|
||||||
import { isRegistered, skillRegistryPrompt } from "../skill-registry.js";
|
import { isRegistered, skillRegistryPrompt } from "../skill-registry.js";
|
||||||
import { pickActiveNeed } from "../manifesto/state.js";
|
import { pickActiveNeed } from "../manifesto/state.js";
|
||||||
|
import { pickCurrentStep } from "../goal/state.js";
|
||||||
import { isAvailable as llmAvailable } from "../llm/provider.js";
|
import { isAvailable as llmAvailable } from "../llm/provider.js";
|
||||||
import { askAnalytical } from "./llm-call.js";
|
import { askAnalytical } from "./llm-call.js";
|
||||||
import { info, warn } from "../log.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 diary = readDiaryTail(stateDir);
|
||||||
const plan = readPlan(stateDir);
|
const plan = readPlan(stateDir);
|
||||||
const activeNeed = pickActiveNeed(snap);
|
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);
|
_llmCallTimes.push(now);
|
||||||
|
|
||||||
const parsed = await askAnalyticalFn({ system, user, json: true });
|
const parsed = await askAnalyticalFn({ system, user, json: true });
|
||||||
@@ -172,13 +174,16 @@ function readPlan(stateDir) {
|
|||||||
try { return readFileSync(f, "utf8"); } catch { return ""; }
|
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 pos = snap?.position;
|
||||||
const inv = snap?.inventory ? Object.keys(snap.inventory).slice(0, 12).join(", ") : "(empty)";
|
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 lastResult = snap?.lastResult ? JSON.stringify(snap.lastResult).slice(0, 200) : "(none)";
|
||||||
const needLine = activeNeed
|
const needLine = activeNeed
|
||||||
? `L${activeNeed.need.level} ${activeNeed.need.id} → ${activeNeed.skillId} (${activeNeed.need.title})`
|
? `L${activeNeed.need.level} ${activeNeed.need.id} → ${activeNeed.skillId} (${activeNeed.need.title})`
|
||||||
: "(satisfied through L10 / no active need)";
|
: "(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 = [
|
const system = [
|
||||||
"You are pepa, an autonomous Minecraft survival bot, reflecting on your own progress.",
|
"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"}`,
|
`- hp: ${snap?.health ?? "?"} food: ${snap?.food ?? "?"} day: ${snap?.isDay ? "yes" : "no"}`,
|
||||||
`- runtimeState: ${snap?.runtimeState ?? "?"}`,
|
`- runtimeState: ${snap?.runtimeState ?? "?"}`,
|
||||||
`- activeSkill: ${snap?.activeSkill ?? "(idle)"}`,
|
`- activeSkill: ${snap?.activeSkill ?? "(idle)"}`,
|
||||||
|
`- storylineStep: ${storyLine}`,
|
||||||
`- activeNeed (Maslow ladder L0-L10): ${needLine}`,
|
`- activeNeed (Maslow ladder L0-L10): ${needLine}`,
|
||||||
`- currentMilestone: ${snap?.currentMilestone ?? "?"}`,
|
`- currentMilestone: ${snap?.currentMilestone ?? "?"}`,
|
||||||
`- noProgressReason: ${snap?.noProgressReason ?? "(none)"}`,
|
`- noProgressReason: ${snap?.noProgressReason ?? "(none)"}`,
|
||||||
|
|||||||
@@ -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 };
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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/);
|
||||||
|
});
|
||||||
@@ -94,6 +94,7 @@ let _last = {
|
|||||||
threatHostile: null,
|
threatHostile: null,
|
||||||
dayPart: null,
|
dayPart: null,
|
||||||
noProgressReason: null,
|
noProgressReason: null,
|
||||||
|
storyStepId: null,
|
||||||
};
|
};
|
||||||
let _lastNarrationAt = 0;
|
let _lastNarrationAt = 0;
|
||||||
let _narrationTimes = [];
|
let _narrationTimes = [];
|
||||||
@@ -164,6 +165,15 @@ function tick() {
|
|||||||
_last.noProgressReason = snap.noProgressReason;
|
_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
|
// 5. Milestone done — fires when activeSkill flips to noop and lastResult.ok
|
||||||
const last = snap.lastResult;
|
const last = snap.lastResult;
|
||||||
if (last?.ok && last?.code === "done") {
|
if (last?.ok && last?.code === "done") {
|
||||||
@@ -200,13 +210,19 @@ function inferDayPart(snap) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function maybeNarrate(key) {
|
function maybeNarrate(key) {
|
||||||
|
const line = pickLine(key);
|
||||||
|
if (!line) return;
|
||||||
|
maybeNarrateRaw(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
function maybeNarrateRaw(line) {
|
||||||
|
if (!line) return;
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const hourAgo = now - 3600_000;
|
const hourAgo = now - 3600_000;
|
||||||
_narrationTimes = _narrationTimes.filter((t) => t > hourAgo);
|
_narrationTimes = _narrationTimes.filter((t) => t > hourAgo);
|
||||||
if (now - _lastNarrationAt < MIN_GAP_MS) return;
|
if (now - _lastNarrationAt < MIN_GAP_MS) return;
|
||||||
if (_narrationTimes.length >= MAX_PER_HOUR) return;
|
if (_narrationTimes.length >= MAX_PER_HOUR) return;
|
||||||
const line = pickLine(key);
|
if (line === _lastTemplate) return;
|
||||||
if (!line) return;
|
|
||||||
const ok = sendChat(line);
|
const ok = sendChat(line);
|
||||||
if (ok) {
|
if (ok) {
|
||||||
_lastNarrationAt = now;
|
_lastNarrationAt = now;
|
||||||
|
|||||||
+21
-5
@@ -29,6 +29,7 @@ import { consult as consultAdvice, reportOutcome as reportAdviceOutcome } from "
|
|||||||
import { tickAdvisor, consumeFreshRecommendation } from "./coach/advisor-trigger.js";
|
import { tickAdvisor, consumeFreshRecommendation } from "./coach/advisor-trigger.js";
|
||||||
import { markRecommendationApplied, markRecommendationOutcome } from "./knowledge/index.js";
|
import { markRecommendationApplied, markRecommendationOutcome } from "./knowledge/index.js";
|
||||||
import { pickActiveNeed } from "./manifesto/state.js";
|
import { pickActiveNeed } from "./manifesto/state.js";
|
||||||
|
import { pickCurrentStep } from "./goal/state.js";
|
||||||
import { situationHash } from "./scenario-memory.js";
|
import { situationHash } from "./scenario-memory.js";
|
||||||
import { tickModes } from "./modes.js";
|
import { tickModes } from "./modes.js";
|
||||||
|
|
||||||
@@ -457,6 +458,15 @@ function curriculumReflex(ctx) {
|
|||||||
ctx.activeNeed = activeNeed;
|
ctx.activeNeed = activeNeed;
|
||||||
}
|
}
|
||||||
const manifestoSkillId = activeNeed?.skillId ?? null;
|
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);
|
const metricRecovery = metricRecoverySkill(ctx, plan?.skillId);
|
||||||
if (metricRecovery) {
|
if (metricRecovery) {
|
||||||
ctx.lastCurriculumAt = Date.now();
|
ctx.lastCurriculumAt = Date.now();
|
||||||
@@ -495,7 +505,7 @@ function curriculumReflex(ctx) {
|
|||||||
// First hint → small wander (might just be 32-block reach issue).
|
// First hint → small wander (might just be 32-block reach issue).
|
||||||
// Every subsequent hint while still inside the backoff window → use
|
// Every subsequent hint while still inside the backoff window → use
|
||||||
// explore.far so the bot actually leaves the patch it's stuck in.
|
// 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();
|
ctx.lastCurriculumAt = Date.now();
|
||||||
const fallbackId = wantWander && consecutiveWanderHints >= 1 ? "explore.far" : "wander";
|
const fallbackId = wantWander && consecutiveWanderHints >= 1 ? "explore.far" : "wander";
|
||||||
// v0.2.0-rc.3 — consult advice on the FALLBACK dispatch too. Without
|
// 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" };
|
return { action: "dispatched", kind: "curriculum-wander", label: "wander" };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pick what to dispatch: manifesto wins over curriculum plan because
|
// Pick what to dispatch. Order: manifesto > storyline > curriculum.
|
||||||
// it expresses concrete needs rather than abstract "next milestone".
|
// Manifesto is highest because L0 alive emergencies (lava, low-HP +
|
||||||
let skillId = manifestoSkillId ?? plan.skillId;
|
// hostile) must override any narrative aspiration. Storyline beats
|
||||||
let skillSource = manifestoSkillId ? `manifesto:${activeNeed.need.id}` : "curriculum";
|
// 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
|
// 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.
|
// (the result of a previous tick's async advise() call), use it.
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ function makeCtx({
|
|||||||
// runtime/manifesto/state.test.js separately.
|
// runtime/manifesto/state.test.js separately.
|
||||||
disableAdvisor = true, // advisor-trigger fires real async LLM calls,
|
disableAdvisor = true, // advisor-trigger fires real async LLM calls,
|
||||||
// tested directly in advisor-trigger.test.js.
|
// tested directly in advisor-trigger.test.js.
|
||||||
|
disableStoryline = true, // storyline tested in goal/storyline.test.js
|
||||||
} = {}) {
|
} = {}) {
|
||||||
const dispatches = [];
|
const dispatches = [];
|
||||||
const ctx = {
|
const ctx = {
|
||||||
@@ -65,6 +66,7 @@ function makeCtx({
|
|||||||
metrics,
|
metrics,
|
||||||
disableManifesto,
|
disableManifesto,
|
||||||
disableAdvisor,
|
disableAdvisor,
|
||||||
|
disableStoryline,
|
||||||
dispatch(fn, label, opts = {}) {
|
dispatch(fn, label, opts = {}) {
|
||||||
dispatches.push({ fn, label, opts });
|
dispatches.push({ fn, label, opts });
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user