diff --git a/docs/API.md b/docs/API.md
index cc4f1a6..73115ed 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,64 @@ 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 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
+```
+
+- `0` — no opinion.
+- a positive number — soft zone: allowed, but avoided when a cheaper route exists.
+- `Infinity` (`>= COST_INF`) — hard zone: never used.
+
+Areas live in three settings; each sums the cost of every function in its list:
+
+| Setting | Checked on every block the bot would… |
+| --- | --- |
+| `exclusionAreasStep` | stand in (the foot block of each move — walk, jump, drop, parkour, tower; optimized paths included). |
+| `exclusionAreasBreak` | break (mine). |
+| `exclusionAreasPlace` | place (build on). |
+
+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
+// 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: [(block) => block.type === farmlandId ? 100 : 0],
+ exclusionAreasBreak: [inBox],
+ exclusionAreasPlace: [inBox]
+})
+```
+
+`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.
+
+
+
Events
diff --git a/docs/AdvancedUsage.md b/docs/AdvancedUsage.md
index 6b20fe2..0d8691d 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,37 @@ class MyMovementOptimizer extends MovementOptimizer {
}
}
```
+
+Exclusion Zones (Keep-Out Areas)
+
+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).
+
+Three lists, each summing the cost of every function in it:
+
+- `exclusionAreasStep` — blocks the bot would stand in.
+- `exclusionAreasBreak` — blocks the bot would break (mine).
+- `exclusionAreasPlace` — blocks the bot would place (build on).
+
+All empty by default (no overhead). Any `(block) => number` works:
+
+```ts
+// 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
+
+bot.pathfinder.setMoveOptions({ exclusionAreasBreak: [protectOres] })
+```
+
+Notes:
+
+- 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/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..3bf20aa
--- /dev/null
+++ b/examples/exclusionZones.js
@@ -0,0 +1,122 @@
+'use strict'
+
+// ---------------------------------------------------------------------------
+// Exclusion zones example
+//
+// 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.
+//
+// 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)
+//
+// 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 } = 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',
+ host: 'localhost',
+ port: 25565
+})
+
+bot.loadPlugin(createPlugin())
+
+// 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]
+ })
+ 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..933b692 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -50,6 +50,12 @@ 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.
+// 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'
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..13170e7
--- /dev/null
+++ b/src/mineflayer-specific/movements/exclusionZones.ts
@@ -0,0 +1,16 @@
+import type { BlockInfo } from '../world/cacheWorld'
+
+/**
+ * An exclusion area: a function that returns the extra cost of letting the bot
+ * use a given 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.
+ *
+ * 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
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..ff43bd5 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.
+ *
+ * Write your own; ready-to-copy box/radius helpers live in
+ * `examples/exclusionZones.js`.
+ */
+ 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,49 @@ 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
+}
+
+/**
+ * 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[] = [
@@ -136,7 +198,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 {
@@ -182,8 +244,29 @@ export abstract class Movement {
return block.physical ? 0 : COST_INF
}
+ /** Extra cost of STANDING in this block (sum of every step exclusion area; 0 if none). */
+ exclusionStep (block: BlockInfo): number {
+ return sumExclusionAreas(this.settings.exclusionAreasStep, block)
+ }
+
+ /** Extra cost of BREAKING this block (sum of every break exclusion area; 0 if none). */
+ exclusionBreak (block: BlockInfo): number {
+ return sumExclusionAreas(this.settings.exclusionAreasBreak, block)
+ }
+
+ /** Extra cost of PLACING a block here (sum of every place exclusion area; 0 if none). */
+ exclusionPlace (block: BlockInfo): number {
+ return sumExclusionAreas(this.settings.exclusionAreasPlace, block)
+ }
+
/**
- * Takes into account if the block is within a break exclusion area.
+ * 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 +291,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 +345,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 +371,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/src/mineflayer-specific/post/optimizer.ts b/src/mineflayer-specific/post/optimizer.ts
index 8966332..fb4f22f 100644
--- a/src/mineflayer-specific/post/optimizer.ts
+++ b/src/mineflayer-specific/post/optimizer.ts
@@ -1,15 +1,76 @@
import { Bot } from 'mineflayer'
+import { Vec3 } from 'vec3'
import type { OptimizationMap } from '.'
-import type { BuildableMoveProvider } from '../movements'
-import { MovementProvider } from '../movements'
+import type { BuildableMoveProvider, ExclusionArea } from '../movements'
+import { MovementProvider, sumExclusionAreas } 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 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 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
+}
+
export abstract class MovementOptimizer {
bot: Bot
world: World
@@ -95,6 +156,7 @@ export abstract class MovementOptimizer {
export class Optimizer {
optMap: OptimizationMap
+ world: World
private pathCopy!: Move[]
private currentIndex: number
@@ -102,6 +164,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 +205,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
new file mode 100644
index 0000000..d844819
--- /dev/null
+++ b/tests/exclusionZones.test.ts
@@ -0,0 +1,264 @@
+import assert from 'node:assert/strict'
+import test from 'node:test'
+import { Vec3 } from 'vec3'
+
+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'
+
+// ---------------------------------------------------------------------------
+// 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: 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 {
+ 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
+}
+
+// ---------------------------------------------------------------------------
+// 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()
+
+ assert.notEqual(a.exclusionAreasStep, DEFAULT_MOVEMENT_OPTS.exclusionAreasStep)
+ assert.notEqual(a.exclusionAreasStep, b.exclusionAreasStep)
+
+ 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 })
+
+ 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.
+// ---------------------------------------------------------------------------
+
+// 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: 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
+ }
+}
+
+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)
+
+ 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}`
+ )
+ }
+
+ 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 () => {
+ const softWall = boxExclusion(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 () => {
+ const onGoal = boxExclusion(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()
+ }
+})
+
+// ---------------------------------------------------------------------------
+// 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')
+})
+
+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')
+})