aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authormat <git@matdoes.dev>2026-01-18 09:50:45 -1245
committermat <git@matdoes.dev>2026-01-19 05:35:49 +0700
commit268c62587e090c72b67a29e1cc42cda6c9d7340b (patch)
tree961d0b4d0bd22d17f4ad6c8b77f02f02566b838e
parentfb92f65b3da49b6487bf6fa05010b12a3ab5d4ed (diff)
downloadazalea-drasl-268c62587e090c72b67a29e1cc42cda6c9d7340b.tar.xz
add simulation-based pathfinder execution engine
-rw-r--r--README.md2
-rw-r--r--azalea-client/src/plugins/mining.rs2
-rw-r--r--azalea-client/tests/simulation/move_and_despawn_entity.rs6
-rw-r--r--azalea-client/tests/simulation/move_despawned_entity.rs3
-rw-r--r--azalea-client/tests/simulation/teleport_movement.rs2
-rw-r--r--azalea-core/src/position.rs17
-rw-r--r--azalea-entity/src/lib.rs4
-rw-r--r--azalea-world/src/container.rs2
-rw-r--r--azalea-world/src/lib.rs2
-rw-r--r--azalea/examples/testbot/main.rs22
-rw-r--r--azalea/src/pathfinder/debug.rs6
-rw-r--r--azalea/src/pathfinder/execute/mod.rs206
-rw-r--r--azalea/src/pathfinder/execute/simulation.rs500
-rw-r--r--azalea/src/pathfinder/mod.rs10
-rw-r--r--azalea/src/pathfinder/moves/mod.rs24
-rw-r--r--azalea/src/pathfinder/simulation.rs20
-rw-r--r--azalea/src/pathfinder/world.rs8
-rw-r--r--azalea/src/swarm/builder.rs12
18 files changed, 759 insertions, 89 deletions
diff --git a/README.md b/README.md
index 689c6c5e..55a5fe5d 100644
--- a/README.md
+++ b/README.md
@@ -18,7 +18,7 @@ _Currently supported Minecraft version: `1.21.11`._
## Features
- [Accurate physics](https://azalea.matdoes.dev/azalea_physics/) (but some features like entity pushing and elytras aren't implemented yet)
-- [Pathfinder](https://azalea.matdoes.dev/azalea/pathfinder/index.html) (partially based on Baritone and several times faster)
+- [Pathfinder](https://azalea.matdoes.dev/azalea/pathfinder/index.html)
- [Swarms](https://azalea.matdoes.dev/azalea/swarm/index.html)
- [Breaking blocks](https://azalea.matdoes.dev/azalea/struct.Client.html#method.mine)
- [Block interactions & building](https://azalea.matdoes.dev/azalea/struct.Client.html#method.block_interact) (this doesn't predict the block interactions/placement on the client yet, but it's usually fine)
diff --git a/azalea-client/src/plugins/mining.rs b/azalea-client/src/plugins/mining.rs
index 35b01a6a..5e724071 100644
--- a/azalea-client/src/plugins/mining.rs
+++ b/azalea-client/src/plugins/mining.rs
@@ -8,7 +8,7 @@ use azalea_inventory::ItemStack;
use azalea_physics::{PhysicsSystems, collision::BlockWithShape};
use azalea_protocol::packets::game::s_player_action::{self, ServerboundPlayerAction};
use azalea_registry::builtin::{BlockKind, ItemKind};
-use azalea_world::{Worlds, WorldName};
+use azalea_world::{WorldName, Worlds};
use bevy_app::{App, Plugin, Update};
use bevy_ecs::prelude::*;
use derive_more::{Deref, DerefMut};
diff --git a/azalea-client/tests/simulation/move_and_despawn_entity.rs b/azalea-client/tests/simulation/move_and_despawn_entity.rs
index f05c64f4..a30870e3 100644
--- a/azalea-client/tests/simulation/move_and_despawn_entity.rs
+++ b/azalea-client/tests/simulation/move_and_despawn_entity.rs
@@ -1,5 +1,8 @@
use azalea_client::test_utils::prelude::*;
-use azalea_core::position::{ChunkPos, Vec3};
+use azalea_core::{
+ entity_id::MinecraftEntityId,
+ position::{ChunkPos, Vec3},
+};
use azalea_protocol::{
common::movements::{PositionMoveRotation, RelativeMovements},
packets::{
@@ -8,7 +11,6 @@ use azalea_protocol::{
},
};
use azalea_registry::builtin::EntityKind;
-use azalea_core::entity_id::MinecraftEntityId;
#[test]
fn test_move_and_despawn_entity() {
diff --git a/azalea-client/tests/simulation/move_despawned_entity.rs b/azalea-client/tests/simulation/move_despawned_entity.rs
index 48c22bf6..57ee3e37 100644
--- a/azalea-client/tests/simulation/move_despawned_entity.rs
+++ b/azalea-client/tests/simulation/move_despawned_entity.rs
@@ -1,9 +1,8 @@
use azalea_client::test_utils::prelude::*;
-use azalea_core::position::ChunkPos;
+use azalea_core::{entity_id::MinecraftEntityId, position::ChunkPos};
use azalea_entity::metadata::Cow;
use azalea_protocol::packets::{ConnectionProtocol, game::ClientboundMoveEntityRot};
use azalea_registry::builtin::EntityKind;
-use azalea_core::entity_id::MinecraftEntityId;
use bevy_ecs::query::With;
use tracing::Level;
diff --git a/azalea-client/tests/simulation/teleport_movement.rs b/azalea-client/tests/simulation/teleport_movement.rs
index ff06ebb5..1b740b19 100644
--- a/azalea-client/tests/simulation/teleport_movement.rs
+++ b/azalea-client/tests/simulation/teleport_movement.rs
@@ -1,6 +1,7 @@
use azalea_client::test_utils::prelude::*;
use azalea_core::{
delta::{LpVec3, PositionDelta8},
+ entity_id::MinecraftEntityId,
position::{BlockPos, ChunkPos, Vec3},
};
use azalea_entity::LookDirection;
@@ -16,7 +17,6 @@ use azalea_protocol::{
},
};
use azalea_registry::builtin::BlockKind;
-use azalea_core::entity_id::MinecraftEntityId;
#[test]
fn test_teleport_movement() {
diff --git a/azalea-core/src/position.rs b/azalea-core/src/position.rs
index f664957f..09246423 100644
--- a/azalea-core/src/position.rs
+++ b/azalea-core/src/position.rs
@@ -126,6 +126,15 @@ macro_rules! vec3_impl {
self.x * other.x + self.y * other.y + self.z * other.z
}
+ #[inline]
+ pub fn cross(&self, other: Self) -> Self {
+ Self::new(
+ self.y * other.z - self.z * other.y,
+ self.z * other.x - self.x * other.z,
+ self.x * other.y - self.y * other.x,
+ )
+ }
+
/// Make a new position with the lower coordinates for each axis.
pub fn min(&self, other: Self) -> Self {
Self {
@@ -333,6 +342,7 @@ impl simdnbt::FromNbtTag for Vec3 {
impl Vec3 {
/// Get the distance of this vector to the origin by doing
/// `sqrt(x^2 + y^2 + z^2)`.
+ #[doc(alias = "modulus")]
pub fn length(&self) -> f64 {
f64::sqrt(self.x * self.x + self.y * self.y + self.z * self.z)
}
@@ -343,6 +353,13 @@ impl Vec3 {
(self - other).length()
}
+ pub fn horizontal_distance_to(self, other: Self) -> f64 {
+ self.horizontal_distance_squared_to(other).sqrt()
+ }
+ pub fn horizontal_distance(self) -> f64 {
+ self.horizontal_distance_squared().sqrt()
+ }
+
pub fn x_rot(self, radians: f32) -> Vec3 {
let x_delta = math::cos(radians);
let y_delta = math::sin(radians);
diff --git a/azalea-entity/src/lib.rs b/azalea-entity/src/lib.rs
index 87e249c1..fab973b1 100644
--- a/azalea-entity/src/lib.rs
+++ b/azalea-entity/src/lib.rs
@@ -182,7 +182,7 @@ impl LookDirection {
pub fn update(&mut self, new: LookDirection) {
self.update_with_sensitivity(new, 1.);
}
- /// Update the `y_rot` to the given value, in degrees.
+ /// Update the `y_rot` (yaw) to the given value, in degrees.
///
/// This is a shortcut for [`Self::update`] while keeping the `x_rot` the
/// same.
@@ -192,7 +192,7 @@ impl LookDirection {
x_rot: self.x_rot,
});
}
- /// Update the `x_rot` to the given value, in degrees.
+ /// Update the `x_rot` (pitch) to the given value, in degrees.
///
/// This is a shortcut for [`Self::update`] while keeping the `y_rot` the
/// same.
diff --git a/azalea-world/src/container.rs b/azalea-world/src/container.rs
index af344dc0..c0fa2633 100644
--- a/azalea-world/src/container.rs
+++ b/azalea-world/src/container.rs
@@ -94,7 +94,7 @@ impl Worlds {
/// If two entities share the same world name, then Azalea assumes that they're
/// in the same world.
#[derive(Clone, Component, Debug, Deref, DerefMut, Eq, Hash, PartialEq)]
-#[doc(alias("worldname", "world name", "dimension"))]
+#[doc(alias("dimension"))]
pub struct WorldName(pub Identifier);
impl WorldName {
/// Create a new `WorldName` with the given name.
diff --git a/azalea-world/src/lib.rs b/azalea-world/src/lib.rs
index 325d3b43..8bb23071 100644
--- a/azalea-world/src/lib.rs
+++ b/azalea-world/src/lib.rs
@@ -12,7 +12,7 @@ mod world;
pub use bit_storage::BitStorage;
pub use chunk_storage::{Chunk, ChunkStorage, PartialChunkStorage, Section};
-pub use container::{Worlds, WorldName};
+pub use container::{WorldName, Worlds};
pub use world::*;
#[deprecated = "renamed to `WorldName`."]
diff --git a/azalea/examples/testbot/main.rs b/azalea/examples/testbot/main.rs
index b2ad0b61..c74dcee7 100644
--- a/azalea/examples/testbot/main.rs
+++ b/azalea/examples/testbot/main.rs
@@ -19,6 +19,8 @@
//! /particle a ton of times to show where it's pathfinding to. You should
//! only have this on if the bot has operator permissions, otherwise it'll
//! just spam the server console unnecessarily.
+//! - `--simulation-pathfinder`: Use the alternative simulation-based execution
+//! engine for the pathfinder.
mod commands;
pub mod killaura;
@@ -26,8 +28,14 @@ pub mod killaura;
use std::{env, process, sync::Arc, thread, time::Duration};
use azalea::{
- ClientInformation, brigadier::command_dispatcher::CommandDispatcher, ecs::prelude::*,
- pathfinder::debug::PathfinderDebugParticles, prelude::*, swarm::prelude::*,
+ ClientInformation,
+ brigadier::command_dispatcher::CommandDispatcher,
+ ecs::prelude::*,
+ pathfinder::{
+ debug::PathfinderDebugParticles, execute::simulation::SimulationPathfinderExecutionPlugin,
+ },
+ prelude::*,
+ swarm::prelude::*,
};
use commands::{CommandSource, register_commands};
use parking_lot::Mutex;
@@ -44,6 +52,10 @@ async fn main() -> AppExit {
.set_handler(handle)
.set_swarm_handler(swarm_handle);
+ if args.simulation_pathfinder {
+ builder = builder.add_plugins(SimulationPathfinderExecutionPlugin);
+ }
+
for username_or_email in &args.accounts {
let account = if username_or_email.contains('@') {
Account::microsoft(username_or_email).await.unwrap()
@@ -210,6 +222,7 @@ pub struct Args {
pub accounts: Vec<String>,
pub server: String,
pub pathfinder_debug_particles: bool,
+ pub simulation_pathfinder: bool,
}
fn parse_args() -> Args {
@@ -217,6 +230,7 @@ fn parse_args() -> Args {
let mut accounts = Vec::new();
let mut server = "localhost".to_owned();
let mut pathfinder_debug_particles = false;
+ let mut simulation_pathfinder = false;
let mut args = env::args().skip(1);
while let Some(arg) = args.next() {
@@ -235,6 +249,9 @@ fn parse_args() -> Args {
"--pathfinder-debug-particles" | "-P" => {
pathfinder_debug_particles = true;
}
+ "--simulation-pathfinder" => {
+ simulation_pathfinder = true;
+ }
_ => {
eprintln!("Unknown argument: {arg}");
process::exit(1);
@@ -251,5 +268,6 @@ fn parse_args() -> Args {
accounts,
server,
pathfinder_debug_particles,
+ simulation_pathfinder,
}
}
diff --git a/azalea/src/pathfinder/debug.rs b/azalea/src/pathfinder/debug.rs
index 42a64982..0a5f583d 100644
--- a/azalea/src/pathfinder/debug.rs
+++ b/azalea/src/pathfinder/debug.rs
@@ -3,6 +3,7 @@ use azalea_core::position::Vec3;
use bevy_ecs::prelude::*;
use super::ExecutingPath;
+use crate::pathfinder::moves::should_mine_block_state;
/// A component that makes bots run /particle commands while pathfinding to show
/// where they're going.
@@ -74,9 +75,8 @@ pub fn debug_render_path_with_particles(
// this isn't foolproof, there might be another block that could be mined
// depending on the move, but it's good enough for debugging
// purposes
- let is_mining = !(super::world::is_block_state_passable(target_block_state)
- || super::world::is_block_state_water(target_block_state))
- || !super::world::is_block_state_passable(above_target_block_state);
+ let is_mining = should_mine_block_state(target_block_state)
+ || should_mine_block_state(above_target_block_state);
let (r, g, b): (f64, f64, f64) = if i == 0 {
(0., 1., 0.)
diff --git a/azalea/src/pathfinder/execute/mod.rs b/azalea/src/pathfinder/execute/mod.rs
index 7b5336b4..ab7a1a82 100644
--- a/azalea/src/pathfinder/execute/mod.rs
+++ b/azalea/src/pathfinder/execute/mod.rs
@@ -1,4 +1,5 @@
pub mod patching;
+pub mod simulation;
use std::{cmp, time::Duration};
@@ -8,7 +9,7 @@ use azalea_client::{
local_player::WorldHolder,
mining::{Mining, MiningSystems, StartMiningBlockEvent},
};
-use azalea_core::tick::GameTick;
+use azalea_core::{position::Vec3, tick::GameTick};
use azalea_entity::{Physics, Position, inventory::Inventory};
use azalea_physics::{PhysicsSystems, get_block_pos_below_that_affects_movement};
use azalea_world::{WorldName, Worlds};
@@ -29,7 +30,7 @@ use crate::{
astar::PathfinderTimeout,
custom_state::CustomPathfinderState,
debug::debug_render_path_with_particles,
- execute,
+ execute::simulation::SimulatingPathState,
moves::{ExecuteCtx, IsReachedCtx},
player_pos_to_block_pos,
},
@@ -37,18 +38,23 @@ use crate::{
pub struct DefaultPathfinderExecutionPlugin;
impl Plugin for DefaultPathfinderExecutionPlugin {
- fn build(&self, app: &mut App) {
+ fn build(&self, _app: &mut App) {}
+
+ fn finish(&self, app: &mut App) {
+ if app.is_plugin_added::<simulation::SimulationPathfinderExecutionPlugin>() {
+ info!("pathfinder simulation executor plugin is enabled, disabling default executor.");
+ return;
+ }
+
app.add_systems(
- // putting systems in the GameTick schedule makes them run every Minecraft tick
- // (every 50 milliseconds).
GameTick,
(
- execute::timeout_movement,
- execute::patching::check_for_path_obstruction,
- execute::check_node_reached,
- execute::tick_execute_path,
- execute::recalculate_near_end_of_path,
- execute::recalculate_if_has_goal_but_no_path,
+ timeout_movement,
+ patching::check_for_path_obstruction,
+ check_node_reached,
+ tick_execute_path,
+ recalculate_near_end_of_path,
+ recalculate_if_has_goal_but_no_path,
)
.chain()
.after(PhysicsSystems)
@@ -77,15 +83,8 @@ pub fn tick_execute_path(
mut jump_events: MessageWriter<JumpEvent>,
mut start_mining_events: MessageWriter<StartMiningBlockEvent>,
) {
- for (
- entity,
- mut executing_path,
- position,
- physics,
- mining,
- world_holder,
- inventory_component,
- ) in &mut query
+ for (entity, mut executing_path, position, physics, mining, world_holder, inventory) in
+ &mut query
{
executing_path.ticks_since_last_node_reached += 1;
@@ -97,8 +96,9 @@ pub fn tick_execute_path(
start: executing_path.last_reached_node,
physics,
is_currently_mining: mining.is_some(),
+ can_mine: true,
world: world_holder.shared.clone(),
- menu: inventory_component.inventory_menu.clone(),
+ menu: inventory.inventory_menu.clone(),
commands: &mut commands,
look_at_events: &mut look_at_events,
@@ -146,7 +146,7 @@ pub fn check_node_reached(
.clone()
.into_iter()
.enumerate()
- .take(20)
+ .take(30)
.rev()
{
let movement = edge.movement;
@@ -184,7 +184,7 @@ pub fn check_node_reached(
let z_predicted_offset = (z_difference_from_center + scaled_velocity.z).abs();
// this is to make sure we don't fall off immediately after finishing the path
- physics.on_ground()
+ (physics.on_ground() || physics.is_in_water())
&& player_pos_to_block_pos(**position) == movement.target
// adding the delta like this isn't a perfect solution but it helps to make
// sure we don't keep going if our delta is high
@@ -256,6 +256,7 @@ pub fn timeout_movement(
&WorldName,
&Inventory,
Option<&CustomPathfinderState>,
+ Option<&SimulatingPathState>,
)>,
worlds: Res<Worlds>,
) {
@@ -268,8 +269,52 @@ pub fn timeout_movement(
world_name,
inventory,
custom_state,
+ simulating_path_state,
) in &mut query
{
+ if !executing_path.path.is_empty() {
+ let (start, end) = if let Some(SimulatingPathState::Simulated(simulating_path_state)) =
+ simulating_path_state
+ {
+ (simulating_path_state.start, simulating_path_state.target)
+ } else {
+ (
+ executing_path.last_reached_node,
+ executing_path.path[0].movement.target,
+ )
+ };
+
+ let (start, end) = (start.center_bottom(), end.center_bottom());
+ // TODO: use an actual 2d point-line distance formula here instead of the 3d one
+ // lol
+ let xz_distance =
+ point_line_distance_3d(&position.with_y(0.), &(start.with_y(0.), end.with_y(0.)));
+ let y_distance = point_line_distance_1d(position.y, (start.y, end.y));
+
+ let xz_tolerance = 3.;
+ // longer moves have more y tolerance (in case we're climbing a hill or smth in
+ // a single movement)
+ let y_tolerance = start.horizontal_distance_to(end) / 2. + 1.5;
+
+ if xz_distance > xz_tolerance || y_distance > y_tolerance {
+ warn!(
+ "pathfinder went too far from path (xz_distance={xz_distance}/{xz_tolerance}, y_distance={y_distance}/{y_tolerance}, line is {start} to {end}, point at {}), trying to patch!",
+ **position
+ );
+ patch_path_from_timeout(
+ entity,
+ &mut executing_path,
+ &mut pathfinder,
+ &worlds,
+ position,
+ world_name,
+ custom_state,
+ inventory,
+ );
+ continue;
+ }
+ }
+
// don't timeout if we're mining
if let Some(mining) = mining {
// also make sure we're close enough to the block that's being mined
@@ -281,46 +326,77 @@ pub fn timeout_movement(
}
}
- if executing_path.ticks_since_last_node_reached > (2 * 20)
+ let mut timeout = 2 * 20;
+
+ if simulating_path_state.is_some() {
+ // longer timeout if we're following a simulated path from the other execution
+ // engine
+ timeout = 5 * 20;
+ }
+
+ if executing_path.ticks_since_last_node_reached > timeout
&& !pathfinder.is_calculating
&& !executing_path.path.is_empty()
{
warn!("pathfinder timeout, trying to patch path");
- executing_path.queued_path = None;
- let cur_pos = player_pos_to_block_pos(**position);
- executing_path.last_reached_node = cur_pos;
-
- let world_lock = worlds
- .get(world_name)
- .expect("Entity tried to pathfind but the entity isn't in a valid world");
- let Some(opts) = pathfinder.opts.clone() else {
- warn!(
- "pathfinder was going to patch path because of timeout, but pathfinder.opts was None"
- );
- return;
- };
- let custom_state = custom_state.cloned().unwrap_or_default();
-
- // try to fix the path without recalculating everything.
- // (though, it'll still get fully recalculated by `recalculate_near_end_of_path`
- // if the new path is too short)
- patching::patch_path(
- 0..=cmp::min(20, executing_path.path.len() - 1),
+ patch_path_from_timeout(
+ entity,
&mut executing_path,
&mut pathfinder,
- inventory,
- entity,
- world_lock,
+ &worlds,
+ position,
+ world_name,
custom_state,
- opts,
+ inventory,
);
- // reset last_node_reached_at so we don't immediately try to patch again
- executing_path.ticks_since_last_node_reached = 0
}
}
}
+fn patch_path_from_timeout(
+ entity: Entity,
+ executing_path: &mut ExecutingPath,
+ pathfinder: &mut Pathfinder,
+ worlds: &Worlds,
+ position: &Position,
+ world_name: &WorldName,
+ custom_state: Option<&CustomPathfinderState>,
+ inventory: &Inventory,
+) {
+ executing_path.queued_path = None;
+ let cur_pos = player_pos_to_block_pos(**position);
+ executing_path.last_reached_node = cur_pos;
+
+ let world_lock = worlds
+ .get(world_name)
+ .expect("Entity tried to pathfind but the entity isn't in a valid world");
+ let Some(opts) = pathfinder.opts.clone() else {
+ warn!(
+ "pathfinder was going to patch path because of timeout, but pathfinder.opts was None"
+ );
+ return;
+ };
+
+ let custom_state = custom_state.cloned().unwrap_or_default();
+
+ // try to fix the path without recalculating everything.
+ // (though, it'll still get fully recalculated by `recalculate_near_end_of_path`
+ // if the new path is too short)
+ patching::patch_path(
+ 0..=cmp::min(20, executing_path.path.len() - 1),
+ executing_path,
+ pathfinder,
+ inventory,
+ entity,
+ world_lock,
+ custom_state,
+ opts,
+ );
+ // reset last_node_reached_at so we don't immediately try to patch again
+ executing_path.ticks_since_last_node_reached = 0
+}
+
pub fn recalculate_near_end_of_path(
mut query: Query<(Entity, &mut Pathfinder, &mut ExecutingPath)>,
mut walk_events: MessageWriter<StartWalkEvent>,
@@ -409,3 +485,33 @@ pub fn recalculate_if_has_goal_but_no_path(
}
}
}
+
+// based on https://stackoverflow.com/a/36425155
+/// Returns the distance of a point from a line.
+///
+/// This is used in the pathfinder for checking if the bot is too far from the
+/// current path.
+pub fn point_line_distance_3d(point: &Vec3, (start, end): &(Vec3, Vec3)) -> f64 {
+ let start_to_end = end - start;
+ let start_to_point = point - start;
+
+ if start_to_point.dot(start_to_end) <= 0. {
+ return start_to_point.length();
+ }
+
+ let end_to_point = point - end;
+ if end_to_point.dot(start_to_end) >= 0. {
+ return end_to_point.length();
+ }
+
+ start_to_end.cross(start_to_point).length() / start_to_end.length()
+}
+pub fn point_line_distance_1d(point: f64, (start, end): (f64, f64)) -> f64 {
+ let min = start.min(end);
+ let max = start.max(end);
+ if point < min {
+ min - point
+ } else {
+ point - max
+ }
+}
diff --git a/azalea/src/pathfinder/execute/simulation.rs b/azalea/src/pathfinder/execute/simulation.rs
index e69de29b..5bb82753 100644
--- a/azalea/src/pathfinder/execute/simulation.rs
+++ b/azalea/src/pathfinder/execute/simulation.rs
@@ -0,0 +1,500 @@
+//! An alternative execution engine for the pathfinder that attempts to skip
+//! nodes in the path by running simulations.
+//!
+//! See [`SimulationPathfinderExecutionPlugin`] for more information.
+
+use std::{borrow::Cow, time::Instant};
+
+use azalea_client::{
+ PhysicsState, SprintDirection, StartSprintEvent, StartWalkEvent,
+ local_player::WorldHolder,
+ mining::{Mining, MiningSystems, StartMiningBlockEvent},
+};
+use azalea_core::{position::BlockPos, tick::GameTick};
+use azalea_entity::{Attributes, LookDirection, Physics, Position, inventory::Inventory};
+use azalea_physics::PhysicsSystems;
+use bevy_app::{App, Plugin};
+use bevy_ecs::{prelude::*, system::SystemState};
+use tracing::{debug, trace};
+
+use crate::{
+ WalkDirection,
+ bot::{JumpEvent, LookAtEvent, direction_looking_at},
+ ecs::{
+ entity::Entity,
+ system::{Commands, Query},
+ },
+ pathfinder::{
+ ExecutingPath,
+ debug::debug_render_path_with_particles,
+ moves::{ExecuteCtx, IsReachedCtx},
+ simulation::{SimulatedPlayerBundle, Simulation},
+ },
+};
+
+/// An alternative execution engine for the pathfinder that attempts to skip
+/// nodes in the path by running simulations.
+///
+/// This allows it to smooth the path and sprint-jump without failing jumps or
+/// looking unnatural. However, this comes at the cost of execution being more
+/// expensive and potentially less stable.
+///
+/// To use it, simply add [`SimulationPathfinderExecutionPlugin`] as a plugin.
+///
+/// ```
+/// use azalea::{simulation::SimulationPathfinderExecutionPlugin, swarm::prelude::*};
+///
+/// let builder = SwarmBuilder::new().add_plugins(SimulationPathfinderExecutionPlugin);
+/// // ...
+/// ```
+///
+/// [`DefaultPathfinderExecutionPlugin`]: super::DefaultPathfinderExecutionPlugin
+pub struct SimulationPathfinderExecutionPlugin;
+impl Plugin for SimulationPathfinderExecutionPlugin {
+ fn build(&self, app: &mut App) {
+ app.add_systems(
+ GameTick,
+ (
+ super::timeout_movement,
+ super::patching::check_for_path_obstruction,
+ super::check_node_reached,
+ tick_execute_path,
+ super::recalculate_near_end_of_path,
+ super::recalculate_if_has_goal_but_no_path,
+ )
+ .chain()
+ .after(PhysicsSystems)
+ .after(azalea_client::movement::send_position)
+ .after(MiningSystems)
+ .after(debug_render_path_with_particles),
+ );
+ }
+}
+
+#[derive(Clone, Component, Debug)]
+pub enum SimulatingPathState {
+ Fail,
+ Simulated(SimulatingPathOpts),
+}
+#[derive(Clone, Component, Debug)]
+pub struct SimulatingPathOpts {
+ pub start: BlockPos,
+ pub target: BlockPos,
+ pub jumping: bool,
+ pub jump_until_target_distance: f64,
+ pub jump_after_start_distance: f64,
+ pub sprinting: bool,
+ pub y_rot: f32,
+}
+
+#[allow(clippy::type_complexity)]
+pub fn tick_execute_path(
+ mut commands: Commands,
+ mut query: Query<(
+ Entity,
+ &mut ExecutingPath,
+ &mut LookDirection,
+ &Position,
+ &Physics,
+ &PhysicsState,
+ Option<&Mining>,
+ &WorldHolder,
+ &Attributes,
+ &Inventory,
+ Option<&SimulatingPathState>,
+ )>,
+ mut look_at_events: MessageWriter<LookAtEvent>,
+ mut sprint_events: MessageWriter<StartSprintEvent>,
+ mut walk_events: MessageWriter<StartWalkEvent>,
+ mut jump_events: MessageWriter<JumpEvent>,
+ mut start_mining_events: MessageWriter<StartMiningBlockEvent>,
+) {
+ for (
+ entity,
+ mut executing_path,
+ mut look_direction,
+ position,
+ physics,
+ physics_state,
+ mining,
+ world_holder,
+ attributes,
+ inventory,
+ mut simulating_path_state,
+ ) in &mut query
+ {
+ executing_path.ticks_since_last_node_reached += 1;
+
+ if executing_path.ticks_since_last_node_reached == 1 {
+ if let Some(SimulatingPathState::Simulated(s)) = simulating_path_state {
+ // only reset the state if we just reached the end of the simulation path (for
+ // performance)
+ if s.target == executing_path.last_reached_node
+ // or if the current simulation target isn't in the path, reset too
+ || !executing_path
+ .path
+ .iter()
+ .any(|e| e.movement.target == s.target)
+ {
+ simulating_path_state = None;
+ }
+ } else {
+ simulating_path_state = None;
+ }
+ }
+
+ let simulating_path_state = if let Some(simulating_path_state) = simulating_path_state {
+ Cow::Borrowed(simulating_path_state)
+ } else {
+ let start = Instant::now();
+ let new_state = run_simulations(
+ &executing_path,
+ world_holder,
+ SimulatedPlayerBundle {
+ position: *position,
+ physics: physics.clone(),
+ physics_state: physics_state.clone(),
+ look_direction: *look_direction,
+ attributes: attributes.clone(),
+ inventory: inventory.clone(),
+ },
+ );
+ debug!("found sim in {:?}: {new_state:?}", start.elapsed());
+ commands.entity(entity).insert(new_state.clone());
+ Cow::Owned(new_state)
+ };
+
+ match &*simulating_path_state {
+ SimulatingPathState::Fail => {
+ if let Some(edge) = executing_path.path.front() {
+ let mut ctx = ExecuteCtx {
+ entity,
+ target: edge.movement.target,
+ position: **position,
+ start: executing_path.last_reached_node,
+ physics,
+ is_currently_mining: mining.is_some(),
+ can_mine: true,
+ world: world_holder.shared.clone(),
+ menu: inventory.inventory_menu.clone(),
+
+ commands: &mut commands,
+ look_at_events: &mut look_at_events,
+ sprint_events: &mut sprint_events,
+ walk_events: &mut walk_events,
+ jump_events: &mut jump_events,
+ start_mining_events: &mut start_mining_events,
+ };
+ ctx.on_tick_start();
+ trace!(
+ "executing move, position: {}, last_reached_node: {}",
+ **position, executing_path.last_reached_node
+ );
+ (edge.movement.data.execute)(ctx);
+ }
+ }
+ SimulatingPathState::Simulated(SimulatingPathOpts {
+ start,
+ target,
+ jumping,
+ jump_until_target_distance,
+ jump_after_start_distance,
+ sprinting,
+ y_rot,
+ }) => {
+ look_direction.update(LookDirection::new(*y_rot, 0.));
+
+ if *sprinting {
+ sprint_events.write(StartSprintEvent {
+ entity,
+ direction: SprintDirection::Forward,
+ });
+ } else {
+ if physics_state.was_sprinting {
+ walk_events.write(StartWalkEvent {
+ entity,
+ direction: WalkDirection::None,
+ });
+ } else {
+ walk_events.write(StartWalkEvent {
+ entity,
+ direction: WalkDirection::Forward,
+ });
+ }
+ }
+ if *jumping
+ && target.center().horizontal_distance_squared_to(**position)
+ > jump_until_target_distance.powi(2)
+ && start.center().horizontal_distance_squared_to(**position)
+ > jump_after_start_distance.powi(2)
+ {
+ jump_events.write(JumpEvent { entity });
+ }
+ }
+ }
+
+ //
+ }
+}
+
+fn run_simulations(
+ executing_path: &ExecutingPath,
+ world_holder: &WorldHolder,
+ player: SimulatedPlayerBundle,
+) -> SimulatingPathState {
+ let swimming = player.physics.is_in_water();
+
+ let mut sim = Simulation::new(world_holder.shared.read().chunks.clone(), player.clone());
+
+ for nodes_ahead in [20, 15, 10, 5, 4, 3, 2, 1, 0] {
+ if nodes_ahead + 1 >= executing_path.path.len() {
+ // don't simulate to the last node since it has stricter checks
+ continue;
+ }
+
+ let mut results = Vec::new();
+
+ if let Some(simulating_to) = executing_path.path.get(nodes_ahead) {
+ let y_rot =
+ direction_looking_at(*player.position, simulating_to.movement.target.center())
+ .y_rot();
+
+ for jump_until_target_distance in [0., 1., 3.] {
+ for jump_after_start_distance in [0., 0.5] {
+ for jumping in [true, false] {
+ if !jumping
+ && (jump_until_target_distance != 0. || jump_after_start_distance != 0.)
+ {
+ continue;
+ }
+
+ // this loop is left here in case you wanna try re-enabling walking, but
+ // it doesn't seem that useful
+ for sprinting in [true] {
+ if !sprinting && nodes_ahead > 2 {
+ continue;
+ }
+ if swimming {
+ if !sprinting
+ || jump_until_target_distance > 0.
+ || jump_after_start_distance > 0.
+ {
+ continue;
+ }
+ } else if jump_until_target_distance == 0. {
+ continue;
+ }
+
+ let state = SimulatingPathOpts {
+ start: BlockPos::from(player.position),
+ target: simulating_to.movement.target,
+ jumping,
+ jump_until_target_distance,
+ jump_after_start_distance,
+ sprinting,
+ y_rot,
+ };
+ let sim_res = run_one_simulation(
+ &mut sim,
+ player.clone(),
+ state.clone(),
+ executing_path,
+ nodes_ahead,
+ if swimming {
+ (nodes_ahead * 12) + 20
+ } else {
+ (nodes_ahead * 4) + 20
+ },
+ );
+ if sim_res.success {
+ results.push((state, sim_res.ticks));
+ }
+ }
+ }
+ }
+ }
+ }
+
+ if !results.is_empty() {
+ let fastest = results.iter().min_by_key(|r| r.1).unwrap().0.clone();
+ return SimulatingPathState::Simulated(fastest);
+ }
+ }
+
+ SimulatingPathState::Fail
+}
+
+struct SimulationResult {
+ success: bool,
+ ticks: usize,
+}
+fn run_one_simulation(
+ sim: &mut Simulation,
+ player: SimulatedPlayerBundle,
+ state: SimulatingPathOpts,
+ executing_path: &ExecutingPath,
+ nodes_ahead: usize,
+ timeout_ticks: usize,
+) -> SimulationResult {
+ let simulating_to = &executing_path.path[nodes_ahead];
+
+ let start = BlockPos::from(player.position);
+ sim.reset(player);
+
+ let simulating_to_block = simulating_to.movement.target;
+
+ let mut success = false;
+ let mut total_ticks = 0;
+
+ for ticks in 1..=timeout_ticks {
+ let position = sim.position();
+ let ecs = sim.app.world_mut();
+
+ ecs.get_mut::<LookDirection>(sim.entity)
+ .unwrap()
+ .update(LookDirection::new(state.y_rot, 0.));
+
+ if state.sprinting {
+ ecs.write_message(StartSprintEvent {
+ entity: sim.entity,
+ direction: SprintDirection::Forward,
+ });
+ } else {
+ if ecs
+ .get::<PhysicsState>(sim.entity)
+ .map(|p| p.trying_to_sprint)
+ .unwrap_or_default()
+ {
+ // have to let go for a tick to be able to start walking
+ ecs.write_message(StartWalkEvent {
+ entity: sim.entity,
+ direction: WalkDirection::None,
+ });
+ } else {
+ ecs.write_message(StartWalkEvent {
+ entity: sim.entity,
+ direction: WalkDirection::Forward,
+ });
+ }
+ }
+ if state.jumping
+ && simulating_to_block
+ .center()
+ .horizontal_distance_squared_to(position)
+ > state.jump_until_target_distance.powi(2)
+ && start.center().horizontal_distance_squared_to(position)
+ > state.jump_after_start_distance.powi(2)
+ {
+ ecs.write_message(JumpEvent { entity: sim.entity });
+ }
+
+ sim.tick();
+
+ let physics = sim.physics();
+ if physics.horizontal_collision
+ || physics.is_in_lava()
+ || (physics.velocity.y < -0.7 && !physics.is_in_water())
+ {
+ // fail
+ break;
+ }
+
+ if (simulating_to.movement.data.is_reached)(IsReachedCtx {
+ target: simulating_to_block,
+ start,
+ position: sim.position(),
+ physics: &physics,
+ }) {
+ success = true;
+ total_ticks = ticks;
+ break;
+ }
+ }
+
+ if success {
+ // now verify that the path is safe by continuing to the next node
+
+ let mut followup_success = false;
+
+ let next_node = &executing_path.path[nodes_ahead + 1];
+ for _ in 1..=30 {
+ // add ticks here so if we sort by ticks later it'll be more accurate
+ total_ticks += 1;
+
+ {
+ let mut system_state = SystemState::<(
+ Commands,
+ Query<(&Position, &Physics, Option<&Mining>, &Inventory)>,
+ MessageWriter<LookAtEvent>,
+ MessageWriter<StartSprintEvent>,
+ MessageWriter<StartWalkEvent>,
+ MessageWriter<JumpEvent>,
+ MessageWriter<StartMiningBlockEvent>,
+ )>::new(sim.app.world_mut());
+ let (
+ mut commands,
+ query,
+ mut look_at_events,
+ mut sprint_events,
+ mut walk_events,
+ mut jump_events,
+ mut start_mining_events,
+ ) = system_state.get_mut(sim.app.world_mut());
+
+ let (position, physics, mining, inventory) = query.get(sim.entity).unwrap();
+
+ if physics.horizontal_collision && physics.velocity.y < -0. {
+ // if the simulated move just made us hit a wall that we aren't already jumping
+ // from then that's bad
+ break;
+ }
+ if physics.velocity.y < -0.7 && !physics.is_in_water() {
+ break;
+ }
+
+ (next_node.movement.data.execute)(ExecuteCtx {
+ entity: sim.entity,
+ target: next_node.movement.target,
+ start: simulating_to_block,
+ position: **position,
+ physics,
+ is_currently_mining: mining.is_some(),
+ // don't modify the world from the simulation
+ can_mine: false,
+ world: sim.world.clone(),
+ menu: inventory.inventory_menu.clone(),
+
+ commands: &mut commands,
+ look_at_events: &mut look_at_events,
+ sprint_events: &mut sprint_events,
+ walk_events: &mut walk_events,
+ jump_events: &mut jump_events,
+ start_mining_events: &mut start_mining_events,
+ });
+ system_state.apply(sim.app.world_mut());
+ }
+
+ sim.tick();
+
+ if (next_node.movement.data.is_reached)(IsReachedCtx {
+ target: next_node.movement.target,
+ start: simulating_to_block,
+ position: sim.position(),
+ physics: &sim.physics(),
+ }) {
+ followup_success = true;
+ break;
+ }
+ }
+
+ if !followup_success {
+ debug!("followup failed");
+ success = false;
+ }
+ }
+
+ SimulationResult {
+ success,
+ ticks: total_ticks,
+ }
+}
diff --git a/azalea/src/pathfinder/mod.rs b/azalea/src/pathfinder/mod.rs
index 04c1d58c..2a1a6220 100644
--- a/azalea/src/pathfinder/mod.rs
+++ b/azalea/src/pathfinder/mod.rs
@@ -3,7 +3,15 @@
//! For the new functions on `Client` that the pathfinder adds, see
//! [`PathfinderClientExt`].
//!
-//! Much of this code is based on [Baritone](https://github.com/cabaletta/baritone).
+//! Note that the pathfinder is highly optimized, but it will be very slow if
+//! it's not compiled with optimizations enabled.
+//!
+//! For more efficient and realistic path execution, also see
+//! [`SimulationPathfinderExecutionPlugin`].
+//!
+//! Much of the pathfinder's code is based on [Baritone](https://github.com/cabaletta/baritone). <3
+//!
+//! [`SimulationPathfinderExecutionPlugin`]: execute::simulation::SimulationPathfinderExecutionPlugin
pub mod astar;
pub mod costs;
diff --git a/azalea/src/pathfinder/moves/mod.rs b/azalea/src/pathfinder/moves/mod.rs
index d977c7bd..4e621a10 100644
--- a/azalea/src/pathfinder/moves/mod.rs
+++ b/azalea/src/pathfinder/moves/mod.rs
@@ -14,6 +14,7 @@ use azalea_client::{
};
use azalea_core::position::{BlockPos, Vec3};
use azalea_inventory::Menu;
+use azalea_registry::builtin::BlockKind;
use azalea_world::World;
use bevy_ecs::{entity::Entity, message::MessageWriter, system::Commands, world::EntityWorldMut};
use parking_lot::RwLock;
@@ -29,7 +30,7 @@ use super::{
use crate::{
auto_tool::best_tool_in_hotbar_for_block,
bot::{JumpEvent, LookAtEvent},
- pathfinder::{player_pos_to_block_pos, world::is_block_state_water},
+ pathfinder::player_pos_to_block_pos,
};
type Edge = astar::Edge<RelBlockPos, MoveData>;
@@ -73,6 +74,7 @@ pub struct ExecuteCtx<'s, 'w1, 'w2, 'w3, 'w4, 'w5, 'w6, 'a> {
pub position: Vec3,
pub physics: &'a azalea_entity::Physics,
pub is_currently_mining: bool,
+ pub can_mine: bool,
pub world: Arc<RwLock<World>>,
pub menu: Menu,
@@ -150,18 +152,17 @@ impl ExecuteCtx<'_, '_, '_, '_, '_, '_, '_, '_> {
/// Returns whether this block could be mined.
pub fn should_mine(&mut self, block: BlockPos) -> bool {
let block_state = self.world.read().get_block_state(block).unwrap_or_default();
- if is_block_state_passable(block_state) || is_block_state_water(block_state) {
- // block is already passable, no need to mine it
- return false;
- }
-
- true
+ should_mine_block_state(block_state)
}
/// Mine the block at the given position.
///
/// Returns whether the block is being mined.
pub fn mine(&mut self, block: BlockPos) -> bool {
+ if !self.can_mine {
+ return false;
+ }
+
let block_state = self.world.read().get_block_state(block).unwrap_or_default();
if is_block_state_passable(block_state) {
// block is already passable, no need to mine it
@@ -217,6 +218,15 @@ impl ExecuteCtx<'_, '_, '_, '_, '_, '_, '_, '_> {
}
}
+pub fn should_mine_block_state(block_state: BlockState) -> bool {
+ if is_block_state_passable(block_state) || BlockKind::from(block_state) == BlockKind::Water {
+ // block is already passable, no need to mine it
+ return false;
+ }
+
+ true
+}
+
pub struct IsReachedCtx<'a> {
/// The node that we're trying to reach.
pub target: BlockPos,
diff --git a/azalea/src/pathfinder/simulation.rs b/azalea/src/pathfinder/simulation.rs
index 94837f9e..a8f24480 100644
--- a/azalea/src/pathfinder/simulation.rs
+++ b/azalea/src/pathfinder/simulation.rs
@@ -130,18 +130,23 @@ fn create_simulation_player(
pub struct Simulation {
pub app: App,
pub entity: Entity,
- _world: Arc<RwLock<World>>,
+ pub world: Arc<RwLock<World>>,
}
impl Simulation {
pub fn new(chunks: ChunkStorage, player: SimulatedPlayerBundle) -> Self {
let (mut app, world) = create_simulation_world(chunks);
let entity = create_simulation_player(app.world_mut(), world.clone(), player);
- Self {
- app,
- entity,
- _world: world,
- }
+ Self { app, entity, world }
+ }
+
+ /// Despawn the old simulated player and create a new one.
+ ///
+ /// This is cheaper than creating a new [`Simulation`] from scratch.
+ pub fn reset(&mut self, player: SimulatedPlayerBundle) {
+ self.app.world_mut().despawn(self.entity);
+ let entity = create_simulation_player(self.app.world_mut(), self.world.clone(), player);
+ self.entity = entity;
}
pub fn tick(&mut self) {
@@ -157,6 +162,9 @@ impl Simulation {
pub fn position(&self) -> Vec3 {
*self.component::<Position>()
}
+ pub fn physics(&self) -> Physics {
+ self.component::<Physics>().clone()
+ }
pub fn is_mining(&self) -> bool {
// return true if the component is present and Some
self.get_component::<azalea_client::mining::MineBlockPos>()
diff --git a/azalea/src/pathfinder/world.rs b/azalea/src/pathfinder/world.rs
index be264889..4e9952cd 100644
--- a/azalea/src/pathfinder/world.rs
+++ b/azalea/src/pathfinder/world.rs
@@ -113,11 +113,9 @@ impl CachedWorld {
cached_blocks: Default::default(),
// this uses about 12mb of memory. it *really* helps though.
cached_mining_costs: UnsafeCell::new(
- vec![
- (RelBlockPos::new(i16::MAX, i32::MAX, i16::MAX), 0.);
- CACHED_MINING_COSTS_SIZE
- ]
- .into_boxed_slice(),
+ (0..CACHED_MINING_COSTS_SIZE)
+ .map(|_| (RelBlockPos::new(i16::MAX, i32::MAX, i16::MAX), 0.))
+ .collect(),
),
}
}
diff --git a/azalea/src/swarm/builder.rs b/azalea/src/swarm/builder.rs
index 560be3d8..f42b4d4a 100644
--- a/azalea/src/swarm/builder.rs
+++ b/azalea/src/swarm/builder.rs
@@ -90,13 +90,17 @@ impl SwarmBuilder<NoState, NoSwarmState, (), ()> {
/// [`DefaultSwarmPlugins`] to this.
///
/// ```
- /// # use azalea::{prelude::*, swarm::prelude::*};
+ /// # use azalea::{prelude::*, swarm::{prelude::*, DefaultSwarmPlugins}, bot::DefaultBotPlugins};
/// use azalea::app::PluginGroup;
///
/// let swarm_builder = SwarmBuilder::new_without_plugins()
- /// .add_plugins(azalea::DefaultPlugins.build().disable::<azalea::chat_signing::ChatSigningPlugin>())
- /// .add_plugins(azalea::bot::DefaultBotPlugins)
- /// .add_plugins(azalea::swarm::DefaultSwarmPlugins);
+ /// .add_plugins((
+ /// DefaultBotPlugins,
+ /// DefaultSwarmPlugins,
+ /// azalea::DefaultPlugins
+ /// .build()
+ /// .disable::<azalea::chat_signing::ChatSigningPlugin>(),
+ /// ));
/// # swarm_builder.set_handler(handle).set_swarm_handler(swarm_handle);
/// # #[derive(Clone, Component, Default, Resource)]
/// # pub struct State;