From 63a63ddc408b6ae4fe2591f46f0cf3d62a8fd5c5 Mon Sep 17 00:00:00 2001
From: XaXayo12 <257781493+XaXayo12@users.noreply.github.com>
Date: Mon, 15 Jun 2026 19:25:53 +0200
Subject: [PATCH 1/3] feat(movements): add exclusion zones to movement settings
(like upstream)
Add "keep out" exclusion zones to the movement settings, matching the
exclusion-area concept from upstream PrismarineJS/mineflayer-pathfinder.
- exclusionAreasStep / exclusionAreasBreak / exclusionAreasPlace in
MovementOptions (default []). Each is a (block: BlockInfo) => number;
a sum >= COST_INF forbids the block, a positive value is a soft penalty.
- Step exclusion is applied inside each movement provider, on the block the
bot lands in (already fetched during move generation), folded into the move
cost BEFORE the move is created. So Move.cost stays readonly, there is no
extra/unrouted block lookup, and forbidden landings are simply never
generated (no array splicing). Covers walk/jump/drop/parkour/tower.
- Break/place exclusion is folded into Movement.breakCost / placeCost, which
every break/place caller already routes through.
- buildMovementOptions() merges over the defaults and always hands out fresh
copies of the three arrays (defaults are frozen), so bots/sessions never
share or mutate the same array.
- Helpers createBoxExclusion / createRadiusExclusion /
createColumnRadiusExclusion, EXCLUSION_NEVER, and the ExclusionArea type.
- Tests (28/28), docs (API + AdvancedUsage), and an example.
---
docs/API.md | 103 ++++++++
docs/AdvancedUsage.md | 72 ++++++
examples/README.md | 1 +
examples/exclusionZones.js | 98 +++++++
src/ThePathfinder.ts | 6 +-
src/index.ts | 10 +
.../movements/exclusionZones.ts | 140 ++++++++++
src/mineflayer-specific/movements/index.ts | 1 +
src/mineflayer-specific/movements/movement.ts | 130 +++++++++-
.../movements/movementProvider.ts | 4 +-
.../movements/movementProviders.ts | 65 +++--
tests/exclusionZones.test.ts | 240 ++++++++++++++++++
12 files changed, 838 insertions(+), 32 deletions(-)
create mode 100644 examples/exclusionZones.js
create mode 100644 src/mineflayer-specific/movements/exclusionZones.ts
create mode 100644 tests/exclusionZones.test.ts
diff --git a/docs/API.md b/docs/API.md
index cc4f1a6..e7146a7 100644
--- a/docs/API.md
+++ b/docs/API.md
@@ -25,6 +25,7 @@
- [GoalCompositeAny](#goalcompositeany)
- [GoalCompositeAll](#goalcompositeall)
- [Settings](#settings)
+- [Exclusion Zones](#exclusion-zones)
- [Events](#events)
- [pathGenerated](#pathGenerated)
- [goalSet](#goalSet)
@@ -560,6 +561,9 @@ These are the currently available settings.
| `infiniteLiquidDropdownDistance` | `boolean` | Whether or not to have an infinite liquid dropdown distance. | `true` |
| `allowSprinting` | `boolean` | Whether or not to allow sprinting. | `true` |
| `careAboutLookAlignment` | `boolean` | Whether or not to care about look alignment. | `true` |
+| `exclusionAreasStep` | `ExclusionArea[]` | "Keep out" rules for blocks the bot would **stand in**. See [Exclusion Zones](#exclusion-zones). | `[]` |
+| `exclusionAreasBreak` | `ExclusionArea[]` | "Keep out" rules for blocks the bot would **break** (mine). | `[]` |
+| `exclusionAreasPlace` | `ExclusionArea[]` | "Keep out" rules for blocks the bot would **place** (build on). | `[]` |
```ts
@@ -583,12 +587,111 @@ interface MovementOptions {
infiniteLiquidDropdownDistance: boolean
allowSprinting: boolean
careAboutLookAlignment: boolean
+
+ movementTimeoutMs: number
+
+ // "Keep out" zones. Empty by default. See the Exclusion Zones section below.
+ exclusionAreasStep: ExclusionArea[]
+ exclusionAreasBreak: ExclusionArea[]
+ exclusionAreasPlace: ExclusionArea[]
}
```
+
Exclusion Zones
+
+Exclusion zones let you tell the bot **"keep out of here"** — either softly (an
+area is allowed but more expensive, so the bot prefers to go around) or hard
+(an area is completely off-limits). This is the same idea as upstream
+[`PrismarineJS/mineflayer-pathfinder`](https://github.com/PrismarineJS/mineflayer-pathfinder),
+so exclusion functions you wrote for that library keep working here.
+
+How it works
+
+An **exclusion area** is just a function. You give it one block, and it returns
+the *extra cost* of using that block:
+
+```ts
+type ExclusionArea = (block: BlockInfo) => number
+```
+
+- return `0` → "I don't care about this block."
+- return a positive number (e.g. `50`) → a **soft** zone: the bot may use the block, but it costs that much more, so it avoids it when there is a cheaper way around.
+- return `EXCLUSION_NEVER` (a.k.a. `Infinity`) → a **hard** zone: the bot will never use this block. Any value `>= COST_INF` counts as "never".
+
+There are three independent lists in the settings, one per kind of action:
+
+| Setting | Asked about every block the bot would… |
+| --- | --- |
+| `exclusionAreasStep` | **stand in** / walk into (checked on the block the bot's feet end up in, for every movement type: walking, jumping, dropping, parkour, towers). |
+| `exclusionAreasBreak` | **break** (mine). |
+| `exclusionAreasPlace` | **place** (build on). |
+
+> When all three lists are empty (the default), exclusion costs nothing to
+> evaluate — there is zero overhead for normal pathfinding.
+
+Ready-made zone shapes
+
+You usually don't need to write the function yourself. These helpers build the
+common shapes for you (all are exported from the package root):
+
+▸ **createBoxExclusion(`corner1: Vec3, corner2: Vec3, cost = EXCLUSION_NEVER`): `ExclusionArea`**
+
+A box between two opposite corners (inclusive, any order — like a WorldEdit selection).
+
+▸ **createRadiusExclusion(`center: Vec3, radius: number, cost = EXCLUSION_NEVER`): `ExclusionArea`**
+
+A ball (sphere): every block within `radius` of `center`. Height counts.
+
+▸ **createColumnRadiusExclusion(`center: Vec3, radius: number, cost = EXCLUSION_NEVER`): `ExclusionArea`**
+
+A pillar (vertical column): like the ball, but it ignores height — only X/Z distance matters.
+
+Examples
+
+```ts
+const { Vec3 } = require('vec3')
+const {
+ createBoxExclusion,
+ createRadiusExclusion,
+ createColumnRadiusExclusion
+} = require('@nxg-org/mineflayer-pathfinder')
+
+// 1) Hard no-go box: the bot will never set foot in this region.
+const spawnArea = createBoxExclusion(new Vec3(-10, 60, -10), new Vec3(10, 80, 10))
+
+// 2) Soft danger zone: the bot may pass within 8 blocks of the turret,
+// but only if going around would be even more expensive.
+const turret = createRadiusExclusion(new Vec3(100, 64, 100), 8, 60)
+
+// 3) Never dig or build inside the protected spawn box.
+const protectedBox = createBoxExclusion(new Vec3(-10, 0, -10), new Vec3(10, 320, 10))
+
+bot.pathfinder.setMoveOptions({
+ exclusionAreasStep: [spawnArea, turret],
+ exclusionAreasBreak: [protectedBox],
+ exclusionAreasPlace: [protectedBox]
+})
+```
+
+You can also write a fully custom rule — any function `(block) => number` works:
+
+```ts
+// Avoid stepping on farmland so the bot never tramples crops.
+const farmlandId = bot.registry.blocksByName.farmland.id
+const dontTrample = (block) => block.type === farmlandId ? 100 : 0
+
+bot.pathfinder.setMoveOptions({ exclusionAreasStep: [dontTrample] })
+```
+
+> **Note:** `exclusionAreasStep` is checked on the block the bot's **feet** land
+> in. If you need to guarantee the bot's head also stays out of a region, make
+> the box one block taller at the bottom.
+
+
+
Events
diff --git a/docs/AdvancedUsage.md b/docs/AdvancedUsage.md
index 6b20fe2..2feb325 100644
--- a/docs/AdvancedUsage.md
+++ b/docs/AdvancedUsage.md
@@ -9,6 +9,7 @@
- [Custom Movement Providers](#custom-movement-providers)
- [Custom Movement Executors](#custom-movement-executors)
- [Custom Movement Optimizers](#custom-movement-optimizers)
+- [Exclusion Zones (Keep-Out Areas)](#exclusion-zones-keep-out-areas)
@@ -311,3 +312,74 @@ class MyMovementOptimizer extends MovementOptimizer {
}
}
```
+
+Exclusion Zones (Keep-Out Areas)
+
+Sometimes you don't want to write a whole custom `MovementProvider` — you just
+want to tell the bot **"stay out of this area"**. That is what exclusion zones
+are for. They are a setting, so you turn them on by changing `moveSettings`,
+not by subclassing anything.
+
+The one idea you need
+
+An **exclusion area** is a function that looks at a single block and returns a
+number — the *extra* cost of using that block:
+
+- `0` → the bot doesn't care, business as usual.
+- a positive number → **soft** zone: allowed, but more expensive, so the bot goes around when it can.
+- `Infinity` / `EXCLUSION_NEVER` → **hard** zone: the bot will never use that block.
+
+There are three lists in the settings, one for each kind of action the bot can
+take on a block:
+
+- `exclusionAreasStep` — blocks the bot would **stand in**.
+- `exclusionAreasBreak` — blocks the bot would **break** (mine).
+- `exclusionAreasPlace` — blocks the bot would **place** (build on).
+
+All three are empty by default, which means "no zones" and costs nothing.
+
+The quick way: ready-made shapes
+
+```ts
+const { Vec3 } = require('vec3')
+const {
+ createBoxExclusion, // a box between two corners
+ createRadiusExclusion, // a ball around a point
+ createColumnRadiusExclusion // a vertical pillar around a point (ignores height)
+} = require('@nxg-org/mineflayer-pathfinder')
+
+// Hard no-go box around spawn protection.
+const spawn = createBoxExclusion(new Vec3(-16, 60, -16), new Vec3(16, 90, 16))
+
+// Soft "stay away" ball around a mob spawner (cost 50, not forbidden).
+const spawner = createRadiusExclusion(new Vec3(40, 64, -120), 10, 50)
+
+bot.pathfinder.setMoveOptions({
+ exclusionAreasStep: [spawn, spawner]
+})
+```
+
+The flexible way: your own function
+
+Any function `(block) => number` works, so you can base the rule on block type,
+position, height, whatever you like:
+
+```ts
+// Never mine valuable ores, and never break anything below y=0.
+const diamondId = bot.registry.blocksByName.diamond_ore.id
+const protectOres = (block) =>
+ (block.type === diamondId || block.position.y < 0) ? Infinity : 0
+
+bot.pathfinder.setMoveOptions({ exclusionAreasBreak: [protectOres] })
+```
+
+Good to know
+
+- You can combine as many areas as you want in each list — their costs add up.
+- `exclusionAreasStep` is checked on the block the bot's **feet** land in, for
+ every movement type (walking, jumping, dropping, parkour, towers). If you need
+ the bot's head kept out too, make the box one block taller at the bottom.
+- This matches upstream `mineflayer-pathfinder`'s exclusion areas, so functions
+ written for that library work here unchanged.
+- Prefer `setMoveOptions({ ... })` with a fresh array over mutating the existing
+ array in place, so every part of the pathfinder picks up the new zones.
diff --git a/examples/README.md b/examples/README.md
index 61bed6e..aaa1e16 100644
--- a/examples/README.md
+++ b/examples/README.md
@@ -2,3 +2,4 @@
`basic.js` shows off the basic functionality of this pathfinder, while `example.js` goes into more depth.
`bridging/bridge.ts` is the bridge demo, and `neos/neo.ts` is a neo-jump-focused variant based on the same bot setup.
+`exclusionZones.js` shows how to add "keep out" areas (exclusion zones) to the movement settings.
diff --git a/examples/exclusionZones.js b/examples/exclusionZones.js
new file mode 100644
index 0000000..65772f6
--- /dev/null
+++ b/examples/exclusionZones.js
@@ -0,0 +1,98 @@
+'use strict'
+
+// ---------------------------------------------------------------------------
+// Exclusion zones example
+//
+// This shows how to tell the bot "keep out of here" using the three exclusion
+// lists in the movement settings. Run a local server, then:
+//
+// node examples/exclusionZones.js
+//
+// In game, type these in chat:
+// goto -> walk there, but respecting the zones below
+// zones on -> turn the example zones on
+// zones off -> turn them all off again
+// ---------------------------------------------------------------------------
+
+const { createBot } = require('mineflayer')
+const { Vec3 } = require('vec3')
+const {
+ createPlugin,
+ goals,
+ createBoxExclusion,
+ createRadiusExclusion,
+ createColumnRadiusExclusion
+} = require('../dist')
+
+const { GoalBlock } = goals
+
+const bot = createBot({
+ username: 'exclusion-demo',
+ auth: 'offline',
+ host: 'localhost',
+ port: 25565
+})
+
+bot.loadPlugin(createPlugin())
+
+// A few example zones. Tweak the coordinates to match your world.
+//
+// - A HARD box the bot must never set foot in.
+// - A SOFT ball the bot prefers to stay out of, but may cross if it must.
+// - A pillar where the bot is never allowed to mine.
+const noGoBox = createBoxExclusion(new Vec3(-8, 60, -8), new Vec3(8, 80, 8))
+const softBall = createRadiusExclusion(new Vec3(30, 64, 30), 6, 50)
+const noMinePillar = createColumnRadiusExclusion(new Vec3(0, 0, 0), 4)
+
+function enableZones () {
+ bot.pathfinder.setMoveOptions({
+ exclusionAreasStep: [noGoBox, softBall],
+ exclusionAreasBreak: [noMinePillar]
+ })
+ bot.chat('Exclusion zones: ON')
+}
+
+function disableZones () {
+ bot.pathfinder.setMoveOptions({
+ exclusionAreasStep: [],
+ exclusionAreasBreak: [],
+ exclusionAreasPlace: []
+ })
+ bot.chat('Exclusion zones: OFF')
+}
+
+bot.once('spawn', () => {
+ enableZones()
+ bot.chat('Ready. Try: "goto ", "zones on", "zones off".')
+})
+
+bot.on('chat', async (username, message) => {
+ if (username === bot.username) return
+
+ const [cmd, ...args] = message.trim().split(/\s+/)
+
+ if (cmd === 'zones') {
+ if (args[0] === 'off') disableZones()
+ else enableZones()
+ return
+ }
+
+ if (cmd === 'goto') {
+ const [x, y, z] = args.map(Number)
+ if ([x, y, z].some(Number.isNaN)) {
+ bot.chat('Usage: goto ')
+ return
+ }
+
+ bot.chat(`Heading to ${x} ${y} ${z}, avoiding the zones...`)
+ try {
+ await bot.pathfinder.goto(new GoalBlock(x, y, z))
+ bot.chat('Arrived!')
+ } catch (err) {
+ bot.chat(`Could not get there: ${err.message}`)
+ }
+ }
+})
+
+bot.on('kicked', console.log)
+bot.on('error', console.log)
diff --git a/src/ThePathfinder.ts b/src/ThePathfinder.ts
index 173337f..8ab1b14 100644
--- a/src/ThePathfinder.ts
+++ b/src/ThePathfinder.ts
@@ -15,7 +15,7 @@ import type {
import {
MovementHandler,
MovementExecutor,
- DEFAULT_MOVEMENT_OPTS
+ buildMovementOptions
} from './mineflayer-specific/movements'
import {
@@ -209,7 +209,7 @@ export class ThePathfinder {
const optimizers = opts.optimizers ?? DEFAULT_OPTIMIZATION
const moveSetup = opts.movements ?? DEFAULT_SETUP
- Object.assign(moveSettings, { ...DEFAULT_MOVEMENT_OPTS, ...opts.moveSettings })
+ Object.assign(moveSettings, buildMovementOptions(opts.moveSettings))
Object.assign(pathfinderSettings, { ...DEFAULT_PATHFINDER_OPTS, ...opts.pathfinderSettings })
const moves = new Map()
@@ -279,7 +279,7 @@ export class ThePathfinder {
}
setMoveOptions(settings: Partial): void {
- this.defaultMoveSettings = Object.assign({}, DEFAULT_MOVEMENT_OPTS, settings)
+ this.defaultMoveSettings = buildMovementOptions(settings)
this.optimizerRegistry.setSettings(this.defaultMoveSettings)
for (const [, executor] of this.movements) {
executor.settings = this.defaultMoveSettings
diff --git a/src/index.ts b/src/index.ts
index 4805c55..fd0e673 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -50,6 +50,16 @@ export * as goals from './mineflayer-specific/goals'
export { MovementExecutor, MovementProvider } from './mineflayer-specific/movements'
export type { BuildableMoveExecutor, BuildableMoveProvider, MovementSetup } from './mineflayer-specific/movements'
+export type { MovementOptions } from './mineflayer-specific/movements'
+
+// Exclusion zones ("keep out" areas), like upstream mineflayer-pathfinder.
+export {
+ createBoxExclusion,
+ createRadiusExclusion,
+ createColumnRadiusExclusion,
+ EXCLUSION_NEVER
+} from './mineflayer-specific/movements/exclusionZones'
+export type { ExclusionArea } from './mineflayer-specific/movements/exclusionZones'
export { MovementOptimizer } from './mineflayer-specific/post'
export type { BuildableMoveOptimizer, OptimizationSetup, OptimizationMap } from './mineflayer-specific/post'
export { Move } from './mineflayer-specific/move'
diff --git a/src/mineflayer-specific/movements/exclusionZones.ts b/src/mineflayer-specific/movements/exclusionZones.ts
new file mode 100644
index 0000000..969f7b9
--- /dev/null
+++ b/src/mineflayer-specific/movements/exclusionZones.ts
@@ -0,0 +1,140 @@
+import type { Vec3 } from 'vec3'
+import type { BlockInfo } from '../world/cacheWorld'
+import { COST_INF } from './costs'
+
+/**
+ * An "exclusion area" is a very small function.
+ *
+ * You hand it ONE block, and it hands you back ONE number: the EXTRA cost of
+ * letting the bot use that block.
+ *
+ * Think of it like a price tag the bot reads before touching a block:
+ *
+ * - return `0` -> "I don't care about this block. Do whatever."
+ * - return `50` -> "You may use this block, but it costs 50 extra.
+ * Go around it if there is a cheaper way."
+ * - return `Infinity` -> "Never, ever use this block." Any number that is
+ * (or `COST_INF`) `>= COST_INF` counts as "never".
+ *
+ * The pathfinder keeps THREE separate lists of these functions in its settings
+ * (see {@link MovementOptions}):
+ *
+ * - `exclusionAreasStep` -> asked about every block the bot would STAND in.
+ * - `exclusionAreasBreak` -> asked about every block the bot would BREAK (mine).
+ * - `exclusionAreasPlace` -> asked about every block the bot would PLACE (build).
+ *
+ * This is the exact same idea as upstream `PrismarineJS/mineflayer-pathfinder`,
+ * so exclusion functions written for that library keep working here. The only
+ * difference is that here the function is handed a {@link BlockInfo} (which has
+ * a `.position`), instead of a raw prismarine block.
+ */
+export type ExclusionArea = (block: BlockInfo) => number
+
+/**
+ * The number the helpers below use to mean "never use this block".
+ *
+ * It is just the pathfinder's idea of "infinitely expensive" (`COST_INF`).
+ * Re-exported here so you do not have to dig around for it.
+ */
+export const EXCLUSION_NEVER = COST_INF
+
+/**
+ * Make a BOX shaped "keep out" zone between two corners.
+ *
+ * Picture two opposite corners of a Minecraft selection (like a WorldEdit
+ * `//pos1` and `//pos2`). Every block inside that box — corners included — gets
+ * the given `cost`.
+ *
+ * You can pass the two corners in ANY order; this function figures out which
+ * corner is the small one and which is the big one for you.
+ *
+ * @example
+ * // The bot must never enter the box from (10, 64, -5) to (20, 70, 5):
+ * const noGo = createBoxExclusion(new Vec3(10, 64, -5), new Vec3(20, 70, 5))
+ * bot.pathfinder.setMoveOptions({ exclusionAreasStep: [noGo] })
+ *
+ * @example
+ * // The bot CAN cross the box, but it is 80 cost more expensive, so it will
+ * // only cut through when there is no cheaper way around:
+ * const slowZone = createBoxExclusion(corner1, corner2, 80)
+ *
+ * @param corner1 one corner of the box (block coordinates, inclusive).
+ * @param corner2 the opposite corner of the box (block coordinates, inclusive).
+ * @param cost extra cost for blocks inside the box. Defaults to "never"
+ * ({@link EXCLUSION_NEVER}). Pass a smaller positive number for a "soft" zone.
+ */
+export function createBoxExclusion (corner1: Vec3, corner2: Vec3, cost: number = EXCLUSION_NEVER): ExclusionArea {
+ // Work out the smaller and bigger value on each axis once, up front, so the
+ // returned function only has to do cheap comparisons.
+ const minX = Math.min(corner1.x, corner2.x)
+ const minY = Math.min(corner1.y, corner2.y)
+ const minZ = Math.min(corner1.z, corner2.z)
+ const maxX = Math.max(corner1.x, corner2.x)
+ const maxY = Math.max(corner1.y, corner2.y)
+ const maxZ = Math.max(corner1.z, corner2.z)
+
+ return (block: BlockInfo): number => {
+ const p = block.position
+ const inside =
+ p.x >= minX && p.x <= maxX &&
+ p.y >= minY && p.y <= maxY &&
+ p.z >= minZ && p.z <= maxZ
+ return inside ? cost : 0
+ }
+}
+
+/**
+ * Make a BALL (sphere) shaped "keep out" zone around a center point.
+ *
+ * Every block whose center is within `radius` blocks of `center` gets the given
+ * `cost`. This is handy for "stay at least N blocks away from this spot".
+ *
+ * @example
+ * // Keep the bot more than 8 blocks away from a turret at (0, 64, 0):
+ * const danger = createRadiusExclusion(new Vec3(0, 64, 0), 8)
+ * bot.pathfinder.setMoveOptions({ exclusionAreasStep: [danger] })
+ *
+ * @param center the middle of the ball (block coordinates).
+ * @param radius how far the ball reaches, in blocks.
+ * @param cost extra cost for blocks inside the ball. Defaults to "never".
+ */
+export function createRadiusExclusion (center: Vec3, radius: number, cost: number = EXCLUSION_NEVER): ExclusionArea {
+ // Compare squared distances so we never need a (slow) square root.
+ const radiusSquared = radius * radius
+
+ return (block: BlockInfo): number => {
+ const p = block.position
+ const dx = p.x - center.x
+ const dy = p.y - center.y
+ const dz = p.z - center.z
+ const distanceSquared = dx * dx + dy * dy + dz * dz
+ return distanceSquared <= radiusSquared ? cost : 0
+ }
+}
+
+/**
+ * Make a PILLAR (vertical column) shaped "keep out" zone around a point.
+ *
+ * Like {@link createRadiusExclusion}, but height does NOT matter: it only looks
+ * at the X/Z distance. Use this when you want to block a spot at every height,
+ * for example "never go near this base, no matter how high or low".
+ *
+ * @example
+ * const keepAway = createColumnRadiusExclusion(new Vec3(100, 0, 100), 12)
+ * bot.pathfinder.setMoveOptions({ exclusionAreasStep: [keepAway] })
+ *
+ * @param center the middle of the column (only X and Z are used).
+ * @param radius how far the column reaches outward, in blocks.
+ * @param cost extra cost for blocks inside the column. Defaults to "never".
+ */
+export function createColumnRadiusExclusion (center: Vec3, radius: number, cost: number = EXCLUSION_NEVER): ExclusionArea {
+ const radiusSquared = radius * radius
+
+ return (block: BlockInfo): number => {
+ const p = block.position
+ const dx = p.x - center.x
+ const dz = p.z - center.z
+ const distanceSquared = dx * dx + dz * dz
+ return distanceSquared <= radiusSquared ? cost : 0
+ }
+}
diff --git a/src/mineflayer-specific/movements/index.ts b/src/mineflayer-specific/movements/index.ts
index e62d625..c03624b 100644
--- a/src/mineflayer-specific/movements/index.ts
+++ b/src/mineflayer-specific/movements/index.ts
@@ -16,4 +16,5 @@ export * from './movementExecutors'
export * from './movementProviders'
export * from './movementExecutor'
export * from './movementProvider'
+export * from './exclusionZones'
// export * from './pp'
diff --git a/src/mineflayer-specific/movements/movement.ts b/src/mineflayer-specific/movements/movement.ts
index 40d824f..2709800 100644
--- a/src/mineflayer-specific/movements/movement.ts
+++ b/src/mineflayer-specific/movements/movement.ts
@@ -9,6 +9,7 @@ import type { InteractType } from './interactionUtils'
import type { Block } from '../../types'
import { Vec3Properties } from '../../types'
import { COST_INF } from './costs'
+import type { ExclusionArea } from './exclusionZones'
export interface MovementOptions {
allowDiagonalBridging: boolean
@@ -32,6 +33,25 @@ export interface MovementOptions {
careAboutLookAlignment: boolean
movementTimeoutMs: number
+
+ /**
+ * "Keep out" rules for blocks the bot would STAND in / walk into.
+ *
+ * Each {@link ExclusionArea} is a function that returns the extra cost of a
+ * block (return `>= COST_INF` to forbid it entirely). The cost of every area
+ * in the list is added together. An empty list (the default) means "no zones",
+ * and costs nothing to evaluate.
+ *
+ * Build these by hand, or use the helpers in `./exclusionZones`
+ * (`createBoxExclusion`, `createRadiusExclusion`, `createColumnRadiusExclusion`).
+ */
+ exclusionAreasStep: ExclusionArea[]
+
+ /** "Keep out" rules for blocks the bot would BREAK (mine). See {@link exclusionAreasStep}. */
+ exclusionAreasBreak: ExclusionArea[]
+
+ /** "Keep out" rules for blocks the bot would PLACE (build on). See {@link exclusionAreasStep}. */
+ exclusionAreasPlace: ExclusionArea[]
}
export const DEFAULT_MOVEMENT_OPTS: MovementOptions = {
@@ -53,7 +73,36 @@ export const DEFAULT_MOVEMENT_OPTS: MovementOptions = {
forceLook: true,
careAboutLookAlignment: true,
allowDiagonalBridging: true,
- movementTimeoutMs: 1000
+ movementTimeoutMs: 1000,
+ // No exclusion zones by default. Add your own with bot.pathfinder.setMoveOptions(...).
+ exclusionAreasStep: [],
+ exclusionAreasBreak: [],
+ exclusionAreasPlace: []
+}
+
+// Lock the default exclusion lists so the single shared instances above can never
+// be mutated in place. Real settings always receive their own fresh arrays via
+// buildMovementOptions() below, so this is just a safety net.
+Object.freeze(DEFAULT_MOVEMENT_OPTS.exclusionAreasStep)
+Object.freeze(DEFAULT_MOVEMENT_OPTS.exclusionAreasBreak)
+Object.freeze(DEFAULT_MOVEMENT_OPTS.exclusionAreasPlace)
+
+/**
+ * Merge user-supplied movement options on top of {@link DEFAULT_MOVEMENT_OPTS}
+ * and return a complete {@link MovementOptions}.
+ *
+ * The three exclusion-area lists are ALWAYS returned as their own fresh arrays.
+ * The defaults hold a single shared `[]` per list, so copying here is what stops
+ * two different bots — or two `setMoveOptions` calls — from accidentally sharing
+ * (and then mutating) the same array. Use this everywhere instead of a bare
+ * `Object.assign({}, DEFAULT_MOVEMENT_OPTS, settings)`.
+ */
+export function buildMovementOptions (settings: Partial = {}): MovementOptions {
+ const merged = Object.assign({}, DEFAULT_MOVEMENT_OPTS, settings)
+ merged.exclusionAreasStep = [...merged.exclusionAreasStep]
+ merged.exclusionAreasBreak = [...merged.exclusionAreasBreak]
+ merged.exclusionAreasPlace = [...merged.exclusionAreasPlace]
+ return merged
}
const cardinalVec3s: Vec3[] = [
@@ -136,7 +185,7 @@ export abstract class Movement {
public constructor (bot: Bot, world: World, settings: Partial = {}) {
this.bot = bot
this.world = world
- this.settings = Object.assign({}, DEFAULT_MOVEMENT_OPTS, settings)
+ this.settings = buildMovementOptions(settings)
}
loadMove (move: Move): void {
@@ -183,7 +232,56 @@ export abstract class Movement {
}
/**
- * Takes into account if the block is within a break exclusion area.
+ * Add up the extra cost of STANDING in / walking into this block, using every
+ * "step" exclusion area in the settings (see {@link MovementOptions.exclusionAreasStep}).
+ *
+ * Returns 0 when there are no step areas configured (the normal, default case),
+ * so this is essentially free unless the user opted in to exclusion zones.
+ */
+ exclusionStep (block: BlockInfo): number {
+ const areas = this.settings.exclusionAreasStep
+ if (areas.length === 0) return 0
+ let weight = 0
+ for (const area of areas) weight += area(block)
+ return weight
+ }
+
+ /**
+ * Add up the extra cost of BREAKING this block, using every "break" exclusion
+ * area in the settings (see {@link MovementOptions.exclusionAreasBreak}).
+ *
+ * Returns 0 when there are no break areas configured.
+ */
+ exclusionBreak (block: BlockInfo): number {
+ const areas = this.settings.exclusionAreasBreak
+ if (areas.length === 0) return 0
+ let weight = 0
+ for (const area of areas) weight += area(block)
+ return weight
+ }
+
+ /**
+ * Add up the extra cost of PLACING a block here, using every "place" exclusion
+ * area in the settings (see {@link MovementOptions.exclusionAreasPlace}).
+ *
+ * Returns 0 when there are no place areas configured.
+ */
+ exclusionPlace (block: BlockInfo): number {
+ const areas = this.settings.exclusionAreasPlace
+ if (areas.length === 0) return 0
+ let weight = 0
+ for (const area of areas) weight += area(block)
+ return weight
+ }
+
+ /**
+ * Whether this block is allowed to be broken at all (ignoring cost).
+ *
+ * This only answers the "is it physically/configurably breakable" question
+ * (can we dig, would it create flowing water, would a block fall on us, is it
+ * an unbreakable block like bedrock). The "is it inside a no-mining zone"
+ * question is handled separately as a cost, in {@link breakCost} via
+ * {@link exclusionBreak}.
* @param {BlockInfo} block
* @returns
*/
@@ -208,17 +306,23 @@ export abstract class Movement {
}
// console.log('block type:', this.bot.registry.blocks[block.type], block.position, !BlockInfo.blocksCantBreak.has(block.type))
- return BlockInfo.replaceables.has(block.type) || !BlockInfo.blocksCantBreak.has(block.type) // && this.exclusionBreak(block) < COST_INF
+ return BlockInfo.replaceables.has(block.type) || !BlockInfo.blocksCantBreak.has(block.type)
}
/**
- * Takes into account if the block is within the stepExclusionAreas. And returns COST_INF if a block to be broken is within break exclusion areas.
- * @param {import('prismarine-block').Block} block block
- * @param {[]} toBreak
+ * Cost of either walking through `block` (if it is already passable) or
+ * breaking it so the bot can pass.
+ *
+ * Returns `COST_INF` (or more) when the block cannot be used — for example an
+ * unbreakable block, or one inside a break-exclusion zone (see
+ * {@link breakCost} / {@link exclusionBreak}). Step-exclusion zones are NOT
+ * checked here; each movement provider applies {@link exclusionStep} to the
+ * block the bot lands in, folding the cost in before the move is created.
+ * @param {BlockInfo} block block
+ * @param {BreakHandler[]} toBreak
* @returns {number}
*/
safeOrBreak (block: BlockInfo, toBreak: BreakHandler[]): number {
- // cost += this.exclusionStep(block) // Is excluded so can't move or break
// cost += this.getNumEntitiesAt(block.position, 0, 0, 0) * this.entityCost
// if (block.breakCost !== undefined) return block.breakCost // cache breaking cost.
@@ -256,7 +360,11 @@ export abstract class Movement {
// const effects = this.bot.entity.effects
// const digTime = block.block.digTime(tool ? tool.type : null, false, false, false, enchants, effects)
const laborCost = (1 + 3 * digTime / 1000) * this.settings.digCost
- return laborCost
+
+ // Add the break-exclusion penalty (0 unless the user configured "no mining" zones).
+ // If the block sits inside a forbidden zone this pushes the cost past COST_INF,
+ // which every caller treats as "do not break this block".
+ return laborCost + this.exclusionBreak(block)
}
safeOrPlace (block: BlockInfo, toPlace: PlaceHandler[], type: InteractType = 'solid'): number {
@@ -278,7 +386,9 @@ export abstract class Movement {
* TODO: calculate more accurate place costs.
*/
placeCost (block: BlockInfo): number {
- return this.settings.placeCost
+ // Add the place-exclusion penalty (0 unless the user configured "no building" zones).
+ // A forbidden zone pushes this past COST_INF, which callers treat as "do not place here".
+ return this.settings.placeCost + this.exclusionPlace(block)
}
}
diff --git a/src/mineflayer-specific/movements/movementProvider.ts b/src/mineflayer-specific/movements/movementProvider.ts
index 7558048..2bc07cb 100644
--- a/src/mineflayer-specific/movements/movementProvider.ts
+++ b/src/mineflayer-specific/movements/movementProvider.ts
@@ -2,7 +2,7 @@ import { Bot } from 'mineflayer'
import { Move } from '../move'
import * as goals from '../goals'
import { World } from '../world/worldInterface'
-import { DEFAULT_MOVEMENT_OPTS, Movement, MovementOptions } from './movement'
+import { Movement, MovementOptions, buildMovementOptions } from './movement'
import { MovementProvider as AMovementProvider } from '../../abstract'
import type { ExecutorMap } from '.'
@@ -137,7 +137,7 @@ export class MovementHandler implements AMovementProvider {
recMovement: ExecutorMap,
settings: Partial = {}
): MovementHandler {
- const opts = Object.assign({}, DEFAULT_MOVEMENT_OPTS, settings)
+ const opts = buildMovementOptions(settings)
return new MovementHandler(
bot,
world,
diff --git a/src/mineflayer-specific/movements/movementProviders.ts b/src/mineflayer-specific/movements/movementProviders.ts
index 059c356..5fc9581 100644
--- a/src/mineflayer-specific/movements/movementProviders.ts
+++ b/src/mineflayer-specific/movements/movementProviders.ts
@@ -67,6 +67,10 @@ export class Forward extends MovementProvider {
if ((cost += this.safeOrBreak(blockB, toBreak)) > COST_INF) return
if ((cost += this.safeOrBreak(blockC, toBreak)) > COST_INF) return
+ // Exclusion zones: blockC is where the bot's feet end up. Fold the step cost
+ // in now (before the move exists) so Move.cost stays read-only.
+ if ((cost += this.exclusionStep(blockC)) > COST_INF) return
+
// set cachedVec to center of wanted block
neighbors.push(Move.fromPrevious(cost, blockC.position.offset(0.5, 0, 0.5), start, this, toPlace, toBreak))
}
@@ -133,6 +137,9 @@ export class Diagonal extends MovementProvider {
cost += this.safeOrBreak(this.getBlockInfo(node, 0, 1, dir.z), toBreak)
if (cost > COST_INF) return
+ // Exclusion zones: block0 is the destination foot block.
+ if ((cost += this.exclusionStep(block0)) > COST_INF) return
+
neighbors.push(Move.fromPrevious(cost, block0.position.offset(0.5, 0, 0.5), node, this, toPlace, toBreak))
}
}
@@ -198,8 +205,6 @@ export class ForwardJump extends MovementProvider {
if ((cost += this.breakCost(blockD)) > COST_INF) return
toBreak.push(BreakHandler.fromVec(blockD.position, 'solid'))
}
- // cost += this.exclusionPlace(blockD)
-
if ((cost += this.safeOrPlace(blockD, toPlace, 'solid')) > COST_INF) return
}
@@ -221,6 +226,9 @@ export class ForwardJump extends MovementProvider {
if ((cost += this.safeOrBreak(blockH, toBreak)) > COST_INF) return
if (toPlace.length > 0) return
+ // Exclusion zones: blockB is the destination foot block.
+ if ((cost += this.exclusionStep(blockB)) > COST_INF) return
+
// set cachedVec to center of block we want.
neighbors.push(Move.fromPrevious(cost, blockB.position.offset(0.5, 0, 0.5), node, this, toPlace, toBreak))
}
@@ -316,6 +324,9 @@ export class ForwardDropDown extends DropDownProvider {
if ((cost += this.safeOrBreak(blockC, toBreak)) > COST_INF) return
if ((cost += this.safeOrBreak(blockD, toBreak)) > COST_INF) return
+ // Exclusion zones: blockLand is where the bot lands and stands.
+ if ((cost += this.exclusionStep(blockLand)) > COST_INF) return
+
// cost += this.getNumEntitiesAt(blockLand.position, 0, 0, 0) * this.entityCost // add cost for entities
neighbors.push(Move.fromPrevious(cost, blockLand.position.offset(0.5, 0, 0.5), node, this, toPlace, toBreak))
}
@@ -351,6 +362,9 @@ export class StraightDown extends DropDownProvider {
if ((cost += this.safeOrBreak(block1, toBreak)) > COST_INF) return
+ // Exclusion zones: blockLand is where the bot lands and stands.
+ if ((cost += this.exclusionStep(blockLand)) > COST_INF) return
+
// cost += this.getNumEntitiesAt(blockLand.position, 0, 0, 0) * this.entityCost // add cost for entities
neighbors.push(Move.fromPrevious(cost, blockLand.position.offset(0.5, 0, 0.5), node, this, toPlace, toBreak))
@@ -402,6 +416,12 @@ export class StraightUp extends MovementProvider {
}
}
+ // Exclusion zones: the bot ends up standing one block above (node y + 1).
+ // Only do the (cache-routed) block lookup when step zones are configured.
+ if (this.settings.exclusionAreasStep.length > 0) {
+ if ((cost += this.exclusionStep(this.getBlockInfo(node, 0, 1, 0))) > COST_INF) return
+ }
+
neighbors.push(Move.fromPrevious(cost, block1.position.offset(0.5, 1, 0.5), node, this, toPlace, toBreak))
}
}
@@ -470,31 +490,33 @@ export class ParkourForward extends MovementProvider {
// Down
const blockE = this.getBlockInfo(node, dx, -2, dz)
if (blockE.physical) { // TODO: support jumping into liquid.
- // cost += this.exclusionStep(blockD)
- // cost += this.getNumEntitiesAt(blockD.position, 0, 0, 0) * this.entityCost
- neighbors.push(Move.fromPrevious(cost, blockD.position.offset(0.5, 0, 0.5), node, this))
- // neighbors.push(new Move(blockD.position.x, blockD.position.y, blockD.position.z, node.remainingBlocks, cost, [], [], true))
+ // Exclusion zones: blockD is where the bot lands. Fold the step cost in
+ // before creating the move, and skip the move entirely if it is forbidden.
+ const stepCost = this.exclusionStep(blockD)
+ if (stepCost < COST_INF) {
+ neighbors.push(Move.fromPrevious(cost + stepCost, blockD.position.offset(0.5, 0, 0.5), node, this))
+ }
}
floorCleared = floorCleared && !blockE.physical
} else if (flag1 && ceilingClear && blockB.walkthrough && blockC.walkthrough && blockD.physical) {
// if (d === 5) continue
const cost1 = cost + 3 // potential slowdown (will fix later.)
- // cost += this.exclusionStep(blockB)
// Forward
-
- neighbors.push(Move.fromPrevious(cost1, blockC.position.offset(0.5, 0, 0.5), node, this))
- // neighbors.push(new Move(blockC.position.x, blockC.position.y, blockC.position.z, node.remainingBlocks, cost, [], [], true))
+ const stepCost = this.exclusionStep(blockC)
+ if (stepCost < COST_INF) {
+ neighbors.push(Move.fromPrevious(cost1 + stepCost, blockC.position.offset(0.5, 0, 0.5), node, this))
+ }
break
} else if (flag2 && ceilingClear && blockA.walkthrough && blockB.walkthrough && blockC.physical) {
// Up
if (d === 5) continue
// 4 Blocks forward 1 block up is very difficult and fails often
- // cost += this.exclusionStep(blockA)
if (blockC.height - block0.height > 1.2) break // Too high to jump
- // cost += this.getNumEntitiesAt(blockB.position, 0, 0, 0) * this.entityCost
- neighbors.push(Move.fromPrevious(cost, blockB.position.offset(0.5, 0, 0.5), node, this))
- // neighbors.push(new Move(blockB.position.x, blockB.position.y, blockB.position.z, node.remainingBlocks, cost, [], [], true))
+ const stepCost = this.exclusionStep(blockB)
+ if (stepCost < COST_INF) {
+ neighbors.push(Move.fromPrevious(cost + stepCost, blockB.position.offset(0.5, 0, 0.5), node, this))
+ }
break
// }
} else if (!blockB.walkthrough || !blockC.walkthrough) {
@@ -609,16 +631,25 @@ export class ParkourDiagonal extends MovementProvider {
if (flag0 && ceilingClear && blockB.walkthrough && blockC.walkthrough && blockD.walkthrough && blockFrontD.walkthrough && !floorCleared) {
if (blockE.physical) {
- neighbors.push(Move.fromPrevious(cost, blockD.position.offset(0.5, 0, 0.5), node, this))
+ const stepCost = this.exclusionStep(blockD)
+ if (stepCost < COST_INF) {
+ neighbors.push(Move.fromPrevious(cost + stepCost, blockD.position.offset(0.5, 0, 0.5), node, this))
+ }
return true
}
} else if (flag1 && ceilingClear && blockB.walkthrough && blockC.walkthrough && blockD.physical && blockFrontC.walkthrough) {
- neighbors.push(Move.fromPrevious(cost + 3, blockC.position.offset(0.5, 0, 0.5), node, this))
+ const stepCost = this.exclusionStep(blockC)
+ if (stepCost < COST_INF) {
+ neighbors.push(Move.fromPrevious(cost + 3 + stepCost, blockC.position.offset(0.5, 0, 0.5), node, this))
+ }
return true
} else if (flag2 && ceilingClear && blockA.walkthrough && blockB.walkthrough && blockC.physical && blockFrontB.walkthrough) {
if (blockC.height - block0.height > 1.2) return false
if (travel > PARKOUR_DIAGONAL_3_3_TRAVEL) return false
- neighbors.push(Move.fromPrevious(cost, blockB.position.offset(0.5, 0, 0.5), node, this))
+ const stepCost = this.exclusionStep(blockB)
+ if (stepCost < COST_INF) {
+ neighbors.push(Move.fromPrevious(cost + stepCost, blockB.position.offset(0.5, 0, 0.5), node, this))
+ }
return true
}
diff --git a/tests/exclusionZones.test.ts b/tests/exclusionZones.test.ts
new file mode 100644
index 0000000..a7d71fb
--- /dev/null
+++ b/tests/exclusionZones.test.ts
@@ -0,0 +1,240 @@
+import assert from 'node:assert/strict'
+import test from 'node:test'
+import { Vec3 } from 'vec3'
+
+import { createPlugin, goals } from '../src'
+import {
+ createBoxExclusion,
+ createRadiusExclusion,
+ createColumnRadiusExclusion,
+ EXCLUSION_NEVER,
+ type ExclusionArea
+} from '../src'
+import { BlockInfo } from '../src/mineflayer-specific/world/cacheWorld'
+import { buildMovementOptions, DEFAULT_MOVEMENT_OPTS } from '../src/mineflayer-specific/movements'
+import { createCacheWorld } from './setup'
+
+// ---------------------------------------------------------------------------
+// Small shared helpers (kept local so this file stands on its own), mirroring
+// the style of tests/pathfinder.test.ts.
+// ---------------------------------------------------------------------------
+
+type PathResult = {
+ status: string
+ path: Array<{ x: number, y: number, z: number }>
+}
+
+function withTimeout (promise: Promise, ms: number, message: string): Promise {
+ let timer: NodeJS.Timeout | undefined
+ return Promise.race([
+ promise.finally(() => {
+ if (timer != null) clearTimeout(timer)
+ }),
+ new Promise((_, reject) => {
+ timer = setTimeout(() => reject(new Error(message)), ms)
+ })
+ ])
+}
+
+function preparePathRig (moveSettings?: Record) {
+ const rig = createCacheWorld('1.20.4', 64, new Vec3(0, 64, 0)).rig
+ rig.bot.loadPlugin(createPlugin({
+ pathfinderSettings: { partialPathProducer: true, partialPathLength: 50 },
+ moveSettings: moveSettings as any
+ }))
+ return rig
+}
+
+async function collectPathResult (
+ bot: ReturnType['bot'],
+ goal: goals.Goal,
+ timeoutMs = 15000
+): Promise {
+ let final: { result: PathResult } | undefined
+
+ await withTimeout((async () => {
+ for await (const res of bot.pathfinder.getPathTo(goal)) {
+ final = res as { result: PathResult }
+ }
+ })(), timeoutMs, `timed out while planning to ${goal.constructor.name}`)
+
+ if (final == null) {
+ throw new Error('path planner finished without a final result')
+ }
+
+ return final.result
+}
+
+/** Build a fake BlockInfo that only carries a position (all the helpers look at). */
+function blockAt (x: number, y: number, z: number): BlockInfo {
+ return { position: new Vec3(x, y, z) } as unknown as BlockInfo
+}
+
+// ---------------------------------------------------------------------------
+// Unit tests for the zone-builder helpers. These are pure functions, so we can
+// check their math directly without spinning up the pathfinder.
+// ---------------------------------------------------------------------------
+
+test('createBoxExclusion forbids blocks inside the box and ignores blocks outside', () => {
+ const box = createBoxExclusion(new Vec3(0, 64, 0), new Vec3(4, 68, 4))
+
+ // corners and an interior point are inside -> forbidden.
+ assert.equal(box(blockAt(0, 64, 0)), EXCLUSION_NEVER)
+ assert.equal(box(blockAt(4, 68, 4)), EXCLUSION_NEVER)
+ assert.equal(box(blockAt(2, 66, 2)), EXCLUSION_NEVER)
+
+ // just outside on each axis -> free.
+ assert.equal(box(blockAt(-1, 66, 2)), 0)
+ assert.equal(box(blockAt(5, 66, 2)), 0)
+ assert.equal(box(blockAt(2, 63, 2)), 0)
+ assert.equal(box(blockAt(2, 69, 2)), 0)
+})
+
+test('createBoxExclusion accepts the two corners in any order', () => {
+ const ordered = createBoxExclusion(new Vec3(0, 64, 0), new Vec3(4, 68, 4))
+ const swapped = createBoxExclusion(new Vec3(4, 68, 4), new Vec3(0, 64, 0))
+
+ for (const p of [blockAt(0, 64, 0), blockAt(2, 66, 2), blockAt(5, 66, 2)]) {
+ assert.equal(ordered(p), swapped(p))
+ }
+})
+
+test('createBoxExclusion supports a custom soft cost', () => {
+ const soft = createBoxExclusion(new Vec3(0, 0, 0), new Vec3(2, 2, 2), 25)
+ assert.equal(soft(blockAt(1, 1, 1)), 25)
+ assert.equal(soft(blockAt(9, 9, 9)), 0)
+})
+
+test('createRadiusExclusion forbids blocks within the radius (a ball)', () => {
+ const ball = createRadiusExclusion(new Vec3(0, 0, 0), 3)
+
+ assert.equal(ball(blockAt(0, 0, 0)), EXCLUSION_NEVER) // center
+ assert.equal(ball(blockAt(3, 0, 0)), EXCLUSION_NEVER) // on the radius
+ assert.equal(ball(blockAt(0, 3, 0)), EXCLUSION_NEVER) // height counts
+ assert.equal(ball(blockAt(4, 0, 0)), 0) // just outside
+})
+
+test('createColumnRadiusExclusion ignores height (a pillar)', () => {
+ const pillar = createColumnRadiusExclusion(new Vec3(0, 64, 0), 3)
+
+ // Same X/Z, wildly different Y -> still inside the pillar.
+ assert.equal(pillar(blockAt(0, -40, 0)), EXCLUSION_NEVER)
+ assert.equal(pillar(blockAt(2, 250, 0)), EXCLUSION_NEVER)
+ // Outside the X/Z radius -> free, regardless of height.
+ assert.equal(pillar(blockAt(4, 64, 0)), 0)
+})
+
+// ---------------------------------------------------------------------------
+// Regression tests: the default arrays must never be shared/mutated. Without
+// fresh copies, mutating one bot's defaultMoveSettings.exclusionAreasStep would
+// leak zones into other bots and into later setMoveOptions calls.
+// ---------------------------------------------------------------------------
+
+test('buildMovementOptions gives every settings object its own exclusion arrays', () => {
+ const a = buildMovementOptions()
+ const b = buildMovementOptions()
+
+ // Fresh arrays, not the shared default instance, and not shared with each other.
+ assert.notEqual(a.exclusionAreasStep, DEFAULT_MOVEMENT_OPTS.exclusionAreasStep)
+ assert.notEqual(a.exclusionAreasStep, b.exclusionAreasStep)
+
+ // Mutating one must not leak into the other, nor back into the defaults.
+ a.exclusionAreasStep.push(() => 0)
+ assert.equal(a.exclusionAreasStep.length, 1)
+ assert.equal(b.exclusionAreasStep.length, 0)
+ assert.equal(DEFAULT_MOVEMENT_OPTS.exclusionAreasStep.length, 0)
+})
+
+test('buildMovementOptions copies a user-supplied array instead of holding its reference', () => {
+ const mine: ExclusionArea[] = []
+ const opts = buildMovementOptions({ exclusionAreasBreak: mine })
+
+ // The settings hold a copy, so edits to either side stay independent.
+ assert.notEqual(opts.exclusionAreasBreak, mine)
+ mine.push(() => 0)
+ assert.equal(opts.exclusionAreasBreak.length, 0)
+})
+
+test('the default exclusion arrays are frozen so they cannot be mutated in place', () => {
+ assert.ok(Object.isFrozen(DEFAULT_MOVEMENT_OPTS.exclusionAreasStep))
+ assert.ok(Object.isFrozen(DEFAULT_MOVEMENT_OPTS.exclusionAreasBreak))
+ assert.ok(Object.isFrozen(DEFAULT_MOVEMENT_OPTS.exclusionAreasPlace))
+})
+
+// ---------------------------------------------------------------------------
+// Functional tests: run the real pathfinder over a flat world and confirm the
+// zones change the chosen path.
+// ---------------------------------------------------------------------------
+
+// A solid "wall" of forbidden blocks straddling the straight-line route from
+// (0,64,0) to (20,64,0). The bot stands at y=64, so y 64-66 covers feet+head.
+function makeWall (): { area: ExclusionArea, isInside: (x: number, y: number, z: number) => boolean } {
+ const minX = 8; const maxX = 12
+ const minZ = -3; const maxZ = 3
+ const minY = 64; const maxY = 66
+ return {
+ area: createBoxExclusion(new Vec3(minX, minY, minZ), new Vec3(maxX, maxY, maxZ)),
+ isInside: (x, y, z) => x >= minX && x <= maxX && y >= minY && y <= maxY && z >= minZ && z <= maxZ
+ }
+}
+
+test('a hard step exclusion forces the bot to detour around a wall', async () => {
+ const wall = makeWall()
+ const rig = preparePathRig({ exclusionAreasStep: [wall.area] })
+
+ try {
+ const result = await collectPathResult(rig.bot, new goals.GoalBlock(20, 64, 0))
+ const last = result.path[result.path.length - 1]
+
+ assert.equal(result.status, 'success')
+ assert.equal(last?.x, 20)
+ assert.equal(last?.y, 64)
+ assert.equal(last?.z, 0)
+
+ // The whole point: no step in the plan lands inside the forbidden wall.
+ for (const node of result.path) {
+ assert.ok(
+ !wall.isInside(node.x, node.y, node.z),
+ `path stepped into the excluded wall at ${node.x},${node.y},${node.z}`
+ )
+ }
+
+ // Going around is strictly longer than the unobstructed straight line (21).
+ assert.ok(result.path.length > 21, `expected a detour longer than 21 moves, got ${result.path.length}`)
+ } finally {
+ rig.stopPassivePhysics()
+ }
+})
+
+test('a soft step exclusion still reaches the goal (avoid, never forbid)', async () => {
+ // A soft, finite cost makes the zone undesirable but not impossible to cross.
+ const softWall = createBoxExclusion(new Vec3(8, 64, -3), new Vec3(12, 66, 3), 40)
+ const rig = preparePathRig({ exclusionAreasStep: [softWall] })
+
+ try {
+ const result = await collectPathResult(rig.bot, new goals.GoalBlock(20, 64, 0))
+ const last = result.path[result.path.length - 1]
+
+ assert.equal(result.status, 'success')
+ assert.equal(last?.x, 20)
+ assert.equal(last?.y, 64)
+ assert.equal(last?.z, 0)
+ } finally {
+ rig.stopPassivePhysics()
+ }
+})
+
+test('a hard step exclusion on the only goal block makes the goal unreachable', async () => {
+ // Forbid standing on the exact goal block; the bot can never finish.
+ const onGoal = createBoxExclusion(new Vec3(20, 64, 0), new Vec3(20, 64, 0))
+ const rig = preparePathRig({ exclusionAreasStep: [onGoal] })
+
+ try {
+ await assert.rejects(
+ collectPathResult(rig.bot, new goals.GoalBlock(20, 64, 0), 4000),
+ /timed out while planning to GoalBlock/
+ )
+ } finally {
+ rig.stopPassivePhysics()
+ }
+})
From 56560c9f3eda045753a2240b98867dc339a1a3b8 Mon Sep 17 00:00:00 2001
From: XaXayo12 <257781493+XaXayo12@users.noreply.github.com>
Date: Wed, 17 Jun 2026 00:03:27 +0200
Subject: [PATCH 2/3] Address review: optimizer exclusion, drop utilities, trim
docs
- Post-processing: the Optimizer now refuses to straight-line a merge whose
swept line crosses a HARD step-exclusion zone, so optimized/straight-lined
paths no longer cut through "keep out" terrain the A* route went around.
Soft zones still allow straightening (a preference, not a wall). The check
is central in Optimizer.compute, so every optimizer respects it.
- Remove the box/radius/column zone-builder utilities from the library and
keep only the ExclusionArea type; the shape helpers now live in
examples/exclusionZones.js for users to copy. Utilities are out of scope.
- Trim the Exclusion Zones docs (API.md, AdvancedUsage.md) to be concise.
- Tests: add two deterministic optimizer-guard tests (merge skipped across a
hard zone; merged freely without one); drop the now-removed helper unit tests.
Build clean, 25/25 tests pass.
---
docs/API.md | 95 +++------
docs/AdvancedUsage.md | 73 ++-----
examples/exclusionZones.js | 72 ++++---
src/index.ts | 8 +-
.../movements/exclusionZones.ts | 142 +------------
src/mineflayer-specific/movements/movement.ts | 4 +-
src/mineflayer-specific/post/optimizer.ts | 48 ++++-
tests/exclusionZones.test.ts | 186 +++++++++---------
8 files changed, 248 insertions(+), 380 deletions(-)
diff --git a/docs/API.md b/docs/API.md
index e7146a7..73115ed 100644
--- a/docs/API.md
+++ b/docs/API.md
@@ -602,93 +602,46 @@ interface MovementOptions {
Exclusion Zones
-Exclusion zones let you tell the bot **"keep out of here"** — either softly (an
-area is allowed but more expensive, so the bot prefers to go around) or hard
-(an area is completely off-limits). This is the same idea as upstream
-[`PrismarineJS/mineflayer-pathfinder`](https://github.com/PrismarineJS/mineflayer-pathfinder),
-so exclusion functions you wrote for that library keep working here.
-
-How it works
-
-An **exclusion area** is just a function. You give it one block, and it returns
-the *extra cost* of using that block:
+Exclusion zones mark areas as off-limits. An exclusion area is a function
+returning the extra cost of a block; the same idea as upstream
+[`PrismarineJS/mineflayer-pathfinder`](https://github.com/PrismarineJS/mineflayer-pathfinder).
```ts
type ExclusionArea = (block: BlockInfo) => number
```
-- return `0` → "I don't care about this block."
-- return a positive number (e.g. `50`) → a **soft** zone: the bot may use the block, but it costs that much more, so it avoids it when there is a cheaper way around.
-- return `EXCLUSION_NEVER` (a.k.a. `Infinity`) → a **hard** zone: the bot will never use this block. Any value `>= COST_INF` counts as "never".
+- `0` — no opinion.
+- a positive number — soft zone: allowed, but avoided when a cheaper route exists.
+- `Infinity` (`>= COST_INF`) — hard zone: never used.
-There are three independent lists in the settings, one per kind of action:
+Areas live in three settings; each sums the cost of every function in its list:
-| Setting | Asked about every block the bot would… |
+| Setting | Checked on every block the bot would… |
| --- | --- |
-| `exclusionAreasStep` | **stand in** / walk into (checked on the block the bot's feet end up in, for every movement type: walking, jumping, dropping, parkour, towers). |
-| `exclusionAreasBreak` | **break** (mine). |
-| `exclusionAreasPlace` | **place** (build on). |
-
-> When all three lists are empty (the default), exclusion costs nothing to
-> evaluate — there is zero overhead for normal pathfinding.
-
-Ready-made zone shapes
-
-You usually don't need to write the function yourself. These helpers build the
-common shapes for you (all are exported from the package root):
-
-▸ **createBoxExclusion(`corner1: Vec3, corner2: Vec3, cost = EXCLUSION_NEVER`): `ExclusionArea`**
-
-A box between two opposite corners (inclusive, any order — like a WorldEdit selection).
-
-▸ **createRadiusExclusion(`center: Vec3, radius: number, cost = EXCLUSION_NEVER`): `ExclusionArea`**
-
-A ball (sphere): every block within `radius` of `center`. Height counts.
-
-▸ **createColumnRadiusExclusion(`center: Vec3, radius: number, cost = EXCLUSION_NEVER`): `ExclusionArea`**
-
-A pillar (vertical column): like the ball, but it ignores height — only X/Z distance matters.
+| `exclusionAreasStep` | stand in (the foot block of each move — walk, jump, drop, parkour, tower; optimized paths included). |
+| `exclusionAreasBreak` | break (mine). |
+| `exclusionAreasPlace` | place (build on). |
-Examples
+Empty by default, so there is zero overhead when unused. The library ships no
+ready-made shapes — write your own. Box/radius helpers are in
+[`examples/exclusionZones.js`](../examples/exclusionZones.js).
```ts
-const { Vec3 } = require('vec3')
-const {
- createBoxExclusion,
- createRadiusExclusion,
- createColumnRadiusExclusion
-} = require('@nxg-org/mineflayer-pathfinder')
-
-// 1) Hard no-go box: the bot will never set foot in this region.
-const spawnArea = createBoxExclusion(new Vec3(-10, 60, -10), new Vec3(10, 80, 10))
-
-// 2) Soft danger zone: the bot may pass within 8 blocks of the turret,
-// but only if going around would be even more expensive.
-const turret = createRadiusExclusion(new Vec3(100, 64, 100), 8, 60)
-
-// 3) Never dig or build inside the protected spawn box.
-const protectedBox = createBoxExclusion(new Vec3(-10, 0, -10), new Vec3(10, 320, 10))
+// Never break or place inside a protected box; soft-avoid stepping on farmland.
+const farmlandId = bot.registry.blocksByName.farmland.id
+const inBox = (block) =>
+ block.position.x >= -10 && block.position.x <= 10 &&
+ block.position.z >= -10 && block.position.z <= 10 ? Infinity : 0
bot.pathfinder.setMoveOptions({
- exclusionAreasStep: [spawnArea, turret],
- exclusionAreasBreak: [protectedBox],
- exclusionAreasPlace: [protectedBox]
+ exclusionAreasStep: [(block) => block.type === farmlandId ? 100 : 0],
+ exclusionAreasBreak: [inBox],
+ exclusionAreasPlace: [inBox]
})
```
-You can also write a fully custom rule — any function `(block) => number` works:
-
-```ts
-// Avoid stepping on farmland so the bot never tramples crops.
-const farmlandId = bot.registry.blocksByName.farmland.id
-const dontTrample = (block) => block.type === farmlandId ? 100 : 0
-
-bot.pathfinder.setMoveOptions({ exclusionAreasStep: [dontTrample] })
-```
-
-> **Note:** `exclusionAreasStep` is checked on the block the bot's **feet** land
-> in. If you need to guarantee the bot's head also stays out of a region, make
-> the box one block taller at the bottom.
+`exclusionAreasStep` is checked on the block the bot's **feet** land in; extend a
+box one block lower if you also need the head kept out.
diff --git a/docs/AdvancedUsage.md b/docs/AdvancedUsage.md
index 2feb325..0d8691d 100644
--- a/docs/AdvancedUsage.md
+++ b/docs/AdvancedUsage.md
@@ -315,57 +315,21 @@ class MyMovementOptimizer extends MovementOptimizer {
Exclusion Zones (Keep-Out Areas)
-Sometimes you don't want to write a whole custom `MovementProvider` — you just
-want to tell the bot **"stay out of this area"**. That is what exclusion zones
-are for. They are a setting, so you turn them on by changing `moveSettings`,
-not by subclassing anything.
+To keep the bot out of an area without writing a custom `MovementProvider`, add
+an **exclusion area** to the move settings. It is a function returning the extra
+cost of a block: `0` (no opinion), a positive number (soft — avoided when a
+cheaper route exists), or `Infinity` (hard — never used).
-The one idea you need
+Three lists, each summing the cost of every function in it:
-An **exclusion area** is a function that looks at a single block and returns a
-number — the *extra* cost of using that block:
+- `exclusionAreasStep` — blocks the bot would stand in.
+- `exclusionAreasBreak` — blocks the bot would break (mine).
+- `exclusionAreasPlace` — blocks the bot would place (build on).
-- `0` → the bot doesn't care, business as usual.
-- a positive number → **soft** zone: allowed, but more expensive, so the bot goes around when it can.
-- `Infinity` / `EXCLUSION_NEVER` → **hard** zone: the bot will never use that block.
-
-There are three lists in the settings, one for each kind of action the bot can
-take on a block:
-
-- `exclusionAreasStep` — blocks the bot would **stand in**.
-- `exclusionAreasBreak` — blocks the bot would **break** (mine).
-- `exclusionAreasPlace` — blocks the bot would **place** (build on).
-
-All three are empty by default, which means "no zones" and costs nothing.
-
-The quick way: ready-made shapes
-
-```ts
-const { Vec3 } = require('vec3')
-const {
- createBoxExclusion, // a box between two corners
- createRadiusExclusion, // a ball around a point
- createColumnRadiusExclusion // a vertical pillar around a point (ignores height)
-} = require('@nxg-org/mineflayer-pathfinder')
-
-// Hard no-go box around spawn protection.
-const spawn = createBoxExclusion(new Vec3(-16, 60, -16), new Vec3(16, 90, 16))
-
-// Soft "stay away" ball around a mob spawner (cost 50, not forbidden).
-const spawner = createRadiusExclusion(new Vec3(40, 64, -120), 10, 50)
-
-bot.pathfinder.setMoveOptions({
- exclusionAreasStep: [spawn, spawner]
-})
-```
-
-The flexible way: your own function
-
-Any function `(block) => number` works, so you can base the rule on block type,
-position, height, whatever you like:
+All empty by default (no overhead). Any `(block) => number` works:
```ts
-// Never mine valuable ores, and never break anything below y=0.
+// Never mine valuable ores or anything below y=0.
const diamondId = bot.registry.blocksByName.diamond_ore.id
const protectOres = (block) =>
(block.type === diamondId || block.position.y < 0) ? Infinity : 0
@@ -373,13 +337,12 @@ const protectOres = (block) =>
bot.pathfinder.setMoveOptions({ exclusionAreasBreak: [protectOres] })
```
-Good to know
+Notes:
-- You can combine as many areas as you want in each list — their costs add up.
-- `exclusionAreasStep` is checked on the block the bot's **feet** land in, for
- every movement type (walking, jumping, dropping, parkour, towers). If you need
- the bot's head kept out too, make the box one block taller at the bottom.
-- This matches upstream `mineflayer-pathfinder`'s exclusion areas, so functions
- written for that library work here unchanged.
-- Prefer `setMoveOptions({ ... })` with a fresh array over mutating the existing
- array in place, so every part of the pathfinder picks up the new zones.
+- Costs from multiple areas in a list add up.
+- `exclusionAreasStep` is checked on the foot block of every move (walk, jump,
+ drop, parkour, tower), and optimized/straight-lined paths are blocked from
+ cutting through hard zones too.
+- Pass a fresh array to `setMoveOptions({ ... })` rather than mutating the
+ existing one.
+- Ready-to-copy box/radius helpers live in `examples/exclusionZones.js`.
diff --git a/examples/exclusionZones.js b/examples/exclusionZones.js
index 65772f6..3bf20aa 100644
--- a/examples/exclusionZones.js
+++ b/examples/exclusionZones.js
@@ -3,29 +3,59 @@
// ---------------------------------------------------------------------------
// Exclusion zones example
//
-// This shows how to tell the bot "keep out of here" using the three exclusion
-// lists in the movement settings. Run a local server, then:
+// An "exclusion area" is just a function (block) => number that returns the
+// extra cost of using a block: 0 = fine, a positive number = soft avoid,
+// Infinity = hard "keep out". The library ships no ready-made shapes on
+// purpose, so the small box/radius builders below are yours to copy and adapt.
//
-// node examples/exclusionZones.js
+// They go into three movement settings:
+// exclusionAreasStep -> blocks the bot may stand in / walk into
+// exclusionAreasBreak -> blocks the bot may break (mine)
+// exclusionAreasPlace -> blocks the bot may place (build on)
//
-// In game, type these in chat:
-// goto -> walk there, but respecting the zones below
-// zones on -> turn the example zones on
-// zones off -> turn them all off again
+// Run a local server, then: node examples/exclusionZones.js
+// In chat: "goto ", "zones on", "zones off".
// ---------------------------------------------------------------------------
const { createBot } = require('mineflayer')
const { Vec3 } = require('vec3')
-const {
- createPlugin,
- goals,
- createBoxExclusion,
- createRadiusExclusion,
- createColumnRadiusExclusion
-} = require('../dist')
+const { createPlugin, goals } = require('../dist')
const { GoalBlock } = goals
+// --- copy these helpers into your own project ------------------------------
+
+// A box between two opposite corners (inclusive, any order).
+function boxExclusion (corner1, corner2, cost = Infinity) {
+ const minX = Math.min(corner1.x, corner2.x)
+ const minY = Math.min(corner1.y, corner2.y)
+ const minZ = Math.min(corner1.z, corner2.z)
+ const maxX = Math.max(corner1.x, corner2.x)
+ const maxY = Math.max(corner1.y, corner2.y)
+ const maxZ = Math.max(corner1.z, corner2.z)
+ return (block) => {
+ const p = block.position
+ const inside =
+ p.x >= minX && p.x <= maxX &&
+ p.y >= minY && p.y <= maxY &&
+ p.z >= minZ && p.z <= maxZ
+ return inside ? cost : 0
+ }
+}
+
+// A ball (sphere) of the given radius around a center point.
+function radiusExclusion (center, radius, cost = Infinity) {
+ const r2 = radius * radius
+ return (block) => {
+ const dx = block.position.x - center.x
+ const dy = block.position.y - center.y
+ const dz = block.position.z - center.z
+ return dx * dx + dy * dy + dz * dz <= r2 ? cost : 0
+ }
+}
+
+// ---------------------------------------------------------------------------
+
const bot = createBot({
username: 'exclusion-demo',
auth: 'offline',
@@ -35,19 +65,13 @@ const bot = createBot({
bot.loadPlugin(createPlugin())
-// A few example zones. Tweak the coordinates to match your world.
-//
-// - A HARD box the bot must never set foot in.
-// - A SOFT ball the bot prefers to stay out of, but may cross if it must.
-// - A pillar where the bot is never allowed to mine.
-const noGoBox = createBoxExclusion(new Vec3(-8, 60, -8), new Vec3(8, 80, 8))
-const softBall = createRadiusExclusion(new Vec3(30, 64, 30), 6, 50)
-const noMinePillar = createColumnRadiusExclusion(new Vec3(0, 0, 0), 4)
+// Example zones (tweak the coordinates to match your world):
+const noGoBox = boxExclusion(new Vec3(-8, 60, -8), new Vec3(8, 80, 8)) // hard
+const softBall = radiusExclusion(new Vec3(30, 64, 30), 6, 50) // soft
function enableZones () {
bot.pathfinder.setMoveOptions({
- exclusionAreasStep: [noGoBox, softBall],
- exclusionAreasBreak: [noMinePillar]
+ exclusionAreasStep: [noGoBox, softBall]
})
bot.chat('Exclusion zones: ON')
}
diff --git a/src/index.ts b/src/index.ts
index fd0e673..933b692 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -53,12 +53,8 @@ export type { BuildableMoveExecutor, BuildableMoveProvider, MovementSetup } from
export type { MovementOptions } from './mineflayer-specific/movements'
// Exclusion zones ("keep out" areas), like upstream mineflayer-pathfinder.
-export {
- createBoxExclusion,
- createRadiusExclusion,
- createColumnRadiusExclusion,
- EXCLUSION_NEVER
-} from './mineflayer-specific/movements/exclusionZones'
+// Only the type is exported; users write their own zone functions
+// (see examples/exclusionZones.js for ready-to-copy box/radius helpers).
export type { ExclusionArea } from './mineflayer-specific/movements/exclusionZones'
export { MovementOptimizer } from './mineflayer-specific/post'
export type { BuildableMoveOptimizer, OptimizationSetup, OptimizationMap } from './mineflayer-specific/post'
diff --git a/src/mineflayer-specific/movements/exclusionZones.ts b/src/mineflayer-specific/movements/exclusionZones.ts
index 969f7b9..13170e7 100644
--- a/src/mineflayer-specific/movements/exclusionZones.ts
+++ b/src/mineflayer-specific/movements/exclusionZones.ts
@@ -1,140 +1,16 @@
-import type { Vec3 } from 'vec3'
import type { BlockInfo } from '../world/cacheWorld'
-import { COST_INF } from './costs'
/**
- * An "exclusion area" is a very small function.
+ * An exclusion area: a function that returns the extra cost of letting the bot
+ * use a given block.
*
- * You hand it ONE block, and it hands you back ONE number: the EXTRA cost of
- * letting the bot use that block.
+ * - `0` -> no opinion on this block.
+ * - a positive number -> a soft penalty; the bot avoids the block when it can.
+ * - `>= COST_INF` -> a hard "keep out"; the bot will never use the block.
*
- * Think of it like a price tag the bot reads before touching a block:
- *
- * - return `0` -> "I don't care about this block. Do whatever."
- * - return `50` -> "You may use this block, but it costs 50 extra.
- * Go around it if there is a cheaper way."
- * - return `Infinity` -> "Never, ever use this block." Any number that is
- * (or `COST_INF`) `>= COST_INF` counts as "never".
- *
- * The pathfinder keeps THREE separate lists of these functions in its settings
- * (see {@link MovementOptions}):
- *
- * - `exclusionAreasStep` -> asked about every block the bot would STAND in.
- * - `exclusionAreasBreak` -> asked about every block the bot would BREAK (mine).
- * - `exclusionAreasPlace` -> asked about every block the bot would PLACE (build).
- *
- * This is the exact same idea as upstream `PrismarineJS/mineflayer-pathfinder`,
- * so exclusion functions written for that library keep working here. The only
- * difference is that here the function is handed a {@link BlockInfo} (which has
- * a `.position`), instead of a raw prismarine block.
+ * These are stored in the three movement settings `exclusionAreasStep`,
+ * `exclusionAreasBreak` and `exclusionAreasPlace`. The pathfinder intentionally
+ * ships no ready-made shapes — write your own, or copy the box/radius helpers
+ * from `examples/exclusionZones.js`. This mirrors upstream mineflayer-pathfinder.
*/
export type ExclusionArea = (block: BlockInfo) => number
-
-/**
- * The number the helpers below use to mean "never use this block".
- *
- * It is just the pathfinder's idea of "infinitely expensive" (`COST_INF`).
- * Re-exported here so you do not have to dig around for it.
- */
-export const EXCLUSION_NEVER = COST_INF
-
-/**
- * Make a BOX shaped "keep out" zone between two corners.
- *
- * Picture two opposite corners of a Minecraft selection (like a WorldEdit
- * `//pos1` and `//pos2`). Every block inside that box — corners included — gets
- * the given `cost`.
- *
- * You can pass the two corners in ANY order; this function figures out which
- * corner is the small one and which is the big one for you.
- *
- * @example
- * // The bot must never enter the box from (10, 64, -5) to (20, 70, 5):
- * const noGo = createBoxExclusion(new Vec3(10, 64, -5), new Vec3(20, 70, 5))
- * bot.pathfinder.setMoveOptions({ exclusionAreasStep: [noGo] })
- *
- * @example
- * // The bot CAN cross the box, but it is 80 cost more expensive, so it will
- * // only cut through when there is no cheaper way around:
- * const slowZone = createBoxExclusion(corner1, corner2, 80)
- *
- * @param corner1 one corner of the box (block coordinates, inclusive).
- * @param corner2 the opposite corner of the box (block coordinates, inclusive).
- * @param cost extra cost for blocks inside the box. Defaults to "never"
- * ({@link EXCLUSION_NEVER}). Pass a smaller positive number for a "soft" zone.
- */
-export function createBoxExclusion (corner1: Vec3, corner2: Vec3, cost: number = EXCLUSION_NEVER): ExclusionArea {
- // Work out the smaller and bigger value on each axis once, up front, so the
- // returned function only has to do cheap comparisons.
- const minX = Math.min(corner1.x, corner2.x)
- const minY = Math.min(corner1.y, corner2.y)
- const minZ = Math.min(corner1.z, corner2.z)
- const maxX = Math.max(corner1.x, corner2.x)
- const maxY = Math.max(corner1.y, corner2.y)
- const maxZ = Math.max(corner1.z, corner2.z)
-
- return (block: BlockInfo): number => {
- const p = block.position
- const inside =
- p.x >= minX && p.x <= maxX &&
- p.y >= minY && p.y <= maxY &&
- p.z >= minZ && p.z <= maxZ
- return inside ? cost : 0
- }
-}
-
-/**
- * Make a BALL (sphere) shaped "keep out" zone around a center point.
- *
- * Every block whose center is within `radius` blocks of `center` gets the given
- * `cost`. This is handy for "stay at least N blocks away from this spot".
- *
- * @example
- * // Keep the bot more than 8 blocks away from a turret at (0, 64, 0):
- * const danger = createRadiusExclusion(new Vec3(0, 64, 0), 8)
- * bot.pathfinder.setMoveOptions({ exclusionAreasStep: [danger] })
- *
- * @param center the middle of the ball (block coordinates).
- * @param radius how far the ball reaches, in blocks.
- * @param cost extra cost for blocks inside the ball. Defaults to "never".
- */
-export function createRadiusExclusion (center: Vec3, radius: number, cost: number = EXCLUSION_NEVER): ExclusionArea {
- // Compare squared distances so we never need a (slow) square root.
- const radiusSquared = radius * radius
-
- return (block: BlockInfo): number => {
- const p = block.position
- const dx = p.x - center.x
- const dy = p.y - center.y
- const dz = p.z - center.z
- const distanceSquared = dx * dx + dy * dy + dz * dz
- return distanceSquared <= radiusSquared ? cost : 0
- }
-}
-
-/**
- * Make a PILLAR (vertical column) shaped "keep out" zone around a point.
- *
- * Like {@link createRadiusExclusion}, but height does NOT matter: it only looks
- * at the X/Z distance. Use this when you want to block a spot at every height,
- * for example "never go near this base, no matter how high or low".
- *
- * @example
- * const keepAway = createColumnRadiusExclusion(new Vec3(100, 0, 100), 12)
- * bot.pathfinder.setMoveOptions({ exclusionAreasStep: [keepAway] })
- *
- * @param center the middle of the column (only X and Z are used).
- * @param radius how far the column reaches outward, in blocks.
- * @param cost extra cost for blocks inside the column. Defaults to "never".
- */
-export function createColumnRadiusExclusion (center: Vec3, radius: number, cost: number = EXCLUSION_NEVER): ExclusionArea {
- const radiusSquared = radius * radius
-
- return (block: BlockInfo): number => {
- const p = block.position
- const dx = p.x - center.x
- const dz = p.z - center.z
- const distanceSquared = dx * dx + dz * dz
- return distanceSquared <= radiusSquared ? cost : 0
- }
-}
diff --git a/src/mineflayer-specific/movements/movement.ts b/src/mineflayer-specific/movements/movement.ts
index 2709800..77106ef 100644
--- a/src/mineflayer-specific/movements/movement.ts
+++ b/src/mineflayer-specific/movements/movement.ts
@@ -42,8 +42,8 @@ export interface MovementOptions {
* in the list is added together. An empty list (the default) means "no zones",
* and costs nothing to evaluate.
*
- * Build these by hand, or use the helpers in `./exclusionZones`
- * (`createBoxExclusion`, `createRadiusExclusion`, `createColumnRadiusExclusion`).
+ * Write your own; ready-to-copy box/radius helpers live in
+ * `examples/exclusionZones.js`.
*/
exclusionAreasStep: ExclusionArea[]
diff --git a/src/mineflayer-specific/post/optimizer.ts b/src/mineflayer-specific/post/optimizer.ts
index 8966332..b016974 100644
--- a/src/mineflayer-specific/post/optimizer.ts
+++ b/src/mineflayer-specific/post/optimizer.ts
@@ -1,15 +1,49 @@
import { Bot } from 'mineflayer'
+import { Vec3 } from 'vec3'
import type { OptimizationMap } from '.'
-import type { BuildableMoveProvider } from '../movements'
+import type { BuildableMoveProvider, ExclusionArea } from '../movements'
import { MovementProvider } from '../movements'
import { World } from '../world/worldInterface'
import { Move } from '../move'
+import { COST_INF } from '../movements/costs'
import { BaseSimulator, BotcraftPhysics } from '@nxg-org/mineflayer-physics-util'
const debug = require('debug')
const log = debug('minecraft-pathfinding:Optimizer')
const logMerge = debug('minecraft-pathfinding:Optimizer:merge')
+/**
+ * Walk the straight line from `from` to `to` block-by-block and return true if any
+ * cell falls inside a HARD step-exclusion zone (one whose summed weight reaches
+ * COST_INF). Used to stop an optimizer from straight-lining a path through a
+ * "keep out" area the original A* route deliberately went around.
+ *
+ * Only hard zones block a merge. Soft zones (a finite extra cost) are a
+ * preference, not a wall, so the optimizer is allowed to straighten through them.
+ */
+function lineCrossesHardExclusion (world: World, from: Vec3, to: Vec3, areas: ExclusionArea[]): boolean {
+ const dx = to.x - from.x
+ const dy = to.y - from.y
+ const dz = to.z - from.z
+ const dist = Math.sqrt(dx * dx + dy * dy + dz * dz)
+ const steps = Math.max(1, Math.ceil(dist * 2)) // sample roughly every half block
+ let lastKey = ''
+ for (let i = 0; i <= steps; i++) {
+ const t = i / steps
+ const x = Math.floor(from.x + dx * t)
+ const y = Math.floor(from.y + dy * t)
+ const z = Math.floor(from.z + dz * t)
+ const key = `${x},${y},${z}`
+ if (key === lastKey) continue // same cell as the previous sample
+ lastKey = key
+ const block = world.getBlockInfo(new Vec3(x, y, z))
+ let weight = 0
+ for (const area of areas) weight += area(block)
+ if (weight >= COST_INF) return true
+ }
+ return false
+}
+
export abstract class MovementOptimizer {
bot: Bot
world: World
@@ -95,6 +129,7 @@ export abstract class MovementOptimizer {
export class Optimizer {
optMap: OptimizationMap
+ world: World
private pathCopy!: Move[]
private currentIndex: number
@@ -102,6 +137,7 @@ export class Optimizer {
constructor (bot: Bot, world: World, optMap: OptimizationMap) {
this.currentIndex = 0
this.optMap = optMap
+ this.world = world
}
loadPath (path: Move[]): void {
@@ -142,6 +178,16 @@ export class Optimizer {
if (newEnd > this.currentIndex) {
log(`[Index ${this.currentIndex}] Optimizer identified mergable sequence ending at index ${newEnd}.`)
const newMove = opt.optimizer.mergeMoves(this.currentIndex, newEnd, this.pathCopy)
+
+ // Exclusion zones: an optimizer may straight-line a path across cells the
+ // original A* route went around. Never let a merge cut through a hard
+ // "keep out" (step) zone -- fall back to the unoptimized moves instead.
+ const stepAreas = newMove.moveType.settings.exclusionAreasStep
+ if (stepAreas.length > 0 && lineCrossesHardExclusion(this.world, newMove.entryPos, newMove.exitPos, stepAreas)) {
+ log(`[Index ${this.currentIndex}] Merge would cross a hard exclusion zone; skipping this optimizer.`)
+ continue
+ }
+
newMove.optimizedExecutor = opt.optimizedExecutor
// Splice the newly merged move into the array, replacing all intermediate moves
diff --git a/tests/exclusionZones.test.ts b/tests/exclusionZones.test.ts
index a7d71fb..5dffe25 100644
--- a/tests/exclusionZones.test.ts
+++ b/tests/exclusionZones.test.ts
@@ -2,26 +2,33 @@ import assert from 'node:assert/strict'
import test from 'node:test'
import { Vec3 } from 'vec3'
-import { createPlugin, goals } from '../src'
-import {
- createBoxExclusion,
- createRadiusExclusion,
- createColumnRadiusExclusion,
- EXCLUSION_NEVER,
- type ExclusionArea
-} from '../src'
-import { BlockInfo } from '../src/mineflayer-specific/world/cacheWorld'
+import { createPlugin, goals, Move } from '../src'
+import type { ExclusionArea } from '../src'
import { buildMovementOptions, DEFAULT_MOVEMENT_OPTS } from '../src/mineflayer-specific/movements'
+import { Optimizer } from '../src/mineflayer-specific/post'
import { createCacheWorld } from './setup'
// ---------------------------------------------------------------------------
-// Small shared helpers (kept local so this file stands on its own), mirroring
-// the style of tests/pathfinder.test.ts.
+// Local helpers (kept here so this file stands on its own). The library ships
+// no zone-builders on purpose, so the tests define their own — just like a user
+// would (see examples/exclusionZones.js).
// ---------------------------------------------------------------------------
type PathResult = {
status: string
- path: Array<{ x: number, y: number, z: number }>
+ path: Move[]
+}
+
+/** A hard/soft box zone between two corners (inclusive). */
+function boxExclusion (min: Vec3, max: Vec3, cost = Infinity): ExclusionArea {
+ return (block) => {
+ const p = block.position
+ const inside =
+ p.x >= min.x && p.x <= max.x &&
+ p.y >= min.y && p.y <= max.y &&
+ p.z >= min.z && p.z <= max.z
+ return inside ? cost : 0
+ }
}
function withTimeout (promise: Promise, ms: number, message: string): Promise {
@@ -65,80 +72,19 @@ async function collectPathResult (
return final.result
}
-/** Build a fake BlockInfo that only carries a position (all the helpers look at). */
-function blockAt (x: number, y: number, z: number): BlockInfo {
- return { position: new Vec3(x, y, z) } as unknown as BlockInfo
-}
-
-// ---------------------------------------------------------------------------
-// Unit tests for the zone-builder helpers. These are pure functions, so we can
-// check their math directly without spinning up the pathfinder.
-// ---------------------------------------------------------------------------
-
-test('createBoxExclusion forbids blocks inside the box and ignores blocks outside', () => {
- const box = createBoxExclusion(new Vec3(0, 64, 0), new Vec3(4, 68, 4))
-
- // corners and an interior point are inside -> forbidden.
- assert.equal(box(blockAt(0, 64, 0)), EXCLUSION_NEVER)
- assert.equal(box(blockAt(4, 68, 4)), EXCLUSION_NEVER)
- assert.equal(box(blockAt(2, 66, 2)), EXCLUSION_NEVER)
-
- // just outside on each axis -> free.
- assert.equal(box(blockAt(-1, 66, 2)), 0)
- assert.equal(box(blockAt(5, 66, 2)), 0)
- assert.equal(box(blockAt(2, 63, 2)), 0)
- assert.equal(box(blockAt(2, 69, 2)), 0)
-})
-
-test('createBoxExclusion accepts the two corners in any order', () => {
- const ordered = createBoxExclusion(new Vec3(0, 64, 0), new Vec3(4, 68, 4))
- const swapped = createBoxExclusion(new Vec3(4, 68, 4), new Vec3(0, 64, 0))
-
- for (const p of [blockAt(0, 64, 0), blockAt(2, 66, 2), blockAt(5, 66, 2)]) {
- assert.equal(ordered(p), swapped(p))
- }
-})
-
-test('createBoxExclusion supports a custom soft cost', () => {
- const soft = createBoxExclusion(new Vec3(0, 0, 0), new Vec3(2, 2, 2), 25)
- assert.equal(soft(blockAt(1, 1, 1)), 25)
- assert.equal(soft(blockAt(9, 9, 9)), 0)
-})
-
-test('createRadiusExclusion forbids blocks within the radius (a ball)', () => {
- const ball = createRadiusExclusion(new Vec3(0, 0, 0), 3)
-
- assert.equal(ball(blockAt(0, 0, 0)), EXCLUSION_NEVER) // center
- assert.equal(ball(blockAt(3, 0, 0)), EXCLUSION_NEVER) // on the radius
- assert.equal(ball(blockAt(0, 3, 0)), EXCLUSION_NEVER) // height counts
- assert.equal(ball(blockAt(4, 0, 0)), 0) // just outside
-})
-
-test('createColumnRadiusExclusion ignores height (a pillar)', () => {
- const pillar = createColumnRadiusExclusion(new Vec3(0, 64, 0), 3)
-
- // Same X/Z, wildly different Y -> still inside the pillar.
- assert.equal(pillar(blockAt(0, -40, 0)), EXCLUSION_NEVER)
- assert.equal(pillar(blockAt(2, 250, 0)), EXCLUSION_NEVER)
- // Outside the X/Z radius -> free, regardless of height.
- assert.equal(pillar(blockAt(4, 64, 0)), 0)
-})
-
// ---------------------------------------------------------------------------
-// Regression tests: the default arrays must never be shared/mutated. Without
-// fresh copies, mutating one bot's defaultMoveSettings.exclusionAreasStep would
-// leak zones into other bots and into later setMoveOptions calls.
+// Default-array safety: the three exclusion lists must never be shared/mutated.
+// Without fresh copies, mutating one bot's defaultMoveSettings.exclusionAreasStep
+// would leak zones into other bots and into later setMoveOptions calls.
// ---------------------------------------------------------------------------
test('buildMovementOptions gives every settings object its own exclusion arrays', () => {
const a = buildMovementOptions()
const b = buildMovementOptions()
- // Fresh arrays, not the shared default instance, and not shared with each other.
assert.notEqual(a.exclusionAreasStep, DEFAULT_MOVEMENT_OPTS.exclusionAreasStep)
assert.notEqual(a.exclusionAreasStep, b.exclusionAreasStep)
- // Mutating one must not leak into the other, nor back into the defaults.
a.exclusionAreasStep.push(() => 0)
assert.equal(a.exclusionAreasStep.length, 1)
assert.equal(b.exclusionAreasStep.length, 0)
@@ -149,7 +95,6 @@ test('buildMovementOptions copies a user-supplied array instead of holding its r
const mine: ExclusionArea[] = []
const opts = buildMovementOptions({ exclusionAreasBreak: mine })
- // The settings hold a copy, so edits to either side stay independent.
assert.notEqual(opts.exclusionAreasBreak, mine)
mine.push(() => 0)
assert.equal(opts.exclusionAreasBreak.length, 0)
@@ -162,18 +107,17 @@ test('the default exclusion arrays are frozen so they cannot be mutated in place
})
// ---------------------------------------------------------------------------
-// Functional tests: run the real pathfinder over a flat world and confirm the
-// zones change the chosen path.
+// Functional tests: run the real pathfinder over a flat world.
// ---------------------------------------------------------------------------
-// A solid "wall" of forbidden blocks straddling the straight-line route from
-// (0,64,0) to (20,64,0). The bot stands at y=64, so y 64-66 covers feet+head.
+// A forbidden "wall" straddling the straight route from (0,64,0) to (20,64,0).
+// The bot stands at y=64, so y 64-66 covers feet+head.
function makeWall (): { area: ExclusionArea, isInside: (x: number, y: number, z: number) => boolean } {
const minX = 8; const maxX = 12
const minZ = -3; const maxZ = 3
const minY = 64; const maxY = 66
return {
- area: createBoxExclusion(new Vec3(minX, minY, minZ), new Vec3(maxX, maxY, maxZ)),
+ area: boxExclusion(new Vec3(minX, minY, minZ), new Vec3(maxX, maxY, maxZ)),
isInside: (x, y, z) => x >= minX && x <= maxX && y >= minY && y <= maxY && z >= minZ && z <= maxZ
}
}
@@ -191,7 +135,6 @@ test('a hard step exclusion forces the bot to detour around a wall', async () =>
assert.equal(last?.y, 64)
assert.equal(last?.z, 0)
- // The whole point: no step in the plan lands inside the forbidden wall.
for (const node of result.path) {
assert.ok(
!wall.isInside(node.x, node.y, node.z),
@@ -199,7 +142,6 @@ test('a hard step exclusion forces the bot to detour around a wall', async () =>
)
}
- // Going around is strictly longer than the unobstructed straight line (21).
assert.ok(result.path.length > 21, `expected a detour longer than 21 moves, got ${result.path.length}`)
} finally {
rig.stopPassivePhysics()
@@ -207,8 +149,7 @@ test('a hard step exclusion forces the bot to detour around a wall', async () =>
})
test('a soft step exclusion still reaches the goal (avoid, never forbid)', async () => {
- // A soft, finite cost makes the zone undesirable but not impossible to cross.
- const softWall = createBoxExclusion(new Vec3(8, 64, -3), new Vec3(12, 66, 3), 40)
+ const softWall = boxExclusion(new Vec3(8, 64, -3), new Vec3(12, 66, 3), 40)
const rig = preparePathRig({ exclusionAreasStep: [softWall] })
try {
@@ -225,8 +166,7 @@ test('a soft step exclusion still reaches the goal (avoid, never forbid)', async
})
test('a hard step exclusion on the only goal block makes the goal unreachable', async () => {
- // Forbid standing on the exact goal block; the bot can never finish.
- const onGoal = createBoxExclusion(new Vec3(20, 64, 0), new Vec3(20, 64, 0))
+ const onGoal = boxExclusion(new Vec3(20, 64, 0), new Vec3(20, 64, 0))
const rig = preparePathRig({ exclusionAreasStep: [onGoal] })
try {
@@ -238,3 +178,73 @@ test('a hard step exclusion on the only goal block makes the goal unreachable',
rig.stopPassivePhysics()
}
})
+
+// ---------------------------------------------------------------------------
+// Post-processing: the optimizer must not straight-line a path through a hard
+// zone the A* route went around. Driven with a synthetic path + an optimizer
+// that always wants to merge everything, so the only thing that can stop the
+// merge is the exclusion guard (no raycast/physics involved).
+// ---------------------------------------------------------------------------
+
+class DummyProvider {}
+
+function makeMoveType (areas: ExclusionArea[]): any {
+ const moveType: any = new DummyProvider()
+ moveType.settings = { exclusionAreasStep: areas }
+ return moveType
+}
+
+// Straight path (0,64,0) -> (4,64,0) -> (10,64,0). Merging it into one move
+// sweeps x=0..10 at z=0, which crosses a hard wall at x in [5,7].
+function makeStraightPath (moveType: any): Move[] {
+ const start = Move.startMove(moveType, new Vec3(0, 64, 0), new Vec3(0, 0, 0), 5)
+ const m1 = Move.fromPrevious(1, new Vec3(4, 64, 0), start, moveType)
+ const m2 = Move.fromPrevious(1, new Vec3(10, 64, 0), m1, moveType)
+ return [start, m1, m2]
+}
+
+const alwaysMergeOptimizer: any = {
+ identEndOpt: (_currentIndex: number, path: Move[]) => path.length - 1,
+ mergeMoves: (startIndex: number, endIndex: number, path: Move[]) => {
+ const startMove = path[startIndex]
+ const endMove = path[endIndex]
+ return new Move(
+ startMove.x, startMove.y, startMove.z,
+ [], [],
+ endMove.remainingBlocks, 99, startMove.moveType,
+ startMove.entryPos, startMove.entryVel, endMove.exitPos, endMove.exitVel,
+ startMove.parent
+ )
+ }
+}
+
+// getBlockInfo just needs to echo the position back; the zone functions only
+// look at block.position.
+const fakeWorld: any = { getBlockInfo: (pos: Vec3) => ({ position: pos }) }
+
+function runOptimizer (path: Move[], moveType: any): Promise {
+ const optMap: any = new Map([[DummyProvider, [{ optimizer: alwaysMergeOptimizer, priority: 100, order: 0 }]]])
+ const optimizer = new Optimizer(null as any, fakeWorld, optMap)
+ optimizer.loadPath(path)
+ return optimizer.compute()
+}
+
+test('the optimizer refuses to straight-line a merge through a hard zone', async () => {
+ const hardWall = boxExclusion(new Vec3(5, 64, -1), new Vec3(7, 66, 1))
+ const moveType = makeMoveType([hardWall])
+
+ const optimized = await runOptimizer(makeStraightPath(moveType), moveType)
+
+ // Every candidate merge sweeps through the wall, so none may be applied:
+ // the path stays unmerged (all 3 moves).
+ assert.equal(optimized.length, 3, 'optimizer should not merge across a hard exclusion zone')
+})
+
+test('the optimizer still merges a straight path when no zone is in the way', async () => {
+ const moveType = makeMoveType([])
+
+ const optimized = await runOptimizer(makeStraightPath(moveType), moveType)
+
+ // With no zones the always-merge optimizer collapses the whole run into one move.
+ assert.equal(optimized.length, 1, 'optimizer should merge freely without exclusion zones')
+})
From 057c9903a22d1780552ebb71285a925f2a86177b Mon Sep 17 00:00:00 2001
From: XaXayo12 <257781493+XaXayo12@users.noreply.github.com>
Date: Wed, 17 Jun 2026 00:12:52 +0200
Subject: [PATCH 3/3] Polish: exact voxel traversal for optimizer guard + DRY
exclusion sum
- Optimizer: replace the approximate 0.5-block sampling in the hard-zone check
with an exact voxel traversal (Amanatides & Woo). It visits exactly the cells
the merged segment crosses, so it can neither skip a thin hard cell nor do
redundant block lookups -- more correct and cheaper.
- Reuse: extract sumExclusionAreas() and call it from exclusionStep/Break/Place
and the optimizer's hard-zone check, instead of repeating the same sum loop
four times.
- Add an optimizer test for diagonal merges (a single hard cell on the diagonal),
which exercises the voxel traversal's tie handling.
Build clean, 26/26 tests pass.
---
src/mineflayer-specific/movements/movement.ts | 53 ++++++---------
src/mineflayer-specific/post/optimizer.ts | 67 +++++++++++++------
tests/exclusionZones.test.ts | 14 ++++
3 files changed, 80 insertions(+), 54 deletions(-)
diff --git a/src/mineflayer-specific/movements/movement.ts b/src/mineflayer-specific/movements/movement.ts
index 77106ef..ff43bd5 100644
--- a/src/mineflayer-specific/movements/movement.ts
+++ b/src/mineflayer-specific/movements/movement.ts
@@ -105,6 +105,19 @@ export function buildMovementOptions (settings: Partial = {}):
return merged
}
+/**
+ * Sum the extra cost every exclusion area in `areas` assigns to `block`.
+ *
+ * Returns 0 immediately when the list is empty (the normal case), so it is
+ * essentially free unless the user opted in to exclusion zones.
+ */
+export function sumExclusionAreas (areas: ExclusionArea[], block: BlockInfo): number {
+ if (areas.length === 0) return 0
+ let weight = 0
+ for (const area of areas) weight += area(block)
+ return weight
+}
+
const cardinalVec3s: Vec3[] = [
// { x: -1, z: 0 }, // West
// { x: 1, z: 0 }, // East
@@ -231,47 +244,19 @@ export abstract class Movement {
return block.physical ? 0 : COST_INF
}
- /**
- * Add up the extra cost of STANDING in / walking into this block, using every
- * "step" exclusion area in the settings (see {@link MovementOptions.exclusionAreasStep}).
- *
- * Returns 0 when there are no step areas configured (the normal, default case),
- * so this is essentially free unless the user opted in to exclusion zones.
- */
+ /** Extra cost of STANDING in this block (sum of every step exclusion area; 0 if none). */
exclusionStep (block: BlockInfo): number {
- const areas = this.settings.exclusionAreasStep
- if (areas.length === 0) return 0
- let weight = 0
- for (const area of areas) weight += area(block)
- return weight
+ return sumExclusionAreas(this.settings.exclusionAreasStep, block)
}
- /**
- * Add up the extra cost of BREAKING this block, using every "break" exclusion
- * area in the settings (see {@link MovementOptions.exclusionAreasBreak}).
- *
- * Returns 0 when there are no break areas configured.
- */
+ /** Extra cost of BREAKING this block (sum of every break exclusion area; 0 if none). */
exclusionBreak (block: BlockInfo): number {
- const areas = this.settings.exclusionAreasBreak
- if (areas.length === 0) return 0
- let weight = 0
- for (const area of areas) weight += area(block)
- return weight
+ return sumExclusionAreas(this.settings.exclusionAreasBreak, block)
}
- /**
- * Add up the extra cost of PLACING a block here, using every "place" exclusion
- * area in the settings (see {@link MovementOptions.exclusionAreasPlace}).
- *
- * Returns 0 when there are no place areas configured.
- */
+ /** Extra cost of PLACING a block here (sum of every place exclusion area; 0 if none). */
exclusionPlace (block: BlockInfo): number {
- const areas = this.settings.exclusionAreasPlace
- if (areas.length === 0) return 0
- let weight = 0
- for (const area of areas) weight += area(block)
- return weight
+ return sumExclusionAreas(this.settings.exclusionAreasPlace, block)
}
/**
diff --git a/src/mineflayer-specific/post/optimizer.ts b/src/mineflayer-specific/post/optimizer.ts
index b016974..fb4f22f 100644
--- a/src/mineflayer-specific/post/optimizer.ts
+++ b/src/mineflayer-specific/post/optimizer.ts
@@ -2,7 +2,7 @@ import { Bot } from 'mineflayer'
import { Vec3 } from 'vec3'
import type { OptimizationMap } from '.'
import type { BuildableMoveProvider, ExclusionArea } from '../movements'
-import { MovementProvider } from '../movements'
+import { MovementProvider, sumExclusionAreas } from '../movements'
import { World } from '../world/worldInterface'
import { Move } from '../move'
import { COST_INF } from '../movements/costs'
@@ -13,33 +13,60 @@ const log = debug('minecraft-pathfinding:Optimizer')
const logMerge = debug('minecraft-pathfinding:Optimizer:merge')
/**
- * Walk the straight line from `from` to `to` block-by-block and return true if any
- * cell falls inside a HARD step-exclusion zone (one whose summed weight reaches
- * COST_INF). Used to stop an optimizer from straight-lining a path through a
- * "keep out" area the original A* route deliberately went around.
+ * Walk the straight segment from `from` to `to` with a voxel traversal
+ * (Amanatides & Woo) and return true as soon as a cell lands inside a HARD step
+ * zone (summed weight >= COST_INF). Visiting exactly the cells the segment
+ * crosses keeps the check both correct (no skipped cells, no false hits) and
+ * cheap (one block lookup per crossed cell).
*
* Only hard zones block a merge. Soft zones (a finite extra cost) are a
* preference, not a wall, so the optimizer is allowed to straighten through them.
*/
function lineCrossesHardExclusion (world: World, from: Vec3, to: Vec3, areas: ExclusionArea[]): boolean {
+ let x = Math.floor(from.x)
+ let y = Math.floor(from.y)
+ let z = Math.floor(from.z)
+ const endX = Math.floor(to.x)
+ const endY = Math.floor(to.y)
+ const endZ = Math.floor(to.z)
+
const dx = to.x - from.x
const dy = to.y - from.y
const dz = to.z - from.z
- const dist = Math.sqrt(dx * dx + dy * dy + dz * dz)
- const steps = Math.max(1, Math.ceil(dist * 2)) // sample roughly every half block
- let lastKey = ''
- for (let i = 0; i <= steps; i++) {
- const t = i / steps
- const x = Math.floor(from.x + dx * t)
- const y = Math.floor(from.y + dy * t)
- const z = Math.floor(from.z + dz * t)
- const key = `${x},${y},${z}`
- if (key === lastKey) continue // same cell as the previous sample
- lastKey = key
- const block = world.getBlockInfo(new Vec3(x, y, z))
- let weight = 0
- for (const area of areas) weight += area(block)
- if (weight >= COST_INF) return true
+
+ const stepX = Math.sign(dx)
+ const stepY = Math.sign(dy)
+ const stepZ = Math.sign(dz)
+
+ // The segment is parameterised by t in [0, 1]. tMax* is the t at which we next
+ // cross a cell boundary on that axis; tDelta* is the t to cross one whole cell.
+ // Axes that do not move get Infinity so they are never chosen to advance.
+ const tDeltaX = stepX !== 0 ? Math.abs(1 / dx) : Infinity
+ const tDeltaY = stepY !== 0 ? Math.abs(1 / dy) : Infinity
+ const tDeltaZ = stepZ !== 0 ? Math.abs(1 / dz) : Infinity
+
+ let tMaxX = stepX !== 0 ? (stepX > 0 ? x + 1 - from.x : from.x - x) / Math.abs(dx) : Infinity
+ let tMaxY = stepY !== 0 ? (stepY > 0 ? y + 1 - from.y : from.y - y) / Math.abs(dy) : Infinity
+ let tMaxZ = stepZ !== 0 ? (stepZ > 0 ? z + 1 - from.z : from.z - z) / Math.abs(dz) : Infinity
+
+ // Number of cells to visit = Manhattan distance in cells + 1. Looping a fixed
+ // number of times (rather than on tMax comparisons) keeps termination
+ // floating-point safe.
+ const cells = Math.abs(endX - x) + Math.abs(endY - y) + Math.abs(endZ - z)
+
+ for (let i = 0; i <= cells; i++) {
+ if (sumExclusionAreas(areas, world.getBlockInfo(new Vec3(x, y, z))) >= COST_INF) return true
+
+ if (tMaxX <= tMaxY && tMaxX <= tMaxZ) {
+ x += stepX
+ tMaxX += tDeltaX
+ } else if (tMaxY <= tMaxZ) {
+ y += stepY
+ tMaxY += tDeltaY
+ } else {
+ z += stepZ
+ tMaxZ += tDeltaZ
+ }
}
return false
}
diff --git a/tests/exclusionZones.test.ts b/tests/exclusionZones.test.ts
index 5dffe25..d844819 100644
--- a/tests/exclusionZones.test.ts
+++ b/tests/exclusionZones.test.ts
@@ -248,3 +248,17 @@ test('the optimizer still merges a straight path when no zone is in the way', as
// With no zones the always-merge optimizer collapses the whole run into one move.
assert.equal(optimized.length, 1, 'optimizer should merge freely without exclusion zones')
})
+
+test('the optimizer voxel-checks diagonal merges (single hard cell on the diagonal)', async () => {
+ // A diagonal run (0,64,0) -> (6,64,6). One hard cell sits on the diagonal at
+ // (3,64,3); the voxel traversal must catch it and refuse the straight-line merge.
+ const hardCell = boxExclusion(new Vec3(3, 64, 3), new Vec3(3, 64, 3))
+ const moveType = makeMoveType([hardCell])
+
+ const start = Move.startMove(moveType, new Vec3(0, 64, 0), new Vec3(0, 0, 0), 5)
+ const m1 = Move.fromPrevious(1, new Vec3(3, 64, 3), start, moveType)
+ const m2 = Move.fromPrevious(1, new Vec3(6, 64, 6), m1, moveType)
+
+ const optimized = await runOptimizer([start, m1, m2], moveType)
+ assert.equal(optimized.length, 3, 'a diagonal merge across a hard cell must be refused')
+})