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:
@@ -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
@@ -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",
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
Reference in New Issue
Block a user