v0.3.1: survival behaviour overhaul — storyline, biome-aware scout, wedge-relocate, food/perf fixes, monitor TUI #28

Merged
halofourteen merged 9 commits from v0.3.1 into main 2026-05-28 09:43:57 +03:00
12 changed files with 733 additions and 15 deletions
Showing only changes of commit 7f545723b5 - Show all commits
+1 -1
View File
@@ -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",
+5
View File
@@ -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);
+3 -2
View File
@@ -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
+8 -3
View File
@@ -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");
} }
+8 -2
View File
@@ -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)"}`,
+88
View File
@@ -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 };
+320
View File
@@ -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,
};
+165
View File
@@ -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/);
});
+18 -2
View File
@@ -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
View File
@@ -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.
+2
View File
@@ -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 });
}, },
+94
View File
@@ -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);
});