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') +})