From 73d5642ebddfdab0ab40b50e70d3ee7b958f534e Mon Sep 17 00:00:00 2001 From: XaXayo12 <257781493+XaXayo12@users.noreply.github.com> Date: Thu, 18 Jun 2026 17:56:54 +0200 Subject: [PATCH] feat(costs): tick-based movement costs instead of arbitrary numbers Every movement provider used made-up costs (forward = 1, diagonal = sqrt2, jump = +0.5, a 3-block drop = +1.5, break = 1 + 3*digTime/1000). They had no shared unit, so A* could not honestly compare "walk around" vs "dig through" vs "drop down". Now every cost is measured in game ticks (20/sec) using Baritone's hardcoded estimates, which already lived (unused) in costs.ts. - travelCost(blocks): sprint (or walk) time per block. - Forward / Diagonal: travelCost(1) / travelCost(sqrt2). - ForwardJump: max(jump arc, walk) - you rise and move at once. - ForwardDropDown / StraightDown: walk-off + real FALL_N_BLOCKS_COST fall time. - StraightUp: ladder speed when climbing, else jump + place. - Parkour (forward + diagonal): jump + sprint time across the gap. - breakCost: real mining time in ticks (ms / 50), not a made-up formula. - movement.ts defaults: liquid / place / jump / dig costs are now tick-based. The goal heuristic was already in ticks (walk cost per block). Kept it at the walk cost (not the cheaper sprint cost) on purpose: A* stays a lightly weighted search - same fast beeline - but returned paths are now <=1.3x optimal (walk / sprint = 4.633 / 3.564) instead of the old ~4.6x (per-move cost was ~1 while the heuristic was ~4.633). Centralized that number as COST_HEURISTIC. Verified: build + ts-standard clean, 31/31 tests pass. A deterministic flat benchmark shows identical visited/generated node counts before and after (no search regression); only path quality improves. Added docs/MovementCosts.md. The hard-exclusion detour test asserted "detour > 21 moves"; with realistic costs A* now finds the optimal detour around the wall using diagonals at the same move count (it swings out to |z| >= 4, 0 nodes inside the zone), so that proxy was wrong. The test now asserts that geometric fact directly. --- docs/MovementCosts.md | 123 ++++++++++++++++++ src/mineflayer-specific/goals.ts | 13 +- src/mineflayer-specific/movements/costs.ts | 64 +++++++-- src/mineflayer-specific/movements/movement.ts | 52 ++++++-- .../movements/movementProviders.ts | 54 +++++--- tests/exclusionZones.test.ts | 7 +- 6 files changed, 259 insertions(+), 54 deletions(-) create mode 100644 docs/MovementCosts.md diff --git a/docs/MovementCosts.md b/docs/MovementCosts.md new file mode 100644 index 0000000..77d4ee1 --- /dev/null +++ b/docs/MovementCosts.md @@ -0,0 +1,123 @@ +

Movement Costs

