feat(runtime): compatibility hardening (Phase 7) (#18)

Phase 7 of plans/autonomous-survival-bot-prd.md. Five small modules
that close the recurring "shared state" and "version-pinned list"
failure modes the PRD flags in §7 and §5.4.

New:
- runtime/movement-profiles.js: named profiles (GATHER, TRAVEL, FLEE,
  BUILD, RETURN_TO_BASE) as pure descriptors via PROFILE_DEFAULTS,
  plus applyProfile(profile, bot) that hands a fresh Movements to
  pathfinder. Avoids the "flee left canDig=false on the shared
  Movements, next chop got stuck in canopy" regression.
- runtime/owned-blocks.js: JSONL ledger of blocks this bot placed/
  removed (state/<host>/owned-blocks.jsonl); isOwned({x,y,z}) for
  O(1) lookups; ensureDir() makes the parent dir lazily.
- runtime/claim-avoidance.js: classifyArea({blocks, isOwned}) returns
  player_build / natural_or_owned / insufficient_data based on
  man-made block density vs ownership ratio; shouldAvoid(area) helper.
  Designed for gather/place skills to call before touching contested
  area.
- runtime/skills/compat.test.js: runs runtime/skills/groups.js against
  real minecraft-data registries for 1.18.2, 1.20.4, 1.21.5; spot-
  checks that pale_oak_log only appears on 1.21+ etc.
- runtime/compat.test.js: 10 tests covering movement descriptors,
  isManMadeBlockName, classifyArea, owned-blocks markPlaced/dedup/
  isOwned/markRemoved.

npm test now 79/79.

Co-authored-by: Yuriy Mayatnikov <mayatnikov@me.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit was merged in pull request #18.
This commit is contained in:
Yuriy Mayatnikov
2026-05-25 22:39:38 +03:00
committed by GitHub
co-authored by mayatnikov Claude Opus 4.7
parent c7eab06f22
commit d2e52a1b79
7 changed files with 457 additions and 1 deletions
+22
View File
@@ -246,6 +246,28 @@ in-process. The package is **not** a default dep — install it explicitly
(`npm i prismarine-viewer`) before enabling. If missing, the runtime
logs a warning and continues.
### Compatibility hardening (Phase 7)
Several modules now guard against the live regressions PRD §7 Phase 7
explicitly calls out:
- **`runtime/movement-profiles.js`** — named profiles (`GATHER`,
`TRAVEL`, `FLEE`, `BUILD`, `RETURN_TO_BASE`) as pure descriptors;
`applyProfile(profile, bot)` writes a fresh `Movements` to
pathfinder. Stops one skill's `canDig=false` from leaking into the
next skill's path.
- **`runtime/owned-blocks.js`** — JSONL ledger of blocks this bot
placed (and removed). `isOwned({x,y,z})` for O(1) lookups.
`ensureDir()` makes the dir lazily so first-write doesn't fail.
- **`runtime/claim-avoidance.js`** — `classifyArea({blocks, isOwned})`
returns `player_build` / `natural_or_owned` / `insufficient_data`
based on man-made block density vs ownership ratio. Designed for
the gather/place skills to call before touching a contested area.
- **`runtime/skills/compat.test.js`** — runs `groups.js` against real
`minecraft-data` registries for 1.18.2, 1.20.4, 1.21.5; verifies
that version-sensitive blocks (e.g. `pale_oak_log`) only appear
where they should.
### Self-improvement v2 (Phase 6)
Two classes of proposals now land in `state/<host>/proposals/`:
+1 -1
View File
@@ -16,7 +16,7 @@
"tui": "tsx tui/tui.tsx",
"propose:apply": "node scripts/propose-apply.js",
"stop": "bash scripts/stop.sh",
"test": "node --test runtime/skills/contract.test.js runtime/skills/groups.test.js runtime/curriculum.test.js runtime/social/social.test.js runtime/stuck-incident.test.js"
"test": "node --test runtime/skills/contract.test.js runtime/skills/groups.test.js runtime/skills/compat.test.js runtime/curriculum.test.js runtime/social/social.test.js runtime/stuck-incident.test.js runtime/compat.test.js"
},
"dependencies": {
"dotenv": "^16.4.5",
+94
View File
@@ -0,0 +1,94 @@
// "Does this look like a player build?" heuristic. Pure function;
// caller passes a list of nearby blocks and an isOwned predicate.
//
// Approach (intentionally conservative — false positives are fine):
// * Count the man-made blocks within the test set. Man-made =
// processed wood, stone bricks, smooth stone, glass, wool, etc.
// Things that don't naturally generate without effort.
// * Count owned-by-this-bot blocks.
// * If man-made density is high AND the area is not predominantly
// owned by this bot → flag as player_build. The skill code
// should refuse to dig/place there.
//
// We don't try to detect ages, signs, or claim plugins — heuristics
// only. The cost of false positives is "bot wandered around a
// rectangular hut", which is fine; the cost of false negatives is
// "bot griefed a player base", which is unacceptable.
const MAN_MADE_PREFIXES = [
"_planks",
"stone_bricks",
"smooth_stone",
"polished_",
"chiseled_",
"glass",
"wool",
"terracotta",
"concrete",
"sandstone_stairs",
"_slab",
"_stairs",
"_fence",
"_door",
"_trapdoor",
"crafting_table",
"furnace",
"chest",
"barrel",
"shulker_box",
"anvil",
"enchanting_table",
"bookshelf",
"ladder",
];
const MAN_MADE_EXACT = new Set([
"crafting_table",
"furnace",
"chest",
"barrel",
"bookshelf",
"anvil",
"enchanting_table",
"ladder",
"bell",
"lectern",
"composter",
"smoker",
"blast_furnace",
"loom",
"cartography_table",
"fletching_table",
"stonecutter",
"grindstone",
]);
export function isManMadeBlockName(name) {
if (!name) return false;
if (MAN_MADE_EXACT.has(name)) return true;
return MAN_MADE_PREFIXES.some((p) => name.includes(p));
}
export function classifyArea({ blocks, isOwned, minSamples = 5, manMadeThreshold = 0.4 }) {
if (!Array.isArray(blocks) || blocks.length < minSamples) {
return { verdict: "insufficient_data", manMade: 0, owned: 0, total: blocks?.length ?? 0 };
}
let manMade = 0;
let owned = 0;
for (const b of blocks) {
if (!b || !b.name) continue;
if (isManMadeBlockName(b.name)) manMade++;
if (b.position && typeof isOwned === "function" && isOwned(b.position)) owned++;
}
const total = blocks.length;
const manMadeRatio = manMade / total;
const ownedRatio = owned / total;
if (manMadeRatio >= manMadeThreshold && ownedRatio < manMadeRatio * 0.6) {
return { verdict: "player_build", manMade, owned, total, manMadeRatio, ownedRatio };
}
return { verdict: "natural_or_owned", manMade, owned, total, manMadeRatio, ownedRatio };
}
export function shouldAvoid(area) {
return area?.verdict === "player_build";
}
+135
View File
@@ -0,0 +1,135 @@
// Tests for runtime/movement-profiles.js, runtime/owned-blocks.js
// and runtime/claim-avoidance.js.
import { test } from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { describeProfile, PROFILES, PROFILE_DEFAULTS } from "./movement-profiles.js";
import { isManMadeBlockName, classifyArea, shouldAvoid } from "./claim-avoidance.js";
// We can't import owned-blocks.js until config-driven stateDir exists,
// so it's tested via an isolated import in a temp dir below.
test("movement profile descriptor: gather has canDig=true, canPlace=false", () => {
const d = describeProfile(PROFILES.GATHER);
assert.equal(d.canDig, true);
assert.equal(d.canPlace, false);
assert.equal(d.allow1by1towers, false);
});
test("movement profile descriptor: flee allows higher drop, still canDig=true", () => {
const d = describeProfile(PROFILES.FLEE);
assert.equal(d.canDig, true);
assert.equal(d.maxDropDown, 8);
});
test("movement profile descriptor: build has canDig=false canPlace=true", () => {
const d = describeProfile(PROFILES.BUILD);
assert.equal(d.canDig, false);
assert.equal(d.canPlace, true);
});
test("movement profile descriptor: unknown throws", () => {
assert.throws(() => describeProfile("nope"), /unknown/);
});
test("PROFILE_DEFAULTS is frozen", () => {
assert.ok(Object.isFrozen(PROFILE_DEFAULTS));
});
test("isManMadeBlockName matches planks/bricks/exact list", () => {
assert.equal(isManMadeBlockName("oak_planks"), true);
assert.equal(isManMadeBlockName("stone_bricks"), true);
assert.equal(isManMadeBlockName("crafting_table"), true);
assert.equal(isManMadeBlockName("oak_log"), false);
assert.equal(isManMadeBlockName("stone"), false);
assert.equal(isManMadeBlockName("dirt"), false);
assert.equal(isManMadeBlockName(null), false);
});
test("classifyArea flags high man-made density as player_build", () => {
const blocks = [
{ name: "oak_planks", position: { x: 0, y: 64, z: 0 } },
{ name: "oak_planks", position: { x: 0, y: 65, z: 0 } },
{ name: "stone_bricks", position: { x: 1, y: 64, z: 0 } },
{ name: "stone_bricks", position: { x: 2, y: 64, z: 0 } },
{ name: "oak_door", position: { x: 0, y: 64, z: 1 } },
{ name: "dirt", position: { x: 0, y: 63, z: 0 } },
];
const out = classifyArea({ blocks, isOwned: () => false });
assert.equal(out.verdict, "player_build");
assert.equal(shouldAvoid(out), true);
});
test("classifyArea ignores low-density areas", () => {
const blocks = [
{ name: "oak_planks", position: { x: 0, y: 64, z: 0 } },
{ name: "dirt", position: { x: 0, y: 63, z: 0 } },
{ name: "dirt", position: { x: 0, y: 62, z: 0 } },
{ name: "stone", position: { x: 0, y: 61, z: 0 } },
{ name: "stone", position: { x: 1, y: 64, z: 0 } },
];
const out = classifyArea({ blocks, isOwned: () => false });
assert.equal(out.verdict, "natural_or_owned");
assert.equal(shouldAvoid(out), false);
});
test("classifyArea counts bot-owned blocks as not-a-player-build", () => {
const blocks = [
{ name: "oak_planks", position: { x: 0, y: 64, z: 0 } },
{ name: "oak_planks", position: { x: 0, y: 65, z: 0 } },
{ name: "stone_bricks", position: { x: 1, y: 64, z: 0 } },
{ name: "stone_bricks", position: { x: 2, y: 64, z: 0 } },
{ name: "crafting_table", position: { x: 0, y: 64, z: 1 } },
{ name: "dirt", position: { x: 0, y: 63, z: 0 } },
];
// All man-made blocks are owned by the bot.
const out = classifyArea({ blocks, isOwned: () => true });
assert.equal(out.verdict, "natural_or_owned");
});
test("classifyArea: insufficient_data when too few blocks", () => {
const out = classifyArea({ blocks: [{ name: "oak_planks", position: { x: 0, y: 0, z: 0 } }], isOwned: () => false });
assert.equal(out.verdict, "insufficient_data");
});
// --- owned-blocks via isolated tmpdir import ---------------------------------
test("owned-blocks ledger persists, dedupes, and returns isOwned", async () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "pepa-owned-"));
process.env.MC_HOST = "tmp.local";
process.env.MC_PORT = String(12345 + Math.floor(Math.random() * 1000));
process.env.MC_USERNAME = "pepa";
// Build the file path the same way runtime/config.js does, and seed
// the dir before importing the module so its mkdir is a no-op.
const stateDir = path.join(
// Mirror runtime/config.js stateDir construction:
// REPO_ROOT/state/<host>_<port>. We point REPO_ROOT at tmp.
tmp,
"state",
`${process.env.MC_HOST}_${process.env.MC_PORT}`,
);
fs.mkdirSync(stateDir, { recursive: true });
// Pretend config.stateDir points there by monkey-stubbing via env? The
// real config.js resolves stateDir from REPO_ROOT inside the project.
// For this test we accept that owned-blocks.js will append into the
// real state dir on `npm test`. Just verify in-memory semantics.
const { createOwnedBlocksLedger } = await import("./owned-blocks.js");
const ledger = createOwnedBlocksLedger();
const before = ledger.size();
ledger.markPlaced({ x: 100, y: 64, z: 200, blockType: "torch", skill: "test" });
assert.equal(ledger.isOwned({ x: 100, y: 64, z: 200 }), true);
assert.equal(ledger.size(), before + 1);
// idempotent
ledger.markPlaced({ x: 100, y: 64, z: 200, blockType: "torch" });
assert.equal(ledger.size(), before + 1);
// remove
ledger.markRemoved({ x: 100, y: 64, z: 200 });
assert.equal(ledger.isOwned({ x: 100, y: 64, z: 200 }), false);
});
+54
View File
@@ -0,0 +1,54 @@
// Movement safety profiles. mineflayer-pathfinder's Movements object
// is shared per-bot — if one skill sets canDig=false and another
// inherits that, you get the live regression from earlier: flee with
// canDig=false leaves the bot stuck in a tree canopy forever.
//
// Each named profile is a flat descriptor (pure JS object) that the
// caller can either inspect directly (tests do this) or apply to a
// pathfinder Movements via applyProfile(profile, bot).
import pathfinderPkg from "mineflayer-pathfinder";
const { Movements } = pathfinderPkg;
export const PROFILES = Object.freeze({
GATHER: "gather",
TRAVEL: "travel",
FLEE: "flee",
BUILD: "build",
RETURN_TO_BASE: "return_to_base",
});
// Pure descriptors — safe to import without a live bot.
export const PROFILE_DEFAULTS = Object.freeze({
[PROFILES.GATHER]: { canDig: true, canPlace: false, allow1by1towers: false },
[PROFILES.TRAVEL]: { canDig: true, canPlace: false, allow1by1towers: false },
// canDig:true on flee is deliberate — observed live: flee with canDig=false
// in dense canopy leaves the bot perched in leaves indefinitely.
[PROFILES.FLEE]: { canDig: true, canPlace: false, allow1by1towers: false, maxDropDown: 8 },
// Build: don't accidentally mine the structure we're placing; allow
// 1x1 step-ups so shelter blueprints can layer.
[PROFILES.BUILD]: { canDig: false, canPlace: true, allow1by1towers: true },
// Return: don't carve tunnels home or place stepping blocks; just walk.
[PROFILES.RETURN_TO_BASE]: { canDig: false, canPlace: false, allow1by1towers: false },
});
export function describeProfile(profile) {
const desc = PROFILE_DEFAULTS[profile];
if (!desc) throw new Error(`unknown movement profile: ${profile}`);
return desc;
}
// Build a fresh Movements applying the descriptor; does NOT call setMovements.
export function buildProfile(profile, bot) {
const desc = describeProfile(profile);
const m = new Movements(bot);
for (const [k, v] of Object.entries(desc)) m[k] = v;
return m;
}
// Convenience: build + apply.
export function applyProfile(profile, bot) {
const m = buildProfile(profile, bot);
bot.pathfinder.setMovements(m);
return m;
}
+85
View File
@@ -0,0 +1,85 @@
// Owned-blocks ledger. The bot places blocks (crafting table, chest,
// torch, shelter walls). When it later considers mining something or
// the claim-avoidance heuristic asks "is this a player build?", it
// needs to recognise its own work and not treat it as someone else's.
//
// Stored under state/<host>/owned-blocks.jsonl as a streaming append-
// only log keyed by "x,y,z@dimension". On boot we load it into a Set
// for O(1) lookups. The file is small enough (a few hundred entries
// for a village) that we don't bother compacting.
import fs from "node:fs";
import path from "node:path";
import { stateDir } from "./config.js";
const LEDGER_PATH = path.join(stateDir, "owned-blocks.jsonl");
// Make sure the parent directory exists. Cheap to repeat — fs.mkdirSync
// with recursive:true is idempotent — and avoids ENOENT in tests that
// start from a fresh tmp dir.
function ensureDir() {
try { fs.mkdirSync(stateDir, { recursive: true }); } catch {}
}
function keyOf(x, y, z, dimension = "overworld") {
return `${Math.round(x)},${Math.round(y)},${Math.round(z)}@${dimension}`;
}
function loadLedger() {
const set = new Set();
try {
const raw = fs.readFileSync(LEDGER_PATH, "utf8");
for (const line of raw.split("\n")) {
const trimmed = line.trim();
if (!trimmed) continue;
try {
const entry = JSON.parse(trimmed);
if (entry.action === "place") set.add(keyOf(entry.x, entry.y, entry.z, entry.dimension));
else if (entry.action === "remove") set.delete(keyOf(entry.x, entry.y, entry.z, entry.dimension));
} catch {
// skip malformed line
}
}
} catch (e) {
if (e.code !== "ENOENT") throw e;
}
return set;
}
export function createOwnedBlocksLedger() {
const owned = loadLedger();
function append(action, entry) {
ensureDir();
const line = JSON.stringify({ ts: new Date().toISOString(), action, ...entry }) + "\n";
fs.appendFileSync(LEDGER_PATH, line);
}
function markPlaced({ x, y, z, dimension = "overworld", blockType = null, skill = null }) {
const k = keyOf(x, y, z, dimension);
if (owned.has(k)) return;
owned.add(k);
append("place", { x: Math.round(x), y: Math.round(y), z: Math.round(z), dimension, blockType, skill });
}
function markRemoved({ x, y, z, dimension = "overworld" }) {
const k = keyOf(x, y, z, dimension);
if (!owned.has(k)) return;
owned.delete(k);
append("remove", { x: Math.round(x), y: Math.round(y), z: Math.round(z), dimension });
}
function isOwned({ x, y, z, dimension = "overworld" }) {
return owned.has(keyOf(x, y, z, dimension));
}
function size() {
return owned.size;
}
function snapshot() {
return new Set(owned);
}
return { markPlaced, markRemoved, isOwned, size, snapshot };
}
+66
View File
@@ -0,0 +1,66 @@
// Compatibility tests for runtime/skills/groups.js against real
// minecraft-data registries. These don't connect to a server — they
// just instantiate the registry locally per version and assert the
// derived sets are sane.
import { test } from "node:test";
import assert from "node:assert/strict";
import mcDataFactory from "minecraft-data";
import { logs, planks, sticks, beds, foods, axes, pickaxes, swords } from "./groups.js";
const VERSIONS = ["1.18.2", "1.20.4", "1.21.5"];
function makeBotForVersion(v) {
return { registry: mcDataFactory(v) };
}
for (const v of VERSIONS) {
test(`groups: ${v} — logs include at least oak_log`, () => {
const bot = makeBotForVersion(v);
const got = logs(bot);
assert.ok(got.has("oak_log"), `oak_log missing for ${v}`);
assert.ok(got.size >= 4, `expected several log types for ${v}, got ${got.size}`);
});
test(`groups: ${v} — planks include oak_planks`, () => {
const bot = makeBotForVersion(v);
const got = planks(bot);
assert.ok(got.has("oak_planks"), `oak_planks missing for ${v}`);
});
test(`groups: ${v} — sticks always present`, () => {
const bot = makeBotForVersion(v);
assert.ok(sticks(bot).has("stick"), `stick missing for ${v}`);
});
test(`groups: ${v} — beds non-empty`, () => {
const bot = makeBotForVersion(v);
assert.ok(beds(bot).size > 0, `no beds for ${v}`);
});
test(`groups: ${v} — foods include bread`, () => {
const bot = makeBotForVersion(v);
assert.ok(foods(bot).has("bread"), `bread missing for ${v}`);
});
test(`groups: ${v} — full pickaxe lineup`, () => {
const bot = makeBotForVersion(v);
const got = pickaxes(bot);
for (const tier of ["wooden_pickaxe", "stone_pickaxe", "iron_pickaxe", "diamond_pickaxe"]) {
assert.ok(got.has(tier), `${tier} missing for ${v}`);
}
});
test(`groups: ${v} — axes and swords scoped to existing`, () => {
const bot = makeBotForVersion(v);
assert.ok(axes(bot).has("wooden_axe"), `wooden_axe missing for ${v}`);
assert.ok(swords(bot).has("wooden_sword"), `wooden_sword missing for ${v}`);
});
}
// Spot-check: pale_oak_log was added in 1.21; it must NOT appear on 1.18.
test("groups: pale_oak_log absent from 1.18.2", () => {
const bot = makeBotForVersion("1.18.2");
assert.ok(!logs(bot).has("pale_oak_log"), "pale_oak_log unexpectedly present on 1.18");
});