Skip to main content

azalea/pathfinder/execute/
mod.rs

1pub mod patching;
2pub mod simulation;
3
4use std::{cmp, time::Duration};
5
6use azalea_block::{BlockState, BlockTrait};
7use azalea_client::{
8    StartSprintEvent, StartWalkEvent,
9    local_player::WorldHolder,
10    mining::{Mining, MiningSystems, StartMiningBlockEvent},
11};
12use azalea_core::{position::Vec3, tick::GameTick};
13use azalea_entity::{Physics, Position, inventory::Inventory};
14use azalea_physics::{PhysicsSystems, get_block_pos_below_that_affects_movement};
15use azalea_world::{WorldName, Worlds};
16use bevy_app::{App, Plugin};
17use bevy_ecs::prelude::*;
18use tracing::{debug, info, trace, warn};
19
20use crate::{
21    WalkDirection,
22    bot::{JumpEvent, LookAtEvent},
23    ecs::{
24        entity::Entity,
25        query::Without,
26        system::{Commands, Query, Res},
27    },
28    pathfinder::{
29        ExecutingPath, GotoEvent, Pathfinder, PathfinderSystems,
30        astar::PathfinderTimeout,
31        custom_state::CustomPathfinderState,
32        debug::debug_render_path_with_particles,
33        execute::simulation::SimulatingPathState,
34        moves::{ExecuteCtx, IsReachedCtx},
35        player_pos_to_block_pos,
36    },
37};
38
39pub struct DefaultPathfinderExecutionPlugin;
40impl Plugin for DefaultPathfinderExecutionPlugin {
41    fn build(&self, _app: &mut App) {}
42
43    fn finish(&self, app: &mut App) {
44        if app.is_plugin_added::<simulation::SimulationPathfinderExecutionPlugin>() {
45            info!("pathfinder simulation executor plugin is enabled, disabling default executor.");
46            return;
47        }
48
49        app.add_systems(
50            GameTick,
51            (
52                timeout_movement,
53                patching::check_for_path_obstruction,
54                check_node_reached,
55                tick_execute_path,
56                recalculate_near_end_of_path,
57                recalculate_if_has_goal_but_no_path,
58            )
59                .chain()
60                .after(PhysicsSystems)
61                .after(azalea_client::movement::send_position)
62                .after(MiningSystems)
63                .after(debug_render_path_with_particles)
64                .in_set(PathfinderSystems),
65        );
66    }
67}
68
69#[allow(clippy::type_complexity)]
70pub fn tick_execute_path(
71    mut commands: Commands,
72    mut query: Query<(
73        Entity,
74        &mut ExecutingPath,
75        &Position,
76        &Physics,
77        Option<&Mining>,
78        &WorldHolder,
79        &Inventory,
80    )>,
81    mut look_at_events: MessageWriter<LookAtEvent>,
82    mut sprint_events: MessageWriter<StartSprintEvent>,
83    mut walk_events: MessageWriter<StartWalkEvent>,
84    mut jump_events: MessageWriter<JumpEvent>,
85    mut start_mining_events: MessageWriter<StartMiningBlockEvent>,
86) {
87    for (entity, mut executing_path, position, physics, mining, world_holder, inventory) in
88        &mut query
89    {
90        executing_path.ticks_since_last_node_reached += 1;
91
92        if let Some(edge) = executing_path.path.front() {
93            let mut ctx = ExecuteCtx {
94                entity,
95                target: edge.movement.target,
96                position: **position,
97                start: executing_path.last_reached_node,
98                physics,
99                is_currently_mining: mining.is_some(),
100                can_mine: true,
101                world: world_holder.shared.clone(),
102                menu: inventory.inventory_menu.clone(),
103
104                commands: &mut commands,
105                look_at_events: &mut look_at_events,
106                sprint_events: &mut sprint_events,
107                walk_events: &mut walk_events,
108                jump_events: &mut jump_events,
109                start_mining_events: &mut start_mining_events,
110            };
111            ctx.on_tick_start();
112            trace!(
113                "executing move, position: {}, last_reached_node: {}",
114                **position, executing_path.last_reached_node
115            );
116            (edge.movement.data.execute)(ctx);
117        }
118    }
119}
120
121pub fn check_node_reached(
122    mut query: Query<(
123        Entity,
124        &mut Pathfinder,
125        &mut ExecutingPath,
126        &Position,
127        &Physics,
128        &WorldName,
129    )>,
130    mut walk_events: MessageWriter<StartWalkEvent>,
131    mut commands: Commands,
132    worlds: Res<Worlds>,
133) {
134    for (entity, mut pathfinder, mut executing_path, position, physics, world_name) in &mut query {
135        let Some(world) = worlds.get(world_name) else {
136            warn!("entity is pathfinding but not in a valid world");
137            continue;
138        };
139
140        'skip: loop {
141            // we check if the goal was reached *before* actually executing the movement so
142            // we don't unnecessarily execute a movement when it wasn't necessary
143
144            // see if we already reached any future nodes and can skip ahead
145            for (i, edge) in executing_path
146                .path
147                .clone()
148                .into_iter()
149                .enumerate()
150                .take(30)
151                .rev()
152            {
153                let movement = edge.movement;
154                let is_reached_ctx = IsReachedCtx {
155                    target: movement.target,
156                    start: executing_path.last_reached_node,
157                    position: **position,
158                    physics,
159                };
160                let extra_check = if i == executing_path.path.len() - 1
161                    // only do the extra check if we don't have a new path immediately queued up
162                    && executing_path.is_empty_queued_path()
163                {
164                    // be extra strict about the velocity and centering if we're on the last node so
165                    // we don't fall off
166
167                    let x_difference_from_center = position.x - (movement.target.x as f64 + 0.5);
168                    let z_difference_from_center = position.z - (movement.target.z as f64 + 0.5);
169
170                    let block_pos_below = get_block_pos_below_that_affects_movement(*position);
171
172                    let block_state_below = {
173                        let world = world.read();
174                        world
175                            .chunks
176                            .get_block_state(block_pos_below)
177                            .unwrap_or(BlockState::AIR)
178                    };
179                    let block_below: Box<dyn BlockTrait> = block_state_below.into();
180                    // friction for normal blocks is 0.6, for ice it's 0.98
181                    let block_friction = block_below.behavior().friction as f64;
182
183                    // if the block has the default friction, this will multiply by 1
184                    // for blocks like ice, it'll multiply by a higher number
185                    let scaled_velocity = physics.velocity * (0.4 / (1. - block_friction));
186
187                    let x_predicted_offset = (x_difference_from_center + scaled_velocity.x).abs();
188                    let z_predicted_offset = (z_difference_from_center + scaled_velocity.z).abs();
189
190                    // this is to make sure we don't fall off immediately after finishing the path
191                    (physics.on_ground() || physics.is_in_water())
192                        && player_pos_to_block_pos(**position) == movement.target
193                        // adding the delta like this isn't a perfect solution but it helps to make
194                        // sure we don't keep going if our delta is high
195                        && x_predicted_offset < 0.2
196                        && z_predicted_offset < 0.2
197                } else {
198                    true
199                };
200
201                if (movement.data.is_reached)(is_reached_ctx) && extra_check {
202                    executing_path.path = executing_path.path.split_off(i + 1);
203                    executing_path.last_reached_node = movement.target;
204                    executing_path.ticks_since_last_node_reached = 0;
205                    trace!("reached node {}", movement.target);
206
207                    if let Some(new_path) = executing_path.queued_path.take() {
208                        debug!(
209                            "swapped path to {:?}",
210                            new_path.iter().take(10).collect::<Vec<_>>()
211                        );
212                        executing_path.path = new_path;
213
214                        if executing_path.path.is_empty() {
215                            info!("the path we just swapped to was empty, so reached end of path");
216                            walk_events.write(StartWalkEvent {
217                                entity,
218                                direction: WalkDirection::None,
219                            });
220                            commands.entity(entity).remove::<ExecutingPath>();
221                            break;
222                        }
223
224                        // run the function again since we just swapped
225                        continue 'skip;
226                    }
227
228                    if executing_path.path.is_empty() {
229                        debug!("pathfinder path is now empty");
230                        walk_events.write(StartWalkEvent {
231                            entity,
232                            direction: WalkDirection::None,
233                        });
234                        commands.entity(entity).remove::<ExecutingPath>();
235                        if let Some(goal) = pathfinder.goal.clone()
236                            && goal.success(movement.target)
237                        {
238                            info!("goal was reached!");
239                            pathfinder.goal = None;
240                            pathfinder.opts = None;
241                        }
242                    }
243
244                    break;
245                }
246            }
247            break;
248        }
249    }
250}
251
252#[allow(clippy::type_complexity)]
253pub fn timeout_movement(
254    mut query: Query<(
255        Entity,
256        &mut Pathfinder,
257        &mut ExecutingPath,
258        &Position,
259        Option<&Mining>,
260        &WorldName,
261        &Inventory,
262        Option<&CustomPathfinderState>,
263        Option<&mut SimulatingPathState>,
264    )>,
265    worlds: Res<Worlds>,
266) {
267    for (
268        entity,
269        mut pathfinder,
270        mut executing_path,
271        position,
272        mining,
273        world_name,
274        inventory,
275        custom_state,
276        simulating_path_state,
277    ) in &mut query
278    {
279        if !executing_path.path.is_empty() {
280            let (start, end) = if let Some(s) = &simulating_path_state
281                && let SimulatingPathState::Simulated(simulating_path_state) = &**s
282            {
283                (simulating_path_state.start, simulating_path_state.target)
284            } else {
285                (
286                    executing_path.last_reached_node,
287                    executing_path.path[0].movement.target,
288                )
289            };
290
291            let (start, end) = (start.center_bottom(), end.center_bottom());
292            // TODO: use an actual 2d point-line distance formula here instead of the 3d one
293            // lol
294            let xz_distance =
295                point_line_distance_3d(&position.with_y(0.), &(start.with_y(0.), end.with_y(0.)));
296            let y_distance = point_line_distance_1d(position.y, (start.y, end.y));
297
298            let xz_tolerance = 3.;
299            // longer moves have more y tolerance (in case we're climbing a hill or smth in
300            // a single movement)
301            let y_tolerance = start.horizontal_distance_to(end) / 2. + 1.5;
302
303            if xz_distance > xz_tolerance || y_distance > y_tolerance {
304                warn!(
305                    "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!",
306                    **position
307                );
308
309                if let Some(mut simulating_path_state) = simulating_path_state {
310                    // don't keep executing the simulation
311                    *simulating_path_state = SimulatingPathState::Fail;
312                }
313
314                patch_path_from_timeout(
315                    entity,
316                    &mut executing_path,
317                    &mut pathfinder,
318                    &worlds,
319                    position,
320                    world_name,
321                    custom_state,
322                    inventory,
323                );
324                continue;
325            }
326        }
327
328        // don't timeout if we're mining
329        if let Some(mining) = mining {
330            // also make sure we're close enough to the block that's being mined
331            if mining.pos.distance_squared_to(position.into()) < 6_i32.pow(2) {
332                // also reset the ticks_since_last_node_reached so we don't timeout after we
333                // finish mining
334                executing_path.ticks_since_last_node_reached = 0;
335                continue;
336            }
337        }
338
339        let mut timeout = 2 * 20;
340
341        if simulating_path_state.is_some() {
342            // longer timeout if we're following a simulated path from the other execution
343            // engine
344            timeout = 5 * 20;
345        }
346
347        if executing_path.ticks_since_last_node_reached > timeout
348            && !pathfinder.is_calculating
349            && !executing_path.path.is_empty()
350        {
351            warn!("pathfinder timeout, trying to patch path");
352
353            patch_path_from_timeout(
354                entity,
355                &mut executing_path,
356                &mut pathfinder,
357                &worlds,
358                position,
359                world_name,
360                custom_state,
361                inventory,
362            );
363        }
364    }
365}
366
367#[allow(clippy::too_many_arguments)]
368fn patch_path_from_timeout(
369    entity: Entity,
370    executing_path: &mut ExecutingPath,
371    pathfinder: &mut Pathfinder,
372    worlds: &Worlds,
373    position: &Position,
374    world_name: &WorldName,
375    custom_state: Option<&CustomPathfinderState>,
376    inventory: &Inventory,
377) {
378    executing_path.queued_path = None;
379    let cur_pos = player_pos_to_block_pos(**position);
380    executing_path.last_reached_node = cur_pos;
381
382    let world_lock = worlds
383        .get(world_name)
384        .expect("Entity tried to pathfind but the entity isn't in a valid world");
385    let Some(opts) = pathfinder.opts.clone() else {
386        warn!(
387            "pathfinder was going to patch path because of timeout, but pathfinder.opts was None"
388        );
389        return;
390    };
391
392    let custom_state = custom_state.cloned().unwrap_or_default();
393
394    // try to fix the path without recalculating everything.
395    // (though, it'll still get fully recalculated by `recalculate_near_end_of_path`
396    // if the new path is too short)
397    patching::patch_path(
398        0..=cmp::min(20, executing_path.path.len() - 1),
399        executing_path,
400        pathfinder,
401        inventory,
402        entity,
403        world_lock,
404        custom_state,
405        opts,
406    );
407    // reset last_node_reached_at so we don't immediately try to patch again
408    executing_path.ticks_since_last_node_reached = 0
409}
410
411pub fn recalculate_near_end_of_path(
412    mut query: Query<(Entity, &mut Pathfinder, &mut ExecutingPath)>,
413    mut walk_events: MessageWriter<StartWalkEvent>,
414    mut goto_events: MessageWriter<GotoEvent>,
415    mut commands: Commands,
416) {
417    for (entity, mut pathfinder, mut executing_path) in &mut query {
418        let Some(mut opts) = pathfinder.opts.clone() else {
419            continue;
420        };
421
422        // start recalculating if the path ends soon. 50 is arbitrary, that's just to
423        // make us recalculate once when we start nearing the end. this doesn't account
424        // for skipping nodes, though...
425        // TODO: have a variable to store whether we've recalculated, and then check
426        // that `&& path.len() <= 50` to see if we should recalculate.
427        if (executing_path.path.len() == 50 || executing_path.path.len() < 5)
428            && !pathfinder.is_calculating
429            && executing_path.is_path_partial
430        {
431            match pathfinder.goal.as_ref().cloned() {
432                Some(goal) => {
433                    debug!("Recalculating path because it's empty or ends soon");
434                    debug!(
435                        "recalculate_near_end_of_path executing_path.is_path_partial: {}",
436                        executing_path.is_path_partial
437                    );
438
439                    opts.min_timeout = if executing_path.path.len() == 50 {
440                        // we have quite some time until the node is reached, soooo we might as
441                        // well burn some cpu cycles to get a good path
442                        PathfinderTimeout::Time(Duration::from_secs(5))
443                    } else {
444                        PathfinderTimeout::Time(Duration::from_secs(1))
445                    };
446
447                    goto_events.write(GotoEvent { entity, goal, opts });
448                    pathfinder.is_calculating = true;
449
450                    if executing_path.path.is_empty() {
451                        if let Some(new_path) = executing_path.queued_path.take() {
452                            executing_path.path = new_path;
453                            if executing_path.path.is_empty() {
454                                info!(
455                                    "the path we just swapped to was empty, so reached end of path"
456                                );
457                                walk_events.write(StartWalkEvent {
458                                    entity,
459                                    direction: WalkDirection::None,
460                                });
461                                commands.entity(entity).remove::<ExecutingPath>();
462                                break;
463                            }
464                        } else {
465                            walk_events.write(StartWalkEvent {
466                                entity,
467                                direction: WalkDirection::None,
468                            });
469                            commands.entity(entity).remove::<ExecutingPath>();
470                        }
471                    }
472                }
473                _ => {
474                    if executing_path.path.is_empty() {
475                        // idk when this can happen but stop moving just in case
476                        walk_events.write(StartWalkEvent {
477                            entity,
478                            direction: WalkDirection::None,
479                        });
480                    }
481                }
482            }
483        }
484    }
485}
486
487pub fn recalculate_if_has_goal_but_no_path(
488    mut query: Query<(Entity, &mut Pathfinder), Without<ExecutingPath>>,
489    mut goto_events: MessageWriter<GotoEvent>,
490) {
491    for (entity, mut pathfinder) in &mut query {
492        if pathfinder.goal.is_some()
493            && !pathfinder.is_calculating
494            && let Some(goal) = pathfinder.goal.as_ref().cloned()
495            && let Some(opts) = pathfinder.opts.clone()
496        {
497            debug!("Recalculating path because it has a goal but no ExecutingPath");
498            goto_events.write(GotoEvent { entity, goal, opts });
499            pathfinder.is_calculating = true;
500        }
501    }
502}
503
504// based on https://stackoverflow.com/a/36425155
505/// Returns the distance of a point from a line.
506///
507/// This is used in the pathfinder for checking if the bot is too far from the
508/// current path.
509pub fn point_line_distance_3d(point: &Vec3, (start, end): &(Vec3, Vec3)) -> f64 {
510    let start_to_end = end - start;
511    let start_to_point = point - start;
512
513    if start_to_point.dot(start_to_end) <= 0. {
514        return start_to_point.length();
515    }
516
517    let end_to_point = point - end;
518    if end_to_point.dot(start_to_end) >= 0. {
519        return end_to_point.length();
520    }
521
522    start_to_end.cross(start_to_point).length() / start_to_end.length()
523}
524pub fn point_line_distance_1d(point: f64, (start, end): (f64, f64)) -> f64 {
525    let min = start.min(end);
526    let max = start.max(end);
527    if point < min {
528        min - point
529    } else if point > max {
530        point - max
531    } else {
532        0.
533    }
534}