From 9fb6c7198c9c28e0c5465e7d5be002fc62d982ab Mon Sep 17 00:00:00 2001 From: Yuriy Mayatnikov Date: Mon, 25 May 2026 22:39:09 +0300 Subject: [PATCH] feat(runtime): compatibility hardening (Phase 7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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//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: Claude Opus 4.7 (1M context) --- docs/runtime.md | 22 ++++++ package.json | 2 +- runtime/claim-avoidance.js | 94 +++++++++++++++++++++++ runtime/compat.test.js | 135 ++++++++++++++++++++++++++++++++++ runtime/movement-profiles.js | 54 ++++++++++++++ runtime/owned-blocks.js | 85 +++++++++++++++++++++ runtime/skills/compat.test.js | 66 +++++++++++++++++ 7 files changed, 457 insertions(+), 1 deletion(-) create mode 100644 runtime/claim-avoidance.js create mode 100644 runtime/compat.test.js create mode 100644 runtime/movement-profiles.js create mode 100644 runtime/owned-blocks.js create mode 100644 runtime/skills/compat.test.js diff --git a/docs/runtime.md b/docs/runtime.md index 2ecb6e6..8dfc042 100644 --- a/docs/runtime.md +++ b/docs/runtime.md @@ -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//proposals/`: diff --git a/package.json b/package.json index ac91ce9..50e791e 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "tui": "tsx tui/tui.tsx", "propose:apply": "node scripts/propose-apply.js", "stop": "bash scripts/stop.sh", - "test": "node --test runtime/skills/contract.test.js runtime/skills/groups.test.js runtime/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", diff --git a/runtime/claim-avoidance.js b/runtime/claim-avoidance.js new file mode 100644 index 0000000..a14fea7 --- /dev/null +++ b/runtime/claim-avoidance.js @@ -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"; +} diff --git a/runtime/compat.test.js b/runtime/compat.test.js new file mode 100644 index 0000000..7b41699 --- /dev/null +++ b/runtime/compat.test.js @@ -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/_. 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); +}); diff --git a/runtime/movement-profiles.js b/runtime/movement-profiles.js new file mode 100644 index 0000000..f405554 --- /dev/null +++ b/runtime/movement-profiles.js @@ -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; +} diff --git a/runtime/owned-blocks.js b/runtime/owned-blocks.js new file mode 100644 index 0000000..b64bab3 --- /dev/null +++ b/runtime/owned-blocks.js @@ -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//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 }; +} diff --git a/runtime/skills/compat.test.js b/runtime/skills/compat.test.js new file mode 100644 index 0000000..e238d46 --- /dev/null +++ b/runtime/skills/compat.test.js @@ -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"); +});