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