Skip to main content

azalea/pathfinder/execute/
simulation.rs

1//! An alternative execution engine for the pathfinder that attempts to skip
2//! nodes in the path by running simulations.
3//!
4//! See [`SimulationPathfinderExecutionPlugin`] for more information.
5
6use std::{borrow::Cow, time::Instant};
7
8use azalea_client::{
9    PhysicsState, SprintDirection, StartSprintEvent, StartWalkEvent,
10    local_player::WorldHolder,
11    mining::{Mining, MiningSystems, StartMiningBlockEvent},
12};
13use azalea_core::{position::BlockPos, tick::GameTick};
14use azalea_entity::{Attributes, LookDirection, Physics, Position, inventory::Inventory};
15use azalea_physics::PhysicsSystems;
16use bevy_app::{App, Plugin};
17use bevy_ecs::{prelude::*, system::SystemState};
18use tracing::{debug, trace};
19
20use crate::{
21    WalkDirection,
22    bot::{JumpEvent, LookAtEvent, direction_looking_at},
23    ecs::{
24        entity::Entity,
25        system::{Commands, Query},
26    },
27    pathfinder::{
28        ExecutingPath, PathfinderSystems,
29        debug::debug_render_path_with_particles,
30        moves::{ExecuteCtx, IsReachedCtx},
31        simulation::{SimulatedPlayerBundle, Simulation},
32    },
33};
34
35/// An alternative execution engine for the pathfinder that attempts to skip
36/// nodes in the path by running simulations.
37///
38/// This allows it to smooth the path and sprint-jump without failing jumps or
39/// looking unnatural. However, this comes at the cost of execution being more
40/// expensive and potentially less stable.
41///
42/// To use it, simply add [`SimulationPathfinderExecutionPlugin`] as a plugin.
43///
44/// ```
45/// use azalea::{
46///     pathfinder::execute::simulation::SimulationPathfinderExecutionPlugin, swarm::prelude::*,
47/// };
48///
49/// let builder = SwarmBuilder::new().add_plugins(SimulationPathfinderExecutionPlugin);
50/// // ...
51/// ```
52///
53/// [`DefaultPathfinderExecutionPlugin`]: super::DefaultPathfinderExecutionPlugin
54pub struct SimulationPathfinderExecutionPlugin;
55impl Plugin for SimulationPathfinderExecutionPlugin {
56    fn build(&self, app: &mut App) {
57        app.add_systems(
58            GameTick,
59            (
60                super::timeout_movement,
61                super::patching::check_for_path_obstruction,
62                super::check_node_reached,
63                tick_execute_path,
64                super::recalculate_near_end_of_path,
65                super::recalculate_if_has_goal_but_no_path,
66            )
67                .chain()
68                .after(PhysicsSystems)
69                .after(azalea_client::movement::send_position)
70                .after(MiningSystems)
71                .after(debug_render_path_with_particles)
72                .in_set(PathfinderSystems),
73        );
74    }
75}
76
77#[derive(Clone, Component, Debug)]
78pub enum SimulatingPathState {
79    Fail,
80    Simulated(SimulatingPathOpts),
81}
82#[derive(Clone, Debug)]
83pub struct SimulatingPathOpts {
84    pub start: BlockPos,
85    pub target: BlockPos,
86    pub jumping: bool,
87    pub jump_until_target_distance: f64,
88    pub jump_after_start_distance: f64,
89    pub sprinting: bool,
90    pub y_rot: f32,
91}
92impl SimulatingPathState {
93    pub fn as_simulated(&self) -> Option<&SimulatingPathOpts> {
94        match self {
95            Self::Fail => None,
96            Self::Simulated(s) => Some(s),
97        }
98    }
99}
100
101#[allow(clippy::type_complexity)]
102pub fn tick_execute_path(
103    mut commands: Commands,
104    mut query: Query<(
105        Entity,
106        &mut ExecutingPath,
107        &mut LookDirection,
108        &Position,
109        &Physics,
110        &PhysicsState,
111        Option<&Mining>,
112        &WorldHolder,
113        &Attributes,
114        &Inventory,
115        Option<&SimulatingPathState>,
116    )>,
117    mut look_at_events: MessageWriter<LookAtEvent>,
118    mut sprint_events: MessageWriter<StartSprintEvent>,
119    mut walk_events: MessageWriter<StartWalkEvent>,
120    mut jump_events: MessageWriter<JumpEvent>,
121    mut start_mining_events: MessageWriter<StartMiningBlockEvent>,
122) {
123    for (
124        entity,
125        mut executing_path,
126        mut look_direction,
127        position,
128        physics,
129        physics_state,
130        mining,
131        world_holder,
132        attributes,
133        inventory,
134        mut simulating_path_state,
135    ) in &mut query
136    {
137        executing_path.ticks_since_last_node_reached += 1;
138
139        if executing_path.ticks_since_last_node_reached == 1 {
140            if let Some(SimulatingPathState::Simulated(s)) = simulating_path_state {
141                // only reset the state if we just reached the end of the simulation path (for
142                // performance)
143                if s.target == executing_path.last_reached_node
144                    // or if the current simulation target isn't in the path, reset too
145                    || !executing_path
146                        .path
147                        .iter()
148                        .any(|e| e.movement.target == s.target)
149                {
150                    simulating_path_state = None;
151                }
152            } else {
153                simulating_path_state = None;
154            }
155        }
156
157        let simulating_path_state = if let Some(simulating_path_state) = simulating_path_state {
158            Cow::Borrowed(simulating_path_state)
159        } else {
160            let start = Instant::now();
161
162            let new_state = run_simulations(
163                &executing_path,
164                world_holder,
165                SimulatedPlayerBundle {
166                    position: *position,
167                    physics: physics.clone(),
168                    physics_state: physics_state.clone(),
169                    look_direction: *look_direction,
170                    attributes: attributes.clone(),
171                    inventory: inventory.clone(),
172                },
173            );
174            debug!("found sim in {:?}: {new_state:?}", start.elapsed());
175            commands.entity(entity).insert(new_state.clone());
176            Cow::Owned(new_state)
177        };
178
179        match &*simulating_path_state {
180            SimulatingPathState::Fail => {
181                if let Some(edge) = executing_path.path.front() {
182                    let mut ctx = ExecuteCtx {
183                        entity,
184                        target: edge.movement.target,
185                        position: **position,
186                        start: executing_path.last_reached_node,
187                        physics,
188                        is_currently_mining: mining.is_some(),
189                        can_mine: true,
190                        world: world_holder.shared.clone(),
191                        menu: inventory.inventory_menu.clone(),
192
193                        commands: &mut commands,
194                        look_at_events: &mut look_at_events,
195                        sprint_events: &mut sprint_events,
196                        walk_events: &mut walk_events,
197                        jump_events: &mut jump_events,
198                        start_mining_events: &mut start_mining_events,
199                    };
200                    ctx.on_tick_start();
201                    trace!(
202                        "executing move, position: {}, last_reached_node: {}",
203                        **position, executing_path.last_reached_node
204                    );
205                    (edge.movement.data.execute)(ctx);
206                }
207            }
208            SimulatingPathState::Simulated(SimulatingPathOpts {
209                start,
210                target,
211                jumping,
212                jump_until_target_distance,
213                jump_after_start_distance,
214                sprinting,
215                y_rot,
216            }) => {
217                look_direction.update(LookDirection::new(*y_rot, 0.));
218
219                if *sprinting {
220                    sprint_events.write(StartSprintEvent {
221                        entity,
222                        direction: SprintDirection::Forward,
223                    });
224                } else if physics_state.was_sprinting {
225                    // have to let go for a tick to be able to start walking
226                    walk_events.write(StartWalkEvent {
227                        entity,
228                        direction: WalkDirection::None,
229                    });
230                } else {
231                    walk_events.write(StartWalkEvent {
232                        entity,
233                        direction: WalkDirection::Forward,
234                    });
235                }
236                if *jumping
237                    && target.center().horizontal_distance_squared_to(**position)
238                        > jump_until_target_distance.powi(2)
239                    && start.center().horizontal_distance_squared_to(**position)
240                        > jump_after_start_distance.powi(2)
241                {
242                    jump_events.write(JumpEvent { entity });
243                }
244            }
245        }
246
247        //
248    }
249}
250
251fn run_simulations(
252    executing_path: &ExecutingPath,
253    world_holder: &WorldHolder,
254    player: SimulatedPlayerBundle,
255) -> SimulatingPathState {
256    let swimming = player.physics.is_in_water();
257
258    let mut sim = Simulation::new(world_holder.shared.read().chunks.clone(), player.clone());
259
260    // note that we can't skip more than 50 nodes without causing issues with the
261    // executing_path_limit in goto_listener
262    for nodes_ahead in [20, 15, 10, 5, 4, 3, 2, 1, 0] {
263        if nodes_ahead + 1 >= executing_path.path.len() {
264            // don't simulate to the last node since it has stricter checks
265            continue;
266        }
267
268        let mut results = Vec::new();
269
270        if let Some(simulating_to) = executing_path.path.get(nodes_ahead) {
271            let y_rot =
272                direction_looking_at(*player.position, simulating_to.movement.target.center())
273                    .y_rot();
274
275            for jump_until_target_distance in [0., 1., 3.] {
276                for jump_after_start_distance in [0., 0.5] {
277                    for jumping in [true, false] {
278                        if !jumping
279                            && (jump_until_target_distance != 0. || jump_after_start_distance != 0.)
280                        {
281                            continue;
282                        }
283
284                        // this loop is left here in case you wanna try re-enabling walking, but
285                        // it doesn't seem that useful
286                        for sprinting in [true] {
287                            if !sprinting && nodes_ahead > 2 {
288                                continue;
289                            }
290                            if swimming {
291                                if !sprinting
292                                    || jump_until_target_distance > 0.
293                                    || jump_after_start_distance > 0.
294                                {
295                                    continue;
296                                }
297                            } else if jump_until_target_distance == 0. {
298                                continue;
299                            }
300
301                            let state = SimulatingPathOpts {
302                                start: BlockPos::from(player.position),
303                                target: simulating_to.movement.target,
304                                jumping,
305                                jump_until_target_distance,
306                                jump_after_start_distance,
307                                sprinting,
308                                y_rot,
309                            };
310                            let sim_res = run_one_simulation(
311                                &mut sim,
312                                player.clone(),
313                                state.clone(),
314                                executing_path,
315                                nodes_ahead,
316                                if swimming {
317                                    (nodes_ahead * 12) + 20
318                                } else {
319                                    (nodes_ahead * 4) + 20
320                                },
321                            );
322                            if sim_res.success {
323                                results.push((state, sim_res.ticks));
324                            }
325                        }
326                    }
327                }
328            }
329        }
330
331        if !results.is_empty() {
332            let fastest = results.iter().min_by_key(|r| r.1).unwrap().0.clone();
333            return SimulatingPathState::Simulated(fastest);
334        }
335    }
336
337    SimulatingPathState::Fail
338}
339
340struct SimulationResult {
341    success: bool,
342    ticks: usize,
343}
344fn run_one_simulation(
345    sim: &mut Simulation,
346    player: SimulatedPlayerBundle,
347    state: SimulatingPathOpts,
348    executing_path: &ExecutingPath,
349    nodes_ahead: usize,
350    timeout_ticks: usize,
351) -> SimulationResult {
352    let simulating_to = &executing_path.path[nodes_ahead];
353
354    let start = BlockPos::from(player.position);
355    sim.reset(player);
356
357    // run an Update to initialize some things, including at least the Bot component
358    // (which is needed for jumping)
359    sim.run_update_schedule();
360
361    let simulating_to_block = simulating_to.movement.target;
362
363    let mut success = false;
364    let mut total_ticks = 0;
365
366    for ticks in 1..=timeout_ticks {
367        let position = sim.position();
368        let physics = sim.physics();
369
370        if physics.horizontal_collision
371            || physics.is_in_lava()
372            || (physics.velocity.y < -0.7 && !physics.is_in_water())
373        {
374            // fail
375            break;
376        }
377
378        if (simulating_to.movement.data.is_reached)(IsReachedCtx {
379            target: simulating_to_block,
380            start,
381            position,
382            physics: &physics,
383        }) {
384            success = true;
385            total_ticks = ticks;
386            break;
387        }
388
389        let ecs = sim.app.world_mut();
390
391        ecs.get_mut::<LookDirection>(sim.entity)
392            .unwrap()
393            .update(LookDirection::new(state.y_rot, 0.));
394
395        if state.sprinting {
396            ecs.write_message(StartSprintEvent {
397                entity: sim.entity,
398                direction: SprintDirection::Forward,
399            });
400        } else if ecs
401            .get::<PhysicsState>(sim.entity)
402            .map(|p| p.was_sprinting)
403            .unwrap_or_default()
404        {
405            // have to let go for a tick to be able to start walking
406            ecs.write_message(StartWalkEvent {
407                entity: sim.entity,
408                direction: WalkDirection::None,
409            });
410        } else {
411            ecs.write_message(StartWalkEvent {
412                entity: sim.entity,
413                direction: WalkDirection::Forward,
414            });
415        }
416        if state.jumping
417            && simulating_to_block
418                .center()
419                .horizontal_distance_squared_to(position)
420                > state.jump_until_target_distance.powi(2)
421            && start.center().horizontal_distance_squared_to(position)
422                > state.jump_after_start_distance.powi(2)
423        {
424            ecs.write_message(JumpEvent { entity: sim.entity });
425        }
426
427        sim.tick();
428    }
429
430    if success {
431        // now verify that the path is safe by continuing to the next node
432
433        let mut followup_success = false;
434
435        let next_node = &executing_path.path[nodes_ahead + 1];
436        for _ in 1..=30 {
437            // add ticks here so if we sort by ticks later it'll be more accurate
438            total_ticks += 1;
439
440            {
441                let mut system_state = SystemState::<(
442                    Commands,
443                    Query<(&Position, &Physics, Option<&Mining>, &Inventory)>,
444                    MessageWriter<LookAtEvent>,
445                    MessageWriter<StartSprintEvent>,
446                    MessageWriter<StartWalkEvent>,
447                    MessageWriter<JumpEvent>,
448                    MessageWriter<StartMiningBlockEvent>,
449                )>::new(sim.app.world_mut());
450                let (
451                    mut commands,
452                    query,
453                    mut look_at_events,
454                    mut sprint_events,
455                    mut walk_events,
456                    mut jump_events,
457                    mut start_mining_events,
458                ) = system_state.get_mut(sim.app.world_mut());
459
460                let (position, physics, mining, inventory) = query.get(sim.entity).unwrap();
461
462                if physics.horizontal_collision {
463                    // if the simulated move made us hit a wall then it's bad
464                    break;
465                }
466                if physics.velocity.y < -0.7 && !physics.is_in_water() {
467                    break;
468                }
469
470                (next_node.movement.data.execute)(ExecuteCtx {
471                    entity: sim.entity,
472                    target: next_node.movement.target,
473                    start: simulating_to_block,
474                    position: **position,
475                    physics,
476                    is_currently_mining: mining.is_some(),
477                    // don't modify the world from the simulation
478                    can_mine: false,
479                    world: sim.world.clone(),
480                    menu: inventory.inventory_menu.clone(),
481
482                    commands: &mut commands,
483                    look_at_events: &mut look_at_events,
484                    sprint_events: &mut sprint_events,
485                    walk_events: &mut walk_events,
486                    jump_events: &mut jump_events,
487                    start_mining_events: &mut start_mining_events,
488                });
489                system_state.apply(sim.app.world_mut());
490            }
491
492            sim.tick();
493
494            if (next_node.movement.data.is_reached)(IsReachedCtx {
495                target: next_node.movement.target,
496                start: simulating_to_block,
497                position: sim.position(),
498                physics: &sim.physics(),
499            }) {
500                followup_success = true;
501                break;
502            }
503        }
504
505        if !followup_success {
506            debug!("followup failed");
507            success = false;
508        }
509    }
510
511    SimulationResult {
512        success,
513        ticks: total_ticks,
514    }
515}