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 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 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 let block_friction = block_below.behavior().friction as f64;
178
179 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 (physics.on_ground() || physics.is_in_water())
188 && player_pos_to_block_pos(**position) == movement.target
189 && 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 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 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 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 if let Some(mining) = mining {
320 if mining.pos.distance_squared_to(position.into()) < 6_i32.pow(2) {
322 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 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 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 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 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 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 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
490pub 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}