Operator reported the bot wandered the same 50×50 patch for 2 hours
without making any progress toward food. Diagnosis showed three root
causes; this commit addresses all five open improvement_requests
the LLM (postmortem + tuner) flagged automatically.
Research basis (`Voyager`, `Plan4MC`, `GITM`, `Mindcraft`):
- Coverage / commit-to-cardinal exploration when local scan fails
- Biome-aware strategy switching using a static affordance table
- Wedge detector above the skill layer that triggers RELOCATE not
RETRY (per-skill stuck checks reset on re-entry — useless)
- Time-in-region bbox heuristic + need-duration AND skill-cycle gate
Concrete changes:
1. `runtime/goal/storyline.js`
- orient_self.completed: added timeout fallback (HP=full + session
>120s → done) so barren biomes don't block the bot on step 1.
Closes improvement #2 'Нет навыка оценки когда сменить район'.
- first_food.suggestSkill: now picks survive.scout-food (new) when
no passive mob is nearby; falls back to survive.acquire-food only
when something is in immediate range.
2. `runtime/biome-affordances.js` (new)
- Static table: 40+ biomes → {has_passive_mobs, has_trees,
has_water, has_crops, livable}.
- Unknown biomes return optimistic defaults to avoid regressions.
- Closes improvement #1 'Нет навыка целевого поиска еды по биому'.
3. `runtime/skills/scout-food.js` (new — survive.scout-food)
- Tiered strategy: biome check → scan 32 → scan 64 → commit a
cardinal for 200 blocks rescanning every 16. On cardinal
exhaustion, returns code:"exhausted" so the curriculum can
escalate to village.relocate.
- In barren biomes (desert/ocean/snowy_plains) the scan is
SKIPPED — bot walks straight toward the nearest neighbour
biome that affords passive mobs (8-direction biome probe at
radius 64).
4. `runtime/awareness/wedge-detector.js` (new)
- Rolling 10-min position bbox tracker. observe() called every
tick; isWedged() returns true when bbox<50 AND active need
unmet >5min AND skill cycles ≥3.
- markRelocationStarted() suppresses further wedge firings
until the bot has displaced ≥200b — prevents stack overflow
of relocate calls.
- Lives ABOVE the skill layer (in runtime/reflex.js), because
any per-skill stuck check resets on re-entry.
5. `runtime/skills/relocate.js` (new — village.relocate)
- 300-block walk in least-recently-used cardinal (per-incident
memory in ctx.recentRelocations).
- Re-paths every 32 blocks, soft-tolerates pathfinder failures
(3 consecutive throws → exit with code:"stuck_in_place").
- Closes improvement #2 + #4 ('low success rate trigger').
6. `runtime/skills/escape-pit-safe.js` (new — recovery.escape-pit-safe)
- Surveys 4 cardinals AND ceiling height before committing.
Picks the direction with most open blocks (≥3, no lava).
Falls through to pillar-up only if ceiling clear ≥4b. Returns
code:"no_strategy" if both blocked so curriculum can escalate
to relocate.
- Closes improvement #3 'Нет навыка для безопасного выхода'.
7. `runtime/reflex.js`
- Wedge detector wired before manifesto/storyline. If wedge.wedged
is true, dispatches village.relocate directly and returns —
bypasses every other branch.
- ctx.disableWedge flag for tests.
8. `runtime/coach/advisor-trigger.js`
- LLM provider outage backoff: 3 consecutive http_400 / timeout /
network_error → suppress advisor for 10 min. Today's TimeWeb
gpt-5.4-mini was 400'ing for an hour straight; we were spending
trigger budget on dead calls. Closes improvement implicit gap
in #5.
9. `runtime/bot.js`
- Tracks botSpawnedAt; snapshot._sessionMs exposed for storyline
orient_self timeout fallback.
Tests: 396 green (was 378, +18):
- runtime/biome-affordances.test.js — 8 tests
- runtime/awareness/wedge-detector.test.js — 9 tests
- runtime/goal/storyline.test.js — 1 new test (orient_self timeout)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
176 lines
6.3 KiB
JavaScript
176 lines
6.3 KiB
JavaScript
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("orient_self: barren biome timeout fallback completes step after 120s", () => {
|
|
const n = getStep("orient_self");
|
|
// path (a): full HP + no visible blocks + short session → NOT done (would block).
|
|
assert.equal(n.completed(snap({ _sessionMs: 30_000, nearbyBlocks: {} })), false);
|
|
// path (b): full HP + no visible blocks + long session → done (timeout fallback).
|
|
assert.equal(n.completed(snap({ _sessionMs: 150_000, nearbyBlocks: {} })), true);
|
|
// always: low HP → not done
|
|
assert.equal(n.completed(snap({ health: 8, _sessionMs: 150_000 })), false);
|
|
});
|
|
|
|
test("pickCurrentStep: bot with 8+ logs → first_wood done, picks crafting_basics", () => {
|
|
_resetForTest();
|
|
const r = pickCurrentStep(snap({
|
|
_sessionMs: 60_000,
|
|
nearbyBlocks: { logs: 3 },
|
|
inventory: { oak_log: 10 },
|
|
}));
|
|
assert.ok(r);
|
|
assert.equal(r.step.id, "crafting_basics");
|
|
assert.equal(r.completedSteps, 2, "orient_self + first_wood done");
|
|
});
|
|
|
|
test("pickCurrentStep: 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/);
|
|
});
|