aboutsummaryrefslogtreecommitdiff
path: root/azalea/src/pathfinder/execute
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 /azalea/src/pathfinder/execute
parentfb92f65b3da49b6487bf6fa05010b12a3ab5d4ed (diff)
downloadazalea-drasl-268c62587e090c72b67a29e1cc42cda6c9d7340b.tar.xz
add simulation-based pathfinder execution engine
Diffstat (limited to 'azalea/src/pathfinder/execute')
-rw-r--r--azalea/src/pathfinder/execute/mod.rs206
-rw-r--r--azalea/src/pathfinder/execute/simulation.rs500
2 files changed, 656 insertions, 50 deletions
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,
+ }
+}