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 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 let x_difference_from_center = position.x - (movement.target.x as f64 + 0.5);
165 let z_difference_from_center = position.z - (movement.target.z as f64 + 0.5);
166
167 let block_pos_below = get_block_pos_below_that_affects_movement(*position);
168
169 let block_state_below = {
170 let world = world.read();
171 world
172 .chunks
173 .get_block_state(block_pos_below)
174 .unwrap_or(BlockState::AIR)
175 };
176 let block_below: Box<dyn BlockTrait> = block_state_below.into();
177 let block_friction = block_below.behavior().friction as f64;
179
180 let scaled_velocity = physics.velocity * (0.4 / (1. - block_friction));
183
184 let x_predicted_offset = (x_difference_from_center + scaled_velocity.x).abs();
185 let z_predicted_offset = (z_difference_from_center + scaled_velocity.z).abs();
186
187 (physics.on_ground() || physics.is_in_water())
189 && player_pos_to_block_pos(**position) == movement.target
190 && x_predicted_offset < 0.2
193 && z_predicted_offset < 0.2
194 } else {
195 true
196 };
197
198 if (movement.data.is_reached)(is_reached_ctx) && extra_check {
199 executing_path.path = executing_path.path.split_off(i + 1);
200 executing_path.last_reached_node = movement.target;
201 executing_path.ticks_since_last_node_reached = 0;
202 trace!("reached node {}", movement.target);
203
204 if let Some(new_path) = executing_path.queued_path.take() {
205 debug!(
206 "swapped path to {:?}",
207 new_path.iter().take(10).collect::<Vec<_>>()
208 );
209 executing_path.path = new_path;
210
211 if executing_path.path.is_empty() {
212 info!("the path we just swapped to was empty, so reached end of path");
213 walk_events.write(StartWalkEvent {
214 entity,
215 direction: WalkDirection::None,
216 });
217 commands.entity(entity).remove::<ExecutingPath>();
218 break;
219 }
220
221 continue 'skip;
223 }
224
225 if executing_path.path.is_empty() {
226 debug!("pathfinder path is now empty");
227 walk_events.write(StartWalkEvent {
228 entity,
229 direction: WalkDirection::None,
230 });
231 commands.entity(entity).remove::<ExecutingPath>();
232 if let Some(goal) = pathfinder.goal.clone()
233 && goal.success(movement.target)
234 {
235 info!("goal was reached!");
236 pathfinder.goal = None;
237 pathfinder.opts = None;
238 }
239 }
240
241 break;
242 }
243 }
244 break;
245 }
246 }
247}
248
249#[allow(clippy::type_complexity)]
250pub fn timeout_movement(
251 mut query: Query<(
252 Entity,
253 &mut Pathfinder,
254 &mut ExecutingPath,
255 &Position,
256 Option<&Mining>,
257 &WorldName,
258 &Inventory,
259 Option<&CustomPathfinderState>,
260 Option<&SimulatingPathState>,
261 )>,
262 worlds: Res<Worlds>,
263) {
264 for (
265 entity,
266 mut pathfinder,
267 mut executing_path,
268 position,
269 mining,
270 world_name,
271 inventory,
272 custom_state,
273 simulating_path_state,
274 ) in &mut query
275 {
276 if !executing_path.path.is_empty() {
277 let (start, end) = if let Some(SimulatingPathState::Simulated(simulating_path_state)) =
278 simulating_path_state
279 {
280 (simulating_path_state.start, simulating_path_state.target)
281 } else {
282 (
283 executing_path.last_reached_node,
284 executing_path.path[0].movement.target,
285 )
286 };
287
288 let (start, end) = (start.center_bottom(), end.center_bottom());
289 let xz_distance =
292 point_line_distance_3d(&position.with_y(0.), &(start.with_y(0.), end.with_y(0.)));
293 let y_distance = point_line_distance_1d(position.y, (start.y, end.y));
294
295 let xz_tolerance = 3.;
296 let y_tolerance = start.horizontal_distance_to(end) / 2. + 1.5;
299
300 if xz_distance > xz_tolerance || y_distance > y_tolerance {
301 warn!(
302 "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!",
303 **position
304 );
305 patch_path_from_timeout(
306 entity,
307 &mut executing_path,
308 &mut pathfinder,
309 &worlds,
310 position,
311 world_name,
312 custom_state,
313 inventory,
314 );
315 continue;
316 }
317 }
318
319 if let Some(mining) = mining {
321 if mining.pos.distance_squared_to(position.into()) < 6_i32.pow(2) {
323 executing_path.ticks_since_last_node_reached = 0;
326 continue;
327 }
328 }
329
330 let mut timeout = 2 * 20;
331
332 if simulating_path_state.is_some() {
333 timeout = 5 * 20;
336 }
337
338 if executing_path.ticks_since_last_node_reached > timeout
339 && !pathfinder.is_calculating
340 && !executing_path.path.is_empty()
341 {
342 warn!("pathfinder timeout, trying to patch path");
343
344 patch_path_from_timeout(
345 entity,
346 &mut executing_path,
347 &mut pathfinder,
348 &worlds,
349 position,
350 world_name,
351 custom_state,
352 inventory,
353 );
354 }
355 }
356}
357
358#[allow(clippy::too_many_arguments)]
359fn patch_path_from_timeout(
360 entity: Entity,
361 executing_path: &mut ExecutingPath,
362 pathfinder: &mut Pathfinder,
363 worlds: &Worlds,
364 position: &Position,
365 world_name: &WorldName,
366 custom_state: Option<&CustomPathfinderState>,
367 inventory: &Inventory,
368) {
369 executing_path.queued_path = None;
370 let cur_pos = player_pos_to_block_pos(**position);
371 executing_path.last_reached_node = cur_pos;
372
373 let world_lock = worlds
374 .get(world_name)
375 .expect("Entity tried to pathfind but the entity isn't in a valid world");
376 let Some(opts) = pathfinder.opts.clone() else {
377 warn!(
378 "pathfinder was going to patch path because of timeout, but pathfinder.opts was None"
379 );
380 return;
381 };
382
383 let custom_state = custom_state.cloned().unwrap_or_default();
384
385 patching::patch_path(
389 0..=cmp::min(20, executing_path.path.len() - 1),
390 executing_path,
391 pathfinder,
392 inventory,
393 entity,
394 world_lock,
395 custom_state,
396 opts,
397 );
398 executing_path.ticks_since_last_node_reached = 0
400}
401
402pub fn recalculate_near_end_of_path(
403 mut query: Query<(Entity, &mut Pathfinder, &mut ExecutingPath)>,
404 mut walk_events: MessageWriter<StartWalkEvent>,
405 mut goto_events: MessageWriter<GotoEvent>,
406 mut commands: Commands,
407) {
408 for (entity, mut pathfinder, mut executing_path) in &mut query {
409 let Some(mut opts) = pathfinder.opts.clone() else {
410 continue;
411 };
412
413 if (executing_path.path.len() == 50 || executing_path.path.len() < 5)
415 && !pathfinder.is_calculating
416 && executing_path.is_path_partial
417 {
418 match pathfinder.goal.as_ref().cloned() {
419 Some(goal) => {
420 debug!("Recalculating path because it's empty or ends soon");
421 debug!(
422 "recalculate_near_end_of_path executing_path.is_path_partial: {}",
423 executing_path.is_path_partial
424 );
425
426 opts.min_timeout = if executing_path.path.len() == 50 {
427 PathfinderTimeout::Time(Duration::from_secs(5))
430 } else {
431 PathfinderTimeout::Time(Duration::from_secs(1))
432 };
433
434 goto_events.write(GotoEvent { entity, goal, opts });
435 pathfinder.is_calculating = true;
436
437 if executing_path.path.is_empty() {
438 if let Some(new_path) = executing_path.queued_path.take() {
439 executing_path.path = new_path;
440 if executing_path.path.is_empty() {
441 info!(
442 "the path we just swapped to was empty, so reached end of path"
443 );
444 walk_events.write(StartWalkEvent {
445 entity,
446 direction: WalkDirection::None,
447 });
448 commands.entity(entity).remove::<ExecutingPath>();
449 break;
450 }
451 } else {
452 walk_events.write(StartWalkEvent {
453 entity,
454 direction: WalkDirection::None,
455 });
456 commands.entity(entity).remove::<ExecutingPath>();
457 }
458 }
459 }
460 _ => {
461 if executing_path.path.is_empty() {
462 walk_events.write(StartWalkEvent {
464 entity,
465 direction: WalkDirection::None,
466 });
467 }
468 }
469 }
470 }
471 }
472}
473
474pub fn recalculate_if_has_goal_but_no_path(
475 mut query: Query<(Entity, &mut Pathfinder), Without<ExecutingPath>>,
476 mut goto_events: MessageWriter<GotoEvent>,
477) {
478 for (entity, mut pathfinder) in &mut query {
479 if pathfinder.goal.is_some()
480 && !pathfinder.is_calculating
481 && let Some(goal) = pathfinder.goal.as_ref().cloned()
482 && let Some(opts) = pathfinder.opts.clone()
483 {
484 debug!("Recalculating path because it has a goal but no ExecutingPath");
485 goto_events.write(GotoEvent { entity, goal, opts });
486 pathfinder.is_calculating = true;
487 }
488 }
489}
490
491pub fn point_line_distance_3d(point: &Vec3, (start, end): &(Vec3, Vec3)) -> f64 {
497 let start_to_end = end - start;
498 let start_to_point = point - start;
499
500 if start_to_point.dot(start_to_end) <= 0. {
501 return start_to_point.length();
502 }
503
504 let end_to_point = point - end;
505 if end_to_point.dot(start_to_end) >= 0. {
506 return end_to_point.length();
507 }
508
509 start_to_end.cross(start_to_point).length() / start_to_end.length()
510}
511pub fn point_line_distance_1d(point: f64, (start, end): (f64, f64)) -> f64 {
512 let min = start.min(end);
513 let max = start.max(end);
514 if point < min {
515 min - point
516 } else {
517 point - max
518 }
519}