+ +How the pathfinder decides which way to go: every possible move is given a +**cost**, and A\* looks for the route with the smallest total cost. + +This page explains what that cost means, where the numbers come from, and the +few knobs you can turn. + +- [The big idea: cost = time](#the-big-idea-cost--time) +- [The tick constants](#the-tick-constants) +- [What each move costs](#what-each-move-costs) +- [The heuristic (A\*'s guess)](#the-heuristic-as-guess) +- [Knobs you can tune](#knobs-you-can-tune) + +## The big idea: cost = time + +A cost is **how long the bot is busy doing the move, measured in game ticks**. + +Minecraft runs at **20 ticks per second**, so one tick is 1/20 of a second. +Lower cost = faster = better. + +Everything is measured in the same unit (ticks), which is the whole point. When +walking, jumping, falling, and digging are all expressed as *time*, the +pathfinder can compare them honestly. It can answer questions like "is it faster +to dig straight through this hill, or walk around it?" — because both options are +just numbers of ticks. + +> Before this, moves used made-up numbers (walk = `1`, diagonal = `1.41`, a jump +> was `+0.5`, a 3-block drop was `+1.5`, ...). Those numbers had no real-world +> meaning, so the bot sometimes preferred routes that *looked* cheap but were +> actually slow. + +The numbers come from [Baritone](https://github.com/cabaletta/baritone), a +mature Minecraft pathfinder. They are hardcoded estimates (`20 / speed`) rather +than a live physics simulation — close enough to reality, and very fast to +compute. They live in +[`src/mineflayer-specific/movements/costs.ts`](../src/mineflayer-specific/movements/costs.ts). + +## The tick constants + +| Constant | Ticks | Meaning | +| --- | --- | --- | +| `SPRINT_ONE_BLOCK_COST` | 3.564 | Sprint one block (fastest ground travel). | +| `WALK_ONE_BLOCK_COST` | 4.633 | Walk one block. | +| `WALK_OFF_BLOCK_COST` | 3.706 | Step off the edge of a block (start of a drop). | +| `CENTER_AFTER_FALL_COST` | 0.927 | Re-center after landing. | +| `JUMP_ONE_BLOCK_COST` | 3.163 | The upward arc of a jump. | +| `LADDER_UP_ONE_COST` | 8.511 | Climb up one block on a ladder/vine. | +| `LADDER_DOWN_ONE_COST` | 6.667 | Climb down one block. | +| `WALK_ONE_IN_WATER_COST` | 9.091 | Swim/walk one block while in water. | +| `WALK_ONE_OVER_SOUL_SAND_COST` | 9.266 | Walk one block over soul sand. | +| `SNEAK_ONE_BLOCK_COST` | 15.385 | Sneak one block. | +| `FALL_N_BLOCKS_COST[n]` | table | Ticks to fall `n` blocks (not linear — you speed up). | +| `COST_INF` | 1,000,000 | "Impossible." Any move this expensive is thrown away. | + +## What each move costs + +Each movement provider in +[`movementProviders.ts`](../src/mineflayer-specific/movements/movementProviders.ts) +adds up the ticks for the thing it does: + +| Move | Base cost (ticks) | +| --- | --- | +| **Forward** | `travelCost(1)` — sprint/walk one block. | +| **Diagonal** | `travelCost(√2)` — a diagonal block is √2 blocks of travel. | +| **ForwardJump** (up 1) | `max(JUMP, WALK)` — you rise and move at once, so it's the slower of the two, not the sum. | +| **ForwardDropDown** | `WALK_OFF_BLOCK_COST + FALL_N_BLOCKS_COST[height]`. | +| **StraightDown** | `FALL_N_BLOCKS_COST[height]` — pure falling, no sideways travel. | +| **StraightUp** | ladder → `LADDER_UP_ONE_COST`; otherwise `JUMP_ONE_BLOCK_COST` + a place cost to tower up. | +| **ParkourForward** | `JUMP_ONE_BLOCK_COST + travelCost(gap)` — the jump plus sprinting across the gap. | +| **ParkourDiagonal** | `JUMP_ONE_BLOCK_COST + travelCost(gap)`. | + +On top of the base cost a move also pays for any blocks it must **break** +(`breakCost`, the block's real mining time in ticks) or **place** +(`placeCost`). `travelCost(blocks)` is a small helper on `Movement` that returns +`blocks × (sprinting ? SPRINT : WALK)`. + +## The heuristic (A\*'s guess) + +A\* also needs to *guess* the remaining cost from any point to the goal. That +guess is the **heuristic** (in [`goals.ts`](../src/mineflayer-specific/goals.ts)): + +``` +heuristic = straight_line_distance × COST_HEURISTIC +``` + +`COST_HEURISTIC` is the **walk** cost per block (4.633), not the cheaper +**sprint** cost (3.564). That choice is deliberate: + +- A\* finds the *truly* shortest route only when its guess never costs more than + the cheapest real move (sprinting). Guessing with the slightly-higher walk + cost makes A\* a lightly **"weighted"** search: it leans harder toward the + goal and explores far fewer dead-ends, so it stays **fast**. +- The price is that returned paths can be up to `walk / sprint = 4.633 / 3.564 + ≈ 1.3×` the perfect optimum. That is a small, bounded amount. +- Back when move costs were made-up numbers (~1 per block) but the heuristic was + already ~4.633 per block, that same ratio was ~**4.6×** — paths could be far + from optimal. Putting move costs in ticks shrinks the bound from ~4.6× to + ~1.3× **without making the search any slower.** + +In short: same speed, much better paths. + +## Knobs you can tune + +Pass these in `moveSettings` (see [AdvancedUsage](./AdvancedUsage.md)). All are +in ticks; the defaults are tuned to match reality, so you rarely need to change +them. + +| Option | Default | What it does | +| --- | --- | --- | +| `liquidCost` | `WALK_ONE_IN_WATER - WALK_ONE_BLOCK` (≈ 4.46) | Extra ticks per block for moving through water. | +| `placeCost` | `20` | Ticks charged for placing a block. Placing interrupts movement, so it is deliberately expensive (Baritone's value). | +| `digCost` | `1` | Multiplier on a block's real mining time. Raise it to make digging look worse and prefer going around. | +| `jumpCost` | `0` | Extra ticks per jump on top of the physical jump arc. Raise it to discourage jumpy paths. | + +Example — make the bot hate digging and never want to swim: + +```js +bot.pathfinder.setMoveOptions({ + digCost: 5, // mining looks 5x slower than it is + liquidCost: 40 // water is very costly per block +}) +``` diff --git a/src/mineflayer-specific/goals.ts b/src/mineflayer-specific/goals.ts index 5597941..c6f6dd7 100644 --- a/src/mineflayer-specific/goals.ts +++ b/src/mineflayer-specific/goals.ts @@ -1,6 +1,7 @@ import { Vec3 } from 'vec3' import { Goal as AGoal } from '../abstract' import { Move } from './move' +import { COST_HEURISTIC } from './movements/costs' import { World } from './world/worldInterface' import { AABB } from '@nxg-org/mineflayer-util-plugin' import { PlaceHandler } from './movements/interactionUtils' @@ -282,7 +283,7 @@ export class GoalBlock extends Goal { const dx = this.x - node.x const dy = this.y - node.y const dz = this.z - node.z - return Math.sqrt(dx * dx + dz * dz + dy * dy) * (20 / 4.317) // from baritone. + return Math.sqrt(dx * dx + dz * dz + dy * dy) * COST_HEURISTIC // return (Math.sqrt(dx * dx + dz * dz) + Math.abs(dy)) // return distanceXZ(dx, dz) + Math.abs(dy) } @@ -291,7 +292,7 @@ export class GoalBlock extends Goal { const dx = this.x - node.x const dy = this.y - node.y const dz = this.z - node.z - const distance = Math.sqrt(dx * dx + dz * dz + dy * dy) * (20 / 4.317) // from baritone. + const distance = Math.sqrt(dx * dx + dz * dz + dy * dy) * COST_HEURISTIC return distance } @@ -332,7 +333,7 @@ export class GoalNear extends Goal { const dx = this.x - node.x const dy = this.y - node.y const dz = this.z - node.z - return Math.sqrt(dx * dx + dz * dz + dy * dy) * (20 / 4.317) // from baritone. + return Math.sqrt(dx * dx + dz * dz + dy * dy) * COST_HEURISTIC } distHeuristic (node: Move): number { @@ -363,7 +364,7 @@ export class GoalNearXZ extends Goal { heuristic (node: Move): number { const dx = this.x - node.x const dz = this.z - node.z - return Math.sqrt(dx * dx + dz * dz) * (20 / 4.317) // from baritone. + return Math.sqrt(dx * dx + dz * dz) * COST_HEURISTIC } distHeuristic (node: Move): number { @@ -418,7 +419,7 @@ export class GoalLookAt extends Goal { const dx = this.x - node.x const dy = this.y - (node.y + this.eyeHeight) // eye level const dz = this.z - node.z - return Math.sqrt(dx * dx + dz * dz + dy * dy) * (20 / 4.317) // from baritone. + return Math.sqrt(dx * dx + dz * dz + dy * dy) * COST_HEURISTIC } distHeuristic (node: Move): number { @@ -559,7 +560,7 @@ export class GoalFollowEntity extends GoalDynamic<'entityMoved', 'entityGone'> { const dy = this.y - node.y const dz = this.z - node.z - return Math.sqrt(dx * dx + dy * dy + dz * dz) * (20 / 4.317) // from baritone. + return Math.sqrt(dx * dx + dy * dy + dz * dz) * COST_HEURISTIC } distHeuristic (node: Move): number { diff --git a/src/mineflayer-specific/movements/costs.ts b/src/mineflayer-specific/movements/costs.ts index dc2571a..ae52b04 100644 --- a/src/mineflayer-specific/movements/costs.ts +++ b/src/mineflayer-specific/movements/costs.ts @@ -1,32 +1,70 @@ -// blatantly stolen from Baritone. - -// export const WALK_ONE_BLOCK_COST = 20 / 4.317; -// export const WALK_ONE_IN_WATER_COST = 20 / 2.2; -// export const WALK_ONE_OVER_SOUL_SAND_COST = WALK_ONE_BLOCK_COST * 2; -// export const LADDER_UP_ONE_COST = 20 / 2.35; -// export const LADDER_DOWN_ONE_COST = 20 / 3.0; -// export const SNEAK_ONE_BLOCK_COST = 20 / 1.3; -// export const SPRINT_ONE_BLOCK_COST = 20 / 5.612; -// export const SPRINT_MULTIPLIER = SPRINT_ONE_BLOCK_COST / WALK_ONE_BLOCK_COST; -// export const WALK_OFF_BLOCK_COST = WALK_ONE_BLOCK_COST * 0.8; -// export const CENTER_AFTER_FALL_COST = WALK_ONE_BLOCK_COST - WALK_OFF_BLOCK_COST; -// export const COST_INF = 1000000; +// =========================================================================== +// Movement cost constants — every value below is measured in GAME TICKS. +// +// Minecraft runs at 20 ticks per second, so "ticks" is just "how long the bot +// is busy doing this thing". Lower = faster. Putting every cost in the same +// unit (ticks) is the whole point of this file: the pathfinder can now compare +// "walk around" vs "dig through" vs "jump up" honestly, because they are all +// expressed as time, not as made-up numbers. +// +// The numbers are Baritone's hardcoded estimates. Each one is `20 / speed`, +// where `speed` is how many blocks per second the bot covers doing that action +// (e.g. the player walks at 4.317 blocks/s, so walking one block ~ 20 / 4.317 +// ~ 4.633 ticks). They are "correct enough" — close to in-game reality without +// running a physics simulation for every candidate move. +// =========================================================================== +/** Ticks to WALK one block (player walks 4.317 blocks/s). */ export const WALK_ONE_BLOCK_COST = 4.633 as const +/** Ticks to swim/walk one block while IN water (much slower than on land). */ export const WALK_ONE_IN_WATER_COST = 9.091 as const +/** Ticks to walk one block on top of soul sand (it drags you). */ export const WALK_ONE_OVER_SOUL_SAND_COST = 9.266 as const +/** Ticks to climb UP one block on a ladder/vine. */ export const LADDER_UP_ONE_COST = 8.511 as const +/** Ticks to climb DOWN one block on a ladder/vine. */ export const LADDER_DOWN_ONE_COST = 6.667 as const +/** Ticks to sneak one block (slowest way to move). */ export const SNEAK_ONE_BLOCK_COST = 15.385 as const +/** Ticks to SPRINT one block (player sprints 5.612 blocks/s — the fastest ground travel). */ export const SPRINT_ONE_BLOCK_COST = 3.564 as const +/** Sprinting is this fraction of the time of walking (sprint / walk). */ export const SPRINT_MULTIPLIER = 0.769 as const +/** Ticks to walk OFF the edge of a block (start of a drop; slightly cheaper than a full walk). */ export const WALK_OFF_BLOCK_COST = 3.706 as const +/** Ticks to re-center on the block you just dropped onto. */ export const CENTER_AFTER_FALL_COST = 0.927 as const +/** "Infinitely expensive" — any move costing this much or more is treated as impossible. */ export const COST_INF = 1000000 +/** + * Per-block weight used by the goal HEURISTIC (A*'s estimate of remaining cost), + * also measured in ticks. It is deliberately the WALK cost, not the cheaper + * SPRINT cost. + * + * Why walk and not sprint? A* stays optimal only when the heuristic never + * over-estimates the *cheapest* real cost (sprinting). By estimating with the + * slightly-higher walk cost we run a lightly "weighted" A*: it leans toward the + * goal and explores far fewer dead-ends (fast), while the paths it returns are + * at most ~1.3x the true optimum (walk / sprint = 4.633 / 3.564). Before this + * file's costs were in ticks, the same heuristic was ~4.6x the per-move cost, + * so that bound used to be ~4.6x — this is a large quality win at no speed cost. + */ +export const COST_HEURISTIC = 20 / 4.317 + +/** + * Lookup table: `FALL_N_BLOCKS_COST[n]` = ticks to fall `n` blocks straight down. + * Falling speeds up over time, so this is not linear (falling 2 blocks costs less + * than twice falling 1). Index it with a whole number of blocks. + */ export const FALL_N_BLOCKS_COST = generateFallNBlocksCost() export const FALL_1_25_BLOCKS_COST = distanceToTicks(1.25) export const FALL_0_25_BLOCKS_COST = distanceToTicks(0.25) +/** + * Ticks for the upward arc of a normal jump. A jump lifts the bot ~1.25 blocks + * and it lands back ~0.25 blocks lower than the peak, so the "useful" climb costs + * the difference between those two fall times. + */ export const JUMP_ONE_BLOCK_COST = FALL_1_25_BLOCKS_COST - FALL_0_25_BLOCKS_COST export function distanceToTicks (distance: number): number { diff --git a/src/mineflayer-specific/movements/movement.ts b/src/mineflayer-specific/movements/movement.ts index ff43bd5..c7d471a 100644 --- a/src/mineflayer-specific/movements/movement.ts +++ b/src/mineflayer-specific/movements/movement.ts @@ -8,19 +8,27 @@ import { BreakHandler, InteractHandler, PlaceHandler } from './interactionUtils' import type { InteractType } from './interactionUtils' import type { Block } from '../../types' import { Vec3Properties } from '../../types' -import { COST_INF } from './costs' +import { COST_INF, SPRINT_ONE_BLOCK_COST, WALK_ONE_BLOCK_COST, WALK_ONE_IN_WATER_COST } from './costs' import type { ExclusionArea } from './exclusionZones' export interface MovementOptions { allowDiagonalBridging: boolean allowJumpSprint: boolean allow1by1towers: boolean + + // --- Cost knobs (all in GAME TICKS; see costs.ts) ----------------------- + /** Extra ticks per block when moving through liquid (water is slow). */ liquidCost: number + /** Multiplier on a block's real mining time. 1 = use the true dig time as-is. */ digCost: number - forceLook: boolean + /** Extra ticks charged per jump, on top of the physical jump arc. 0 = trust the physics. */ jumpCost: number + /** Ticks charged for placing a block. Placing interrupts movement, so it is deliberately costly. */ placeCost: number + /** Ticks charged for killing horizontal velocity (e.g. stopping at the edge of a drop). */ velocityKillCost: number + + forceLook: boolean canOpenDoors: boolean canDig: boolean canPlace: boolean @@ -65,10 +73,14 @@ export const DEFAULT_MOVEMENT_OPTS: MovementOptions = { maxDropDown: 3, infiniteLiquidDropdownDistance: true, allowSprinting: true, - liquidCost: 3, - placeCost: 2, + // Extra time water adds over walking the same block on land (~4.46 ticks). + liquidCost: WALK_ONE_IN_WATER_COST - WALK_ONE_BLOCK_COST, + // Baritone's block-placement penalty: building a block to stand on is slow. + placeCost: 20, + // Keep a block's real mining time as-is (1x). Raise to make digging look worse. digCost: 1, - jumpCost: 0.5, + // The jump arc already has a real tick cost (see JUMP_ONE_BLOCK_COST); add no fudge on top. + jumpCost: 0, velocityKillCost: 2, // implement at a later date. forceLook: true, careAboutLookAlignment: true, @@ -244,6 +256,16 @@ export abstract class Movement { return block.physical ? 0 : COST_INF } + /** + * Ticks to travel `blocks` blocks of flat ground. + * + * The bot sprints when allowed (the fastest way to move), otherwise it walks. + * Pass `1` for one straight block, `Math.SQRT2` for one diagonal block, etc. + */ + travelCost (blocks: number): number { + return blocks * (this.settings.allowSprinting ? SPRINT_ONE_BLOCK_COST : WALK_ONE_BLOCK_COST) + } + /** 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) @@ -334,22 +356,24 @@ export abstract class Movement { return cost } + /** + * Ticks to break (mine) `block` with the bot's best tool. + * + * `pathingUtil.digCost` returns the real dig time in MILLISECONDS, and the + * game runs at 20 ticks/s (50 ms/tick), so `ms / 50` is the dig time in ticks + * — the same unit as every other movement cost. `digCost` (default 1) is a + * multiplier so callers can make mining look cheaper or dearer. + */ breakCost (block: BlockInfo): number { if (block.block === null) return COST_INF // Don't know its type, but that's only replaceables so just return. - // const tool = this.bot.pathfinder.bestHarvestTool(block) - - const digTime = this.bot.pathingUtil.digCost(block.block) - // const tool = null as any; - // const enchants = (tool && tool.nbt) ? nbt.simplify(tool.nbt).Enchantments : [] - // 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 + const digTimeMs = this.bot.pathingUtil.digCost(block.block) + const miningTicks = (digTimeMs / 50) * this.settings.digCost // 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) + return miningTicks + this.exclusionBreak(block) } safeOrPlace (block: BlockInfo, toPlace: PlaceHandler[], type: InteractType = 'solid'): number { diff --git a/src/mineflayer-specific/movements/movementProviders.ts b/src/mineflayer-specific/movements/movementProviders.ts index 6defc66..cd7eaef 100644 --- a/src/mineflayer-specific/movements/movementProviders.ts +++ b/src/mineflayer-specific/movements/movementProviders.ts @@ -6,10 +6,22 @@ import { BreakHandler, PlaceHandler } from './interactionUtils' import { emptyVec } from '@nxg-org/mineflayer-physics-util/dist/physics/settings' import { MovementProvider } from './movementProvider' import { BlockInfo } from '../world/cacheWorld' -import { COST_INF } from './costs' +import { + COST_INF, + FALL_N_BLOCKS_COST, + JUMP_ONE_BLOCK_COST, + LADDER_UP_ONE_COST, + WALK_OFF_BLOCK_COST, + WALK_ONE_BLOCK_COST +} from './costs' const PARKOUR_DIAGONAL_3_3_TRAVEL = Math.sqrt(18) // 3 by 3 offset. +// Small fixed tick penalty for the "forward" parkour landing (running across a +// gap onto a same-height block). It is slower than a clean jump-and-drop, so it +// pays a little extra. Kept as a placeholder until per-frame parkour timing exists. +const PARKOUR_FORWARD_LANDING_PENALTY = 3 + // technically, the offsets are slow. Yeah, I know. // However, removing those breaks the code. So I won't fix that for the time being. -Gen @@ -36,7 +48,7 @@ export class Forward extends MovementProvider { getMoveForward (start: Move, dir: Vec3, neighbors: Move[]): void { const pos = start.cachedVec - let cost = 1 // move cost + let cost = this.travelCost(1) // sprint/walk one block forward if (this.getBlockInfo(pos, 0, 0, 0).liquid) cost += this.settings.liquidCost @@ -79,8 +91,6 @@ export class Forward extends MovementProvider { export class Diagonal extends MovementProvider { movementDirs = Movement.diagonalDirs - static diagonalCost = Math.SQRT2 // sqrt(2) - provideMovements (start: Move, storage: Move[], goal: goals.Goal, closed: Set): void { for (const dir of this.movementDirs) { const off = start.cachedVec.plus(dir).floor() @@ -90,7 +100,7 @@ export class Diagonal extends MovementProvider { } getMoveDiagonal (node: Move, dir: Vec3, neighbors: Move[], goal: goals.Goal): void { - let cost = Diagonal.diagonalCost + let cost = this.travelCost(Math.SQRT2) // one diagonal block is sqrt(2) blocks of travel const block0 = this.getBlockInfo(node, dir.x, 0, dir.z) @@ -174,7 +184,9 @@ export class ForwardJump extends MovementProvider { const blockH = this.getBlockInfo(pos, dir.x, 2, dir.z) const blockC = this.getBlockInfo(pos, dir.x, 0, dir.z) - let cost = 1 + this.settings.jumpCost // move cost (move+jump) + // Jumping up one block: you move and rise at the same time, so the cost is + // the slower of "walk one block" and "the jump arc", not their sum. + let cost = Math.max(JUMP_ONE_BLOCK_COST, WALK_ONE_BLOCK_COST) + this.settings.jumpCost const block0 = this.getBlockInfo(pos, 0, 0, 0) if (block0.liquid) cost += this.settings.liquidCost @@ -290,7 +302,7 @@ export class ForwardDropDown extends DropDownProvider { } getMoveDropDown (node: Move, dir: Vec3, neighbors: Move[], closed: Set): void { - let cost = 1 // move cost + let cost = WALK_OFF_BLOCK_COST // step off the ledge const block0 = this.getBlockInfo(node, 0, 0, 0) const blockLand = this.getLandingBlock(block0, node, dir) @@ -301,8 +313,8 @@ export class ForwardDropDown extends DropDownProvider { if (block0.liquid) cost += this.settings.liquidCost - // drop cost - cost += (node.y - blockLand.position.y) * 0.5 + // ticks to fall the height we are dropping (whole blocks) + cost += FALL_N_BLOCKS_COST[node.y - blockLand.position.y] const blockA = this.getBlockInfo(node, dir.x, 2, dir.z) const blockB = this.getBlockInfo(node, dir.x, 1, dir.z) @@ -342,7 +354,7 @@ export class StraightDown extends DropDownProvider { } getMoveDown (node: Move, neighbors: Move[], closed: Set): void { - let cost = 1 // move cost + let cost = 0 // straight down has no horizontal travel; the fall below is the whole cost const block0 = this.getBlockInfo(node, 0, 0, 0) const blockLand = this.getLandingBlock(block0, node) @@ -352,8 +364,8 @@ export class StraightDown extends DropDownProvider { if (block0.liquid) cost += this.settings.liquidCost // dont go underwater - // drop cost - cost += (node.y - blockLand.position.y) * 0.5 + // ticks to fall the height we are dropping (whole blocks) + cost += FALL_N_BLOCKS_COST[node.y - blockLand.position.y] const block1 = this.getBlockInfo(node, 0, -1, 0) @@ -381,12 +393,14 @@ export class StraightUp extends MovementProvider { } getMoveUp (node: Move, neighbors: Move[], closed: Set): void { - let cost = this.settings.jumpCost // move cost - const block1 = this.getBlockInfo(node, 0, 0, 0) if (block1.isInvalid) return // out of range. + // Climbing a ladder/vine has its own speed; otherwise we jump (and below we + // may also place a block to tower up, which adds its own place cost). + let cost = block1.climbable ? LADDER_UP_ONE_COST : JUMP_ONE_BLOCK_COST + this.settings.jumpCost + if (block1.liquid) cost += this.settings.liquidCost // if (this.getNumEntitiesAt(node, 0, 0, 0) > 0) return // an entity (besides the player) is blocking the building area @@ -450,7 +464,7 @@ export class ParkourForward extends MovementProvider { return } - const cost0 = 1 + this.settings.jumpCost // move cost (move+jump) + const cost0 = JUMP_ONE_BLOCK_COST + this.settings.jumpCost // the jump itself // Leaving entities at the ceiling level (along path) out for now because there are few cases where that will be important // cost += this.getNumEntitiesAt(node, dir.x, 0, dir.z) * this.entityCost @@ -464,7 +478,7 @@ export class ParkourForward extends MovementProvider { const maxD = this.settings.allowSprinting ? 5 : 2 for (let d = 2; d <= maxD; d++) { - let cost = cost0 + d * 0.5 // 0.5 per block forward + let cost = cost0 + this.travelCost(d) // jump plus sprinting across d blocks const dx = dir.x * d const dz = dir.z * d @@ -495,7 +509,7 @@ export class ParkourForward extends MovementProvider { floorCleared = floorCleared && !blockE.physical } else if (flag1 && ceilingClear && blockB.walkthrough && blockC.walkthrough && blockD.physical) { // Forward - cost += 3 // potential slowdown (will fix later.) + cost += PARKOUR_FORWARD_LANDING_PENALTY if ((cost += this.exclusionStep(blockC)) < COST_INF) { neighbors.push(Move.fromPrevious(cost, blockC.position.offset(0.5, 0, 0.5), node, this)) } @@ -550,7 +564,7 @@ export class ParkourDiagonal extends MovementProvider { if (!this.getBlockInfo(node, dir.x, 1, 0).walkthrough) return if (!this.getBlockInfo(node, 0, 1, dir.z).walkthrough) return - const cost0 = Diagonal.diagonalCost + this.settings.jumpCost + const cost0 = JUMP_ONE_BLOCK_COST + this.settings.jumpCost // the jump itself const maxD = this.settings.allowSprinting ? 4 : 2 // old behavior @@ -586,7 +600,7 @@ export class ParkourDiagonal extends MovementProvider { const dz = dir.z * zSteps const travel = Math.sqrt(xSteps * xSteps + zSteps * zSteps) - let cost = cost0 + 0.5 * Diagonal.diagonalCost * travel + let cost = cost0 + this.travelCost(travel) // jump plus sprinting across the gap const majorIsX = xSteps > zSteps const majorIsZ = zSteps > xSteps const frontDx = dx - (majorIsX || xSteps === zSteps ? dir.x : 0) @@ -629,7 +643,7 @@ export class ParkourDiagonal extends MovementProvider { return true } } else if (flag1 && ceilingClear && blockB.walkthrough && blockC.walkthrough && blockD.physical && blockFrontC.walkthrough) { - cost += 3 + cost += PARKOUR_FORWARD_LANDING_PENALTY if ((cost += this.exclusionStep(blockC)) < COST_INF) { neighbors.push(Move.fromPrevious(cost, blockC.position.offset(0.5, 0, 0.5), node, this)) } diff --git a/tests/exclusionZones.test.ts b/tests/exclusionZones.test.ts index cc6462b..f483cd3 100644 --- a/tests/exclusionZones.test.ts +++ b/tests/exclusionZones.test.ts @@ -142,7 +142,12 @@ test('a hard step exclusion forces the bot to detour around a wall', async () => ) } - assert.ok(result.path.length > 21, `expected a detour longer than 21 moves, got ${result.path.length}`) + // The wall blocks z in [-3, 3] for x in [8, 12], so any valid path MUST swing + // out to |z| >= 4 to get around it. We assert that geometric fact rather than + // "more than 21 moves": diagonal moves absorb the sideways excursion, so an + // optimal detour can keep the same move count (it just costs more real time). + const maxAbsZ = Math.max(...result.path.map((node) => Math.abs(node.z))) + assert.ok(maxAbsZ >= 4, `expected the path to swing out to |z| >= 4 around the wall, got max |z| = ${maxAbsZ}`) } finally { rig.stopPassivePhysics() }